본문 바로가기
객체 지향 설계 5원칙(SOLID)

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

by 제너럴종 2023. 5. 10.

리스코프 치환 원칙이란?

자료형 S가 자료형 T의 하위형이라면, 필요한 프로그램의 속성(정확성, 수행하는 업무 등)의 변경 없이 자료형 T의 객체를 자료형 S의 객체로 교체(치환)할 수 있어야 한다는 원칙이다.

즉 자료형 S가 자료형 T의 하위형이라면, 프로그램에서 자료형 T의 객체는 프로그램의 속성을 변경하지 않고 자료형 S의 객체로 교체할 수 있다.

리스코프는 '행동적 하위형'이라는 개념으로 '가변 객체(Mutable Object)의 치환성(Substitutability)'이라는 개념을 정의했다. '행동의 하위형화'는 형 이론에서 인수형의 반공변성과 반환형의 공변성에 의존하여 정의한 일반적 기능의 하위형화보다 더 강한 개념이다.

리스코프 치환 원칙은 추상화 수준이 존재하는 객체 지향 프로그래밍 언어의 메서드 시그니처(Method signature)에 관한 몇 가지 표준적인 요구사항을 강제한다.


어떤 표준적인 요구사항이 있는가?

하위 타입에서 메서드 파라미터의 반공변성

반공변성(Contravariance)

S가 T의 하위형이라면, Class<T> 타입은 Class<S>로 사용함에 문제가 없다.


class Stack<T>{
  ...

  public void popAll(Collection<? super T> dst){
    while(dst.hasNext()){
      push(dst.next());
    }
  }

  ...

}

리스코프 치환 원칙에서 하위 타입에 정의된 메서드 파라미터는 반공변성(Contravariance)을 띄어야 한다.

따라서 popAll 메서드는 T 자기 자신의 타입 혹은 T의 부모 타입으로 구현 된 Collection 객체를 파라미터로 받는다.

C#과 다르게 Java의 제네릭은 기본적으로 불공변(invariant)이므로, 한정적 와일드카드(unbounded wildcard type)를 이용해, 반공변성(contravariance)으로 바꾸었다.

하위 타입에서 리턴 타입의 공변성

공변성(Covariance)

S가 T의 하위형이라면, Class<S> 타입은 Class<T>로 사용함에 문제가 없다.


class Stack<T>{
  ...

  public List<? extends T> pushAll(){
    return new ArrayList<? extends T>(elements);
  }

  ...

}

리스코프 치환 원칙에서 하위 타입에 정의된 메서드의 리턴 타입은 공변성(Covariance)을 띄어야 한다.

따라서 pushAll 메서드는 T 자기 자신의 타입 혹은 T의 자식 타입으로 구현 된 List 타입의 객체를 리턴 한다.

C#과 다르게 Java의 제네릭은 기본적으로 불공변(invariant)이므로, 한정적 와일드카드(unbounded wildcard type)를 이용해, 공변성(Covariance)으로 바꾸었다.

단 Java에서 메서드의 리턴 타입은 한정적 와일드카드가 되어선 안된다.  
클라이언트에서 리턴 타입을 받기 위한 객체 또한 한정적 와일드카드 타입으로 받아야 하기 때문이다.

따라서 "하위 타입에서 리턴 타입의 공변성" 을 제대로 증명하기 위해선,
공변성과 반공변성을 개발자가 조작할 수 있는 C# 언어가 적합하다.

하위 타입의 메서드는 상위 타입의 메서드에서 던질 수 있는 예외의 추상화 계층 수준보다 낮은 수준의 예외가 아닌 새로운 예외를 던지면 안된다.


public interface BluetoothSupport<T> {
  public static final String BLUETOOTH_VERSION = "BT5.1";
  public static final String BLUETOOTH_PROTOCOL = "HID";
  public static final int BLUETOOTH_DISTANCE = 75;

  T pairing() throws IOException;
  void unpair();

  default T autoPairing() throws IOException, IllegalAccessException {
    throw new UnsupportedOperationException();
  }
}

class MyMouse implements BluetoothSupport<MyMouse>{
  @Override
  public MyMouse pairing() throws IOException {
    String protocol;
    boolean isPairing = false;

    ...

    if(!isPairing) {      
      throw new InterruptedIOException();
    }

    if(protocol.equalsIgnoreCase(BLUETOOTH_PROTOCOL)) {
      throw new ProtocolException();
    }

    ...
  }

  ...
}

예컨데 ProtocolException과 InterruptedIOException은 IOException의 하위 예외이기 때문에 위와 같은 코드는 문제가 되지 않는다.

그러나 MyMouse의 pairing 메서드에서 ClassNotFoundException와 같이 IOException의 하위 타입이 아닌 예외를 던지려 하면 컴파일 에러가 발생한다.

이는 자바 문법이 상위 타입의 메서드에서 던질 수 있는 수준의 예외보다 낮은 추상화 계층 수준의 예외을 제외하고 새로운 예외를 던지면 안된다는 리스코프 치환 원칙을 준수하기 때문이다.


하위 타입에서 만족해야 하는 추가 조건


class Car{
  private static final int MAX_OIL_VALUE = 1000;
  protected int oilValue;
  //지붕을 접거나 펼 수 있는 자동차 외형인지의 여부
  protected boolean convertible = false;

  Car(int oilValue){
    if(oilValue <= 0 && oilValue > MAX_OIL_VALUE){ 
      throw new IllegalArgumentException();
    }
    this.oilValue = oilValue;
  }

  public void setConvertible(boolean convertible){
    this.convertible = Objects.requireNonNull(convertible);
  }

  public void setOilValue(int oilValue){
      if(oilValue <= 0 && oilValue > MAX_OIL_VALUE){
      this.oilValue = oilValue;
    }
  }

  public int getOilValue(){
    if(OilValue >= 0){
      return OilValue;
    }

    throw new IllegalStateException();
  }
}

class OpenCar extends Car{
  OpenCar(int oilValue){
    super(oilValue);
    this.convertible = true;
  }

  @Override
  public void setOilValue(int oilValue){
    this.oilValue = oilValue;
  }

  @Override
  public int getOilValue(){
    return OilValue;
  }
}

하위형에서 선행조건은 강화될 수 없다.

선행조건

함수(메서드)가 오류 없이 실행되기 위한 모든 조건을 정의한 것

상위 클래스인 Car 객체의 선행 조건은 아래와 같다.

oilValue의 값이 0 이하가 되거나, MAX_OIL_VALUE를 넘어서는 안된다.
oilValue <= 0 && oilValue > MAX_OIL_VALUE

그러나 하위 클래스인 OpenCar 객체의 선행 조건은 아래와 같다.

oilValue의 값이 0 미만이 되거나, MAX_OIL_VALUE를 넘어서는 안된다.
oilValue < 0 && oilValue > MAX_OIL_VALUE

OpenCar 객체의 oilValue 값은 0을 포함 시킬수 없다는 새로운 조건이 추가 됨으로써, 상위 객체인 Car 객체보다 선행조건이 더욱 강화되었다.

기존의 Car 객체를 사용하던 클라이언트는 이런 변화에 당황할 것이다.

하위형에서 후행조건은 약화될 수 없다.

후행조건

함수(메서드)가 호출된 후에 객체(값)의 유효성과 일관성을 검사하는 것

상위 클래스인 Car 객체의 후행 조건은 아래와 같다.

oilValue이 0 이상인 경우에만, 값을 리턴한다.
OilValue >= 0

그러나 하위 클래스인 OpenCar 객체의 후행조건은 없으므로, 상위 객체인 Car 객체보다 후행조건이 약화된 것이다.

OpenCar 객체의 oilValue 값은 음수가 될 수 없지만, 만약 객체의 일관성이 무너진 상태라면 음수값이 될 수도 있다. 이 경우 oilValue값을 기대한 클라이언트의 코드 마저 망가뜨릴 수 있다.

하위형에서 상위형의 불변조건은 반드시 유지되어야 한다.

Car 객체는 아래와 같은 식을 항상 참으로 유지해야 한다.

oilValue의 값이 0 미만이 되거나, MAX_OIL_VALUE를 넘어서는 안된다.

그러나 OpenCar 객체의 재정의된 setOilValue 메서드를 호출하는 것 만으로도, 상위 객체인 Car 객체의 불변식을 무너뜨린다.


전형적인 리스코프 치환 원칙 위반

초등학교 수학시간에 배운 여러가지 유형의 사각형들이다. 그리고 우린 선생님께 정사각형은 직사각형으로 부를 수 있냐는 질문에 "네" 라고 배웠을 것이다. 하지만 실제 직사각형 객체와 정사각형 객체를 상속을 이용한 계층 구조로 설계하는 것은 위험하다.

우리는 네 각의 크기가 같은 사각형을 직사각형, 네 변의 길이가 같으면서 네 각의 크기가 같은 사각형을 정사각형 이라 배웠다. 그러나 정사각형이 직사각형의 하위 타입이라면, 실제 코드 레벨에서 정사각형과 직사각형이 서로의 불변식을 훼손하기 쉽다는 것을 알게 된다.

답은 간단하다.

정사각형과 직사각형의 공통된 부분은 둘 다 사각형 이라는 점이다. 즉 사각형 이란 추상 클래스를 만들는 일반화(generalization) 과정을 거치면 된다.


Reference

개발하는 피자 양목장
https://pizzasheepsdev.tistory.com/9

위키피디아
https://ko.wikipedia.org/wiki/%EB%A6%AC%EC%8A%A4%EC%BD%94%ED%94%84_%EC%B9%98%ED%99%98_%EC%9B%90%EC%B9%99

수학방
https://mathbang.net/151

Roseline Blog
https://roseline.oopy.io/dev/what-is-variance

댓글