hexdrinker

LSP: 리스코프 치환 원칙

-4 min read

리스코프 치환 원칙은 1987년 MIT 교수 바바라 리스코프(Barbara Liskov)가 학회 키노트에서 처음 제시한 객체지향 설계 원칙이다.

자료형 T의 객체는 S가 T의 하위 타입일 때, 프로그램의 정확성을 깨뜨리지 않고 자료형 S로 치환할 수 있어야 한다.

하위 클래스는 상위 클래스의 책임을 완전히 수행해야 하며, 하위 타입으로 변경되어도 오작동 하지 않아야 한다. 즉, 상위 타입을 언제나 하위 타입으로 치환할 수 있어야한다.

이는 다형성 상속 관계에서 하위 클래스가 상위 클래스를 완벽히 대체할 수 있어야 함을 강조한다. 아래의 예제로 살펴보자.

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

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

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

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

정사각형/직사각형 문제

LSP를 위반하는 전형적인 문제인 정사각형/직사각형 문제를 살펴보자.

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

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

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

UserRectangle을 쓰고 있다고 생각할 수 있지만 실제로는 Square일 수도 있다. Square일 때 너비는 4가 될 것이며 이 테스트는 실패하게 된다.

이런 형태의 LSP 위반을 막기 위한 유일한 방법은 User에 타입 체크 로직을 추가하는 것이다. 그러나 이 경우에 User의 행위가 사용하는 타입에 의존하게 되므로 결국 타입을 서로 치환할 수 없다.

LSP와 아키텍처

객체 지향의 초창기에는 LSP는 상속을 사용 가이드 정도로 간주되었지만 시간이 지나면서 인터페이스와 구현체에도 적용되는 더 광범위한 설계 원칙으로 자리 잡았다.

여기서 말하는 인터페이스는 다양한 형태로 나타난다. 자바와 같은 언어라면 인터페이스 하나와 이를 구현하는 여러 개의 클래스로 구성된다. 루비라면 동일한 메소드 시그니처를 공유하는 여러 개의 클래스로 구성된다. 또는 동일한 REST 인터페이스에 응답하는 서비스 집단일 수도 있다.

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

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

프론트엔드에서의 예시

LSP를 설명할 때 보통 클래스의 상속을 예시로 들기 때문에 프론트엔드 엔지니어 입장에서는 와닿지 않을 수도 있다. 그러나 LSP는 추상화라는 약속이 시스템 전체에 유효함을 보장하는 신뢰 장치이다. 실제 구현체에서도 이 계약에 대한 기대를 지키고 예측 가능성을 준수해야한다.

자바를 배웠다면 알 수 있지만 보통은 업캐스팅을 해도 동작에 문제가 없을 거라고 예상한다. 프론트엔드에서는 특정 인터페이스 규율을 따르는 컴포넌트는 해당 인터페이스에 대한 기대 범위를 깨면 안된다는 의미로 해석할 수 있다.

interface ButtonProps {
  onClick: () => void
  disabled?: boolean
}
 
function Button({ onClick, disabled }: ButtonProps) {
  return (
    <button
      disabled={disabled}
      onClick={onClick}
    />
  )
}

위 코드는 disabled가 true면 클릭이 불가능하다는 암묵적인 계약이 있다.

function TestButton({ disabled, onClick }: ButtonProps) {
  return (
    <div
      className={`${disabled ? "disabled" : ""}`}
      onClick={onClick}
    >
    </div>
  )
}
 
function User() {
  return (
    <TestButton
      disabled
      onClick={() => alert("clicked") }
    />
  )
}

위 코드는 UserButtonProps를 받아서 사용하는 버튼을 믿고 구현을 했지만 결국 disabled 상태에서도 클릭이 가능하다. 이렇게 코드를 작성한다면 LSP를 위배하는 컴포넌트가 된다.

그 외에도 예시가 몇 가지 더 있을 것 같다.

  • 필드명이 많이 겹친다고 공통 인터페이스를 만들고 상속하기
  • 편의성 때문에 상태 관리 로직에 UI 로직이 스며든 경우
  • 로그인/로그아웃 같은 처리에 추가된 redirect가 hook의 성격을 바꾸는 경우

이 모든 사례들은 결국 추상화된 계약을 해치는 행위로 귀결된다.

💬

실무에서는 요구사항이 계속 바뀌고, 동료의 코드를 이어받거나 내 코드가 다른 사람의 손을 거치며 수정되는 일이 잦다. 그 과정에서 초기에 정의했던 계약이나 설계가 서서히 무너지는 경우도 자연스럽게 발생한다.


내 경험상 이런 문제는 특히 Base 컴포넌트를 기능적으로 확장할 때 자주 나타났다. “중복 로직을 줄이기 위해”, “개념적으로 큰 차이가 없어서”라는 합리적인 판단 아래 props나 메서드를 추가하다 보면, 어느 순간 Base 컴포넌트는 너무 많은 역할을 떠안게 되고 이를 참조하는 코드가 많기 때문에 수정이 어려워진다. 그 결과, 변경의 여파를 예측하기 어려워지고 테스트 역시 점점 부담스러워진다.


리스코프 치환 원칙을 기준으로 삼으면 관점이 조금 달라진다.


세부 구현을 확장하거나 변경할 때마다 "이 컴포넌트(혹은 메서드)는 여전히 기존 계약을 믿고 사용해도 되는가?" 라는 질문을 던지게 된다. 이 질문에 자신 있게 “그렇다”고 답할 수 없다면, 그 변경은 추상화를 확장하는 것이 아니라 깨뜨리고 있을 가능성이 크다.


LSP는 코드를 바꿔도 시스템이 여전히 예측 가능하게 동작하도록 지켜주는 최소한의 신뢰 장치에 가깝고 이를 하나의 관점으로 삼는다면 좀 더 좋은 아키텍처를 만들 수 있을 것이다.

결론

하위 타입을 상위 타입으로 치환할 수 있어야 한다는 말은 하나의 잘 정의된 계약(인터페이스)을 구현할 때 그것의 예측 가능성과 기대를 깨뜨리는 행위를 막자는 것으로 확장시킬 수 있다.

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