예를 들어가며 SOLID에 대해 알아보자(1)

Let’s take an example and learn about SOLID

이전에 OOP의 5가지 원칙(SOLID)에 대해 알아봤지만 간단하게 정리 해놨기 때문에 잘 이해하기 힘들 수 있다. 상세한 예를 들어가며 알아가보자.

1. 단일 책임 원칙(Single Responsibility Principle)

단일 책임 원칙은 클래스는 단 한 개의 책임을 가져야 된다는 간단한 원칙이다. 하지만 역으로 가장 어려운 원칙이기도 하다. 한 개의 책임에 대한 정의가 모호하고 하나의 책임을 설계하기 위해 상당한 경험이 필요하기 때문이다.

먼저 단일 책임 원칙을 위반하면 어떤 문제점이 있는지 보자.

a. 변경을 어렵게 만든다.

public class DataViewer {

  //두 가지 역활(load, update)
  public void display() {
    String data = loadHtml();
    updateGui(data);
  }

  public String loadHtml() {
    HttpClient client = new HttpClient();
    client.connet(url);
    return client.getResponse();
  }

  private void updateGui(String data) {
    GuiData guiModel = parseDataToGuiData(data);
    tableUI.changeData(guiModel);
  }

  private GuiData parseDataToGuiData(String data){
    //파싱 코드
  }

  ...
}

위 코드의 display 메소드는 loadHtml()을 통해 읽어 온 HTML 응답 문자열을 updateGui()를 통해 데이터를 변경시키고 있다. 위의 DataViewer 클래스를 잘 사용하고 있다가 나중에 데이터를 제공하는 서버가 HTTP 프로토콜에서 소켓 기반의 프로토콜로 변경되면 어떻게 될까?

public class DataViewer {

  public void display() {
    byte[] data = loadHtml(); //변경 필요
    updateGui(data);
  }

  public byte[] loadHtml() {   //변경 필요
    SocketClient client = new SocketClient();   //변경 필요
    client.connet(server, port);    //변경 필요
    return client.read();   //변경 필요
  }

  private void updateGui(byte[] data) {   //변경 필요
    GuiData guiModel = parseDataToGuiData(data);
    tableUI.changeData(guiModel);
  }

  private GuiData parseDataToGuiData(byte[] data){  //변경 필요
    //파싱 코드       //변경 필요
  }

  ...
}

데이터를 읽어 오는 기능의 변화로 위와 같은 많은 코드의 수정이 필요할 것이다. 이러한 코드 수정은 두 개의 책임이 한 클래스에 아주 밀접하게 결합되어 있어서 발생했다. 책임의 개수가 많아질수록 한 책임의 기능 변화가 다른 책임에 주는 영향은 많아진다.

데이터 읽기와 데이터를 화면에 보여주는 책임을 두 개의 클래스로 분리하고 두 클래스 간의 주고받을 데이터를 알맞게 추상화하면 위와 같은 상황을 막을 수 있다.

b. 재사용이 어렵다.

또한 위와 같이 단일 책임 원칙을 위반하는 경우 재사용을 어렵게 만든다.

앞의 DataViwer 관계는 위의 그림과 같다. HttpClient 패키지와 GuiComp 패키지가 각각 별도의 jar 파일로 제공된다고 하면 데이터를 읽어 오는 기능이 필요한 DataRequiredClient 클래스를 만들경우 DataViewer 클래스와 HttpClient만 필요하지만 실제로는 DataViewer가 GuiComp를 필요 하므로 GuiComp jar까지도 필요하므로 실제 사용하지 않는 기능이 의존하는 jar파일 까지 필요하다.

하지만 위처럼 단일 책임 원칙에 따라 책임이 분리되었다면 데이터를 읽어 오는것과 상관없는 GuiComp 패키지나 datadisplay 패키지는 포함시킬 필요가 없어진다.

2. 개방 폐쇄 원칙(Open Closed Principle)

개방 폐쇄 원칙은 확장에는 열려 있고 변경에는 닫혀 있어야 된다는 것인데 즉, 기능을 변경하거나 확장할 수 있으면서 그 기능을 사용하는 코드는 수정하지 않는다. 기능을 변경하는데 그 기능을 사용하는 코드를 변경하지 말라니..? 뭔가 모순되는 말이지만 다음과 같은 방법으로 가능하다.

a. 추상화

  • FileByteSource: 파일에서 byte를 읽어 오는 클래스
  • SocketByteSource: 소켓으로 byte를 읽어 오는 클래스

위 그림에서 메모리에서 byte를 읽어 오는 기능을 추가해야 할 경우 ByteSource 인터페이스를 상속받은 MemoryByteSource 클래스를 구현함으로 기능 추가가 가능하다. 그리고 새로운 기능이 추가되었지만, 이 새로운 기능을 사용할 FlowContrller 클래스의 코드는 변경되지 않는다. 즉, 새로운 기능을 확장하면서도 기능을 사용하는 기존 코드는 변경하지 않은 것이다.

이를 개방 폐쇄 원칙은 (사용되는 기능의) 확장에는 열려 있고 (기능을 사용하는 코드의) 변경에는 닫혀 있다고 표현한다.

b. 상속

클라이언트의 요청이 왔을 때 데이터를 HTTP 응답 프로토콜에 맞춰 데이터를 전송해주는 ResponseSender가 있다고 가정하자.

public class ResponseSender {
  private Data data;

  public ResponseSender(Data data) {
    this.data = data;
  }

  public Data getData() {
    return data;
  }

  public void send() {
    sendHeader()
    sendBody();
  }

  protected void sendHeader() {
    // 헤더 데이터 전송
  }

  protected void sendBody() {
    // 텍스트로 데이터 전송
  }
}

ResponseSender 클래스의 send() 메소드는 sendHeader(), sendBody()를 호출하며 HTTP 응답 데이터를 생성한다. sendHeader()와 sendBody()는 protected 공개 범위로 하위 클래스에서 이 두 메소드를 오버라이딩 할 수 있다.

만약 압축해서 데이터를 전송하는 기능을 추가하고 싶다면 아래와 같이 오버라이딩 해주면 된다.

public class ZippedResponseSender extends ResponseSender {

  public ZippedResponseSender(Data data) {
    super(data);
  }

  @Override
  protected void sendBody() {
    // 데이터 압축 처리
  }
}

ZippedResponseSender 클래스는 기존 기능에 압축 기능을 추가하는데 ResponseSender 클래스의 코드는 변경되지 않았다. 즉, ResponseSender 클래스는 확장에는 열려 있으면서 변경에는 닫혀 있다고 할 수 있다.

개방 폐쇄 원칙이 깨질 때 주요 증상

추상화나 다형성을 이용해 개방 폐쇄 원칙을 구현하기 때문에 이것이 잘 지켜지지 않은 코드는 개방 페쇄 원칙을 어기게 되는데, 주로 개방 폐쇄 원칙을 어기는 코드의 특징은 다음과 같다.

  1. 다운 캐스팅 사용

    다음과 같은 Character 관계가 있다.

    하지만 아래와 같이 특정 타입인 경우 별도 처리를 하도록 drawCharacter() 메소드를 구현하게되면 Character 클래스가 확장될 때 drawCharacter() 메소드도 같이 수정되어 변경에 닫혀 있지 않게 된다.

     public void drawCharacter(Character character) {
       if (character instanceof Missile) { //타입 확인
         Missile missile = (Missile) character;  //타입 다운 캐스팅
         missile.drawSpecific();
       }else {
         character.draw();
       }
     }
    

    그래서 위 코드의 경우 타입이 Missile이면 타입 변환 뒤 drawSpecific() 메소드를 호출하므로 이 메소드가 실제로 객체마다 다르게 동작할 수 있는 변화 대상인지 확인해보고 앞으로 다르게 동작할 가능성이 높다면 이 메소드를 추상화해 Character 타입에 추가해야 한다.

  2. 비슷한 if-else 블록 존재

    Enemy 캐릭터의 움직이는 경로가 몇가지 패턴에 따라 이동하는 코드를 다음과 같이 작성하게 되면 Enemy 클래스에 새로운 경로 패턴을 추가해야 할 경우 darw() 메소드에 새로운 if 블록이 계속해서 추가된다. 즉 변경에 닫혀 있지 않다. 어떻게 바꿔야 될까?

     public class Enemy extends Character {
          
       private int pathPattern;
    
       public Enemy(int pathPattern) {
         this.pathpattern = pathPattern;
       }
    
       public void draw() {
         if (pathPattern == 1) {
           x += 4;
         } else if (pathPattern == 2) {
           y += 10; 
         } else if (pathPattern == 4) {
           x += 4;
           y += 10; 
         }
         ...; // 그려주는 코드
       }
     }
    

    경로가 앞으로 계속해서 확장(변경)되기 때문에 이 부분을 추상화하여 표현하면 된다. 그렇게하면 다음과 같이 경로 패턴을 추상화하여 Enemy에서 추상화 타입을 사용하는 구조로 바뀐다.

       public class Enemy extends Character {
    
         private PathPattern pathPattern;
    
         public Enemy(PathPattern pathPattern) {
           this.pathpattern = pathPattern;
         }
    
         public void draw() {
           int x = pathPattern.nextX();
           int y = pathPattern.nexyY();
           ...; // 그려주는 코드
         }
     }
    

    이렇게 되면 이제 새로운 이동 패턴이 생기더라도 draw() 메소드는 변경되지 않으며, PathPattern 구현 클래스만 새로 추가해주면 된다.

즉, 개방 폐쇄 원칙은 변화가 예상되는 것을 추상화해서 변경의 유연함을 얻도록 해주는 것이다. 변화되는 부분을 추상화 하지 못하면 개방 폐쇄 원칙을 지킬 수 없게 되어 시간이 흐를수록 기능 변경이나 확장이 어렵다.


글이 길어져 여기서 한번 끊고 다음글에서 나머지 원칙인 리스코프 치환 원칙, 인터페이스 분리 원칙, 의존 역전 원칙에 대해 자세히 알아보자.


참고: 개발자가 반드시 정복해야 할 객체지향과 디자인 패턴

*틀린 부분이 있으면 언제든지 말씀해 주시면 공부해서 수정하겠습니다.


© 2022. All rights reserved.

Powered by 애송이