Unisquads
로그인
홈
이용약관·개인정보처리방침·콘텐츠정책·© 2026 Unisquads
이용약관·개인정보처리방침·콘텐츠정책
© 2026 Unisquads. All rights reserved.

LSP (r1)

이 문서의 과거 버전 (r1)을 보고 있습니다. 수정일: 2026.02.14 23:09

LSP

이름

LSP

전체 명칭

Liskov Substitution Principle

분류

객체 지향 프로그래밍 설계 원칙

약칭

LSP

소속 원칙

SOLID 원칙

제안자

바바라 리스코프

제안 연도

1987년

원칙 상세

핵심 정의

자식 클래스는 부모 클래스의 자리를 대체할 수 있어야 한다.

공식 설명

S가 T의 하위 타입이라면, 프로그램에서 T 타입의 객체를 S 타입의 객체로 교체해도 프로그램의 속성(정확성 등)이 변경되지 않아야 한다.

주요 목적

상속 관계의 정확성과 안정성을 보장하여 코드의 재사용성과 유지보수성을 높인다.

위반 사례

정사각형-직사각형 문제, 오리-펭귄 문제 등

위반 시 문제점

다형성 오용, 런타임 오류, 예상치 못한 동작 발생

준수 방법

계약에 의한 설계(Design by Contract), 사전 조건 약화/사후 조건 강화 준수

관련 개념

다형성, 상속, 인터페이스 분리 원칙, 개방-폐쇄 원칙

적용 분야

소프트웨어 아키텍처, 클래스 설계, API 설계

1. 개요

LSP는 SOLID 원칙의 세 번째 원칙으로, 객체 지향 프로그래밍에서 상속 관계를 올바르게 설계하기 위한 지침을 제공한다. 이 원칙은 1988년 바바라 리스코프가 논문에서 제시한 개념으로, '하위 타입은 상위 타입을 완전히 대체할 수 있어야 한다'는 핵심 명제를 담고 있다.

이는 프로그램의 정확성을 깨뜨리지 않으면서, 상위 타입의 객체를 하위 타입의 객체로 치환할 수 있어야 함을 의미한다. 클라이언트 코드는 자신이 사용하는 객체의 구체적인 하위 타입을 알 필요 없이, 오직 상위 타입(기반 클래스나 인터페이스)에 정의된 계약만을 믿고 동작할 수 있어야 한다.

LSP를 준수하는 시스템은 개방-폐쇄 원칙을 실현하는 데 중요한 기반이 된다. 하위 타입의 확장이 기존 코드를 변경하지 않고도 이루어질 수 있도록 보장하기 때문이다. 결과적으로 LSP는 코드의 재사용성, 유지보수성, 그리고 테스트 용이성을 크게 향상시키는 역할을 한다.

2. LSP의 기본 원리

LSP는 객체 지향 프로그래밍에서 상속 관계가 가져야 할 핵심 속성을 정의한다. 이 원리는 "S는 T의 하위 타입이라면, 프로그램의 정확성을 깨뜨리지 않으면서 T 타입의 객체를 S 타입의 객체로 치환할 수 있어야 한다"는 명제로 요약된다[1]. 이는 단순한 구문 호환성을 넘어, 클라이언트 코드가 기대하는 행위의 호환성을 보장하는 것을 의미한다.

핵심은 하위 타입 치환 가능성이다. 부모 클래스의 인스턴스를 사용하는 모든 코드는, 그 인스턴스를 자식 클래스의 인스턴스로 대체했을 때도 동일한 사전 조건과 사후 조건 하에서 정상적으로 동작해야 한다. 여기서 계약이 중요한데, 메서드는 특정 사전 조건(입력 제약)을 만족하는 호출에 대해, 약속된 사후 조건(결과 상태)을 반드시 충족시켜야 한다. 하위 타입은 이 계약을 약화시켜서는 안 된다. 즉, 사전 조건을 더 강하게 만들거나[2], 사후 조건을 더 약하게 만들면 안 된다[3].

이 원칙을 준수하는 상속 관계는 다음과 같은 특성을 지닌다.

특성

설명

메서드 시그니처 호환성

하위 타입은 상위 타입의 메서드 시그니처(이름, 매개변수 타입, 반환 타입)를 그대로 유지하거나 호환되는 방식으로 확장한다.

행위의 불변성

상위 타입이 정의한 불변식(객체의 생명주기 동안 항상 참이어야 하는 조건)을 하위 타입이 훼손하지 않는다.

예외의 일관성

하위 타입의 메서드는 상위 타입 메서드가 던지는 예외의 하위 타입이거나 새로운 검사 예외를 던지지 않는다.

결국 LSP는 상속이 'is-a' 관계를 구현하는 수단이 아니라, '행위의 대체 가능성'을 보장하는 계약의 관계임을 강조한다. 이 원리를 위반하면, 코드 재사용의 이점을 얻는 대신 예측하지 못한 버그와 취약한 설계를 초래하게 된다.

2.1. 하위 타입 치환 가능성

하위 타입 치환 가능성(Substitutability)은 LSP의 핵심 개념으로, 프로그램에서 기반 클래스(부모 클래스)의 객체가 사용되는 모든 곳에서 그 객체를 파생 클래스(자식 클래스)의 객체로 치환해도 프로그램의 정확성에 아무런 문제가 없어야 한다는 원칙이다.

이는 수학적 개념인 형식 명세(formal specification)와 계약에 기반을 둔다. 클래스 간의 상속 관계는 단순히 코드를 재사용하기 위한 수단이 아니라, "IS-A" 관계를 의미하는 타입 계층 구조를 정의한다. 따라서 하위 타입은 상위 타입이 제공하는 모든 공개 인터페이스(public interface)를 반드시 준수해야 하며, 상위 타입의 클라이언트가 기대하는 사전 조건(precondition)을 더 강화하거나 사후 조건(postcondition)을 더 약화시켜서는 안 된다[4].

이 원칙을 위반하는 전형적인 예는 직사각형 클래스를 상속받는 정사각형 클래스를 설계하는 경우이다. 정사각형은 수학적으로 직사각형의 일종이지만, setWidth와 setHeight 메서드를 개별적으로 변경할 수 있는 직사각형의 행동 계약을 정사각형이 깨뜨리게 된다. 클라이언트 코드가 직사각형 객체를 기대하고 너비만 설정했을 때, 정사각형 객체로 치환되면 높이도 함께 변경되는 부작용이 발생하여 프로그램의 정확성이 훼손된다.

2.2. 계약과 사전/사후 조건

LSP의 핵심인 하위 타입 치환 가능성은 단순한 구문 호환성을 넘어, 상위 타입과 하위 타입 사이의 행위적 계약을 준수하는 것을 요구합니다. 이 계약은 주로 사전 조건과 사후 조건으로 정의됩니다.

사전 조건은 메서드가 실행되기 전에 참이어야 하는 조건입니다. 하위 타입은 상위 타입이 정의한 사전 조건을 더 강화해서는 안 됩니다. 즉, 상위 타입이 요구하는 조건보다 더 많은 제약을 클라이언트에게 요구할 수 없습니다. 예를 들어, 상위 타입의 메서드가 '입력값이 0 이상이어야 한다'는 사전 조건을 가진다면, 하위 타입은 '입력값이 10 이상이어야 한다'고 강화할 수 없습니다. 이는 기존 클라이언트 코드가 하위 타입으로 치환되었을 때, 추가된 조건을 만족하지 못해 오류를 발생시킬 수 있기 때문입니다.

사후 조건은 메서드 실행이 종료된 후 보장되는 조건입니다. 하위 타입은 상위 타입이 보장하는 사후 조건을 약화시켜서는 안 되며, 동일하거나 더 강화할 수 있습니다. 예를 들어, 상위 타입의 메서드가 '반환값이 항상 양수임'을 보장한다면, 하위 타입도 이 조건을 반드시 만족시켜야 합니다. 또한, 불변식—객체의 생명주기 동안 항상 유지되어야 하는 조건—도 하위 타입에서 훼손되어서는 안 됩니다. 하위 타입이 상위 타입의 불변식을 깨는 상태를 도입하면, 상위 타입을 사용하는 로직의 정합성이 무너질 수 있습니다.

조건 유형

상위 타입 대비 하위 타입의 요구사항

설명

사전 조건

약화하거나 동일하게 유지 가능

하위 타입은 더 적은 제약을 요구할 수 있지만, 더 많은 제약을 추가할 수는 없습니다.

사후 조건

강화하거나 동일하게 유지 가능

하위 타입은 더 강력한 결과를 보장할 수 있지만, 약한 보장으로 후퇴할 수는 없습니다.

불변식

반드시 유지 및 보존

하위 타입은 상위 타입이 정의한 객체의 기본 규칙을 훼손해서는 안 됩니다.

이러한 계약의 엄격한 준수는 클라이언트 코드가 구체적인 하위 타입의 구현 세부사항에 의존하지 않고, 추상화(상위 타입)에만 의존하여 안정적으로 동작할 수 있도록 보장합니다.

3. LSP 위반 사례와 문제점

LSP 위반은 상속을 사용한 설계에서 흔히 발생하며, 이는 예상치 못한 버그와 시스템의 취약성을 초래한다. 가장 유명한 사례는 직사각형-정사각형 문제이다. 수학적으로 정사각형은 직사각형의 특수한 경우이므로, Square 클래스가 Rectangle 클래스를 상속받는 것은 자연스러워 보인다. 그러나 Rectangle의 setWidth나 setHeight 메서드는 각각 너비와 높이를 독립적으로 변경한다는 암묵적인 계약을 가진다. Square에서 이 메서드들을 오버라이드하여 너비와 높이를 항상 같게 유지하도록 구현하면, 클라이언트 코드가 Rectangle 타입으로 Square 객체를 사용할 때 동작이 달라진다. 예를 들어, 면적을 계산하는 로직이 예상과 다른 결과를 낳을 수 있다[5].

또 다른 주요 위반 사례는 파생 클래스가 기반 클래스의 메서드에서 새로운 예외를 발생시키거나 동작을 축소·변경하는 경우이다. 예를 들어, 기반 클래스의 withdraw(amount) 메서드가 음수 잔고에 대한 검사 없이 출금을 수행한다고 가정하자. 파생 클래스인 MinBalanceAccount에서 이 메서드를 오버라이드하여 최소 잔고 조건을 검사하고 조건을 위반하면 예외를 던진다면, 기반 클래스 타입을 통해 파생 클래스 객체를 사용하는 클라이언트는 기존에 처리하지 않던 새로운 예외에 직면하게 된다. 이는 클라이언트 코드의 정상적인 흐름을 깨뜨린다. 마찬가지로, 기반 클래스의 메서드가 특정 값을 반환하는 동작을 파생 클래스에서 null을 반환하거나 아무 동작도 하지 않도록 변경하는 것도 LSP를 위반한다.

이러한 위반으로 인해 발생하는 문제점은 다음과 같이 정리할 수 있다.

문제점

설명

예측 불가능한 동작

클라이언트 코드가 기반 타입의 명세를 신뢰하고 작성했지만, 런타임에 실제 파생 타입 객체의 변경된 동작으로 인해 오류가 발생한다.

리팩토링과 유지보수의 어려움

코드를 수정할 때 상속 관계를 신뢰할 수 없어, 타입 체크나 예외 처리 등 불필요한 방어적 코드가 늘어난다.

테스트의 복잡성 증가

기반 클래스에 대한 단위 테스트가 파생 클래스에서도 통과해야 한다는 계약이 깨지므로, 각 파생 클래스를 별도로 철저히 테스트해야 한다.

개방-폐쇄 원칙(OCP) 훼손

새로운 파생 클래스를 추가해도 기존 클라이언트 코드를 수정하지 않아야 하는 OCP의 목적을 달성할 수 없다.

결국 LSP 위반은 다형성의 이점을 상쇄시키고, 시스템을 취약하게 만들어 확장과 변경을 어렵게 만든다.

3.1. 직사각형-정사각형 문제

직사각형과 정사각형의 관계는 상속을 논할 때 흔히 등장하는 고전적인 예시이자, 리스코프 치환 원칙 위반의 대표적인 사례이다. 직관적으로는 "정사각형은 모든 변의 길이가 같은 직사각형이다"라고 생각할 수 있어, Square 클래스가 Rectangle 클래스를 상속받는 것이 자연스러워 보인다.

그러나 이 상속 관계는 행동 측면에서 문제를 일으킨다. 일반적으로 Rectangle 클래스는 setWidth(int w)와 setHeight(int h)라는 두 개의 독립적인 메서드를 제공한다. Square가 이 클래스를 상속받으면, 정사각형의 불변식—즉, 너비와 높이가 항상 같아야 한다는 조건—을 유지하기 위해 두 메서드를 오버라이드하여 한쪽 속성을 변경할 때 다른 쪽도 함께 변경하도록 구현해야 한다.

```java

// LSP를 위반하는 전형적인 예

class Square extends Rectangle {

@Override

void setWidth(int w) {

super.setWidth(w);

super.setHeight(w); // 높이도 강제로 변경

}

@Override

void setHeight(int h) {

super.setHeight(h);

super.setWidth(h); // 너비도 강제로 변경

}

}

```

이러한 구현은 Square 객체가 Rectangle 객체를 기대하는 코드 문맥에서 치환될 때 예상치 못한 동작을 초래한다. 예를 들어, Rectangle의 면적을 계산하는 함수가 너비를 5, 높이를 4로 설정했다면, 사용자는 면적이 20이 될 것이라 기대한다. 하지만 이 함수에 Square 객체가 전달되면, setHeight(4) 호출은 내부에서 setWidth(4)도 동시에 실행시켜 너비를 4로 변경해 버린다. 결과적으로 면적은 16이 되어 사용자의 기대를 저버린다[6].

이 문제의 핵심은 Square가 Rectangle의 *계약*을 완전히 준수하지 못하기 때문이다. Rectangle의 setWidth와 setHeight에 대한 사용자의 암묵적 기대는 "다른 한쪽 속성은 변하지 않는다"는 것이다. Square는 이 기대를 깨뜨리므로, Rectangle의 하위 타입으로서 치환 가능하지 않다. 이는 is-a 관계가 단순히 "일반-특수"의 개념적 관계가 아니라, 행동의 치환 가능성에 기반해야 함을 보여준다.

이 문제를 해결하는 한 가지 방법은 두 클래스가 공통의 추상 인터페이스(Shape 등)를 구현하되, 서로 독립적인 계층 구조로 만드는 것이다. 또는 너비와 높이를 동시에 설정하는 메서드(resize 등)를 도입하여, 두 클래스가 각자의 불변식을 유지할 수 있는 새로운 추상화를 제공하는 설계를 고려할 수 있다.

3.2. 예외 발생 또는 동작 변경

LSP 위반의 또 다른 흔한 형태는 기반 클래스의 메서드가 예외를 던지지 않는 반면, 파생 클래스의 오버라이딩된 메서드가 새로운 예외를 발생시키는 경우이다. 이는 클라이언트 코드가 기반 타입에 대해 가정한 '예외가 발생하지 않는다'는 계약을 파생 타입이 깨뜨리는 상황이다. 클라이언트는 특정 예외에 대한 처리 로직이 없기 때문에 프로그램이 비정상적으로 종료되거나 오동작할 수 있다.

동작의 의미적 변경 또한 심각한 LSP 위반 사례이다. 예를 들어, 기반 클래스의 정렬() 메서드가 오름차순 정렬을 수행한다고 명시되어 있다면, 파생 클래스에서 이를 오버라이딩하여 내림차순 정렬을 수행하는 것은 명백한 위반이다. 비록 메서드 시그니처(이름, 매개변수, 반환 타입)는 동일하더라도, 메서드가 제공하는 '사후 조건'이나 행위의 의미가 근본적으로 달라지기 때문이다. 클라이언트는 동일한 결과를 기대하지만 전혀 다른 결과를 받게 된다.

이러한 문제는 단순히 상속 관계를 잘못 설정했을 때 빈번히 발생한다. "is-a" 관계가 성립하지 않음에도 코드 재사용만을 목적으로 무리한 상속을 적용하면, 파생 클래스가 기반 클래스의 규약을 지키지 못하게 된다. 결과적으로, LSP를 위반한 코드는 다음과 같은 문제점을 보인다.

문제점

설명

예측 불가능성

클라이언트 코드가 객체의 구체적 타입을 확인(instanceof)해야만 안전하게 사용할 수 있다.

취약한 기반 클래스 문제

파생 클래스의 동작 변경으로 인해 기반 클래스를 사용하는 모든 코드가 영향을 받을 수 있다[7].

테스트 복잡도 증가

기반 클래스에 대한 단위 테스트가 파생 클래스에서도 동일하게 통과한다는 보장을 할 수 없다.

따라서, 상속을 설계할 때는 '행위적 호환성'에 집중해야 한다. 파생 클래스는 기반 클래스가 정의한 공개 인터페이스의 모든 명세(예외, 선행 조건, 사후 조건, 불변식)를 반드시 준수하거나 더 약화시켜야 하며, 이를 강화해서는 안 된다.

4. LSP 준수를 위한 설계 지침

LSP를 준수하는 설계를 위해서는 몇 가지 핵심적인 지침을 따를 수 있다. 첫째, 인터페이스를 명확하게 분리하고 세분화하는 것이다. 클라이언트가 사용하지 않는 메서드에 의존하지 않도록 하여, 하위 타입이 불필요한 동작을 강제로 구현하거나 변경하는 상황을 방지한다. 이는 인터페이스 분리 원칙과도 깊이 연관된다. 둘째, 상속보다는 컴포지션을 우선적으로 고려하는 것이다. 상속은 is-a 관계가 논리적으로 완벽하게 성립하고, 하위 클래스가 상위 클래스의 모든 계약을 훼손하지 않을 때만 사용해야 한다. 그렇지 않은 경우, 객체를 속성으로 포함시키고 필요한 인터페이스를 위임하는 방식이 더 안전하다.

구체적인 설계 단계에서는 사전 조건과 사후 조건을 명시적으로 정의하는 것이 도움이 된다. 하위 타입은 상위 타입의 사전 조건을 더 강화해서는 안 되며, 사후 조건을 더 약화시켜서도 안 된다[8]. 예를 들어, 상위 타입의 메서드가 인덱스 >= 0을 사전 조건으로 한다면, 하위 타입은 이를 인덱스 > 0으로 강화할 수 없다. 이는 클라이언트가 기대하는 조건을 깨뜨리기 때문이다.

또한, 추상 클래스나 인터페이스를 통해 변하지 않는 고정적인 동작의 골격을 정의하고, 확장 가능한 부분은 훅 메서드나 추상 메서드로 남겨두는 방법도 효과적이다. 이렇게 하면 하위 타입이 특정 단계의 알고리즘만 변경할 수 있게 되어, 전체적인 프로그램의 흐름을 훼손할 가능성이 줄어든다. 모든 공개 메서드에 대한 명세를 문서화하고, 하위 타입이 이 명세를 어떻게 준수하는지 검증하는 과정도 중요하다.

다음 표는 LSP 준수를 위한 주요 설계 접근법을 비교한 것이다.

접근법

핵심 개념

LSP 준수에 미치는 영향

인터페이스 분리

클라이언트별 세분화된 인터페이스 제공

하위 타입이 불필요한 동작 변경을 강요받지 않아 치환 가능성 향상

컴포지션 우선

객체를 속성으로 포함하여 기능 조합

is-a 관계가 명확하지 않은 경우 유연하고 안전한 대체 수단 제공

계약 명시

사전/사후 조건, 불변식을 명확히 정의

하위 타입의 행위가 상위 타입의 기대를 벗어나는지 객관적으로 검증 가능

템플릿/훅 활용

고정 골격 내에서 확장점 제공

전체 알고리즘 구조를 훼손하지 않고 특정 단계만 안전하게 변경 가능

4.1. 인터페이스 분리와 명세

인터페이스 분리 원칙은 클라이언트가 사용하지 않는 메서드에 의존하지 않도록 광범위한 인터페이스를 더 작고 구체적인 인터페이스로 분리할 것을 권장한다. 이 원칙을 준수하면 LSP 위반 가능성을 줄이는 데 도움이 된다. 과도하게 많은 메서드를 가진 '뚱뚱한 인터페이스'는 하위 타입이 모든 메서드를 의미 있게 구현하기 어렵게 만들어, 일부 메서드에서 예외를 던지거나 무의미한 동작을 유발할 수 있기 때문이다.

인터페이스를 클라이언트의 필요에 따라 분리하면, 각 인터페이스는 더 명확하고 강력한 계약을 정의하게 된다. 이는 하위 타입이 그 계약을 완전히 준수해야 한다는 LSP의 요구사항과 맞닿아 있다. 예를 들어, Printer 인터페이스에 print, scan, fax 메서드가 모두 포함되어 있다면, 간단한 프린터는 스캔이나 팩스 기능을 구현할 수 없어 LSP를 위반할 수 있다. 대신 Printable, Scannable 등의 세분화된 인터페이스를 정의하고, 클래스가 필요한 인터페이스만 구현하도록 하는 것이 바람직하다.

명세는 인터페이스가 정의하는 계약을 문서화하거나 코드로 표현하는 것을 의미한다. 이는 메서드의 사전 조건, 사후 조건, 불변 조건을 포함한다. 명확한 명세는 LSP 준수를 검증하는 기준이 된다. 하위 타입은 상위 타입의 명세를 약화시키지 않고도 대체 가능해야 한다. 즉, 사전 조건을 더 강하게 만들거나[9], 사후 조건을 더 약하게 만들면 안 된다.

명세 요소

상위 타입의 규칙

하위 타입이 준수해야 할 규칙 (LSP 관점)

사전 조건

특정 입력 조건을 정의

사전 조건을 약화시킬 수 있음 (더 넓은 입력을 허용)

사후 조건

특정 출력/상태 보장

사후 조건을 강화시킬 수 있음 (더 엄격한 결과 보장)

불변 조건

객체 생명주기 동안 유지되는 조건

불변 조건을 반드시 유지해야 함

따라서, 인터페이스를 분리하고 그에 대한 명확한 명세를 작성하는 것은 하위 타입이 단일하고 명확한 책임을 지며, 상위 타입의 계약을 훼손하지 않도록 보장하는 핵심적인 설계 활동이다.

4.2. 상속보다는 컴포지션

상속은 코드 재사용과 계층 구조 표현에 강력한 도구이지만, 리스코프 치환 원칙을 위반하기 쉬운 구조를 만들 수 있다. 상속은 부모 클래스의 구현 세부 사항에 자식 클래스가 강하게 결합되게 하여, 부모 클래스의 변경이 예상치 못하게 자식 클래스의 동작을 깨뜨릴 위험이 있다. 또한, 자식 클래스가 부모 클래스의 모든 메서드를 필요로 하지 않더라도 강제로 물려받아야 하는 경우가 발생하며, 이는 인터페이스 분리 원칙 위반으로 이어질 수 있다.

이에 대한 대안으로 컴포지션은 한 객체가 다른 객체를 포함하거나 참조하여 기능을 재사용하는 방식이다. 컴포지션은 "has-a" 관계를 형성하며, 런타임에 포함된 객체를 교체할 수 있어 유연성이 높다. 상속이 화이트박스 재사용이라면, 컴포지션은 블랙박스 재사용에 가깝다. 클라이언트는 포함된 객체의 내부 구현을 알 필요 없이 공개된 인터페이스를 통해 협력하므로, 결합도가 낮아지고 LSP를 준수하기 쉬워진다.

컴포지션을 적용할 때는 인터페이스를 통해 협력 관계를 정의하는 것이 좋다. 이는 구체적인 구현 클래스가 아닌 추상화에 의존하게 함으로써, 포함된 객체를 동일한 인터페이스를 만족하는 다른 구현체로 쉽게 교체할 수 있게 한다. 다음은 상속과 컴포지션의 주요 차이점을 비교한 표이다.

특성

상속 (Inheritance)

컴포지션 (Composition)

관계

Is-a (이다)

Has-a (를 가진다)

결합도

강함 (컴파일 타임에 결정)

약함 (런타임에 결정 가능)

재사용 유형

화이트박스 재사용

블랙박스 재사용

유연성

부모 클래스 변경에 취약

포함 객체 교체가 용이

LSP 준수

위반 가능성 높음

위반 가능성 낮음

따라서, LSP를 고려한 설계에서는 상속을 사용하기 전에 컴포지션으로 요구사항을 충족할 수 있는지 먼저 검토하는 것이 바람직하다. 상속은 진정한 계층적 분류와 다형성이 필요한 경우에 제한적으로 사용하고, 기능 확장이나 코드 재사용의 주된 수단으로는 컴포지션과 인터페이스를 우선하는 것이 더 안정적인 설계로 이어진다. 이 접근법은 개방-폐쇄 원칙 준수에도 기여한다.

5. LSP와 다른 SOLID 원칙의 관계

LSP는 SOLID 원칙 중 하나로, 다른 네 가지 원칙과 밀접하게 연결되어 있다. 특히 단일 책임 원칙 및 인터페이스 분리 원칙과 강한 상호작용을 보인다.

단일 책임 원칙은 하나의 클래스가 변경되어야 하는 이유가 하나만 존재해야 함을 강조한다. LSP를 위반하는 설계는 종종 SRP도 함께 위반하는 경우가 많다. 예를 들어, 기반 클래스가 지나치게 많은 책임을 지니고 있으면, 이를 상속받은 파생 클래스가 일부 책임을 올바르게 수행하지 못할 가능성이 높아진다. 이는 하위 타입 치환 가능성을 해친다. 따라서 SRP를 준수하여 각 클래스의 책임을 명확히 분리하는 것은, 파생 클래스가 기반 클래스의 계약을 훼손하지 않고도 특정 책임에 집중할 수 있도록 돕는다. 이는 LSP 준수를 위한 토대를 마련한다.

인터페이스 분리 원칙은 클라이언트가 사용하지 않는 메서드에 의존하지 않아야 함을 뜻한다. ISP와 LSP는 공통적으로 '인터페이스'의 올바른 설계에 주목하지만, 초점이 다르다. ISP는 인터페이스 자체의 크기와 응집도에 중점을 두는 반면, LSP는 그 인터페이스의 행동적 계약과 하위 타입의 치환 가능성을 중시한다. 구체적으로, 지나치게 비대한 인터페이스(ISP 위반)는 파생 클래스가 모든 메서드를 의미 있게 구현하기 어렵게 만들어, 일부 메서드에서 예외를 던지거나 무의미한 동작을 하도록 강요할 수 있다. 이는 명백한 LSP 위반 사례이다. 따라서 ISP를 준수하여 클라이언트별로 세분화된 인터페이스를 제공하는 것은, 각 하위 타입이 자신에게 필요한 계약만을 정확히 이행하도록 유도하여 LSP 준수를 간접적으로 지원한다.

원칙

주요 초점

LSP와의 관계

단일 책임 원칙 (SRP)

클래스의 책임과 변경 이유

책임이 명확해야 하위 타입이 기반 타입의 핵심 책임을 훼손하지 않음

인터페이스 분리 원칙 (ISP)

인터페이스의 크기와 클라이언트 의존

과도한 인터페이스는 LSP 위반(예외 발생 등)을 유발할 수 있음

LSP

하위 타입의 행동적 치환 가능성

SRP와 ISP의 적절한 적용은 LSP 달성을 용이하게 함

결론적으로, LSP는 독립적으로 지켜지기보다는 SRP와 ISP 같은 다른 설계 원칙과 함께 적용될 때 그 효과가 극대화된다. 이들 원칙은 서로 보완적으로 작용하여 유연하고 견고한 객체 지향 설계를 가능하게 한다.

5.1. 단일 책임 원칙(SRP)과의 연관성

단일 책임 원칙(SRP)은 하나의 클래스가 변경되어야 하는 이유는 단 하나여야 한다는 원칙이다. 이 원칙은 클래스의 응집도를 높이고, 변경으로 인한 부작용을 최소화하는 데 목적이 있다. 리스코프 치환 원칙(LSP)은 SRP가 잘 지켜진 설계를 바탕으로 하위 타입의 치환 가능성을 보장하는, 보다 구체적인 행동 규약을 다룬다. SRP는 '무엇을 책임져야 하는가'에 초점을 맞춘다면, LSP는 '그 책임을 어떻게 올바르게 수행해야 하는가'에 초점을 맞춘다.

SRP를 위반하는 클래스, 즉 여러 책임을 지니고 있는 클래스는 자연스럽게 LSP 위반의 가능성을 높인다. 하나의 클래스가 다양한 책임을 수행하려면 그만큼 많은 공개 인터페이스를 노출하게 되고, 이는 하위 클래스가 모든 인터페이스를 올바르게 구현하기 어렵게 만든다. 하위 클래스는 부모 클래스의 일부 책임만 필요로 할 수 있지만, SRP 위반으로 인해 불필요한 인터페이스까지 강제로 상속받아 오버라이드해야 하는 상황이 발생한다. 이 과정에서 하위 클래스의 동작이 부모 클래스의 기대를 저버릴 가능성이 커지며, 이는 LSP 위반으로 이어진다.

반대로, SRP를 준수하여 각 클래스가 명확하고 단일한 책임을 가지면, LSP를 준수하기 위한 조건이 명확해진다. 책임이 단일하면 그에 따른 행동 계약도 단순하고 명확해지기 때문이다. 예를 들어, 저장소라는 책임만 가진 클래스는 데이터를 저장한다는 하나의 주요 계약을 갖는다. 이 클래스를 상속받는 파일 저장소나 데이터베이스 저장소는 그 단일한 책임을 어떻게 수행할지 구체화하면 되므로, LSP를 지키며 부모 타입을 대체하기 쉬워진다. 따라서 SRP는 LSP를 실현하기 위한 필수적인 선결 조건으로 작용한다고 볼 수 있다.

결론적으로, 두 원칙은 서로 보완적 관계에 있다. SRP는 모듈의 내부 구조와 책임의 분리를 통해 변경의 축을 관리하고, LSP는 그러한 모듈들이 다형성을 통해 안전하게 협력할 수 있도록 외부 행동의 일관성을 보장한다. 잘 설계된 시스템은 SRP를 통해 견고한 구성 요소를 만들고, LSP를 통해 그 구성 요소들을 유연하게 조합할 수 있게 된다.

5.2. 인터페이스 분리 원칙(ISP)과의 차이

인터페이스 분리 원칙(ISP)은 클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다. 이 원칙은 하나의 거대한 인터페이스를 여러 개의 구체적이고 작은 인터페이스로 분리하는 것을 권장한다. 반면, 리스코프 치환 원칙(LSP)은 상위 타입의 객체를 하위 타입의 객체로 치환해도 프로그램의 정확성이 깨지지 않아야 한다는 원칙에 초점을 맞춘다.

두 원칙의 핵심 차이는 문제 해결의 접근 방식과 범위에 있다. ISP는 주로 인터페이스의 설계와 클라이언트의 의존성을 다룬다. 불필요한 메서드로 인한 인터페이스 오염을 방지하여 시스템의 결합도를 낮추고 유연성을 높이는 것이 목표이다. 예를 들어, Printer 인터페이스에 print, scan, fax 메서드가 모두 선언되어 있다면, 단순 인쇄만 필요한 클라이언트는 사용하지 않는 scan과 fax 메서드에 의존하게 된다. ISP는 이를 Printer, Scanner, FaxMachine 인터페이스로 분리할 것을 제안한다.

LSP는 이보다 더 근본적인 행위의 호환성과 계약 준수를 다룬다. ISP가 인터페이스의 '크기'와 '의존성'에 주목한다면, LSP는 상속 계층 구조 내에서의 '행위의 일관성'을 강조한다. LSP 위반은 ISP를 준수하는 작은 인터페이스에서도 발생할 수 있다. 예를 들어, Bird 인터페이스에 fly() 메서드만 있다 하더라도, 이를 상속받은 Penguin 클래스가 fly() 메서드를 예외를 발생시키거나 아무 동작도 하지 않도록 구현한다면, 이는 ISP는 지켰지만 LSP를 위반한 사례가 된다.

요약하면, ISP는 인터페이스를 클라이언트 중심으로 세분화하여 불필요한 결합을 피하도록 안내하는 구조적 설계 원칙이다. LSP는 하위 타입이 상위 타입의 기대 행위와 계약을 반드시 유지하도록 요구하는 행위적 상속 원칙이다. 효과적인 객체 지향 설계를 위해서는 두 원칙을 상호 보완적으로 적용해야 한다.

6. 실전 적용: LSP 검증 방법

단위 테스트는 LSP 준수 여부를 검증하는 실용적인 도구이다. 클라이언트 코드가 기반 클래스에 의존하여 작성된 테스트 케이스는, 해당 클래스의 모든 하위 타입 객체로 치환하여 실행해도 동일하게 통과해야 한다. 예를 들어, Bird 클래스에 대한 테스트가 있다면, 이를 상속한 Penguin이나 Ostrich 객체로 테스트를 실행했을 때 실패하지 않아야 LSP를 준수한다고 볼 수 있다. 이는 하위 타입이 기반 타입의 계약을 깨뜨리지 않았는지를 런타임이 아닌 개발 단계에서 확인하는 방법이다.

리팩토링 과정에서 LSP를 고려하는 것은 시스템의 안정성을 유지하는 데 중요하다. 기존 클래스를 상속받는 새로운 클래스를 도입하거나, 상속 계층 구조를 변경할 때는 항상 "치환 가능성"을 검토해야 한다. 클라이언트 코드의 사전 조건을 강화하거나 사후 조건을 약화시키지 않는지, 기대하는 불변식을 훼손하지 않는지 주의 깊게 확인한다. 리팩토링의 목표가 기능 추가나 수정이라 하더라도, 기존에 LSP를 통해 형성된 추상화에 대한 신뢰를 무너뜨리지 않도록 설계해야 한다.

LSP 검증은 공식적인 증명보다는 실용적인 테스트와 코드 리뷰를 통해 이루어진다. 다음 체크리스트를 활용할 수 있다.

검증 항목

설명

치환 테스트

기반 타입용 단위 테스트를 모든 하위 타입으로 실행해 성공하는가?

계약 변경 여부

하위 타입이 메서드의 사전 조건을 강화하거나 사후 조건을 약화시키지 않는가?

예외 일관성

기반 타입이 던지지 않는 새로운 예외를 하위 타입이 던지지 않는가?

의도 불변식

상속을 통해 부모 클래스가 유지하려고 한 핵심 불변식(예: 상태 규칙)을 깨뜨리지 않는가?

이러한 방법론적 접근은 개방-폐쇄 원칙과도 깊이 연결되어, 확장에는 열려 있되 기존 코드의 동작에는 변경이 없도록 보장하는 데 기여한다.

6.1. 단위 테스트를 통한 검증

단위 테스트는 LSP 준수 여부를 검증하는 실용적이고 효과적인 도구이다. 클라이언트 코드의 관점에서 작성된 테스트는 하위 타입이 기대되는 계약을 완전히 이행하는지 확인하는 객관적인 기준을 제공한다.

구체적인 검증 방법은 기반 클래스나 인터페이스에 대한 테스트 스위트를 먼저 작성하는 것이다. 이후 이 테스트 스위트를 파생 클래스의 인스턴스로 실행하여 모든 테스트가 통과하는지 확인한다. 만약 파생 클래스가 테스트를 실패한다면, 그것은 하위 타입 치환 가능성을 위반했을 가능성이 높다. 예를 들어, Bird 클래스에 fly() 메서드에 대한 테스트가 있고, Penguin 클래스가 Bird를 상속받았다면, Penguin 인스턴스로 fly() 테스트를 실행할 때 문제가 발생할 수 있다. 이는 LSP 위반을 암시하는 신호이다.

테스트 작성 시에는 메서드의 사전 조건과 사후 조건, 불변식을 명확히 정의하는 것이 중요하다. 단위 테스트는 이러한 계약을 검증하는 구체적인 사례가 된다. 또한 모의 객체를 사용하여 클라이언트의 관점에서 상호작용을 테스트하면, 하위 타입이 상위 타입과 동일한 방식으로 협력 객체와 소통하는지 확인할 수 있다. 이 접근법은 행동의 치환 가능성을 보장하는 데 도움을 준다.

6.2. 리팩토링 시 고려사항

코드 리팩토링 과정에서 LSP를 준수하는지는 시스템의 안정성을 보장하는 핵심 요소이다. 리팩토링은 외부 동작을 변경하지 않고 내부 구조를 개선하는 작업이므로, 기존 클라이언트 코드가 기대하는 계약을 깨뜨리지 않아야 한다. 특히 상속 관계를 수정하거나 인터페이스를 변경할 때는 하위 타입이 상위 타입을 완전히 대체할 수 있는지 철저히 검증해야 한다.

리팩토링 시 구체적인 고려사항은 다음과 같다. 첫째, 메서드의 사전 조건을 강화하거나 사후 조건을 약화시키지 않아야 한다. 예를 들어, 기존 메서드가 null을 입력받을 수 있었다면, 리팩토링 후 하위 클래스에서 null 입력을 금지하는 것은 LSP 위반이다. 둘째, 예외의 종류를 변경하거나 새로운 검사 예외를 추가하지 않아야 한다. 클라이언트가 기대하지 않은 예외가 발생하면 프로그램의 흐름이 깨질 수 있다.

고려 대상

LSP 준수 예시

LSP 위반 예시

메서드 시그니처

반환 타입을 더 구체적인 하위 타입으로 변경[10]

매개변수 타입을 더 구체적인 타입으로 제한

예외 처리

기존 예외를 더 구체적인 하위 예외로 대체

새로운 검사 예외를 추가로 발생시킴

상태 조건

사전 조건을 완화하거나 사후 조건을 강화

사전 조건을 강화하거나 사후 조건을 완화

마지막으로, 리팩토링 후에는 반드시 기존 단위 테스트를 모두 통과하는지 확인해야 한다. 이 테스트들은 클라이언트의 관점에서 기대하는 동작을 정의한 계약의 역할을 한다. 만약 리팩토링으로 인해 테스트가 실패한다면, 이는 하위 타입이 상위 타입의 동작을 변경했음을 의미하며, LSP 위반 가능성을 시사한다. 따라서 리팩토링은 테스트 주도로 진행하고, 상속 계층 구조의 변경 시에는 해당 타입을 사용하는 모든 클라이언트 코드의 영향을 평가하는 것이 안전하다.

7. 관련 디자인 패턴

LSP 준수 설계를 돕는 대표적인 디자인 패턴으로는 전략 패턴과 템플릿 메서드 패턴이 자주 언급된다. 이 두 패턴은 각기 다른 방식으로 상속의 함정을 피하고 하위 타입의 치환 가능성을 보장하는 데 기여한다.

전략 패턴은 알고리즘 군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만든다. 이 패턴은 상속 대신 인터페이스나 추상 클래스를 통해 공통의 연산을 정의하고, 구체적인 행위는 별도의 전략 객체에 위임한다. 클라이언트는 구체적인 전략 객체의 변경에 영향을 받지 않으며, 새로운 전략을 추가하더라도 기존 코드를 수정할 필요가 없다. 이는 LSP의 핵심인 '하위 타입은 기반 타입을 대체할 수 있어야 한다'는 원칙을 컴포지션을 통해 안전하게 실현하는 방법이다.

반면, 템플릿 메서드 패턴은 상속 구조 내에서 LSP를 준수하도록 유도한다. 이 패턴은 알고리즘의 골격을 추상 클래스의 템플릿 메서드에 정의하고, 알고리즘의 특정 단계들은 하위 클래스에서 구현하도록 남겨둔다. 핵심은 상위 클래스가 정의한 템플릿 메서드의 흐름을 하위 클래스가 깨뜨리지 않도록 하는 것이다. 하위 클래스는 추상 메서드나 훅 메서드를 오버라이드하여 구체적인 동작을 제공하지만, 상위 클래스가 의도한 전체 작업의 순서와 사전/사후 조건을 반드시 유지해야 한다. 이는 상속 계층에서의 계약을 명확히 함으로써 LSP 위반을 방지한다.

다음 표는 두 패턴이 LSP 준수에 기여하는 방식을 비교한다.

패턴

주요 메커니즘

LSP 준수에 기여하는 방식

유의점

전략 패턴

컴포지션, 인터페이스

알고리즘의 변경을 상속이 아닌 객체 조합으로 처리하여, 하위 타입의 행위 변경이 기반 타입의 계약을 훼손하지 않도록 보장한다.

전략 인터페이스의 설계가 명확해야 하며, 전략 객체 간에 완전한 치환 가능성이 유지되어야 한다.

템플릿 메서드 패턴

상속, 추상 클래스

알고리즘의 불변 구조를 상위 클래스가 정의하고, 가변 부분만 하위 클래스가 책임지도록 강제하여, 하위 클래스가 전체 알고리즘의 정확성을 해치지 않도록 한다.

상위 클래스의 템플릿 메서드가 잘 정의된 골격을 제공해야 하며, 하위 클래스는 훅 메서드를 오버라이드할 때 상위 클래스의 사전/사후 조건을 준수해야 한다.

결론적으로, 전략 패턴은 'has-a' 관계를 통해, 템플릿 메서드 패턴은 'is-a' 관계 내에서 각각 LSP를 준수하는 안정적인 설계를 가능하게 한다. 설계자는 문제의 맥락에 따라 두 패턴 중 하나를 선택하거나 조합하여 사용할 수 있다.

7.1. 전략 패턴

전략 패턴은 행위 패턴의 일종으로, 실행 중에 알고리즘을 선택할 수 있게 하는 디자인 패턴이다. 이 패턴은 특정 작업을 수행하는 여러 알고리즘이 존재하고, 클라이언트가 런타임에 적절한 알고리즘을 결정해야 할 때 유용하게 적용된다. 전략 패턴의 핵심은 알고리즘 군을 정의하고 각각을 별도의 클래스로 캡슐화하여, 이들을 상호 교환 가능하게 만드는 것이다.

이 패턴은 일반적으로 세 가지 주요 구성 요소로 이루어진다. 첫째는 모든 구체적인 전략 클래스가 구현해야 하는 공통 인터페이스인 Strategy 인터페이스이다. 둘째는 이 인터페이스를 구현하는 하나 이상의 ConcreteStrategy 클래스들이다. 셋째는 Strategy 인터페이스를 참조하여 사용하는 Context 클래스이다. Context는 구체적인 전략 객체에 대한 의존성을 주입받아, 그 객체에게 실제 작업을 위임한다.

구성 요소

역할

Strategy

모든 구체적 전략이 구현해야 하는 공통 인터페이스를 정의한다.

ConcreteStrategy

Strategy 인터페이스를 구현하는 구체적인 알고리즘 클래스이다.

Context

Strategy 객체를 참조하며, 필요에 따라 이를 교체할 수 있다. 실제 작업은 연결된 전략 객체에 위임한다.

전략 패턴은 LSP 준수와 밀접한 관련이 있다. 각 ConcreteStrategy 클래스는 Strategy 인터페이스의 계약을 완전히 준수해야 하며, 클라이언트(Context)는 구체적인 전략의 종류에 관계없이 동일한 방식으로 상호작용할 수 있어야 한다. 이는 곧 모든 구체적 전략이 상위 타입(Strategy)을 완전히 대체할 수 있어야 함을 의미하며, LSP의 핵심인 하위 타입 치환 가능성을 잘 보여준다. 예를 들어, 정렬 알고리즘을 QuickSortStrategy와 MergeSortStrategy로 구현했다면, Context는 이 둘을 아무 문제 없이 교체하며 사용할 수 있어야 한다.

7.2. 템플릿 메서드 패턴

템플릿 메서드 패턴은 LSP 준수를 촉진하는 대표적인 행위 패턴이다. 이 패턴은 알고리즘의 골격을 슈퍼클래스에 정의된 템플릿 메서드로 제공하고, 알고리즘의 특정 단계들은 서브클래스에서 재정의하도록 한다. 핵심은 템플릿 메서드가 호출하는 훅 메서드나 추상 메서드의 구현이 부모 클래스가 정의한 사전/사후 조건을 깨뜨리지 않는 것이다.

패턴을 적용할 때는 서브클래스가 템플릿 메서드의 흐름을 역전시키지 않아야 한다. 예를 들어, 부모 클래스의 템플릿 메서드가 initialize() -> execute() -> cleanup() 순서로 메서드를 호출한다면, 모든 서브클래스는 이 순서를 변경해서는 안 된다. 서브클래스는 각 단계의 구체적인 내용만 변경할 뿐, 알고리즘의 전체 구조와 각 단계 간의 계약을 준수해야 한다.

패턴 요소

역할

LSP 준수 관점

AbstractClass

알고리즘의 골격(템플릿 메서드)과 필요한 추상 연산을 정의한다.

변하지 않는 알고리즘 구조와 서브클래스가 준수해야 할 계약을 명시한다.

ConcreteClass

추상 연산을 구체적으로 구현하여 알고리즘의 특정 단계를 제공한다.

부모 클래스가 정의한 각 단계의 의도와 사전/사후 조건을 위반하지 않도록 구현한다.

이 패턴은 LSP를 잘 지키면 코드 재사용과 확장성을 높이지만, 위반하면 알고리즘의 정합성이 깨질 수 있다. 따라서 템플릿 메서드 패턴을 사용할 때는 서브클래스가 부모 클래스의 템플릿 메서드를 올바른 컨텍스트에서 대체할 수 있도록 설계하는 것이 중요하다.

8. 여담

"여담" 섹션은 LSP의 핵심 원칙을 넘어서는 역사적 배경, 문화적 영향, 또는 실무에서의 흥미로운 관점을 다룹니다.

이 원칙은 로버트 C. 마틴이 1996년 논문 "The Liskov Substitution Principle"[11]을 통해 정립했으며, 원래 개념은 바바라 리스코프가 1987년 발표한 "Data Abstraction and Hierarchy" 논문에서 기원합니다. 따라서 원칙의 이름은 창시자인 리스코프의 이름을 따서 명명되었습니다. 실무에서는 "IS-A" 관계에 대한 직관적인 이해와 LSP의 엄격한 요구사항 사이에 간극이 존재하는 경우가 많아, 개발자들에게 지속적인 설계 고민을 제공합니다.

일부 커뮤니티에서는 LSP를 단순히 상속 문제로만 보는 경향이 있지만, 이 원칙은 인터페이스 구현과 다형성을 사용하는 모든 타입 계층 구조에 적용되는 광범위한 원칙입니다. 이로 인해 자바나 C# 같은 정적 타입 언어뿐만 아니라, 파이썬이나 자바스크립트 같은 동적 타입 언어에서도 코드의 신뢰성과 예측 가능성을 높이는 중요한 지침으로 여겨집니다.

9. 관련 문서

  • Microsoft - Language Server Protocol

  • Wikipedia - Language Server Protocol

  • GitHub - microsoft/language-server-protocol

  • Visual Studio Code - Language Servers

  • Eclipse Foundation - LSP4J

  • Red Hat Developer - What is the Language Server Protocol?

  • langserver.org - Language Server Protocol Implementations

리비전 정보

버전r1
수정일2026.02.14 23:09
편집자unisquads
편집 요약AI 자동 생성