넘치게 채우기

3-3. LSP: 리스코프 치환 원칙(Liskov Substitution Principle) 본문

개발/Clean Architecture

3-3. LSP: 리스코프 치환 원칙(Liskov Substitution Principle)

riveroverflow 2023. 11. 11. 01:23
728x90
반응형

1988년 바바라 리스코프(Barbara Liskov)는 하위 타입(subtype)을 아래와 같이 정의했다:

 

“여기에서 필요한 것은 다음과 같은 치환(substitution) 원칙이다. S 타입의 객체 o1 각각에 대응하는 T타입 객체 o2가 있고, T타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 행위가 변하지 않는다면, S는 T의 하위 타입이다:"

 

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

그림 9.1과 같이 License라는 클래스가 있다고 해보자. 이 클래스는 calcFee()라는 메서드를 가지며, Billing 애플리케이션에서 이 메서드를 호출한다.

License에는 PersonalLicenseBusinessLicense라는 두 가지 ‘하위 타입’이 존재한다.

이들 두 하위 타입은 서로 다른 알고리즘을 이용해서 라이선스 비용을 계산한다.

 

그림 9.1: License와 파생 클래스는 LSP를 준수한다.

 

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

 

정사각형 / 직사각형 문제

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

 

그림 9.2: 악명 높은 정사각형 / 직사각형 문제

 

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

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

 

아래의 코드를 보면 혼동의 이유가 분명해진다.

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

… 코드에서 Square를 생성한다면 assert문은 실패하게 된다.

이런 형태의 LSP 위반을 막기 위한 유일한 방법은 (if문을 이용해서) Rectangle이 실제로는 Square인지를 검사하는 메커니즘을 User에 추가하는 것이다.

하지만 이렇게 하면 User의 행위가 사용하는 타입에 의존하게 되므로, 결국 타입을 서로 치환할 수 없게 된다.

 

LSP와 아키텍처

객체 지향이 혁명처럼 등장한 초창기에는 앞서 본 것처럼 LSP는 상속을 사용 하도록 가이드하는 방법 정도로 간주되었다.

하지만 시간이 지나면서 LSP는 인터페이스와 구현체에도 적용되는 더 광범위한 소프트웨어 설계 원칙으로 변모해 왔다.

여기에서 말하는 인터페이스는 다양한 형태로 나타난다.

 

자바스러운 언어라면 인터페이스 하나와 이를 구현하는 여러 개의 클래스로 구성된다.

루비라면 동일한 메서드 시그니처를 공유하는 여러 개의 클래스로 구성된다.

또는 동일한 REST 인터페이스에 응답하는 서비스 집단일 수도 있다.

 

이상의 모든 상황은 물론 더 많은 경우에 LSP를 적용할 수 있다.

잘 정의된 인터페이스와 그 인터페이스의 구현체끼리의 상호 치환 가능성에 기대는 사용자들이 존재하기 때문이다.

 

LSP 위배 사례

다양한 택시 파견 서비스를 통합하는 애플리케이션을 만들고 있다고 해보자.

고객은 어느 택시업체인지는 신경쓰지 않고 자신의 상황에 가장 적합한 택시를 찾는다.

고객이 이용할 택시를 결정하면 시스템은 REST 서비스를 통해 선택된 택시를 고객 위치로 파견한다.

 

택시 파견 REST 서비스의 URI가 운전기사 데이터베이스에 저장되어 있다고 가정해 보자.

시스템이 고객에게 알맞은 기사를 선택하면, 해당 기사의 레코드로부터 URI 정보를 얻은 다음, 그 URI 정보를 이용하여 해당 기사를 고객 위치로 파견한다.

예를 들어 택시기사인 Bob의 택시 파견 URI는 다음과 같다.

purplecab.com/driver/Bob

 

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

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

이 예제에서 분명한 점은 파견 서비스를 만들 때 다양한 택시업체에서 동일한 REST 인터페이스를 반드시 준수하도록 만들어야 한다는 사실이다.

 

서로 다른 택시업체가 pickupAddress, pickupTime, destination 필드를 모두 동일한 방식으로 처리해야 한다.

이제 택시업체 ACME에서 프로그래머를 몇 명 고용했는데, 이들이 서비스 사양서를 신중히 읽지 않았다고 해보자.

  1. 그래서 destination 필드를 dest로 축약했다.
  2. 그런데 AMCE는 지역에서 가장 큰 택시업체일 뿐만 아니라, 대표의 전처는 우리 회사 시스템 대표의 아내가 되었다.
  3. 이제 무슨일이 일어날까?

뻔한 일이지만, 우리는 이 예외 사항을 처리하는 로직을 추가해야 할 것이다.

애크미 소속 기사를 파견하는 요청은 나머지 업체의 기사를 파견할 때와는 다른 규칙을 이용하여 구성해야만 한다.

이를 위한 가장 간단한 방법은 파견 명령어를 구성하는 모듈에 if문장을 추가하는 것이다.

if (driver.getDispatchUri().startsWith("acme.com")) ...

하지만 실력 있는 아키텍트라면 이 상황을 용납하지 않는다.

이는 온갖 종류의 에러가 발생할 여지를 만들게 한다.

미래를 더 혼란스럽게 할 수도 있다.

 

나중에 애크미가 지금보다 더 성장해서 다른 택시업체인 퍼플을 인수한다면 어떻게 될까?

그러면 또 다른 if문을 추가할 것인가?

 

아키텍트는 이 같은 버그로부터 시스템을 격리해야 한다.

이때 파견 URI를 Key로 사용하는 설정용 데이터베이스를 이용하는 파견 명령 생성 모듈을 만들어야 할 수도 있다.

설정 정보는 대체로 아래와 같을 것이다.

 

URI Dispatch Format
Acme.com /pickupAddress/%s/pickupTime/%s/dest/%s
. /pickupAddress/%s/pickupTime/%s/destination/%s

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

728x90
반응형