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

Let’s take an example and learn about SOLID

이전 글을 안 읽고 오셨다면 읽고 오시는 것을 추천드립니다.

3. 리스코프 치환 원칙(Liskov Substitution Principle)

리스코프 치환 법칙은 앞서 설명한 개방 폐쇄 원칙을 받쳐 주는 다형성에 관한 원칙을 제공한다. 리스코프 치환 원칙이 지켜지지 않으면 다형성에 기반한 개방 폐쇄 원칙 또한 지켜지지 않기 때문에 중요하다.

  • 상위 타입의 객체를 하위 타입의 객체로 치환해도 정상적으로 동작해야 한다.

간단하게 예를 들어보면

//상위 타입 SuperClass, 하위 타입 SubClass

public void method(SuperClass sc){
  sc.someMethod();
}

위처럼 코드가 있을 때 아래처럼 객체를 전달해도 정상적으로 동작해야 되는 것이 리스코프 치환 원칙이다.

someMethod(new SubClass());

리스코프 치환 원칙을 지키지 않았을 때 문제

a. 직사각형-정사각형 문제

정사각형(Square)을 직사각형(Rectangle)의 특수한 경우로 보고 상속받도록 구현을 했다고 가정을 하자. 그래서 정사각형의 경우 setWidth()나 setHeigh()가 길이가 같도록 재정의 하였다.

public class Rectange {
  private int width;
  private int height;

  public void setWidth(int width) {
    this.width = width;
  }

  public void setHeight(int height) {
    this.height = height;
  }

  public int getWidth() {
    return width;
  }

  public int getHeight() {
    return width;
  }
}
public class Square extends Rectangle {

  @Override
  public void setWidth(int width) {
    super.setWidth(width);
    super.setHeight(width);
  }

  @Override
  public void setHeight(int height) {
      super.setWidth(height);
    super.setHeight(height);
  }
}

그 다음 이제 Rectangle을 이용해 코드를 구현해보자

public void increaseHeight(Rectangle rec) {
  if (rec.getHeight() <= rec.getWidth()){
    rec.setHeight(rec.getWidth() + 10);
  }
}

increaseHeight() 메소드의 의도는 height가 width보다 작거나 같은 경우 높이를 늘리는 것이다. 근데 파라미터에 Square 객체가 전달되면 이 의도는 깨지게 된다. Square 같은 경우는 높이와 폭을 다 같게 만들기 때문에 이 메소드를 실행해도 원래 의도와 다르게 된다.

여기서 직사각형, 정사각형 문제는 개념적으로 상속 관계에 있더라도 실제 구현할 때는 상속 관계가 아닐 수도 있다는 것을 보여준다. increaseHeight() 같은 기능이 필요하다면 실제로는 상속받아 구현하는 것이 아닌 별개의 타입으로 구현해 줘야 된다.

b. 상위 타입에서 지정한 리턴 값의 범위에 해당하지 않는값 리턴

public class CopyUtil {
  public static void copy(InputStream is, OutputStream out) {
    byte[] data = new byte[512];
    int len = -1;

    while ((len = is.read(data)) != -1) {
      out.write(data, 0 , len);
    }
  }
}

InputStream의 read() 메소드는 스트림 끝에 도달해 더 이상 데이터를 읽어 올 수 없을때 -1을 리턴한다고 정의되어있고 CopyUtil.copy() 메소드는 이 규칙에 따라 is.read()의 리턴 값이 -1이 아닐 때까지 반복해서 데이터를 읽어와 쓴다.

하지만 아래처럼 InputStream을 구현해서 파라미터에 넣으면 어떻게 될까?

public class SatanInputStream implements InputStream {
  public int read(byte[] data) {
    ...
    return 0; //데이터가 없을 때 0을 리턴하도록 구현
  }
}

이렇게 되면 is.read()가 더 읽을 것이 없더라도 0을 반환하기 때문에 while문이 끝나지 않아 CopyUtil.copy() 메소드는 무한 루프 상태에 빠지게 된다. 여기서도 이런 문제가 생기는 이유는 하위 타입인 SatanInputStream이 상위 타입인 InputStream을 올바르게 대체하지 않았기 때문이다.

리스코프 치환 원칙 정리

리스코프 치환 원칙은 기능의 명세에 대한 내용이다. 하위 타입 구현이 명세에서 벗어나게 되면 비정상적으로 동작할 수 있기 때문에 상위 타입에서 정의한 명세를 벗어나지 않는 범위에서 구현해야 한다.

위반 사례로는 주로 다음 것들이 있다.

  • 명시된 명세에서 벗어난 값을 리턴
  • 명시된 명세에서 벗어난 익셉션을 발생
  • 명시된 명세에서 벗어난 기능을 수행

4. 인터페이스 분리 원칙(Interface Segregation Principle)

인터페이스 분리 원칙은 클라이언트는 자신이 이용하지 않는 메소드에 의존하지 않아야 된다는 원칙이다. 이 원칙은 C나 C++ 같이 컴파일과 링크를 직접 해주는 언어를 사용할 때 장점이 잘 드러나기 때문에 C++로 예를 들어보자.

인터페이스 분리 원칙을 지키지 않았을 때

아래와 같은 기능을 제공하는 ArticleService 클래스를 구현할 때 헤더 파일인 ArticleService.h 파일에 클래스의 인터페이스 명세가 코딩되고, ArticleService.cpp 파일에는 구현이 코딩된다.

최종 실행 파일을 만들려면 각각의 UI와 ArticleService.cpp를 컴파일한 결과 오브젝트 파일을 만들어내고, 그 오브젝트 파일들을 링크하게 된다. 그 과정은 간략하게 다음과 같다.

그런데 만약에 기능 중 게시글 목록 읽기와 관련된 함수의 변경이 발생하게 되었다고 가정해보자. 우선 변경이 일어난 부분인 ArticleService.h, ArticleService.cpp, 게시글 목록 UI 파일에 변경을 반영한 뒤에 컴파일하여 다시 오브젝트 파일을 생성하게 될 것이다.

하지만 이것만 변경되는 것이 아니고 ArticleService.h 파일이 변경되었기 때문에 이 헤더 파일을 사용하는 게시글 작성 UI와 게시글 삭제 UI의 소스 코드도 다시 컴파일하여 오브젝트 파일을 만들어 주어야 한다. 즉, 변경이 없는 파일들도 재컴파일 해주어야 되는 불필요한 상황이 발생한 것이다.

만약에 다음과 같이 인터페이스들을 분리했다면?

각각의 기능은 개별적인 인터페이스에 의존하고 있기 때문에 ArticleListService.h 인터페이스에 변경이 발생하더라도 게시글 목록 UI만 영향을 받고 나머지는 영향을 받지 않는다.

물론 C++이 아닌 자바 언어를 사용하고 있다면 자바 가상 머신이 동적으로 링크 과정을 해주기 때문에 위와 같은 소스 재컴파일 문제는 발생하지 않는다. 하지만 인터페이스 분리 원칙이 재컴파일 문제만 관련 있는 것은 아니다.

적절한 인터페이스 분리는 단일 책임 원칙과도 연결되는데 하나의 타입에 여러 기능이 있을 경우 한 기능 변화로 다른 기능이 영향을 받을 가능성이 높아진다. 따라서 사용하는 기능만 제공하도록 인터페이스를 분리함으로써 한 기능에 대한 변경의 여파를 최소화할 수도 있다.

5. 의존 역전 원칙(Dependency Inversion Principle)

의존 역전 원칙은 고수준 모듈이 저수준 모듈의 구현에 의존해서는 안 되고 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 된다는 것이다. 여기서 말하는 고수준 모듈이란 어떤 의미 있는 단일 기능을 제공하는 모듈이고, 저수준 모듈은 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현이라 할 수 있다.

고수준 모듈이 저수준 모듈에 의존할 때 문제

상품의 가격을 결정하는 정책을 생각해 보면 고수준 모듈로 다음과 같이 나올 수 있다.

  • 쿠폰을 적용해서 가격 할인을 받을 수 있다.
  • 쿠폰은 동시에 한 개만 적용 가능하다.

저수준 모듈로 들어가 보면 일정 금액 할인 쿠폰, 비율 할인 쿠폰 등 다양한 쿠폰이 존재할 수 있다. 여기서 쿠폰을 이용한 가격 계산 모듈(고수준)이 개별적인 쿠폰(저수준) 구현에 의존하게 되면 아래처럼 새로운 쿠폰 구현이 추가되거나 변경될 때마다 가격 계산 모듈이 변경되는 상황이 발생한다.

public int calculate() {
  ...
  if (someCondition) {
    CouponType1 type1 = ...
  } else {
    // 쿠폰2 추가에 따라
    // 가격 계산 모듈 변경
    CouponType2 type2 = ...
    ...
  }

}

이런 상황은 프로그램 변경을 어렵게 만든다. 우리가 원하는 것은 저수준 모듈이 변경되더라도 고수준 모듈은 변경되지 않는 것인데 이것이 바로 의존 역전 원칙이다.

의존 역전 원칙을 통한 변경의 유연함

의존 역전 원칙은 방금과 같은 문제를 역으로 뒤집어 저수준 모듈이 고수준 모듈을 의존하게 만들어 해결한다. 어떻게 가능할까? 바로 다음과 같이 추상화를 통해 가능하다.

원래 FlowController(고수준 모듈)는 FileDataReader(저수준 모듈)를 의존하고 있었으나 ByteSource로 추상화를 하여 FlowController와 FileDataReader가 모두 추상 타입인 ByteSource에 의존하도록 했다. 즉, 고수준 모듈과 저수준 모듈이 모두 추상 타입에 의존하게 만들어 고수준 모듈의 변경 없이 저수준 모듈을 변경할 수 있는 유연함을 얻게 되었다.


마무리

여기까지 OOP의 5가지 원칙인 SOLID에 대해 예를 들어가며 살펴보았다. 확실히 정의만 봤을 때는 뭔가 잘 와닿지 않고 추상적이었는데 예를 통해 살펴보니 한층 깊게 알아볼 수 있는 좋은 시간이었다. 추상화와 다형성에 대해 얘기가 많이 나오는데 이 둘을 잘 응용할 수 있는 능력을 길러야 될 필요가 있을 것 같다.


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

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


© 2022. All rights reserved.

Powered by 애송이