리스코프 치환 원칙
1. 개요
1. 개요
리스코프 치환 원칙은 객체 지향 프로그래밍의 핵심 설계 원칙 중 하나로, SOLID 원칙의 'L'에 해당한다. 이 원칙은 1988년 바바라 리스코프가 '데이터 추상화와 계층 구조'라는 논문에서 제시한 개념이다.
이 원칙의 핵심은 "상위 타입의 객체를 사용하는 코드는, 그 객체를 하위 타입의 객체로 치환하더라도 정상적으로 동작해야 한다"는 것이다. 즉, 프로그램의 정확성을 깨뜨리지 않으면서도 기반 클래스를 그 파생 클래스로 대체할 수 있어야 한다. 이는 단순한 상속 관계가 아닌, 행동의 호환성을 강조하는 다형성의 올바른 사용을 의미한다.
리스코프 치환 원칙은 견고한 소프트웨어 아키텍처를 구축하는 데 중요한 역할을 한다. 이 원칙을 준수하면 코드의 재사용성과 유지보수성이 향상되며, 예기치 않은 부작용과 버그를 줄일 수 있다. 반대로, 이 원칙을 위반하면 개방-폐쇄 원칙을 지키기 어려워지고, 시스템이 취약해지는 결과를 초래한다.
2. 정의와 핵심 개념
2. 정의와 핵심 개념
리스코프 치환 원칙은 객체 지향 프로그래밍의 핵심 원칙 중 하나로, 바바라 리스코프가 1987년 논문에서 제시한 개념이다. 이 원칙은 상속 관계에 있는 클래스들이 프로그램의 정확성을 깨뜨리지 않으면서 서로 치환 가능해야 함을 의미한다. 간단히 말해, 어떤 프로그램에서 부모 클래스 타입의 객체가 사용되는 모든 자리에 그 자식 클래스의 객체를 넣어도 프로그램이 여전히 올바르게 동작해야 한다.
원칙의 공식적 정의는 "S가 T의 하위 타입이라면, 프로그램에서 T 타입의 객체를 사용하는 모든 곳에서 S 타입의 객체로 치환해도 프로그램의 속성(정확성, 수행하는 작업 등)은 변경되지 않아야 한다"는 것이다. 이는 단순한 구문적 호환성을 넘어 의미론적, 행동적 호환성을 요구한다. 즉, 자식 클래스는 부모 클래스가 제공하는 퍼블릭 인터페이스를 모두 준수할 뿐만 아니라, 부모 클래스의 행동 규약(기대되는 동작)까지도 보존해야 한다.
핵심 개념인 행동적 하위 타입은 이 원칙을 구현하는 구체적인 방법을 제시한다. 행동적 하위 타입이 되기 위해서는 자식 클래스가 다음 규칙을 따라야 한다.
* 강화된 사전 조건: 자식 클래스의 메서드는 부모 클래스 메서드보다 더 약한(덜 제한적인) 사전 조건만을 요구할 수 있다.
* 약화된 사후 조건: 자식 클래스의 메서드는 부모 클래스 메서드보다 더 강한(더 많은 보장을 하는) 사후 조건을 제공해야 한다.
* 불변 조건 유지: 자식 클래스는 부모 클래스가 유지하는 모든 불변식을 반드시 보존해야 한다.
이러한 규칙들은 계약에 의한 설계 개념과 깊이 연관되어 있으며, 상속을 통한 다형성이 예측 가능하고 안정적으로 작동하도록 보장하는 기반이 된다.
2.1. 리스코프 치환 원칙의 공식적 정의
2.1. 리스코프 치환 원칙의 공식적 정의
리스코프 치환 원칙은 1987년 바바라 리스코프가 발표한 논문 "데이터 추상화와 계층 구조"에서 제시된 개념이다. 이 원칙은 객체 지향 프로그래밍에서 하위 타입의 관계를 정의하는 핵심 규칙을 제공한다.
리스코프 치환 원칙의 공식적 정의는 다음과 같다. "S 타입의 객체 o1 각각에 대해, T 타입의 객체 o2가 있고, T에 의해 정의된 모든 프로그램 P에서 o2의 자리에 o1을 치환해도 P의 행동이 변하지 않는다면, S는 T의 하위 타입이다." 간단히 말해, 상위 타입 T를 사용하는 모든 프로그램은 하위 타입 S의 객체로 대체되어도 정확히 동일하게 작동해야 한다는 것이다.
이 정의는 수학적 치환 가능성에 기반을 두며, 다형성의 올바른 사용을 보장한다. 원칙의 핵심은 상속이 단순히 코드 재사용을 위한 메커니즘이 아니라, 행동의 호환성을 보장하는 계약의 관계여야 함을 강조한다. 따라서 하위 클래스는 상위 클래스가 제공하는 공개 인터페이스의 규약을 반드시 준수해야 한다.
용어 | 설명 |
|---|---|
프로그램 P | 상위 타입 T에 의존하는 모든 클라이언트 코드 또는 모듈을 의미한다. |
치환 가능성 | 하위 타입 S의 인스턴스가 상위 타입 T의 인스턴스 위치에 아무런 문제 없이 대체될 수 있는 성질이다. |
행동 불변성 | 치환 후 프로그램의 정확성, 예외 발생, 상태 변화 등 모든 관찰 가능한 행동이 원본과 동일하게 유지되어야 함을 의미한다. |
이 원칙은 타입 시스템의 이론적 기초를 넘어, 실제 소프트웨어 설계에서 인터페이스 분리 원칙 및 의존 관계 역전 원칙과 함께 작동하여 유연하고 견고한 시스템을 구축하는 데 기여한다.
2.2. 행동적 하위 타입
2.2. 행동적 하위 타입
행동적 하위 타입은 리스코프 치환 원칙을 준수하는 하위 클래스가 가져야 할 핵심 속성을 설명하는 개념이다. 이 개념은 단순히 프로그래밍 언어의 구문적 상속 관계(예: extends 또는 implements)를 넘어, 타입의 사용 가능한 행동에 초점을 맞춘다. 즉, 어떤 타입 S가 타입 T의 행동적 하위 타입이 되려면, T의 객체를 기대하는 모든 프로그램에서 S의 객체로 치환해도 프로그램의 정확성이 깨지지 않아야 한다[1].
행동적 하위 타입 관계를 검증하기 위해서는 상위 타입이 정의한 계약을 하위 타입이 준수하는지 확인해야 한다. 이 계약은 메서드의 사전 조건, 사후 조건, 그리고 불변식으로 구성된다. 행동적 하위 타입은 다음과 같은 규칙을 따라야 한다.
* 사전 조건 약화: 하위 타입의 메서드는 상위 타입의 메서드보다 더 약한(덜 제한적인) 사전 조건을 가질 수 있다. 즉, 상위 타입이 요구하는 조건보다 더 넓은 입력을 받아들일 수 있다.
* 사후 조건 강화: 하위 타입의 메서드는 상위 타입의 메서드보다 더 강한(더 제한적인) 사후 조건을 가져야 한다. 즉, 상위 타입이 보장하는 결과보다 더 구체적이고 엄격한 결과를 보장해야 한다.
* 불변식 유지: 하위 타입은 상위 타입이 정의한 모든 불변식을 반드시 유지해야 하며, 추가적인 불변식을 도입할 수 있다.
이 접근법은 "is-a" 관계를 구문이 아닌 의미론과 행동으로 재정의한다. 예를 들어, 수학적으로 "정사각형은 직사각형이다"라는 명제는 참이지만, 가변적인 setWidth와 setHeight 메서드를 가진 클래스 계층 구조에서는 Square가 Rectangle의 행동적 하위 타입이 되지 못한다[2]. 따라서 행동적 하위 타입은 상속의 올바른 사용을 위한 실용적인 지침을 제공한다.
3. 원칙의 중요성과 목적
3. 원칙의 중요성과 목적
리스코프 치환 원칙은 객체 지향 프로그래밍 설계의 견고함과 유연성을 보장하는 핵심 원리이다. 이 원칙은 상속 관계가 논리적으로 타당하고, 시스템의 예측 가능성을 유지하도록 한다. 상속을 단순히 코드 재사용의 수단으로만 보는 시각을 넘어, 타입 간의 행동 호환성을 강조함으로써 설계의 신뢰도를 높인다.
이 원칙의 주요 목적은 다형성을 안전하게 활용할 수 있는 기반을 마련하는 것이다. 클라이언트 코드가 기반 클래스나 인터페이스를 통해 작업할 때, 그 하위 타입을 아무런 의심 없이 치환하여 사용할 수 있어야 한다. 이를 통해 클라이언트 코드는 구체적인 하위 타입에 대한 지식 없이도, 추상화에 의존하여 정확하게 동작할 수 있다. 결과적으로 시스템은 확장에 열려 있으면서도(개방-폐쇄 원칙), 기존 코드를 손상시키지 않는 변경이 가능해진다.
원칙을 준수하지 않을 경우 발생하는 문제는 다음과 같다.
문제점 | 설명 |
|---|---|
의도하지 않은 동작 | 하위 타입이 상위 타입의 기대 행동을 깨뜨려, 런타임 오류나 논리적 오류를 초래한다. |
취약한 상속 계층 | 하위 타입을 사용할 때마다 특별한 조건을 확인해야 하며, 이는 상속의 이점을 무효화한다. |
테스트의 어려움 | 상위 타입에 대한 단위 테스트가 하위 타입에서 실패할 수 있어, 테스트 신뢰도가 떨어진다. |
확장의 두려움 | 새로운 하위 클래스를 추가할 때 기존 기능을 파괴할지 모른다는 불안감이 생겨 시스템 진화를 막는다. |
따라서 이 원칙은 소프트웨어 구성 요소의 상호 교환성을 보증하는 일종의 '계약' 역할을 한다. 이는 단순한 구문 호환성을 넘어, 의미론적이고 행동적인 호환성을 요구함으로써, 대규모 시스템에서 모듈 간의 결합도를 낮추고 유지보수성을 극대화하는 데 기여한다.
4. 원칙 위반 사례와 문제점
4. 원칙 위반 사례와 문제점
리스코프 치환 원칙 위반은 상속 관계가 논리적으로는 타당해 보이지만, 실제 다형성을 사용하는 클라이언트 코드에서 예상치 못한 오류나 동작을 초래하는 경우를 말한다. 가장 유명한 고전적인 사례는 직사각형-정사각형 문제이다. 이 문제는 수학적으로 정사각형이 직사각형의 특수한 경우이므로, Square 클래스가 Rectangle 클래스를 상속받는 것이 자연스럽게 보인다. 그러나 Rectangle 클래스에 setWidth와 setHeight라는 별도의 메서드가 존재할 경우, Square 클래스에서는 두 메서드가 서로 영향을 미치도록 오버라이드해야 한다. 이로 인해 Rectangle 객체를 기대하는 클라이언트 코드가 Square 객체로 치환되었을 때, 너비와 높이를 독립적으로 설정할 수 있다는 가정이 깨지고 프로그램의 정확성이 훼손된다[3].
일반적인 위반 패턴은 크게 몇 가지로 구분된다. 첫째, 하위 타입이 상위 타입의 메서드를 무효화하는 경우다. 예를 들어, 상위 타입의 메서드가 어떤 작업을 수행하도록 정의되어 있는데, 하위 타입에서 이를 빈 구현이나 예외를 발생시키는 방식으로 오버라이드하면 원칙을 위반한다. 둘째, 하위 타입이 상위 타입의 메서드 사전 조건을 강화하거나 사후 조건을 약화하는 경우다. 상위 타입의 메서드가 0 이상의 정수를 입력받는다고 정의했을 때, 하위 타입에서 1 이상의 정수로 조건을 강화하면, 상위 타입을 사용하던 클라이언트 코드가 하위 타입으로 치환될 때 유효했던 입력이 거부될 수 있다. 반대로 사후 조건을 약화하면 클라이언트가 기대하는 결과의 강도를 보장할 수 없다.
위반 패턴 | 설명 | 간단한 예시 |
|---|---|---|
메서드 무효화 | 하위 타입이 상위 타입 메서드의 본래 의도나 기능을 없애는 오버라이드. |
|
사전 조건 강화 | 하위 타입이 상위 타입 메서드보다 더 엄격한 입력 조건을 요구함. |
|
사후 조건 약화 | 하위 타입이 상위 타입 메서드보다 더 약한 출력이나 상태 변경을 보장함. |
|
예외 추가 | 상위 타입 메서드가 던지지 않던 새로운 예외를 하위 타입에서 던짐. |
|
이러한 위반은 시스템의 신뢰성을 떨어뜨린다. 클라이언트 코드는 항상 구체적인 하위 타입을 확인하는 방어적 코드를 작성해야 하며, 이는 개방-폐쇄 원칙을 위반하고 코드 복잡성을 증가시킨다. 궁극적으로는 상속의 가장 큰 장점인 다형성과 확장성을 활용하지 못하게 만든다.
4.1. 직사각형-정사각형 문제
4.1. 직사각형-정사각형 문제
직사각형-정사각형 문제는 리스코프 치환 원칙을 위반하는 고전적인 사례로 자주 인용된다. 이 문제는 객체 지향 프로그래밍에서 상속 관계를 직관적으로 모델링할 때 발생할 수 있는 함정을 보여준다. 수학적으로 정사각형은 직사각형의 특수한 경우이므로, Square 클래스가 Rectangle 클래스를 상속받는 것은 자연스러워 보인다. 그러나 이 상속 관계는 객체의 행동 측면에서 문제를 일으킬 수 있다.
일반적인 Rectangle 클래스는 setWidth와 setHeight 메서드를 독립적으로 제공한다. 반면 Square는 너비와 높이가 항상 같아야 하므로, 한쪽을 설정할 때 다른 쪽도 함께 변경되어야 한다. 만약 Square가 Rectangle을 상속받고 setWidth 메서드를 오버라이드하여 높이도 함께 변경하도록 구현하면, 클라이언트 코드에 예기치 않은 영향을 미친다.
예를 들어, Rectangle 객체의 너비를 증가시키는 다음 함수를 고려해 보자.
```java
void increaseWidth(Rectangle r) {
r.setWidth(r.getWidth() + 10);
}
```
이 함수는 인자로 Rectangle의 인스턴스를 받는다. 다형성에 따라 Square 인스턴스를 이 함수에 전달할 수 있다. 그러나 increaseWidth 함수는 Rectangle의 행동을 가정하고 작성되었기 때문에, Square의 setWidth 메서드가 높이까지 변경한다는 사실을 알지 못한다. 결과적으로 Square 객체의 면적은 예상과 다르게 변할 수 있으며, 이는 리스코프 치환 원칙 위반이다. Square는 Rectangle을 대체했을 때 프로그램의 정확성을 해치지 않아야 하지만, 실제로는 그렇지 않다.
이 문제의 해결책은 행동 호환성에 초점을 맞추는 것이다. Square와 Rectangle 사이에 IS-A 관계가 성립하더라도, 두 클래스의 불변식과 메서드의 계약이 충돌하면 상속을 사용해서는 안 된다. 대신 두 클래스가 공통의 인터페이스나 추상 클래스(예: Shape)를 구현하거나, 컴포지션을 사용하는 등의 대안을 고려할 수 있다. 이 사례는 상속을 결정할 때 "is-a" 관계보다 "행동적으로 대체 가능한가"라는 질문이 더 중요함을 보여준다.
4.2. 일반적인 위반 패턴
4.2. 일반적인 위반 패턴
리스코프 치환 원칙 위반은 여러 가지 일반적인 패턴으로 나타납니다. 이러한 패턴들은 대체로 부모 클래스가 정의한 계약을 자식 클래스가 약화시키거나 무효화함으로써 발생합니다.
하위 타입이 상위 타입의 사전 조건을 강화하거나 사후 조건을 약화시키는 경우가 대표적입니다. 예를 들어, 상위 타입의 메서드가 값 >= 0을 사전 조건으로 요구한다면, 하위 타입은 이를 값 > 0으로 강화해서는 안 됩니다. 이는 상위 타입이 허용하는 입력(0)을 하위 타입이 거부하게 만들어 치환 가능성을 깨뜨립니다. 반대로, 상위 타입이 반환값 != null을 사후 조건으로 보장한다면, 하위 타입이 null을 반환할 수 있도록 약화시키는 것도 위반입니다.
위반 패턴 | 설명 | 예시 |
|---|---|---|
사전 조건 강화 | 하위 타입이 상위 타입보다 더 엄격한 입력 조건을 요구함. | 부모: |
사후 조건 약화 | 하위 타입이 상위 타입보다 더 약한 출력이나 상태 보장을 제공함. | 부모: |
예외 발생 | 상위 타입이 발생시키지 않는 새로운 예외를 하위 타입이 발생시킴. | 부모의 메서드가 |
역할 거부 | 하위 타입이 상위 타입의 특정 메서드나 동작을 "아무 작업도 하지 않음"이나 "의미 없는 값 반환"으로 구현함. |
|
또 다른 흔한 위반 패턴은 하위 타입이 상위 타입과 관련 없는 새로운 예외를 발생시키는 것입니다. 이는 상위 타입을 사용하는 클라이언트 코드가 처리하지 않은 예외에 직면하게 만들어 시스템의 안정성을 해칩니다. 마지막으로, 하위 타입이 상위 타입의 특정 메서드를 "역할 거부" 방식으로 구현하는 경우도 있습니다. 예를 들어, fly() 메서드를 가진 Bird 클래스를 상속받은 Penguin 클래스가 fly() 메서드를 아무 동작도 하지 않거나 UnsupportedOperationException을 던지도록 구현하는 것이 이에 해당합니다. 이는 "새는 난다"는 상위 타입의 암묵적 계약을 하위 타입이 위반하는 사례입니다.
5. 원칙 준수를 위한 설계 지침
5. 원칙 준수를 위한 설계 지침
리스코프 치환 원칙을 준수하는 설계를 위해서는 계약에 의한 설계 방법론을 적용하는 것이 효과적이다. 이 방법론은 객체의 행동을 클라이언트와 객체 간의 명시적 계약으로 정의하며, 이 계약은 사전 조건, 사후 조건, 그리고 불변식으로 구성된다. 하위 타입은 상위 타입의 계약을 약화시키지 않는 방향으로만 변경할 수 있다. 구체적으로, 하위 타입은 상위 타입의 사전 조건을 더 강하게 만들거나, 사후 조건을 더 약하게 만들어서는 안 된다.
사전 조건은 메서드가 호출되기 전에 클라이언트가 반드시 만족시켜야 할 조건을 의미한다. 하위 클래스는 이 사전 조건을 완화(약화)할 수는 있지만, 강화해서는 안 된다. 예를 들어, 상위 타입의 메서드가 "입력값은 0 이상이어야 한다"는 사전 조건을 가진다면, 하위 타입은 이를 "입력값은 -10 이상이어야 한다"로 완화할 수 있다. 그러나 "입력값은 10 이상이어야 한다"로 강화하면, 상위 타입에서는 유효했던 호출이 하위 타입에서는 실패하게 되어 치환 가능성을 훼손한다.
반대로, 사후 조건은 메서드 실행 후 객체가 보장해야 하는 조건을 의미한다. 하위 클래스는 이 사후 조건을 강화할 수는 있지만, 완화해서는 안 된다. 상위 타입의 메서드가 "반환값은 0 이상 100 이하이다"라는 사후 조건을 가진다면, 하위 타입은 이를 "반환값은 10 이상 90 이하이다"로 강화하는 것은 허용된다. 그러나 "반환값은 0 이상 200 이하이다"로 범위를 넓히는 것은 허용되지 않는다. 이는 상위 타입이 보장하던 결과의 범위를 하위 타입이 훼손할 수 있기 때문이다.
또한, 상위 타입이 유지하는 중요한 불변식은 하위 타입에서도 반드시 유지되어야 한다. 불변식은 객체의 수명 주기 전체에 걸쳐 항상 참이어야 하는 조건이다. 하위 타입이 이 불변식을 깨뜨리는 속성을 추가하거나 메서드를 변경하면, 상위 타입의 객체를 사용하는 코드의 가정이 무너지게 되어 원칙 위반으로 이어진다. 설계 시에는 상위 타입의 추상화가 하위 타입의 모든 가능한 구현에 대해 의미 있는 계약을 제공하는지 신중히 검토해야 한다.
5.1. 계약에 의한 설계
5.1. 계약에 의한 설계
계약에 의한 설계(Design by Contract, DbC)는 리스코프 치환 원칙을 준수하기 위한 구체적인 방법론으로, 소프트웨어 구성 요소 간의 상호작용을 명확한 계약 조건으로 정의하는 접근법이다. 이 개념은 버트란드 메이어(Bertrand Meyer)가 제안했으며, 에펠(Eiffel) 프로그래밍 언어에 구현되었다. 계약에 의한 설계는 클라이언트(호출자)와 공급자(피호출자, 예: 슈퍼클래스 또는 서브클래스) 간의 의무와 이익을 사전 조건, 사후 조건, 불변식으로 명시한다.
계약의 세 가지 주요 요소는 다음과 같다.
계약 요소 | 설명 | 책임 주체 |
|---|---|---|
사전 조건(Precondition) | 메서드가 올바르게 실행되기 위해 클라이언트가 반드시 만족시켜야 하는 조건이다. 예를 들어, 입력 매개변수가 양수여야 한다는 조건이 포함될 수 있다. | 주로 클라이언트(호출자)의 책임이다. 서브클래스는 사전 조건을 강화할 수 없다(약화는 가능). |
사후 조건(Postcondition) | 메서드 실행이 완료된 후 공급자가 보장해야 하는 조건이다. 예를 들어, 반환값이 특정 범위 내에 있거나 객체 상태가 특정 조건을 유지하는 것을 보장한다. | 공급자(피호출자)의 책임이다. 서브클래스는 사후 조건을 약화할 수 없다(강화는 가능). |
불변식(Invariant) | 객체의 수명 주기 동안(메서드 호출 전후를 제외하고) 항상 유지되어야 하는 클래스 수준의 조건이다. 예를 들어, 객체의 내부 상태가 일관성을 유지해야 한다. | 공급자(클래스)의 책임이다. 서브클래스도 상위 클래스의 불변식을 유지해야 한다. |
리스코프 치환 원칙은 이 계약 관계를 하위 타입에 적용한다. 서브클래스는 상위 클래스와의 계약을 위반해서는 안 된다. 구체적으로, 서브클래스의 메서드는 상위 클래스 메서드의 사전 조건보다 더 강력한(제한적인) 요구사항을 가져서는 안 되며, 사후 조건과 불변식은 상위 클래스의 것보다 더 약해져서는 안 된다[4]. 이 규칙을 지키면 클라이언트 코드는 자신이 상위 클래스와 맺은 계약을 신뢰한 채, 하위 타입의 객체를 안전하게 치환하여 사용할 수 있다. 계약에 의한 설계는 이러한 규칙을 명시적으로 정의하고 검증함으로써 리스코프 치환 원칙의 실질적인 준수를 도와준다.
5.2. 사전 조건과 사후 조건
5.2. 사전 조건과 사후 조건
계약에 의한 설계에서 핵심을 이루는 요소는 사전 조건과 사후 조건, 그리고 불변 조건입니다. 이들은 메서드나 서브루틴의 호출자와 피호출자 사이의 명시적 계약을 정의합니다.
사전 조건은 메서드가 올바르게 실행되기 위해 호출자가 반드시 만족시켜야 하는 조건입니다. 예를 들어, 매개변수 값이 특정 범위 내에 있어야 하거나, 객체가 특정 상태여야 할 수 있습니다. 리스코프 치환 원칙에 따르면, 하위 클래스의 메서드는 상위 클래스의 메서드보다 더 강력한 사전 조건을 요구해서는 안 됩니다. 즉, 하위 클래스는 상위 클래스가 받아들일 수 있는 모든 입력을 동일하게 받아들여야 합니다. 사전 조건을 강화하면, 상위 클래스 인스턴스가 동작하던 컨텍스트에서 하위 클래스 인스턴스로 치환했을 때, 기존에 유효했던 입력이 새로 강화된 조건에 의해 거부될 수 있기 때문입니다.
반대로, 사후 조건은 메서드 실행이 성공적으로 종료된 후에 보장되는 조건입니다. 이는 반환 값의 상태나 객체의 상태 변화를 포함할 수 있습니다. 리스코프 치환 원칙은 하위 클래스의 메서드가 상위 클래스의 메서드보다 더 약한 사후 조건을 제공해서는 안 된다고 요구합니다. 하위 클래스는 상위 클래스가 보장하는 모든 결과와 상태 변화를 최소한 그대로 보장해야 하며, 추가적인 보장을 할 수는 있습니다. 사후 조건을 약화시키면, 호출자가 상위 클래스로부터 기대했던 보장을 하위 클래스가 제공하지 못하게 되어 시스템의 신뢰성이 떨어집니다.
이러한 조건들을 준수함으로써, 하위 타입의 객체는 상위 타입의 객체를 대체했을 때 호출자의 관점에서 계약의 차이를 인지하지 못하게 됩니다. 이는 행동적 하위 타입의 개념을 실현하는 구체적인 방법이 됩니다.
6. 다른 SOLID 원칙과의 관계
6. 다른 SOLID 원칙과의 관계
리스코프 치환 원칙은 SOLID 원칙의 'L'에 해당하며, 나머지 네 가지 원칙과 긴밀하게 연결되어 객체 지향 설계의 일관성을 제공한다. 특히 개방-폐쇄 원칙과 강한 상호 의존성을 가진다. 개방-폐쇄 원칙은 확장에는 열려 있고 수정에는 닫혀 있는 모듈 구조를 지향하는데, 이 확장의 정확성을 보장하는 것이 바로 리스코프 치환 원칙이다. 하위 클래스가 상위 클래스를 올바르게 대체할 수 있을 때, 기존 코드를 수정하지 않고도 새로운 하위 타입을 안전하게 추가할 수 있게 된다.
단일 책임 원칙과도 간접적으로 연관된다. 단일 책임 원칙을 위반하여 하나의 클래스가 지나치게 많은 책임을 지면, 그 클래스를 상속받은 하위 클래스가 부모의 모든 행동을 보장하기 어려워져 리스코프 치환 원칙 위반으로 이어지는 경우가 많다. 또한 인터페이스 분리 원칙은 클라이언트가 사용하지 않는 메서드에 의존하지 않도록 인터페이스를 작게 유지하는 것을 강조한다. 이는 하위 타입이 불필요하거나 지원하지 않는 연산을 강제로 구현하게 만드는 상황을 방지함으로써, 리스코프 치환 원칙 준수를 돕는다.
의존관계 역전 원칙은 추상화에 의존하도록 하여 구체적인 모듈 간의 결합을 낮춘다. 이때 고수준 모듈이 의존하는 추상화(인터페이스나 추상 클래스)를 구체화하는 저수준 모듈들은, 그 추상화의 계약을 반드시 지켜야 한다. 리스코프 치환 원칙은 이 계약 준수를 통해 의존관계 역전 원칙이 의도한 유연성과 교체 가능성을 실제로 실현할 수 있는 기반을 마련해준다.
다른 SOLID 원칙 | 리스코프 치환 원칙과의 관계 |
|---|---|
개방-폐쇄 원칙 (OCP) | LSP는 OCP를 가능하게 하는 선행 조건이다. 올바른 하위 타입 치환이 보장되어야 확장 시 기존 코드 수정을 피할 수 있다. |
단일 책임 원칙 (SRP) | SRP 위반은 과도한 책임으로 인해 하위 클래스가 상위 클래스의 계약을 이행하기 어렵게 만들어 LSP 위반을 초래할 수 있다. |
인터페이스 분리 원칙 (ISP) | ISP는 클라이언트별로 세분화된 인터페이스를 제공함으로써, 하위 타입이 사용하지 않는 메서드 구현을 강제받아 LSP를 위반하는 상황을 예방한다. |
의존관계 역전 원칙 (DIP) | DIP가 제안하는 추상화에의 의존은, 구체적인 하위 타입들이 그 추상화의 계약을 지킬 때(LSP) 비로소 안정적으로 작동한다. |
7. 실제 적용 사례
7. 실제 적용 사례
리스코프 치환 원칙은 이론적인 개념을 넘어 자바와 같은 현대 프로그래밍 언어의 핵심 라이브러리 설계에 깊이 반영되어 있다. 대표적인 예로 컬렉션 프레임워크와 스트림 API를 들 수 있다.
자바 컬렉션 프레임워크에서 List 인터페이스와 그 구현체들(ArrayList, LinkedList 등)의 관계는 이 원칙의 모범적인 적용 사례이다. List 인터페이스는 순서가 있는 컬렉션의 계약을 정의한다. 사용자는 List 타입으로 참조하는 객체가 add(), get(), remove() 등의 메서드를 특정 방식으로 수행할 것이라고 기대한다. ArrayList나 LinkedList는 내부 구현 방식(배열 기반, 노드 연결 기반)이 완전히 다르지만, List 인터페이스의 계약을 모두 충실히 이행한다. 따라서 코드에서 List<String> list = new ArrayList<>();라고 선언한 부분을 List<String> list = new LinkedList<>();로 변경하더라도, 프로그램의 정확성에는 전혀 영향을 미치지 않는다. 이것이 바로 하위 타입(ArrayList, LinkedList)이 상위 타입(List)을 완전히 대체할 수 있는 리스코프 치환의 실현이다.
자바 8에서 도입된 스트림 API의 설계 또한 이 원칙을 따르고 있다. Stream 인터페이스와 이를 상속받는 IntStream, LongStream, DoubleStream은 특화된 스트림이다. 이들 특화 스트림은 Stream의 연산(map, filter, collect 등)을 그대로 물려받으면서, 기본형(int, long, double)을 처리함에 따른 오토박싱 오버헤드를 제거하는 방식으로 행동을 강화(약화시키지 않고)하였다. 사용자는 Stream 인터페이스를 통해 정의된 연산 체인이 IntStream에서도 동일한 의미론으로 작동할 것이라고 신뢰할 수 있다. 이는 하위 타입(IntStream)이 상위 타입(Stream)의 모든 계약을 지키면서도 효율성을 개선한 경우로, 원칙을 준수하면서도 유용한 최적화를 제공하는 좋은 예시이다.
적용 영역 | 상위 타입(계약) | 하위 타입(구현체) | 치환 가능성과 효과 |
|---|---|---|---|
컬렉션 |
|
| 내부 구현과 성능 특성은 다르지만, |
스트림 |
|
| 기본형 처리에 특화되어 성능이 향상되었지만, |
7.1. 컬렉션 프레임워크
7.1. 컬렉션 프레임워크
자바의 컬렉션 프레임워크는 리스코프 치환 원칙을 잘 보여주는 대표적인 사례이다. List, Set, Map과 같은 상위 수준의 인터페이스와 ArrayList, LinkedList, HashSet, TreeMap과 같은 구체적인 구현 클래스들이 그 관계를 구성한다. 예를 들어, List 인터페이스는 순서가 있는 컬렉션의 동작을 정의하며, 이를 구현하는 ArrayList나 LinkedList는 그 계약을 준수하면서 내부 구현 방식은 다르다. 클라이언트 코드는 List 타입으로 객체를 참조하고, add(), get(), remove()와 같은 메서드를 호출할 때 구체적인 구현 클래스가 무엇인지 알 필요 없이 일관된 행동을 기대할 수 있다.
이 원칙의 준수는 다형성을 통한 유연한 설계를 가능하게 한다. 아래 표는 주요 컬렉션 인터페이스와 그 구현체의 예시를 보여준다.
인터페이스 (상위 타입) | 주요 구현체 (하위 타입) | 핵심 특성 |
|---|---|---|
|
| 순서 유지, 중복 허용 |
|
| 중복 불허 |
|
| 키-값 쌍, 키 중복 불허 |
Collections.unmodifiableList()와 같은 메서드가 반환하는 불변 리스트도 이 원칙을 따르는 좋은 예다. 이 메서드는 기존 리스트를 감싸서 수정 작업(add, remove 등)을 금지시키지만, List 인터페이스 타입으로 반환한다. 클라이언트는 이를 일반 List처럼 사용할 수 있으나, 수정을 시도하면 예외가 발생한다. 이는 하위 타입(UnmodifiableList)이 상위 타입(List)의 계약 중 '읽기' 관련 계약은 완전히 준수하면서, '쓰기' 계약에 대해서는 사전 조건을 강화하여[5] 명시적으로 금지하는 방식으로 동작한다. 이는 상위 타입의 기대 행동을 깨뜨리지 않는 범위 내에서의 변형으로 이해될 수 있다.
따라서 컬렉션 프레임워크는 클라이언트 코드가 추상화에 의존하게 함으로써 구현체를 자유롭게 교체할 수 있는 유연성을 제공하며, 이는 리스코프 치환 원칙이 설계의 기초에 잘 녹아들어가 있기 때문에 가능하다.
7.2. 스트림 API
7.2. 스트림 API
스트림 API는 자바 8에서 도입된 기능으로, 데이터 소스의 요소들을 선언적이고 함수형 스타일로 처리하기 위한 추상화를 제공한다. 이 API는 리스코프 치환 원칙을 잘 반영하는데, 핵심 인터페이스인 Stream<T>와 그 하위 스트림(IntStream, LongStream, DoubleStream) 사이의 관계가 원칙을 준수하기 때문이다. 숫자형 특화 스트림은 BaseStream 인터페이스를 공통으로 상속받으며, Stream 인터페이스의 일반적인 연산(filter, map, reduce 등)을 그대로 사용할 수 있다. 이는 클라이언트 코드가 Stream<Integer>를 기대하는 곳에 IntStream을 사용하더라도 예상된 동작을 보장함을 의미한다.
스트림 파이프라인의 각 단계(중간 연산)는 불변성을 유지하며 새로운 스트림을 반환하는데, 이 설계는 LSP의 핵심인 '행동 호환성'을 강화한다. 예를 들어, sorted()나 distinct() 같은 상태 저장 중간 연산을 적용해도 원본 데이터 소스를 변경하지 않으며, 단순히 새로운 스트림을 생성한다. 이는 상위 타입인 Stream이 제공하는 연산의 계약을 하위 구현체가 깨뜨리지 않음을 보여준다. 또한 최종 연산(collect, forEach)은 파이프라인의 결과를 소비하는 일관된 방식을 제공하여, 클라이언트가 스트림의 구체적인 타입을 알지 못해도 안전하게 사용할 수 있게 한다.
스트림 API의 또 다른 LSP 준수 사례는 반환 타입의 공변성과 관련 있다. Stream.map() 연산은 Function 매퍼를 적용한 후 새로운 Stream<R>을 반환한다. 이때 IntStream.map()은 IntUnaryOperator를 받아 IntStream을 반환하는 등, 특화된 스트림 타입은 자신의 타입을 유지하는 더 구체적인 연산을 제공한다. 이는 상위 타입의 연산을 더 제한적으로(공변적으로) 오버라이드하여, 기대하는 반환 타입의 하위 타입을 돌려줌으로써 LSP를 준수한다. 클라이언트는 Stream 인터페이스만으로 모든 스트림을 동일하게 다룰 수 있으며, 특화된 스트림이 추가적인 효율성(오토박싱 방지)을 제공하더라도 기본 계약을 위반하지 않는다.
연산 유형 |
|
|
|---|---|---|
중간 연산 (상태 비저장) |
|
|
중간 연산 (매핑) |
|
|
최종 연산 (집계) |
|
|
최종 연산 (수집) |
|
|
이러한 설계 덕분에 개발자는 List<Integer>를 IntStream으로 변환하여 성능을 높이거나, Stream<String>과 동일한 패러다임으로 코드를 작성할 수 있다. 스트림 API는 추상화와 다형성을 통해 LSP가 추구하는 대체 가능성을 구체적인 라이브러리 수준에서 실현한典型案例이다.
8. 장점과 한계
8. 장점과 한계
리스코프 치환 원칙을 준수하는 설계는 소프트웨어의 유지보수성과 신뢰성을 크게 향상시킨다. 가장 큰 장점은 다형성을 안전하게 활용할 수 있게 되어, 클라이언트 코드가 기반 클래스에 의존하면서도 다양한 하위 클래스를 예측 가능하게 사용할 수 있다는 점이다. 이는 코드 재사용을 촉진하고, 시스템의 확장성을 높인다. 새로운 기능을 추가할 때 기존 코드를 수정하지 않고도 새로운 하위 타입을 도입할 수 있으므로 개방-폐쇄 원칙과도 깊이 연관된다. 또한, 원칙을 통해 계약에 의한 설계 사고가 강화되어, 메서드의 사전 조건과 사후 조건이 명확해지고 모듈 간의 의존 관계가 견고해진다.
그러나 이 원칙에는 실용적인 한계와 도전 과제도 존재한다. 가장 큰 어려움은 행동적 호환성을 보장하기 위해 과도하게 제약 조건을 완화해야 할 수 있다는 점이다. 예를 들어, 하위 타입이 상위 타입의 메서드 사전 조건을 더 강화하거나 사후 조건을 약화시키지 못하도록 제한함으로써, 특정 하위 타입에 최적화된 효율적인 구현을 방해할 수 있다. 때로는 논리적으로 보이는 "is-a" 관계(예: 정사각형은 직사각형이다)가 실제 행동에서는 치환 가능성을 만족시키지 못하는 경우도 발생한다. 이는 상속보다는 구성을 우선시하라는 조언으로 이어지곤 한다.
또 다른 한계는 원칙의 검증이 주로 개발자의 설계 판단과 테스트에 의존한다는 점이다. 대부분의 정적 타입 언어는 형식 시스템 수준에서 리스코프 치환 원칙을 완전히 강제하지 않는다. 컴파일러가 통과시키는 코드라도 원칙을 위반할 수 있으며, 이는 런타임에 예기치 않은 오류로 나타날 수 있다. 따라서 원칙 준수 여부는 꼼꼼한 단위 테스트와 코드 리뷰를 통해 보완해야 한다. 결국, 리스코프 치환 원칙은 이상적인 지침이지만, 실제 프로젝트에서는 성능 요구사항이나 비즈니스 복잡성과 같은 다른 요소들과 균형을 맞추며 적용해야 한다.
