블로그명..?

클린 아키텍처(Clean Architecture) - 9장 LSP: 리스코프 치환 원칙 본문

CleanArchitecture

클린 아키텍처(Clean Architecture) - 9장 LSP: 리스코프 치환 원칙

bs_choi 2021. 3. 19. 22:55

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

 

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

 

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

 

 

License와 파생 클랫스는 LSP를 준수한다.

 

위 License라는 클래스는 calcFee()라는 메서드를 가지며, Billing 애플리케이션에서 이 메서드를 호출한다. License에는 PersonalLicense와 BusinessLicense라는 두 가지 '하위 타입'이 존재한다. 이들 두 하위 타입은 서로 다른 알고리즘을 이용해서 라이선스 비용을 계산한다.

 

위 설계는 LSP를 준수한다.

Billing 애플리케이션의 행위가 License 하위 타입 중 무엇을 사용하는지에 전혀 의존하지 않기 때문이다.

이들 하위 타입은 모두 License 타입을 치환할 수 있다.

 

정사각형/직사각형 문제

 

악명 높은 정사각형/직사각형 문제

 

이 예제에서 Square는 Rectangle의 하위 타입으로는 적합하지 않다.

Rectangle의 높이와 너비는 서로 독립적으로 변경될 수 있는 반면, Square의 높이와 너비는 반드시 함께 변경되기 때문이다.

 

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

 

아래의 코드를 보자

 

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

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

 

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

 

하지만 그럴 경우에는 User의 행위가 사용하는 타입에 의존하게 되므로, 서로 치환할 수 없게 된다.

-> User에 Rentangle과 Square라는 타입에 따라 바뀌는 행위를 정의함으로써 Rentangle과 Square를 치환할 경우 정상적인 동작이 불가능해진다.

 

LSP와 아키텍처

앞서 본 것처럼 LSP는 상속을 사용하도록 가이드하는 방법 정도로 가이드되었다.

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

 

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

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

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

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

 

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

 

LSP 위배 사례

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

 

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

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

 

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

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

 

purplecab.com/driver/Bob

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

 

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

이 예제에서 분명한 점은 파견 서비스를 만들 때 다양한 택시업체에서 동일한 REST 인터페이스를 반드시 준수하도록 만들어야 한다는 사실이다. 서로 다른 택시업체가 pickupAddress, pickupTime, destination 필드를 모두 동일한 방식으로 처리해야 한다.

 

택시업체 A의 프로그래머들이 destination 필드를 dest로 축약해서 사용했다고 해보자.

A는 이 지역에서 가장 큰 택시업체이다. 그리고 가장 영향력 높은 회사이다.

이럴 경우 우리 회사의 시스템 아키텍처에는 무슨 일이 벌어질까?

 

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

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

 

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

위와 같은 if 문을 추가하여 예외처리를 해야 한다.

 

하지만 "acme"라는 단어를 코드 자체에 추가하는 것은 그 자체로 끔찍할 뿐만 아니라 이해할 수 없는 온갖 종류의 에러가 발생할 확률이 높아진다.

 

예를 들어 A사가 P사를 인수한다면 어떻게 될까?

합병된 P사의 브랜드와 웹 사이트는 A와는 독립적으로 유지하되, 회사 시스템은 통합할 경우 P를 위해 "perple"을 처리하는 또 다른 if문을 추가해야 할 것이다.

 

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

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

 

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

 

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

 

결론

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

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

Comments