오늘도 어김없이 객체지향의 5대 원칙 중에 리스코프 치환의 원칙을 살펴보도록 하자. 이것만 하면 이제 단일책임원칙만 마무리하면 된다. 리스코프 치환의 원칙이라.. 말은 어렵지만 내용은 이해하는 자체는 어려운 편은 아니다. 5대원칙들 중에 나머지들은 이름만 봐도 대충 감을 잡을 수 있었지만 이 리스코프 치환 원칙은 이름만 보고는 전혀 감이 오지 않는다. 이름만 봐도 거부감이 들어서 맨 나중에 살펴본 내용이다. (그럼 단일책임 부터 했어야 했나..)

그럼 일단 리스코프 치환의 원칙의 정의를 보자.

상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.

라고 하지만 정의를 봐도 전혀 이해가 가지 않는다. 그러니 어서 코드를 보자. 리스코프 치환원칙에서 가장 유명한 직사각형 정삼각형 예제가 있으니 일단 그걸 살펴보도록 하자.

public class Rectangle {
    protected double width;
    protected double height;

    public void setWidth(double width) {
        this.width = width;
    }
    public double getWidth() {
        return this.width;
    }
    public void setHeight(double height) {
        this.height = height;
    }
    public double getHeight() {
        return this.height;
    }
    public double getArea() {
        return this.getWidth() * this.getHeight();
    }
}

간단한 예제이다. 직사각형의 면적 구하는 공식은 가로 * 세로이다. 특별한 내용 없이 아주 완벽한(?) 코드이다.
우리는 다음과 같이 사용할 수 있다.

public class DoWork {

  public void work() {
    Rectangle rectangle = new Square();
    rectangle.setHeight(5);
    rectangle.setWidth(4);
    System.out.println(rectangle.getArea())
  }
}

public class Main {
  public static void main(String[] args) {
    Main main = new Main();
    main.work();
  }
}

문제 없이 우리는 20의 값을 출력할 수 있다. 만약 면적이 20이 아니면 예외를 던지라고 요구사항을 받았다 가정해보자. 그래서 우리는 다음과 같이 코드를 변경하였다.

public class DoWork {
  public void work() {
    Rectangle rectangle = new Rectangle();
    rectangle.setHeight(5);
    rectangle.setWidth(4);
    if(!isCheck(rectangle)) {
      throw new RuntimeException();
    }
  }

  public boolean isCheck(Rectangle rectangle) {
    return rectangle.getArea() == 20;
  }
}

public class Main {
  public static void main(String[] args) {
    Main main = new Main();
    main.work();
  }
}

여기 까지도 문제 없이 우리는 20이 아닐 경우 예외를 던진다. 꽤 시간이 흘러 또 다시 요구사항이 추가되었다. 정사각형을 추가로 만들어 달란 요구사항이다. 그래서 우리는 이렇게 생각 할 수 있다. 직사각형을 상속받아 정사각형을 구현할 수 있지 않을까 하고 구현해보도록 하자.

public class Square extends Rectangle {
    @Override
    public void setWidth(double width) {
        this.width = width;
        this.height = width;
    }
    @Override
    public void setHeight(double height) {
        this.height = height;
        this.width = height;
    }    
}

우리는 별다른 생각없이 setWidth 와 setHeight를 오버라이딩 받았다. 정사각형은 가로 세로가 같으니 동일하게 값을 넣어 주었다. 그리고 나서 다시 실행 코드를 변경해보다.

public class DoWork {
  public void work() {
    Rectangle rectangle = new Square();
    rectangle.setHeight(5);
    rectangle.setWidth(4);
    if(!isCheck(rectangle)) {
      throw new RuntimeException();
    }
  }

  public boolean isCheck(Rectangle rectangle) {
    return rectangle.getArea() == 20;
  }
}

public class Main {
  public static void main(String[] args) {
    Main main = new Main();
    main.work();
  }
}

그리고 나서 우리는 Rectangle 대신에 Square 클래스를 사용 하였다. 하지만 우리는 원하는 결과를 얻지 못하고 예외를 던져버린다. 이것이 바로 리스코프 치환의 원칙을 어겨서 일어난 일이다. 이제야 조금 이해 가지 않는가?

다시 정의를 살펴보면 조금 이해가 될 싶다. 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.

그럼 위의 문제를 어떻게 해결할까? 실제 직사각형과 정사각형 문제는 개념적으로 상속 관계에 있는 것처럼 보이지만 구현에서는 상속 관계가 아닐 수도 있다는 것을 보여 주고 있다. 개념상 정사각형은 높이와 폭이 같은 직사각형이므로 상속을 받는게 괜찮은 방법이라고 생각하지 모르겠지만 실제 프로그램에서의 이둘은 상속 관계로 묶을 수 없다는 것이다. 만약 isCheck() 메서드 같은 기능이 필요하다면 상속보다는 별개의 타입으로 구현해 주는 것이 맞다.

이 경우 말고도 상위 타입에서 지정한 리턴 코드(값) 등을 하위 타입이 상속받아 잘 못된 코드를 리턴한다면 이것 역시 리스코프치환 원칙을 어기는 일이 된다.

예를들자면 어떤 파일을 읽는데 파일의 데이터가 없다면 -1 리턴한다고 가정해보자. (실제로 InputStream 경우에도 -1을 리턴한다)

class FileCopy {
  public void copy(InputStream inputStream) {
    while (inputStream.read(data) != -1){
      // blabla
    }
  }
}

위와 같이 InputStream의 read() 메서드는 파일의 데이터가 없을 경우에 -1을 리턴한다. 하지만 만약에 InputStream의을 상속받아 구현한 하위 클래스가 파일의 데이터가 없을 경우 -1이 아닌 다른 값을 리턴하면 어떻게 될까?

class SomeInputStream extends InputStream {
  @Override
  public int read(byte[] data) throws IOException {
    //blabla
    return 0; //파일의 데이터가 없을 경우
  }
}

이러면 FileCopy의 copy 메서드는 결코 끝나지 않는 무한루프가 돈다. 이와 같은 문제가 발생하는 이유는 하위 클래스가 상위 클래스의 규칙을 지키지 않았기 때문이다. 이제는 조금 이해가 가지 않는가? 이게 바로 리스코프 치환의 원칙을 의미한다. 내용 자체는 그리 어려운 내용은 아닌 듯 싶다. 이름이 왜케 어려운건지..

리스코프 치환의 원칙은 기능의 명세(계약) 에 대한 내용이다. 기능 실행의 계약과 관련해서 흔히 발생하는 위반 사례로는 다음과 같다.

  1. 명시된 명세에서 벗어난 값을 리턴한다.
  2. 명시된 명세에서 벗어난 익셉션을 발생시킨다.
  3. 명시된 명세에서 벗어난 기능을 수행한다.

리턴 값은 0이나 또는 그 이상을 리턴하도록 정의되어 있는데 하위 타입에서 음수값을 리턴한다거나 IOException을 발생시킨다고 했는데 기타 다른 타 Exception을 발생시킨다던가 하는 위반사례들이 있으면 구현한 코드는 비정상적으로 동작할 수 있기에 하위 타입은 상위타입에서 정의한 명세에 벗어나지 않도록 주의해야 한다.

이렇게 오늘은 리스코프 치환의 원칙에 대해서 살펴봤다. 다음에는 마지막으로 단일책임원칙에 대해서 알아보도록 하자!