LSP: 개발에서 바바라 리스코프 치환 원칙의 본질

저자: IT Sectr 게시일: 2026-05-12 읽는 시간: 9 분

LSP(Liskov Substitution Principle)는 SOLID의 세 번째 원칙으로, 객체 지향 프로그래밍에서 올바른 상속의 조건을 정의합니다. 이 원칙은 1987년 바바라 리스코프에 의해 공식화되었으며 다음과 같이 정형화되었습니다: S가 T의 하위 타입인 경우, T 타입의 객체는 프로그램의 속성을 변경하지 않고 S 타입의 객체로 대체될 수 있습니다. 로버트 마틴의 저서 Clean Architecture(2017)에서 언급된 바와 같이, 치환 원칙은 하위 클래스가 기본 클래스의 계약을 약화시켜서는 안 된다고 요구합니다.

핵심 사항

  • LSP — 리스코프 치환 원칙, 올바른 상속에 관한 SOLID의 세 번째 원칙
  • 하위 클래스는 기본 클래스의 계약을 유지해야 함 — 선행 조건과 후행 조건
  • LSP 위반은 «정사각형과 직사각형 문제»와 던져진 예외에서 나타남
  • 컴포지션이 LSP 준수를 위해 상속보다 선호되는 경우가 많음
  • 계약에 의한 설계(Design by Contract) — LSP를 검증하는 공식적인 방법

LSP(Liskov Substitution Principle)란?

LSP(Liskov Substitution Principle)는 1987년 OOPSLA 컨퍼런스에서 바바라 리스코프가 공식화한 치환 원칙입니다. 공식 정의: q(x)를 T 타입의 객체 x에 대한 증명 가능한 속성이라고 하자. 그러면 q(y)는 S 타입의 객체 y에 대해 증명 가능해야 하며, 여기서 S는 T의 하위 타입입니다. 간단히 말해: 하위 클래스의 객체는 기본 클래스로 작업하는 코드가 하위 클래스에서도 계속 올바르게 작동하도록 행동해야 합니다.

실제로 LSP는 하위 클래스가 기본 클래스의 계약을 위반해서는 안 된다는 것을 의미합니다. 계약에는 선행 조건(메서드를 호출하는 데 필요한 것), 후행 조건(호출 후 보장되는 것), 그리고 불변 조건(객체의 수명 동안 유지되는 조건)이 포함됩니다. 하위 클래스가 선행 조건을 강화하거나 후행 조건을 약화시키는 것이 바로 LSP 위반입니다.

LSP 위반의 고전적인 예는 직사각형에서 상속받는 정사각형입니다. 직사각형의 setWidth 메서드는 너비를 설정하는 반면, 정사각형에서는 너비와 높이를 모두 설정합니다. 직사각형의 동작(한 변을 변경해도 다른 변에 영향을 주지 않음)을 기대하는 클라이언트는 예상치 못한 결과를 얻습니다. 정사각형은 직사각형의 유효한 하위 타입이 아닙니다.

LSP의 공식 조건

LSP는 올바른 상속을 위해 세 가지 조건을 설정합니다: 하위 클래스의 선행 조건은 기본 클래스의 선행 조건보다 강할 수 없고(하위 클래스가 더 요구하지 않음), 하위 클래스의 후행 조건은 기본 클래스의 후행 조건보다 약할 수 없으며(하위 클래스가 더 적게 보장하지 않음), 기본 클래스의 불변 조건은 하위 클래스에서 유지되어야 합니다. 이러한 조건은 Bertrand Meyer의 계약에 의한 설계 규칙으로 알려져 있습니다.

하나라도 조건이 위반되면 다형성을 사용하는 코드가 실패할 수 있습니다. 컴파일러는 의미론적 계약이 아닌 구문론적 계약만 확인합니다. 따라서 LSP는 정적 타이핑의 문제가 아니라 아키텍처 규율의 문제입니다.

리스코프 치환 원칙의 작동 방식

LSP 메커니즘은 타입의 행동 호환성에 기반합니다. 클래스 S가 클래스 T에서 상속받는 경우, 클라이언트 코드는 T가 예상되는 곳에서 S를 동작 변경 없이 사용할 수 있어야 합니다. 여기에는 메서드 시그니처뿐만 아니라 그 의미론도 포함됩니다.

LSP는 하위 클래스가 새 동작을 추가하는 것을 금지하지 않습니다. 기본 클래스를 위해 작성된 코드의 기대를 위반하는 것은 금지됩니다. 기본 클래스가 save 메서드가 예외를 던지지 않음을 보장하는 경우, 하위 클래스는 예외를 던지면 안 됩니다. 기본 클래스가 음수가 아닌 값을 반환하는 경우, 하위 클래스는 음수 값을 반환하면 안 됩니다.

실제 프로젝트에서 LSP는 하위 클래스의 메서드에 조건부 로직을 추가할 때 가장 자주 위반됩니다: «조건이면 — 예외 던지기», «조건이면 — null 반환». 이러한 각각의 «놀라움»은 다형성을 약화시키고 클라이언트 코드가 호출 전에 객체 타입을 확인하도록 강제합니다 — 이는 객체 지향 설계의 개념 자체에 위배됩니다.

모바일 프로젝트에서 일반적인 LSP 위반은 기본 ViewModel을 만들 때 발생합니다. BaseViewModel이 onCleared 메서드가 모든 리소스를 해제함을 보장하고, 하위 클래스가 이 메서드를 빈 상태로 재정의하는 경우 — 다형성 onCleared 호출을 통한 리소스 정리에 의존하는 모든 코드가 잘못 작동합니다. LSP는 하위 클래스가 super.onCleared()를 호출하거나 동일한 작업을 직접 수행할 것을 요구합니다. LifecycleObserver를 통한 컴포지션은 수명 주기 관리에서 LSP 위반을 제거하는 대안입니다.

코드에서 LSP 위반 징후

LSP 위반의 주요 지표는 다음과 같습니다: 메서드를 호출하기 전에 instanceof 또는 is를 통해 객체 타입을 확인, 빈 메서드 구현(스텁), NotImplementedError 또는 UnsupportedOperationException 던지기, 값 대신 null 반환. 이러한 각 패턴은 하위 클래스가 유효한 하위 타입이 아님을 나타냅니다.

또 다른 일반적인 징후는 «is-a» 관계를 모델링하기보다 코드 재사용을 목적으로 한 상속입니다. Bird 클래스에는 fly() 메서드가 있습니다. Penguin 클래스는 Bird에서 상속받고 fly()를 빈 상태나 예외를 던지는 방식으로 재정의합니다. 이것은 LSP 위반입니다: 펭귄은 새의 유효한 하위 타입이 아닙니다.

모바일 개발에서 스텁 메서드가 있는 기본 ViewHolder, Fragment 또는 ViewController 클래스를 만들 때 LSP가 위반됩니다. 하위 클래스가 기본 클래스 메서드의 절반을 사용하지 않는 경우 — 상속이 잘못 선택된 것입니다. 컴포지션 또는 인터페이스 분리가 문제를 더 올바르게 해결합니다.

LSP 테스트

LSP를 확인하는 간단한 테스트: 기본 클래스의 계약(반환 값, 예외, 부작용)을 검증하는 단위 테스트를 작성합니다. 모든 하위 클래스에 대해 이 테스트를 실행합니다. 테스트가 실패하면 LSP가 위반된 것입니다. 이 접근 방식을 «기본 클래스 계약을 통한 테스트»라고 합니다.

Android 프로젝트에서 이러한 테스트는 ViewModel과 Repository에 유용합니다. BaseViewModel이 오류 전에 Loading 상태를 보장하고, 하위 클래스가 Loading 없이 오류를 던지는 경우 — 테스트가 CI 단계에서 LSP 위반을 감지합니다.

모바일 개발에서 LSP 예시

ClickListener 처리를 사용한 Android 예시를 살펴보겠습니다. 기본 구현이 무언가를 보장하고 하위 클래스가 이를 위반할 때 LSP 위반이 발생합니다.

kotlin
// 보장된 기본 클래스: onClick이 호출됩니다
open class BaseClickListener {
    open fun onClick(view: View) {
        // 기본 처리
    }
}

// LSP 위반: 하위 클래스가 예외를 던지는 조건을 추가
class RestrictedClickListener : BaseClickListener() {
    override fun onClick(view: View) {
        if (!isLoggedIn) {
            throw IllegalStateException("Not logged in")
        }
        super.onClick(view)
    }
}

// 올바른 해결책: 계약이 위반되지 않음
class ConditionalClickListener : BaseClickListener() {
    override fun onClick(view: View) {
        if (isLoggedIn) {
            super.onClick(view)
        }
    }
}

DataSource 프로토콜을 사용한 iOS 예시는 데이터 대신 nil을 반환하여 LSP 위반을 보여줍니다:

swift
// 계약이 있는 프로토콜: 데이터 또는 오류 반환
protocol DataProvider {
    func fetchData() async throws -> [String]
}

// LSP 위반: 오류 없이 nil 반환
class SilentFailProvider: DataProvider {
    func fetchData() async throws -> [String] {
        return [] // 오류 대신 빈 배열
    }
}

// 올바른 LSP 준수
class NetworkProvider: DataProvider {
    func fetchData() async throws -> [String] {
        throw NetworkError.timeout
    }
}

실용적인 규칙: 하위 클래스가 기본 클래스의 계약을 이행할 수 없다면, 하위 클래스가 되어서는 안 됩니다. 대안은 최소 계약으로 인터페이스를 추출하고 각 타입에서 자신만의 방식으로 구현하는 것입니다.

LSP와 상속: 컴포지션을 선택해야 할 때

컴포지션은 «is-a» 관계가 모호하거나 조건부인 상황에서 상속보다 선호됩니다. 고전적인 예: Manager는 Employee인가요? 네. 하지만 Square는 유효한 Rectangle인가요? LSP는 «아니요»라고 말합니다. 상속의 정확성이 의심된다면 — 컴포지션을 선택하세요.

모바일 개발에서 컴포지션은 종종 의존성 주입을 통해 사용됩니다: 기본 클래스에서 동작을 상속받는 대신, 클래스가 생성자를 통해 동작을 받습니다. ViewModel은 Repository에서 상속받지 않고 의존성으로 받아들입니다. 이는 정의에 따라 LSP 위반을 제거합니다 — 상속이 없으면 계약 위반도 없습니다.

상속을 컴포지션으로 대체해야 하는 징후: 하위 클래스가 기본 클래스의 일부 메서드를 사용하지 않음, 하위 클래스가 메서드를 빈 스텁으로 재정의함, 클라이언트 코드가 instanceof로 객체 타입을 확인함. 이러한 경우 상속이 잘못 선택되었으며 LSP가 위반되었습니다.

인터페이스를 통한 해결책

인터페이스는 상속 없이 LSP 문제를 해결합니다: 각 타입은 필요한 메서드만 정확히 구현합니다. fly() 메서드가 있는 공통 기본 Bird 클래스(Penguin은 날 수 없음) 대신 — Flying 인터페이스를 구현합니다. Penguin은 fly() 메서드 없이 Bird를 구현합니다 — LSP가 위반되지 않습니다.

Android 아키텍처에서 이 접근 방식은 분리된 UseCase 인터페이스를 통해 적용됩니다: getAll, getById, save, delete 메서드가 있는 하나의 큰 UseCase 대신 — 별도의 GetItemsUseCase, SaveItemUseCase 인터페이스. 클라이언트는 필요한 인터페이스에만 의존하며, 해당 인터페이스를 구현하는 모든 클래스는 LSP 관점에서 올바릅니다.

자주 묻는 질문

LSP는 단순한 상속과 어떻게 다른가요?

상속은 언어 메커니즘입니다; LSP는 해당 메커니즘의 올바른 사용에 대한 규칙입니다. 상속은 시그니처 호환성(구문)을 보장하고, LSP는 행동 호환성(의미론)을 요구합니다. LSP 없는 상속은 런타임에 깨지는 다형성을 제공합니다.

하위 클래스의 null이 항상 LSP를 위반하나요?

기본 클래스가 null이 아닌 반환을 보장하는 경우 — 예. 계약이 null(선택적 값)을 허용하는 경우 — 아니요. LSP는 null을 금지하는 것이 아니라 계약 약화를 금지합니다. 기본 클래스의 문서를 연구하고 하위 클래스의 계약이 호환되는지 확인하세요.

Swift의 프로토콜에는 LSP가 어떻게 적용되나요?

LSP는 프로토콜에도 클래스와 동일하게 적용됩니다. 프로토콜 구현은 의미론적 계약을 따라야 합니다: 프로토콜이 메서드를 non-throwing으로 정의하는 경우, 구현은 오류를 던지면 안 됩니다. Swift는 컴파일러 수준에서 이를 확인하지 않습니다 — 책임은 개발자에게 있습니다.

sealed class를 사용하면 LSP가 위반될 수 있나요?

Kotlin의 Sealed class는 계층이 닫혀 있고 컴파일러에 알려져 있기 때문에 특별한 경우입니다. LSP는 sealed class에 덜 적용되는데, 모든 하위 타입이 when 표현식에 명시적으로 열거되기 때문입니다. sealed 하위 클래스의 오류는 지역적이며 숨겨진 다형성 오류가 아닙니다.

프로젝트에서 LSP 준수를 테스트하는 방법은?

기본 클래스에 대해 모든 하위 클래스에서 실행되는 매개변수화된 테스트를 작성합니다. 테스트는 주요 행동 계약(반환 값, 예외, 상태)을 검증합니다. 하나의 하위 클래스에서 테스트가 실패하면 LSP가 위반된 것입니다. CI에서 이러한 테스트는 다형성 코드의 회귀를 방지합니다.

요약

  • LSP(Liskov Substitution Principle) — 치환 원칙, SOLID의 세 번째, 상속의 의미론적 호환성에 관한 것
  • 하위 클래스는 기본 클래스의 계약을 유지해야 함: 선행 조건, 후행 조건 및 불변 조건
  • instanceof 확인과 빈 메서드 재정의는 LSP 위반의 주요 징후
  • 컴포지션과 인터페이스는 상속이 부적절한 경우 LSP 문제를 해결
  • 정사각형과 직사각형 문제는 하위 타입 비호환성의 고전적인 예
  • 모든 하위 클래스에 대해 실행되는 기본 클래스의 계약 테스트가 CI에서 LSP 위반을 감지
  • Kotlin의 Sealed class는 컴파일러에 알려진 닫힌 계층 구조로 LSP 위험을 줄임

턴키 방식의 모바일 애플리케이션을 개발해 드립니다

IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.

프로젝트 논의

더 읽어보기