Study/객체지향

LSP: 리스코프 치환 원칙

voider 2021. 7. 13. 21:16

1988년 바바라 리스코프는 하위 타입을 아래와 같이 정의했다.

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

리스코프 치환 원칙으로 알려진 이 개념을 이해하기 위한 몇 가지 예제가 있다.

상속을 사용하도록 가이드

사진1과 같이 License라는 클래스가 있다고 가정하자. 이 클래스는 calcFee()라는 메서드를 가지며, Billing 애플리케이션에서 이 메서드를 호출한다. License에는 PersonalLicenseBusinessLicense라는 두 가지 하위 타입이 존재한다. 이들 두 하위타입은 서로 다른 알고리즘을 이용해서 라이선스 비용을 계산한다.

이 설계는 LSP를 준수한다. Builling 애플리케이션의 행위가 License 하위 타입 중 무엇을 사용하는지 전혀 의존하지 않기 때문이다. 이들 하위 타입은 모두 License 타입을 치환할 수 있다.

정사각형/직사각형 문제

LSP를 위반하는 전형적인 문제로, 유명한 정사각형/직사각형 문제가 있다.

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/f15cc173-32fd-4437-b36a-ce1b9fc3c471/_2021-07-07__6.15.18.png

이 예제에서 SquareRectangle의 하위 타입으로 적합하지 않다. Rectangle의 높이와 너비는 서로 독립적으로 변경될 수 있는 반면 Square의 높이와 너비는 항상 함께 변경되기 때문이다. User는 대화하고 있는 상대가 Rectangle이라고 생각하므로 혼동이 생길 수 있다. 아래의 코드를 보면 혼동하는 이유가 분명해진다.

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

... 코드에서 Square를 생성한다면 assert문은 실패한다. 이런 형태의 LSP 위반을 막기 위한 유일한 방법은 if문 등을 이용하여 Rectangle이 실제로는 Square인지 검사하는 매커니즘을 User에 추가하는 것이다. 하지만 이렇게 하면 User의 행위가 사용하는 타입에 의존하게 되므로 결국 타입을 치환할 수 없게 된다.

LSP와 아키텍처

객체지향 패러다임을 프로그래밍에 적용한 지 얼마 되지 않았을 때는 앞서 본 것처럼 LSP는 상속을 사용하도록 가이드하는 방법 정도로 간주되었다. 하지만 시간이 지나면서 LSP는 인터페이스와 구현체에도 적용되는 더 광범위한 소프트웨어 설계 원칙으로 변모했다.

여기서 말하는 인터페이스는 다양한 형태로 나타난다. 자바스러운 언어라면 인터페이스 하나와 이를 구현하는 여러 클래스로 구성된다. 또는 동일한 REST 인터페이스에 응답하는 서비스 집단일 수도 있다.

이상의 모든 상황은 물론, 더 많은 경우에 LSP를 적용할 수 있다. 잘 정의된 인터페이스와 그 인터페이스의 구현체끼리 상호 치환 가능성에 기대는 사용자들이 존재하기 때문이다.

아키텍처 관점에서 LSP를 이해하는 최선의 방법은 이 원칙을 어겼을 때 시스템 아키텍처에서 무슨 일이 일어나는지 관찰하는 것이다.

LSP 위반 사례

다양한 택시 파견taxi dispatch 서비스를 통합하는 애플리케이션을 만들고 있다거 해보자. 고객은 어느 택시 업체인지는 신경쓰지 않고 자신의 상황에 가장 적합한 택시를 찾는다. 고객이 이용할 택시를 결정하면, 시스템은 REST 서비스를 통해 선택된 택시를 고객 위치로 파견한다. 택시 파견 REST 서비스의 URI가 운전기사 데이터베이스에 저장되어 있다고 가정하자. 시스템이 고객에게 알맞은 기사를 선택하면, 해당 기사의 레코드로부터 URI 정보를 얻은 다음, 그 URI 정보를 이용하여 해당 기사를 고객 위치로 파견한다. 예를 들어 택시기사 밥Bob의 택시 파견 URI는 다음과 같다.

purplecab.com/driver/Bob

시스템은 이 URI에 파견에 필요한 정보를 덧붙인 후, 아래와 같이 PUT 방식으로 호출한다.

purplecab.com/driver/Bob
    /pickupAddress/24MapleSt
    /pickupTime/153
    /destination/ORD

이 예제에서 분명한 점은 파견 서비스를 만들 때 다양한 택시업체에서 동일한 REST 인터페이스를 반드시 준수하도록 만들어야 한다는 사실이다. 서로 다른 택시업체가 pickupAddress, pickupTime, destination 필드를 모두 동일한 방식으로 처리해야 한다. 이제 택시업체 애크미ACME에서 프로그래머를 몇 명 고용했는데, 이들이 서비스 사양서를 그다지 신중하게 읽지 않았다고 하자. 그래서 destination 필드를 dest로 축약해서 사용했다. 하필이면 애크미는 가장 크고 영향력있는 택시회사다. 이때 시스템에서는 어떤 일이 벌어질까?

이 예외를 처리하는 가장 간단한 로직은 파견 명령어를 구성하는 모듈에 if문을 덧대는 것이다.

if (driver.getDispatcherUri().startWith("acme.com"))...

하지만 실력 있는 아키텍트는 시스템을 이런 식으로 구성하는 것을 용납하지 않는다. "acme"라는 단어를 코드 자체에 추가하면 끔찍할 뿐만 아니라 이해할 수도 없는 온갖 종류의 에러가 발생할 여지를 만들게 한다.

이를 테면 애크미가 지금보다 더 성장해서 다른 택시업체 퍼플을 인수한다면? 합명된 퍼블이 브랜드와 웹 사이트는 애크미와는 독립적으로 유지하되, 회사 시스템은 모두 통합한다면? 퍼플을 위해 "purple"을 처리하는 또 다른 if 문을 추가해야 할까?

아키텍트는 이 같은 버그로부터 시스템을 격리해야 한다. 이때 파견 URI를 key로 사용하는 설정용 데이터베이스를 이용하는 것은 파견 명령 생성 모듈을 만들어야 할 수도 있다. 설정 정보는 대체로 아래와 같을 것이다.

또한 아키텍트는 REST 서비스의 인터페이스가 서로 치환 가능하지 않다는 사실을 처리하는 중요하고 복잡한 매커니즘을 추가해야 한다.

결론

LSP는 아키텍처 수준까지 확장할 수 있고, 반드시 확장해야 한다. 치환 가능성을 조금이라도 위배하면 시스템 아키텍처가 오염되어 상당량의 별도 매커니즘을 추가해야 할 수 있기 때문이다.

'Study > 객체지향' 카테고리의 다른 글

DIP: 의존성 역전 원칙  (0) 2021.07.31
ISP: 인터페이스 분리 원칙  (0) 2021.07.31
OCP: 개방 폐쇄 원칙  (0) 2021.07.10
SRP: 단일 책임 원칙  (0) 2021.07.07