Java나 C++이 언어에서 추상화와 다형성을 지원하는 방법 중 하나가 상속이다. 이런 상속의 특별한 사용을 규율하는 설계 법칙은 무엇이고 가장 바람직한 상속 계층 구조는 무엇일까? 그리고 OCP를 따르지 않는 계층 구조를 만들게 해버리는 함정에는 무엇이 있을까? 이러한 질문에 답을 해줄 수 있는 원칙이 리프코프 치환 원칙(LSP)이다.
리스코프 치환 원칙은 "서브타입은 그것의 기반 타입으로 치환 가능해야 한다"는 원칙이다. 예를 들어 함수 f가 그 인자로 클래스 B를 받는다고 가정했을 때, B의 파생 클래스 D가 f에게 넘겨져서 f가 잘못된 동작을 한다면 이 경우는 LSP를 위반한 경우인 것이다.
- LSP 위반은 대게 OCP를 위반하는 런타임 타입 정보(RTTI) 사용으로 이어진다.
- LSP 위반은 잠재적인 OCP 위반이다.
- 파생 클래스에서 기반 클래스의 요소 중 사용하지 않는, 소모적인 것들이 있다면 이것은 설계가 잘못되었다는 증거이다.
- 파생 클래스를 만드는 것이 기반 클래스의 변경으로 이어질 때, 대개는 이 설계에 결점이 있음을 의미한다.
- 자체 모순이 없는 설계라고 해서 반드시 모든 사용자에게 모순이 없는 것은 아니다.
- 어떤 모델의 유효성은 오직 그 고객의 관점에서만 표현 가능하다. 그렇기 때문에 이를 미리 예상하고 처리하려면 합리적인 가정 문제에 부딪히게 되는데, 이것을 모두 예상하고 대처하려한다면 시스템은 불필요한 복잡성의 악취를 풍기게 된다. 그렇기에 관련된 취약성의 악취를 맡을 때까지 가장 명백한 LSP 위반을 제외한 나머지의 처리는 연기하는 것이 최선이다.
- 행위야말로 소프트웨어의 모든 것이다.
- 상속 관계를 따질 때, 그 객체를 사용하는 클라이언트가 기대하는 행위 측면에서 각 객체의 행위가 일치하는지를 따져야한다.
- 8번을 생각해볼 때, 클라이언트 입장에서 생각해볼 수 있는 TDD의 우수한 점을 한번 더 알 수 있다.
위에서 말한 합리적인 가정을 명시적으로 만들어 LSP를 강제하는 테크닉을 "계약에 의한 설계(DBC)"라고 한다. DBC에 의하면 어떤 클래스의 작성자는 그 클래스의 계약사항을 명시적으로 정하여야 한다. 또한 메서드를 실행하기 위해서는 사전조건이 참이 되어야하고, 메서드가 완료되고 나면 사후조건이 참이 됨을 보장해야한다. 파생 클래스는 기반 클래스가 받아들일 수 있는 것은 모두 받아들일 수 있어야하고, 기반 클래스의 모든 사후조건도 따라야한다. 이러한 계약은 주석을 이용해 문서화해놓는 방식도 사용하지만 단위 테스트를 작성함으로써도 구체화할 수 있다.
LSP를 위반하였지만 판단을 내려야하는 경우도 있다. 설계를 고쳐서 완벽하게 LSP에 맞는 설계를 만드는 것보다, 다형적인 행위에 있어서 미묘한 결점을 놔두는 것이 좀 더 적절한 경우도 있다. 이럴 경우, 그냥 해당 결점을 감안하고 타협할 수 있다. 이것이 공학적인 균형인 Trade-off이다. 물론 그렇다고 LSP같은 원칙을 가볍게 포기하면 안된다. LSP를 지킨다는 것은 기반 클래스가 사용되는 곳에서 서브클래스가 항상 제대로 동작함을 보장하는 것이기에 이를 포기한다면 각 서브클래스를 개별적으로 다뤄야한다. 이 점을 잘 고려하면서 타협이 더 유리한지 판단해야한다.
LSP를 지키기위한 방식 중 많은 양의 코드가 작성되지 않았을 때 가장 적용하기 편한 설계 수단이 공통 인자 추출 방법이다. 어떤 클래스 집합이 모두 같은 책임을 진다면, 공통 슈퍼클래스를 뽑는다. 그리고 거기서 그 책임을 상속받도록 하는 것이다. 이렇게 하면 해당 공통 슈퍼클래스를 사용하는 사용자는 파생 클래스가 추가되더라도 처리하는 것에 문제를 느끼지 않을 수 있다.
LSP 위반의 단서를 보여주는 간단한 휴리스틱은 아래와 같다.
- 기반 클래스보다 덜한 동작을 하는 파생 클래스는 보통 그 기반 클래스와 치환이 불가능하므로 LSP를 위반한다. (기반 클래스에서 사용되던 메서드를 파생 클래스에서는 필요없어서 비워놓는 경우 -> 퇴화 함수라고 한다.)
- 기반 클래스가 발생시키지 않는 예외를 파생 클래스의 메서드에 추가할 경우 이들은 치환 가능하지 않을 수 있다. 기반 클래스의 사용자는 예외를 기대하지 않을 수 있기 때문이다.
LSP는 OCP를 가능하게 하는 주요 요인 중 하나이다. 이것은 기반 타입을 표현된 모듈을 수정 없이도 확장 가능하게 만들어준다. 그리고 LSP에서 주요하게 다루는 상속과 관련해서 자주 쓰이는 'IS-A'라는 용어는 추가적으로 치환 가능성이라는 개념을 같이 동반하여 생각해야한다. (추가적으로 사실 'IS-A'보다는 'IS-A-PART-OF'가 더 맞는 설명이라고 생각한다.)