2025-08-09 00:26

Tags:

리스코프 치환 원칙 핸드북

1. 만들어진 이유: “믿음”을 지키기 위한 약속

객체 지향 프로그래밍의 강력한 기능 중 하나는 상속입니다. 부모 클래스의 기능을 자식 클래스가 물려받아 코드를 재사용하고 관계를 명확히 할 수 있죠. 하지만 상속을 잘못 사용하면 예상치 못한 문제가 발생합니다.

“새는 날 수 있다”는 명제를 생각해보겠습니다. 클래스를 만들고 fly() 메서드를 정의했습니다. 그리고 참새 클래스가 를 상_속받는 것은 자연스러워 보입니다. 참새는 새의 특징을 모두 가지며, 잘 날아다니니까요.

class Bird {
    void fly() {
        System.out.println("하늘을 납니다.");
    }
}

class Sparrow extends Bird {
    // 문제 없음
}

그런데 타조는 어떨까요? 타조도 분명 ‘새’의 한 종류입니다. 그래서 타조 클래스가 를 상속받게 했습니다.

class Ostrich extends Bird {
    @Override
    void fly() {
        // 타조는 날 수 없는데... 어떡하지?
        throw new UnsupportedOperationException("타조는 날 수 없습니다!");
    }
}

이제 를 사용하는 코드를 보겠습니다.

void makeBirdFly(Bird bird) {
    bird.fly(); // 이 코드는 Bird 타입의 객체라면 무엇이든 날 수 있을 것이라고 "믿고" 있음
}

// ...
Bird sparrow = new Sparrow();
Bird ostrich = new Ostrich();

makeBirdFly(sparrow); // "하늘을 납니다." -> 예상대로 동작
makeBirdFly(ostrich); // UnsupportedOperationException 발생! -> 프로그램 비정상 종료

makeBirdFly함수는Bird타입의 객체를 받으면 당연히fly()가 가능할 것이라고 기대합니다. 이것이 부모 클래스와 자식 클래스 간의 **암묵적인 약속(계약)**입니다. 하지만 타조` 객체를 전달하자 이 믿음과 약속이 깨지면서 프로그램이 오작동합니다.

이처럼 자식 클래스를 부모 클래스 대신 사용했을 때, 프로그램의 정확성이나 동작 방식이 변하면 안 된다는 원칙이 바로 **리스코프 치환 원칙(LSP)**입니다. 1988년 컴퓨터 과학자 바바라 리스코프(Barbara Liskov)가 처음 소개한 개념으로, 상속 관계가 논리적으로도 올바르게 동작하도록 보장하는 규칙입니다.


2. 구조: 무엇을 지켜야 하는가?

LSP를 만족시키려면 자식 클래스(서브타입)가 부모 클래스(슈퍼타입)의 ‘계약’을 위반하지 않아야 합니다. 이 계약은 크게 다음 규칙들로 구체화할 수 있습니다.

규칙 1: 메서드 시그니처는 동일하게 유지되어야 한다.

자식 클래스는 부모 클래스의 메서드 이름, 파라미터 개수와 타입, 리턴 타입을 그대로 따라야 합니다. (언어에 따라 리턴 타입이나 파라미터 타입의 변형을 일부 허용하기도 하지만, 기본 원칙은 ‘일관성’입니다.)

규칙 2: 부모의 사전 조건(Preconditions)을 더 강화해서는 안 된다.

  • 사전 조건: 메서드가 올바르게 실행되기 위해 만족해야 하는 조건.
  • 예시: 부모 클래스의 calculate(int a) 메서드가 모든 양수를 처리할 수 있다면, 자식 클래스에서 이 조건을 “10보다 큰 양수만 처리”하도록 강화(제한)해서는 안 됩니다. 부모를 사용하던 코드는 아무 양수나 넣어도 괜찮았는데, 자식으로 바꾸니 10 이하의 값에서 에러가 발생하면 안 되기 때문입니다.

규칙 3: 부모의 사후 조건(Postconditions)을 더 약화해서는 안 된다.

  • 사후 조건: 메서드가 실행된 후 보장해야 하는 결과나 상태.
  • 예시: 부모 클래스의 connect() 메서드가 “연결 성공 또는 예외 발생”을 보장한다면, 자식 클래스가 “연결이 될 수도 있고, 안 될 수도 있고, 아무 일도 일어나지 않을 수도 있는” 상태를 만들어서는 안 됩니다. 부모가 보장하던 최소한의 결과는 자식도 보장해야 합니다.

규칙 4: 부모가 가지는 불변성(Invariants)은 자식도 유지해야 한다.

  • 불변성: 객체가 존재하는 동안 항상 유지되어야 하는 상태나 규칙.
  • 예시: ‘직사각형’ 클래스는 “너비와 높이는 항상 양수”라는 불변성을 가집니다. 이를 상속받는 ‘정사각형’ 클래스도 이 불변성을 반드시 지켜야 합니다.

가장 유명한 LSP 위반 사례인 정사각형-직사각형 문제가 바로 이 불변성 규칙을 어긴 경우입니다.

  • 직사각형 클래스는 set너비(w)set높이(h) 메서드를 통해 너비와 높이를 독립적으로 변경할 수 있습니다. 이것이 직사각형의 특징(불변성)입니다.
  • 정사각형은 너비와 높이가 항상 같아야 합니다. 만약 정사각형직사각형을 상속받는다면, set너비(10)를 호출했을 때 높이까지 10으로 바뀌어야 정사각형의 정의를 만족합니다.
  • 하지만 직사각형을 사용하던 코드는 set너비(10)을 호출해도 높이는 변하지 않을 것이라고 기대합니다. 정사각형 객체로 치환되는 순간, 이 기대가 깨지면서 예상치 못한 부작용(side effect)이 발생합니다. 따라서 “정사각형은 직사각형이다(is-a)“라는 현실 세계의 관계가 코드의 상속 관계로는 적절하지 않은 것입니다.

3. 사용법: 어떻게 적용하는가?

LSP를 지키는 것은 “이 상속이 타당한가?”를 끊임없이 질문하는 과정입니다.

올바른 예: 파일 읽기

FileReader라는 부모 클래스가 있고, read() 메서드를 통해 파일 내용을 문자열로 반환한다고 가정해봅시다.

  • TextFileReaderFileReader를 상속받아 텍스트 파일을 읽습니다.
  • CsvFileReaderFileReader를 상속받아 CSV 파일을 읽습니다.

이 경우, FileReader를 사용하던 코드는 어떤 자식 클래스(TextFileReader, CsvFileReader)로 치환되어도 “파일을 읽어 문자열을 얻는다”는 핵심 동작이 변하지 않습니다. 이는 LSP를 잘 지킨 사례입니다.

// LSP 준수 예시
abstract class Document {
    abstract String read();
}
 
class TextDocument extends Document {
    @Override
    String read() {
        // 텍스트 파일 읽기 로직
        return "Text content";
    }
}
 
class CsvDocument extends Document {
    @Override
    String read() {
        // CSV 파일 읽기 로직
        return "CSV,content";
    }
}
 
void printContent(Document doc) {
    System.out.println(doc.read()); // 어떤 Document든 read()가 가능할 것이라 믿음
}

잘못된 예시 해결하기: 새와 타조

타조 문제로 돌아가 봅시다. 이 문제를 해결하려면 어떻게 해야 할까요? “날 수 있다”는 공통되지 않는 특징을 부모 클래스에서 제거하는 것입니다.

  1. 라는 추상적인 개념은 그대로 둡니다.

  2. 나는 새(FlyingBird)날지 못하는 새(NonFlyingBird)라는 중간 단계를 만듭니다.

  3. fly() 메서드는 나는 새 클래스에만 정의합니다.

// LSP를 고려하여 개선한 구조
class Bird {
    void breathe() { /* 모든 새는 숨을 쉰다 */ }
}

class FlyingBird extends Bird {
    void fly() {
        System.out.println("하늘을 납니다.");
    }
}

class NonFlyingBird extends Bird {
    void run() {
        System.out.println("달립니다.");
    }
}

class Sparrow extends FlyingBird { /* 참새는 나는 새 */ }
class Ostrich extends NonFlyingBird { /* 타조는 날지 못하는 새 */ }

이제 fly()가 필요한 코드는 FlyingBird 타입을 사용하고, run()이 필요한 코드는 NonFlyingBird 타입을 사용하면 됩니다. 더 이상 타조에게 날라고 시키는 코드를 작성할 위험이 사라졌습니다. 이처럼 LSP를 고민하는 과정은 자연스럽게 더 나은 클래스 설계를 유도합니다.

4. 심화 내용: 다른 원칙과의 관계

LSP는 다른 SOLID 원칙과 깊은 관련이 있습니다.

  • 개방-폐쇄 원칙 (Open-Closed Principle, OCP): “확장에는 열려 있고, 수정에는 닫혀 있어야 한다”는 원칙입니다. LSP를 위반하면(예: 타조 사례), 클라이언트 코드에서 if (bird instanceof Ostrich) 같은 분기 처리를 추가해야 합니다. 이는 새로운 종류의 새가 추가될 때마다 코드를 ‘수정’해야 하므로 OCP를 위반하게 됩니다. 즉, LSP를 지키는 것은 OCP를 지키는 데 도움이 됩니다.

  • 인터페이스 분리 원칙 (Interface Segregation Principle, ISP): “클라이언트는 자신이 사용하지 않는 메서드에 의존해서는 안 된다”는 원칙입니다. 클래스에 fly(), swim(), run() 등 모든 행동을 넣는 대신, Flyable, Swimmable, Runnable 같은 작은 인터페이스로 분리하는 것이 더 좋습니다. 이는 LSP 위반 가능성을 원천적으로 줄여줍니다.

결론적으로, 리스코프 치환 원칙은 단순히 상속 규칙을 넘어, 객체 지향 시스템의 신뢰성과 안정성을 보장하는 핵심 철학입니다. 자식 클래스가 부모 클래스의 역할을 문제없이 대체할 수 있을 때, 우리는 비로소 유연하고 확장 가능한 코드를 만들 수 있습니다.

현재 작업 중인 코드에서 부모 클래스를 자식 클래스로 바꿨을 때, 아무런 코드 수정 없이도 시스템이 동일하게 동작한다고 확신할 수 있으신가요?

리스코프 치환 원칙 (Liskov Substitution Principle, LSP) 핸드북

이 핸드북은 객체 지향 프로그래밍의 핵심 원칙 중 하나인 **리스코프 치환 원칙(LSP)**에 대한 깊이 있는 이해를 돕기 위해 제작되었습니다. 원칙의 탄생 배경부터 구체적인 사용법과 심화 내용까지 체계적으로 안내합니다.


1. 만들어진 이유: 신뢰성 있는 상속 구조의 필요성

1980년대, 객체 지향 프로그래밍(OOP)이 점차 주류로 자리 잡으면서 ‘상속’이라는 강력한 기능이 널리 사용되기 시작했습니다. 상속은 코드 재사용성을 높이고 개발 속도를 향상시키는 획기적인 방법이었지만, 무분별한 상속은 예기치 않은 문제를 낳았습니다.

가장 큰 문제는 **‘신뢰성’**이었습니다. 부모 클래스(기반 클래스)를 자식 클래스(파생 클래스)로 교체했을 때, 프로그램이 예상과 다르게 동작하거나 오류를 일으키는 경우가 많았습니다. 이는 마치 ‘새’라는 부모 클래스를 상속받은 ‘펭귄’ 자식 클래스가 ‘날다’라는 부모의 행동을 제대로 수행하지 못하는 상황과 같습니다. ‘펭귄’은 분명 ‘새’이지만, 모든 ‘새’처럼 날지는 못하기 때문입니다.

이러한 혼란 속에서 1988년, 컴퓨터 과학자 **바바라 리스코프(Barbara Liskov)**는 컨퍼런스 기조연설에서 ‘데이터 추상화와 계층(Data Abstraction and Hierarchy)‘이라는 주제로 이 문제에 대한 해법을 제시했습니다. 이것이 바로 리스코프 치환 원칙의 시작입니다. 그녀는 “여기서 필요한 것은 다음과 같은 대체 속성입니다. S가 T의 하위 유형이라면, T 타입의 객체는 S 타입의 객체로 교체 가능해야 하며, 프로그램의 정확성에 영향을 주지 않아야 한다.”라고 명확히 정의했습니다.

결국 LSP는 상속 관계의 신뢰성을 보장하여, 자식 클래스가 부모 클래스의 역할을 온전히 수행할 수 있도록 하는, 즉 ‘대체 가능성’을 보장하는 규칙을 만들기 위해 탄생했습니다. 이를 통해 개발자는 특정 클래스의 내부 구현을 몰라도, 해당 클래스가 속한 타입 계층의 규약을 믿고 프로그램을 작성할 수 있게 되었습니다.


2. 구조: 계약에 의한 설계 (Design by Contract)

리스코프 치환 원칙은 단순히 ‘자식 클래스는 부모 클래스로 교체할 수 있어야 한다’는 추상적인 개념을 넘어, ‘계약에 의한 설계(Design by Contract)‘라는 구체적인 규칙들을 통해 정의됩니다. 이는 부모 클래스와 자식 클래스 간의 지켜야 할 약속, 즉 ‘계약’을 명시하는 것입니다.

주요 계약 조건은 다음과 같습니다.

  • 사전 조건(Preconditions) 강화 금지:

    • 의미: 자식 클래스의 메서드는 부모 클래스의 메서드보다 더 까다로운 입력 조건을 가질 수 없음.

    • 비유: 부모 클래스가 ‘아무 숫자나 입력받아 처리하는’ 기능을 제공한다면, 자식 클래스가 ‘오직 양수만 입력받아 처리’하도록 제한해서는 안 됨. 이는 부모를 기대하고 사용하는 클라이언트 코드에 예외를 발생시킬 수 있기 때문.

    • 규칙: 자식 클래스의 사전 조건은 부모 클래스의 사전 조건과 같거나 더 완화되어야 함.

  • 사후 조건(Postconditions) 약화 금지:

    • 의미: 자식 클래스의 메서드는 부모 클래스의 메서드보다 더 약한 결과(느슨한 출력 조건)를 반환할 수 없음.

    • 비유: 부모 클래스가 ‘모든 사용자 정보를 반환하는’ 기능을 약속했다면, 자식 클래스가 ‘일부 사용자 정보만 반환’해서는 안 됨. 클라이언트는 부모의 약속을 믿고 모든 정보를 기대하기 때문.

    • 규칙: 자식 클래스의 사후 조건은 부모 클래스의 사후 조건과 같거나 더 강화되어야 함.

  • 불변 조건(Invariants) 유지:

    • 의미: 자식 클래스의 모든 메서드는 부모 클래스가 유지하는 상태(클래스의 불변 속성)를 그대로 유지해야 함.

    • 비유: ‘사각형’ 부모 클래스가 ‘너비와 높이는 항상 양수’라는 불변 조건을 가지고 있다면, 이를 상속받은 ‘정사각형’ 자식 클래스 역시 이 조건을 깨뜨려서는 안 됨.

  • 역사 규칙(History Constraint):

    • 의미: 자식 객체는 부모 객체의 상태를 변경할 때, 부모가 허용하지 않는 방식으로 변경해서는 안 됨. 이는 객체가 생성된 후 그 상태가 일관성을 유지해야 함을 의미.

이러한 계약들은 수학적으로 **공변성(Covariance)**과 반공변성(Contravariance) 개념과 연결됩니다.

  • 메서드 파라미터 (입력): 반공변성 (Contravariance)

    • 자식 클래스 메서드의 파라미터 타입은 부모 클래스 메서드의 파라미터 타입과 같거나 더 상위 타입이어야 합니다. (사전 조건 강화 금지와 연결)
  • 메서드 반환 타입 (출력): 공변성 (Covariance)

    • 자식 클래스 메서드의 반환 타입은 부모 클래스 메서드의 반환 타입과 같거나 더 하위 타입이어야 합니다. (사후 조건 약화 금지와 연결)

3. 사용법: LSP 위반 사례와 해결책

LSP를 실제로 어떻게 적용할 수 있는지 흔한 위반 사례와 해결책을 통해 알아보겠습니다.

대표적인 위반 사례: 직사각형과 정사각형 문제

Java

// 부모 클래스: 직사각형
class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

// 자식 클래스: 정사각형 (LSP 위반)
class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width); // 너비를 바꾸면 높이도 바꾼다.
    }

    @Override
    public void setHeight(int height) {
        super.setWidth(height); // 높이를 바꾸면 너비도 바꾼다.
        super.setHeight(height);
    }
}

// 클라이언트 코드
public class Client {
    public void test(Rectangle r) {
        r.setWidth(5);
        r.setHeight(4);
        // 클라이언트는 r의 넓이가 20일 것이라고 기대한다.
        // 하지만 r이 Square 인스턴스라면, 넓이는 16이 되어버린다!
        assert r.getArea() == 20; // AssertionError 발생
    }
}

문제점: Square 클래스는 Rectangle의 불변 조건(너비와 높이가 독립적으로 변경될 수 있다)을 깨뜨립니다. test 메서드 입장에서 Rectangle 타입의 객체 r에 너비 5, 높이 4를 설정했으므로 넓이는 당연히 20이 되어야 합니다. 하지만 Square 객체가 들어오면 setHeight(4)가 호출되는 순간 너비까지 4로 바뀌어 넓이가 16이 되는, 예측 불가능한 상황이 발생합니다. 이는 명백한 LSP 위반입니다.

해결책:

  1. 상속 관계 제거: 정사각형이 직사각형의 모든 행동 규약을 따르지 않는다면, 상속은 올바른 선택이 아닙니다. 별도의 클래스로 분리하거나, ‘도형(Shape)‘이라는 공통 인터페이스를 만들어 각각 구현하는 것이 좋습니다.

    Java

    interface Shape {
        int getArea();
    }
    
    class Rectangle implements Shape {
        // ...
    }
    
    class Square implements Shape {
        private int side;
        // ...
    }
    
  2. 불변 객체로 설계: 상태 변경 자체를 막아 문제를 원천적으로 차단할 수도 있습니다.

    Java

    class Rectangle {
        protected final int width;
        protected final int height;
    
        public Rectangle(int width, int height) {
            this.width = width;
            this.height = height;
        }
        // setter 대신 생성자로 초기화
    }
    

4. 심화 내용: 더 넓은 관점에서의 LSP

  • SOLID 원칙의 주춧돌: LSP는 SOLID 원칙 중 **개방-폐쇄 원칙(OCP)**을 지원하는 핵심 기반입니다. OCP는 ‘확장에는 열려 있고, 수정에는 닫혀 있어야 한다’는 원칙인데, LSP가 지켜져야만 새로운 자식 클래스(확장)가 추가되어도 기존 클라이언트 코드를 수정할 필요가 없게 됩니다.

  • 단위 테스트와의 관계: 잘 작성된 단위 테스트는 LSP 준수 여부를 검증하는 좋은 도구가 될 수 있습니다. 부모 클래스에 대해 작성된 테스트 케이스가 모든 자식 클래스에서도 동일하게 통과한다면, LSP를 잘 지키고 있을 확률이 높습니다.

  • API 설계의 관점: 라이브러리나 프레임워크를 설계할 때 LSP는 특히 중요합니다. API 사용자는 기반 클래스나 인터페이스의 명세만 보고 코드를 작성하기 때문에, 하위 클래스가 그 명세를 어기면 전체 시스템의 신뢰도가 무너질 수 있습니다. 따라서 API 설계자는 하위 타입이 지켜야 할 계약을 명확히 문서화하고, 이를 위반할 가능성이 있는 상속 구조를 피해야 합니다.

리스코프 치환 원칙은 단순히 코드 레벨의 기술이 아니라, 시스템 전체의 안정성과 확장성을 보장하는 설계 철학에 가깝습니다.


이 핸드북이 리스코프 치환 원칙에 대한 깊이 있는 이해에 도움이 되었기를 바랍니다. 혹시 이 원칙을 실제 프로젝트에 적용하면서 겪었던 어려움이나 특별한 사례가 있다면 어떻게 해결하셨나요?

References

리스코프 치환 원칙