Featured image of post 9. LSP: 리스코프 치환 원칙

9. LSP: 리스코프 치환 원칙

3부 - 설계 원칙

하위 타입(Subtype)
S타입의 객체 o1 각각에 대응하는 T타입 객체 o2가 있고, T타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면, ST의 하위 타입이다.

상속을 사용하도록 가이드하기

classDiagram
    class Billing {
        
    }
    class License {
        << interface >>
        calcFee()
    }
    class PersonalLicense {
        
    }
    class BusinessLicense {
        users
    }
    
    Billing --> License
    License <|-- PersonalLicense
    License <|-- BusinessLicense

Billing 애플리케이션의 행위가 License타입 중 무엇을 사용하는지에 전혀 의존하지 않이 때문에, 이들 하위 타입은 모두 License 타입을 치환할 수 있으므로 위 설계는 리스코프 치환 원칙을 준수한다.

정사각형/직사각형 문제

리스코프 치환 원칙을 위반하는 전형적인 문제로 유명한 정사각형/직사각형 문제가 있다.

classDiagram
    direction LR
    class User {
        
    }
    
    class Rectangle {
        H, W
        setH()
        setW()
    }
    
    class Square {
        setSide()
    }
    
    User --> Rectangle
    Rectangle <|-- Square

위 예제에서 Rectangle의 높이와 너비는 서로 독립적으로 변경될 수 있는 반면, Square의 높이와 너비는 반드시 함께 변경되므로 SquareRectangle의 하위 타입으로는 부적합하다.

이러한 경우 User는 대화하고 있는 상대가 Rectangle 이라고 생각하므로 혼동이 생길 수 있다.

1
2
3
4
Rectangle r = ...
r.setW(5);
r.setH(2);
assert(r.area() == 10);

위와 같은 경우 ...Square를 생성한다면(치환한다면), assert문은 실패하게된다.


이러한 형태의 리스코프 치환 원칙 위반을 막기 위한 유일한 방법은 검사하는 메커니즘을 User에 추가하는 것 인데, User의 행위가 사용하는 타입에 의존하게 되므로, 결국 타입을 서로 치환할 수 없게 된다.

LSP와 아키텍처

LSP는 상속을 사용하도록 가이드하는 방법 정도로 간주 되었으나, 시간이 지나면서 LSP는 인터페이스와 구현체에도 적용되는 더 광범위한 소프트웨어 설계 원칙으로 변모해왔다.

인터페이스

위에서 말하는 인터페이스는 여러 의미로 해석 가능하다.

  • 인터페이스 하나와 이를 구현하는 여러 개의 클래스
  • 동일한 메서드 시그니처를 공유하는 여러 개의 클래스
  • 동일한 REST 인터페이스에 응답하는 서비스 집단

잘 정의된 인터페이스와 그 인터페이스의 구현체끼리의 상호 치환 가능성에 기대는 사용자들이 존재하기 때문에 대부분의 상황에서 LSP를 적용할 수 있다.

LSP 위배 사례: 택시 파견 서비스

요구사항

  • 고객이 어느 택시업체인지는 신경쓰지 않고 자신의 상황에 가장 적합한 택시를 찾는다.
  • 택시를 결정하면, 시스템은 REST 서비스를 통해 선택된 택시를 고객 위치로 파견한다.
    • URI가 운전기사 데이터베이스에 저장되어 있다.
    • URI 정보를 이용하여 해당 기사를 고객 위치로 파견한다.
    • ex) Bob의 URI: purplecab.com/driver/Bob
    • 요청 예시
    • 1
      2
      3
      4
      
      purplecab.com/driver/Bob
          /picupAddress/24 Maple St.
          /pickupTime/153
          /destination/ORD
      

이러한 서비스를 만들 때 다양한 택시업체에서 동일한 REST 인터페이스를 반드시 준수하도록 만들어야한다.

만약 택시업체 ACME에서 destination 필드를 dest로 축약해서 사용했다고 가정하면, 해당 예외 사항을 처리하는 로직을 추가해야만 한다.

1
if (driver.getDispatchUri().startWith("acme.com"))...
  • “acme"라는 단어를 코드 자체에 추가하면, 끔찍할 뿐만 아니라 이해할 수도 없는 온갖 종류의 에러가 발생할 여지를 만들게 된다.
  • 새로운 택시업체 추가시 또 다른 if문이 필요할 수 있다.
  • 위와 같은 버그를 방지하기 위해 설정용 데이터베이스를 이용하는 파견 명령 생성 모듈을 만들어야 할 수도 있다.
    URIDispatch Format
    Acme.com/pickupAddress/%s/pickupTime/%s/dest/%s
    *.*/pickupAddress/%s/pickupTime/%s/destination/%s
  • REST 서비스들의 인터페이스가 서로 치환 가능하지 않다는 사실을 처리하는 중요하고 복잡한 매커니즘을 추가해야 한다.

결론

LSP는 아키텍처 수준까지 확장할 수 있고, 반드시 확장해야만 한다.

치환 가능성을 조금이라도 위배하면 시스템 아키텍처가 오염되어 상당량의 별도 메커니즘을 추가해야 할 수 있기 때문이다.