앞서 OCP에 대해 알아보았다. 자신의 확장에는 열려 있고, 주변의 변화에 대해서는 닫혀 있어야 한다. 즉, 추상화를 통해 공통 기능을 모델링하여 해당 인터페이스/추상클래스로 접근한다. 여기서 추상화를 적용시킨 클래스가 많아지더라도 기존 코드에는 영향이 없고, 또한 클라이언트 단에서는 이렇게 추가로 설계된 클래스를 적용할 수도 있다. 이번에는 LSP, 리스코프 치환 원칙에 대해 알아보기로 한다.

LSP - Liskov Substitution Principle

Child classes should never break the parent clsss’ type definitions. 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 작동해야 한다. 이런 리스코프 치환 원칙은 OCP를 받쳐주는 다형성에 관한 원칙을 제공한다.

잘못된 예시와 개선 사례 (1)

리스코프 치환 원칙을 지키지 않는 가장 대표적인 예가 ‘직사각형-정사각형 문제’이다.
상위 클래스타입에서 의도했던 대로 구현이 되지 않을 경우이다.

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

  public void setWidth(int width){
    this.width = width;
  }
  public void setHeight(int height){
    this.height = hight;
  }
  public int getWidth(){
    return width;
  }
  public int getHeight(){
    return height;
  }
}

정사각형을 직사각형의 특수한 형태로 보고, 직사각형을 상속받는 형태로 구현했다. 정사각형은 가로, 세로 길이가 모두 동일하기에 setWidth()setHeight() 메소드를 재정의하여 가로, 세로 값이 일치하도록 구현했다.

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()Rectangle클래스의 인스턴스를 받게 되어, 만일 너비가 높이보다 크거나 같으면 높이를 10 추가하는 메소드이다. 하지만 Rectangle 클래스를 상속받은 Square클래스는 내부 오버라이딩된 메소드로 인해, 너비와 높이가 동시에 10씩 증가되어 의도했던대로 작동하지 않는다. 이 경우에는 아예 Square 클래스를 이용하지 않고 Rectangle만 이용해주는 것이 맞다.

public void increaseHeight(Rectangle rec){
  if(rec instanceof Square)
      throw new CantSupportSquareException();

  if(rec.getHeight() <= rec.getWidth())
    rec.setHeight(rec.getWidth() + 10);

}

물론 이 문제를 해결하기 위해 rec의 실제 타입이 Square일 경우 예외를 발생시킬 수 있을 것이다. 하지만, 타입을 확인하는 기능을 사용한다는 것은 클라이언트가 상위 타입만을 사용해서 프로그래밍 할 수 없다는 것을 뜻하며, 이는 하위 타입이 상위 타입을 대체할 수 없다는 것을 의미한다. instanceof 연산자를 사용한다는 것 자체가 리스코프 치환 원칙 위반이 된다. 즉,이는 increaseHeight()메소드가 Rectangle의 확장에 열려있지 않다는 것을 뜻한다.

잘못된 예시와 개선 사례 (2)

또 한가지 리스코프 치환 원칙을 어기는 사례는 상위 타입에서 지정한 리턴 값의 범위에 해당되지 않는 값을 리턴하는 것이다.

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

    //InputStream.read() 메서드는 스트림의 끝에 도달하면 -1을 리턴
    while((len = is.read(data)) != -1){
      out.write(data,0,len);
    }
  }
}

위와 같이 입력 스트림으로부터 데이터를 읽어와 출력 스트림에 복사해주는 copy()메소드가 있다. 메소드를 보면 알 수 있듯이, 인풋스트림의 끝에 도달하여 데이터를 더이상 읽을 수 없을 경우 -1를 리턴한다. 하지만 만약 InputStreamread()메소드가 -1을 반환하지 않는다면?

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

위 코드에서 InputStream을 상속하는 SatanInputStream은 데이터를 끝까지 읽어 더이상 존재하지 않을 경우 0을 반환한다. 이렇게 구현할 경우, SatanInputStreamcopy()메소드의 매개변수로 이용하게 된다면 -1을 리턴하지 않기 때문에 무한루프에 빠지게 된다.

이러한 문제가 발생하는 이유는, SatanInputStream, 즉 하위클래스가 상위클래스인 InputStream을 올바르게 대체하지 않았기 때문이다. 즉, 리스코프 치환 원칙을 지키지 않았기 때문에 문제가 발생하였다.

결론

리스코프 치환 원칙은 기능의 명세와 확장에 대한 것이다. 위 예시에서도 볼 수 있듯이 주로 아래 3가지 케이스를 통해 리스코프 치환을 위반한다.

  • 명시된 명세에서 벗어난 값을 리턴한다(InputStream문제)
  • 명시된 명세에서 벗어난 익셉션을 리턴한다.(정사각형-직사각형 문제)
  • 명시된 명세에서 벗어난 기능을 수행한다.(정사각형-직사각형 문제)

리스코프 치환 원칙이 지켜지지 않으면 개방 폐쇄 원칙을 위반하게 된다. 이는 곧 기능 확장을 위해 더 많은 부분을 수정해야 한다는 것을 의미한다.


참고 자료

  1. 스프링 입문을 위한 객체 지향의 원리와 이해
  2. [SOLID] 리스코프 치환 원칙(LSP)이란?
  3. 객체지향과 디자인패턴
  4. 개발자가 정복해야 할 객체지향-4

oksusutea's blog

꾸준히 기록하려고 만든 블로그