바바라 리스코프가 1988년에 내놓은 정의는 형식적이지만 뜻은 단순하다 — S가 T의 하위 타입이라면, T를 기대하는 모든 자리에 S를 넣어도 프로그램은 여전히 옳게 동작해야 한다. 마틴이 이 원칙을 아키텍처의 책에 넣은 이유는 상속 문법을 잘 쓰라는 훈수 때문이 아니다. 치환 가능성이 무너지는 자리마다 시스템은 그 예외를 메우려 조건문을 자라나게 하고, 그 조건문이 곧 아키텍처를 좀먹기 때문이다. 이 장은 “무엇을 상속할 수 있는가”라는 물음을 “무엇으로 치환할 수 있는가”로 바꾼다 — 그리고 그 둘이 같지 않다는 것을 보인다.

상속은 허가이고 치환은 계약이다

컴파일러는 관대하다. Square extends Rectangle이라고 쓰면 문법은 통과한다. 정사각형은 직사각형의 일종이라는 학교 기하학의 상식도 이 선언을 거든다. 그러나 컴파일러가 허가한 것은 상속이지 치환이 아니다. 치환이 성립하려면, 직사각형을 다루도록 짜인 코드가 그 자리에 정사각형이 들어와도 자기가 믿던 규칙을 배신당하지 않아야 한다.

문제는 직사각형을 다루는 코드가 하나의 믿음을 갖는다는 데 있다. 너비를 바꿔도 높이는 그대로다 — 이것은 직사각형이라는 개념에 붙은 암묵적 계약이다. 정사각형은 이 계약을 지킬 수 없다. 너비를 5로 바꾸면 높이도 5가 되어야 정사각형이니까. 코드가 믿던 “너비와 높이는 독립적으로 움직인다”는 규칙이, 정사각형이 들어오는 순간 소리 없이 깨진다. 상속은 is-a를 문법으로 선언하지만, 치환은 is-a행동의 계약 수준에서 참인지를 묻는다. 정사각형은 직사각형”이지만”, 직사각형처럼 행동하지는 않는다.

판단 기준: B extends A가 옳은지 판단할 때, “B는 A의 일종인가”를 묻지 말고 “A를 쓰는 기존 코드가 믿고 있는 규칙을 B가 하나도 어기지 않는가”를 물어라. 함정: 현실 세계의 분류(정사각형 ⊂ 직사각형)를 코드의 치환 가능성으로 곧장 옮기는 것 — 개념의 포함 관계와 행동의 계약은 다른 축이며, 자주 어긋난다.

정사각형이 직사각형을 배신하는 순간

말로는 추상적이다. 코드로 옮기면 배신의 순간이 정확히 어디인지 손으로 짚을 수 있다. 아래 스텝은 순진한 상속에서 출발해, 치환이 깨지는 지점을 드러내고, 그 대가가 어떻게 청구되는지까지 따라간다.

Refactoring Step 순진한 상속 — 정사각형은 직사각형의 일종이다
1/3
public class Rectangle {    protected int width;    protected int height;    public void setWidth(int w)  { this.width = w; }    public void setHeight(int h) { this.height = h; }    public int area() { return width * height; }}// 학교 기하학의 상식을 그대로 코드로: 정사각형은 너비=높이인 직사각형이다.public class Square extends Rectangle {    @Override public void setWidth(int w)  { this.width = w; this.height = w; }    @Override public void setHeight(int h) { this.width = h; this.height = h; }    // 정사각형을 유지하려면 한 변을 바꿀 때 다른 변도 따라와야 한다.    // 여기까지는 "정사각형답게" 동작한다 — 문제는 남이 이걸 직사각형으로 볼 때 터진다.}

무엇이 드러났나. 세 번째 스텝의 if (r instanceof Square)가 이 장의 핵심 장면이다. Client는 원래 Rectangle 하나만 알면 됐다. 그런데 치환이 깨지자, 이 코드는 자기가 다루는 것이 진짜 무엇인지 런타임에 캐물어야만 안전해졌다. 다형성의 약속 — “구현이 무엇이든 상위 타입의 언어로만 말하면 된다” — 이 무너지고, 고수준 코드가 저수준 타입의 이름을 향해 손을 뻗는다. 상속을 잘못 쓴 대가는 컴파일 에러가 아니라, 시스템 전체에 스며드는 instanceof의 곰팡이다.

판단 기준: 어떤 다형적 코드에서 instanceof나 타입 태그로 하위 타입을 되묻는 분기가 생겨난다면, 그건 리팩터링 냄새 이전에 LSP 위반의 청구서다 — 어느 하위 타입이 상위의 계약을 어겼는지 거슬러 올라가라. 함정: 그 분기를 “특수 케이스 처리”라고 이름 붙여 정당화하는 것 — 특수 케이스는 대개 깨진 치환을 감추는 반창고이며, 하위 타입이 늘수록 함께 자란다.

그럼 무엇이 잘못이었나 — 계층이 아니라 모델이

정사각형/직사각형 문제의 교훈은 “상속을 쓰지 마라”가 아니다. 애초에 모델이 틀렸다는 것이다. setWidthsetHeight를 따로 가진 가변 직사각형은 “두 변이 독립적으로 변한다”는 계약을 자기 인터페이스에 새겨 넣은 것이고, 정사각형은 그 계약을 만족할 수 없는 개념이다. 둘을 상속으로 엮으려 한 것 자체가 계약 위반의 씨앗이었다.

바로잡는 길은 여럿이다. 도형을 불변(immutable)으로 만들어 생성 후 변이 자체를 없애면 “한 변을 바꾸면 다른 변이…”라는 문제가 성립하지 않는다. 혹은 정사각형과 직사각형을 형제로 두고, 공유해야 할 것이 있다면 area()만 요구하는 더 좁은 추상(Shape) 아래 나란히 놓는다. 어느 쪽이든 핵심은 하나다 — 하위 타입이 지킬 수 없는 계약을 상위 타입이 약속하게 두지 않는 것. 계약을 좁히면 치환은 저절로 성립한다.

판단 기준: 치환이 깨질 때, 하위 타입을 고치기 전에 상위 타입이 약속한 계약이 과한지부터 의심하라 — 종종 상위가 너무 많은 것을 보장해서, 어떤 하위도 그 전부를 지킬 수 없는 구조다. 함정: 위반을 하위 타입에서 throw new UnsupportedOperationException()으로 틀어막는 것 — 이는 계약을 지키는 게 아니라 런타임에 폭발하도록 미뤄 둔 것이며, 치환은 여전히 불가능하다.

LSP는 아키텍처 규모로 자란다 — 인터페이스의 치환 계약

여기서 마틴은 리스코프의 원칙을 상속 관계에서 떼어내, 훨씬 넓은 곳으로 끌고 간다. extends가 없어도 치환은 어디에나 있다. REST 엔드포인트를 호출하는 클라이언트에게 그 뒤의 서비스들은 서로 치환 가능한 구현이어야 하고, 플러그인 인터페이스를 쓰는 프레임워크에게 각 플러그인은 치환 가능한 부품이어야 한다. 인터페이스와 그 구현들 사이에도 치환 계약이 있고, 그것이 깨지면 상속에서와 똑같은 병이 아키텍처 규모로 번진다.

마틴이 드는 장면은 택시 디스패치다. 여러 택시 회사를 하나의 집계 서비스가 묶어, “가장 가까운 차를 배차하라”는 요청을 각 회사의 REST 인터페이스로 흘려보낸다고 하자. 모든 회사가 같은 URI 규약을 따르기로 계약했다면, 디스패처는 회사가 누구든 신경 쓰지 않고 동일한 요청을 던지면 된다 — 회사들은 서로 치환 가능한 구현이다. 그런데 한 회사가 규약을 미묘하게 어긴다. 다른 회사는 .../driver/Bob으로 목적지를 받는데, 이 회사만 필드 이름을 dest가 아니라 destination으로 받거나, 배차 확정의 의미를 살짝 다르게 해석한다. 인터페이스는 같아 보이지만 계약이 어긋난 것이다.

판단 기준: 인터페이스를 여러 구현이 공유할 때, 시그니처(모양)의 일치만 보지 말고 의미(계약)의 일치를 확인하라 — 같은 필드에 같은 뜻, 같은 호출에 같은 사후 조건이 성립해야 진짜 치환 가능이다. 함정: 타입이 맞으니 치환된다고 믿는 것 — 컴파일러가 검사하는 건 모양뿐이고, 계약은 사람이 지켜야 하며, 어긋나도 컴파일은 조용하다.

위반 하나가 디스패처를 오염시킨다

계약이 어긋난 그 한 회사 때문에 무슨 일이 벌어지는가. 디스패처는 이제 모두를 똑같이 대할 수 없다. “이 회사면 destination 필드로, 나머지는 dest로” 같은 분기를 배차 로직 한복판에 심어야 한다. 정사각형 문제에서 봤던 if (r instanceof Square)가, 이번엔 if (company == Acme)라는 얼굴로 아키텍처의 심장에 다시 나타난 것이다.

이 오염의 무서움은 그것이 한 곳에 머물지 않는다는 데 있다. 예외 회사를 아는 분기는 배차뿐 아니라 요금 정산, 상태 조회, 로그 집계까지 회사를 다루는 모든 경로에 복제된다. 규약을 어긴 회사가 하나 더 늘면 분기도 곱절로 자란다. 본래 인터페이스 하나로 무한히 많은 회사를 무심하게 확장할 수 있어야 할 시스템이, 특수 케이스의 목록을 손으로 관리하는 시스템으로 퇴화한다. LSP 위반의 비용은 위반한 그 컴포넌트가 아니라, 그것을 치환 가능하다고 믿었던 모든 상위 코드가 대신 치른다. 이것이 마틴이 리스코프의 원칙을 굳이 아키텍처의 책에 넣은 이유다 — 치환 가능성은 개별 클래스의 예의가 아니라, 시스템을 조건문 없이 확장 가능하게 유지하는 아키텍처의 자산이다.

판단 기준: 인터페이스 규약을 못 지키는 구현이 하나라도 있다면, 그 예외를 상위에서 분기로 흡수하지 말고 **경계에서 계약에 맞게 번역(어댑터)**해 버려라 — 오염을 시스템 전체로 퍼뜨리는 대신 한 지점에 가둔다. 함정: “일단 이 회사만 특별 처리하자”로 시작하는 것 — 그 한 줄의 분기는 반드시 둘, 넷으로 늘고, 어느새 아무도 지우지 못하는 특수 케이스의 지층이 된다.

정리 — 무엇을 지불하고 무엇을 사는가

LSP가 파는 것은 명확하다. 치환 가능성을 지키면, 상위 타입 하나에 대고 짠 코드가 앞으로 등장할 무수한 하위 타입·구현을 손대지 않고 받아들인다. 이것이 개방-폐쇄(OCP)가 약속한 확장성의 실제 동력이다 — 새 구현을 꽂아도 기존 코드가 닫힌 채로 있으려면, 그 구현이 계약을 어기지 않아야 하니까. LSP는 OCP가 서 있는 바닥이다.

그 대가로 지불하는 것은 계약을 설계하고 지키는 규율이다. 상위 타입의 계약을 신중히 좁게 잡아야 하고, 모든 구현이 그것을 지키는지 — 컴파일러가 봐주지 않는 의미 수준에서 — 사람이 챙겨야 한다. 이 규율을 게을리하면 절약한 것보다 훨씬 비싼 청구서가, instanceof와 특수 케이스 분기의 형태로 온 시스템에 흩뿌려져 돌아온다.

  • LSP는 상속 문법의 규칙이 아니라 치환 가능성의 계약이다 — “B는 A의 일종인가”가 아니라 “A를 믿는 코드가 B에게 배신당하지 않는가”를 묻는다.
  • 정사각형/직사각형은 개념의 포함 관계(is-a)와 행동의 계약이 어긋나는 전형이다. 계층이 아니라 모델(과한 계약)이 문제였다.
  • 치환이 깨진 대가는 컴파일 에러가 아니라, 하위 타입을 되묻는 조건문이다. 그 분기는 상위 코드에 하위의 이름을 새기고 하위 타입 수만큼 자란다.
  • LSP는 인터페이스·REST 규약 수준으로 확장된다. 택시 디스패치의 규약을 어긴 회사 하나가, 시스템 전체를 특수 케이스로 오염시킨다.
  • 어긋난 계약은 상위의 분기로 흡수하지 말고 경계의 어댑터로 번역해 한 지점에 가둔다.

다음장으로 10장