🎯1주차 Unit 3.3 — LSP (리스코프 치환 원칙)

Psj·2026년 5월 7일

F-lab

목록 보기
32/142

🎯 Unit 3.3 — LSP (리스코프 치환 원칙)

F-lab Java 1주차 / Phase 3 / Unit 3.3 본격 학습 자료
9-섹션 마스터 프롬프트 형식으로 깊이 파헤친다.

선수 지식: Unit 2.3 (상속), Unit 2.4 (다형성), Unit 3.2 (OCP)
다음 Unit: 3.4 — ISP (인터페이스 분리 원칙)

이 Unit의 의미: 상속과 다형성의 진짜 규칙.
OCP만 적용하면 위험할 수 있는 이유를 LSP가 설명한다.
다형성을 안전하게 사용하기 위한 계약.


🌍 1. 세상 속 비유

LSP = "직책 대리 가능성"

회사에 부장 김씨 가 있습니다. 김씨가 휴가를 가서 대리 박씨 가 그 자리를 대신합니다.

대리가 안전하게 가능한 조건:

  • 박씨는 김씨의 모든 업무를 수행 가능해야 함
  • 박씨가 처리한 결과가 김씨의 결과와 본질적으로 같아야
  • 박씨가 갑자기 이상한 행동 하면 안 됨

만약 박씨가:

  • 김씨의 업무 일부를 못 함 → ❌ 대리 불가
  • 같은 일을 했는데 결과가 완전히 다름 → ❌ 대리 부적합
  • 새 규칙을 만들어 거부함 → ❌ 부적합

박씨가 김씨의 자리를 안전하게 대체 할 수 있어야 진짜 대리.

자바에서:

  • 김씨 = 부모 클래스
  • 박씨 = 자식 클래스
  • 대리 가능 = LSP 만족

더 직관적인 비유 — "음식 알레르기"

당신이 친구에게 "과일 어떤 거든 가져와줘" 라고 부탁합니다.

  • 친구가 사과를 가져옴 → ✅ 먹음
  • 친구가 바나나를 가져옴 → ✅ 먹음
  • 친구가 체리를 가져옴 → ✅ 먹음

그런데 만약 친구가 "독이 든 사과" 를 가져왔다면?

  • 형식상 "과일" 의 한 종류
  • 그러나 먹으면 죽음
  • → "과일" 의 약속을 깨뜨림

당신은 모든 과일을 안전하게 먹을 수 있다고 가정했는데, 자식 (독사과) 이 그 가정을 깨뜨림.

이게 LSP 위반. 자식이 부모의 약속을 깨면 안 됨.


핵심 한 문장

"부모를 사용하는 모든 곳에 자식을 넣어도 정상 동작해야 한다."

LSP의 핵심:

  • 부모 타입으로 사용하던 곳에 자식 객체를 넣어도
  • 예상치 못한 동작 / 예외 가 발생해서는 안 됨
  • 자식은 부모의 계약 을 지켜야 함

비유 정리:

비유 요소LSP 적용
부장 김씨부모 클래스
대리 박씨자식 클래스
안전한 대리LSP 만족
박씨가 업무 거부LSP 위반
독사과LSP 위반 자식

🔥 2. 탄생 배경

Barbara Liskov (1987) — LSP의 원조

LSPBarbara Liskov (튜링상 수상자, MIT 교수) 가 1987년 OOPSLA 컨퍼런스에서 발표한 개념.

원래 정의 (학술적 표현):

"Subtype Requirement: Let φ(x) be a property provable about objects x of type T. Then φ(y) should be true for objects y of type S where S is a subtype of T."

번역하면:

"T 타입의 객체 x에 대해 증명 가능한 속성 φ(x) 가 있다면, S가 T의 자식 타입일 때 S 타입의 객체 y에 대해서도 φ(y) 가 참이어야 한다."

더 쉬운 표현 (Robert C. Martin):

"Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it."

("부모 클래스 참조를 사용하는 함수는, 자식 클래스 객체를 모른 채 사용할 수 있어야 한다.")


등장 배경 — "상속의 함정"

1980년대 후반, 객체지향이 보편화되면서 "이상한 상속" 들이 등장:

// 정사각형이 직사각형의 일종이라 생각해서 상속
public class Rectangle {
    private int width;
    private int height;
    
    public void setWidth(int width) { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    public int getArea() { return width * height; }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;  // 정사각형은 width = height
    }
    
    @Override
    public void setHeight(int height) {
        this.width = height;  // 동일
        this.height = height;
    }
}

문제 발생:

public void test(Rectangle r) {
    r.setWidth(5);
    r.setHeight(10);
    
    assert r.getArea() == 50;  // Rectangle: ✅, Square: ❌ (100)
}

test(new Rectangle());  // 통과
test(new Square());     // 실패! Square의 setHeight가 width도 변경

Rectangle을 사용하는 곳에 Square를 넣으면 동작이 깨짐LSP 위반.

→ Barbara Liskov가 이런 함정을 정식으로 이론화.


다형성과 LSP의 관계 ⭐

다형성 (Unit 2.4) 이 작동하려면 LSP가 필수:

[다형성]: 부모 타입으로 자식 객체 사용
        ↓ 그러나
   "자식이 부모처럼 동작" 보장 필요
        ↓
[LSP]: 자식이 부모의 계약을 지킴
        ↓
[안전한 다형성 ✅]

핵심 통찰:

"LSP는 다형성의 안전 계약이다."

LSP를 어기면 다형성이 위험한 도구로 변함. OCP가 가능해도 LSP가 깨지면 시스템이 깨짐.


"is-a" vs "behaves-like-a" ⭐

상속의 흔한 오해:

  • "is-a 관계면 상속해도 됨" (X)
  • "behaves-like-a 관계여야 상속 가능" (O)

정사각형 vs 직사각형:

  • 수학적으로: 정사각형은 직사각형의 일종 (is-a) ✅
  • 행동적으로: 정사각형은 직사각형처럼 동작 X (behaves-like-a 위반) ❌

수학적 관계가 아닌 '행동 호환성' 이 LSP의 본질.


핵심 통찰

"LSP는 '상속을 했다면 부모의 계약을 지켜라' 의 원칙이다."

자식 클래스가 부모의 메서드 시그니처를 그대로 따른다고 해서 LSP를 만족하는 게 아니다. 부모를 사용하던 모든 코드가 자식을 사용해도 안전하게 동작 해야 한다.

LSP를 어기면 다형성이 위험해지고, OCP도 무너진다. 상속은 강력하지만 위험한 도구 — LSP는 그 위험을 통제하는 안전 장치.


💣 3. 없으면 생기는 문제

LSP를 위반했을 때의 구체적 문제를 보겠습니다.

시나리오 1: 새의 분류 — 클래식 LSP 위반 사례

public class Bird {
    public void fly() {
        System.out.println("날고 있다");
    }
}

public class Sparrow extends Bird { }   // 참새는 날 수 있음 ✅
public class Eagle extends Bird { }     // 독수리도 날 수 있음 ✅

public class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("펭귄은 못 난다!");
    }
}

문제 발생:

public void makeAllBirdsFly(List<Bird> birds) {
    for (Bird bird : birds) {
        bird.fly();  // 펭귄 만나면 💥 예외!
    }
}

List<Bird> birds = List.of(
    new Sparrow(),
    new Eagle(),
    new Penguin()  // 폭탄
);

makeAllBirdsFly(birds);  // 💥 UnsupportedOperationException

왜 LSP 위반?:

  • Bird 의 약속: "모든 새는 날 수 있다"
  • Penguin 이 그 약속을 깸
  • 부모 자리에 자식을 넣으면 시스템이 깨짐

해결 — 행동에 따른 계층 재설계

public class Bird { }  // 모든 새

public interface FlyingBird {  // 날 수 있는 새
    void fly();
}

public class Sparrow extends Bird implements FlyingBird {
    @Override
    public void fly() { System.out.println("참새가 난다"); }
}

public class Penguin extends Bird {
    public void swim() { System.out.println("펭귄이 헤엄친다"); }
    // FlyingBird 구현 X — 못 나는 새
}

// 안전한 사용
public void makeAllFlyingBirdsFly(List<FlyingBird> birds) {
    for (FlyingBird bird : birds) {
        bird.fly();  // 모두 안전 ✅
    }
}

타입 자체로 행동을 표현.


시나리오 2: 정사각형 / 직사각형 — 가장 유명한 LSP 위반

public 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; }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;  // 정사각형 보정
    }
    
    @Override
    public void setHeight(int height) {
        this.width = height;
        this.height = height;
    }
}

문제 발생:

public void resizeAndCheck(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    
    int expected = 20;  // Rectangle 가정: 5 × 4 = 20
    int actual = r.getArea();
    
    if (expected != actual) {
        System.out.println("이상함!");
    }
}

resizeAndCheck(new Rectangle());  // 20 == 20 ✅
resizeAndCheck(new Square());     // 20 != 16 ❌
                                   // Square: setWidth(5) → 5x5
                                   //         setHeight(4) → 4x4 (덮어씀)
                                   //         결과: 16

왜 LSP 위반?:

  • Rectangle 의 약속: "width와 height는 독립적으로 변경 가능"
  • Square 가 그 약속을 깸 (width 변경 시 height도 변함)
  • → 같은 코드의 결과가 객체에 따라 다름

해결 — 상속 대신 별개의 클래스

public interface Shape {
    int getArea();
}

public class Rectangle implements Shape {
    private final int width;
    private final int height;
    
    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }
    
    @Override
    public int getArea() { return width * height; }
}

public class <Square implements Shape {
    private final int side;
    
    public Square(int side) { this.side = side; }
    
    @Override
    public int getArea() { return side * side; }
}

상속 관계 없음. 각자 독립적인 도형.


시나리오 3: ILIC 결제 시스템

public class PaymentMethod {
    public void process(int amount) {
        // 결제 처리
    }
    
    public void refund(int amount) {
        // 환불 처리
    }
}

public class CreditCardPayment extends PaymentMethod { }   // OK
public class BankTransferPayment extends PaymentMethod { } // OK

public class GiftCardPayment extends PaymentMethod {
    @Override
    public void refund(int amount) {
        throw new UnsupportedOperationException("기프트카드는 환불 불가");
    }
}

문제:

public void cancelOrder(Order order, PaymentMethod payment) {
    payment.refund(order.getAmount());  // 기프트카드 만나면 💥
}

PaymentMethod 의 약속을 GiftCard가 깸 = LSP 위반.

해결:

public interface PaymentMethod {
    void process(int amount);
}

public interface RefundablePayment extends PaymentMethod {
    void refund(int amount);
}

public class CreditCardPayment implements RefundablePayment { ... }
public class BankTransferPayment implements RefundablePayment { ... }
public class GiftCardPayment implements PaymentMethod { }  // 환불 X

public void cancelOrder(Order order, RefundablePayment payment) {
    payment.refund(order.getAmount());  // 안전 ✅
}

LSP 위반의 5가지 심각한 문제

1. 다형성 깨짐

  • 부모 타입으로 받았는데 자식이 다른 동작 → 다형성의 가치 상실

2. OCP 위반

  • "이 자식만 다르게 처리해야지" → if-else 분기 → OCP 위반

3. 예외 발생

  • UnsupportedOperationException 같은 런타임 폭탄

4. 디버깅 지옥

  • "왜 이 객체에서만 결과가 다르지?" 추적 어려움

5. 신뢰 무너짐

  • 부모 타입 사용자가 자식의 동작을 신뢰할 수 없음 → 모든 곳에 instanceof 검사

✅ 4. 해결책 — LSP를 지키는 5가지 규칙

규칙 1: 메서드 시그니처 호환

자식 메서드는 부모 메서드의 시그니처를 따라야 함:

// 부모
public class Parent {
    public Animal getAnimal(int id) { ... }
}

// 자식 — OK (covariant return)
public class Child extends Parent {
    @Override
    public Dog getAnimal(int id) { ... }  // ✅ Animal의 자식 반환
}

→ Java 5+ 에서는 공변 반환 타입 (Covariant Return Type) 허용.


규칙 2: 자식 메서드는 부모의 사전 조건을 강화하면 안 됨

사전 조건 (Preconditions): 메서드 호출 전 만족해야 할 조건.

public class Parent {
    // 사전 조건: 0 이상의 정수
    public void process(int value) {
        if (value < 0) throw new IllegalArgumentException();
        // ...
    }
}

// ❌ LSP 위반 — 사전 조건 강화
public class Child extends Parent {
    @Override
    public void process(int value) {
        // 사전 조건: 100 이상 (더 엄격) ❌
        if (value < 100) throw new IllegalArgumentException();
        // ...
    }
}

// 부모를 사용하던 코드
public void useParent(Parent p) {
    p.process(50);  // Parent 가정: 50도 OK
                    // 그런데 Child가 들어오면 💥
}

자식은 부모보다 더 엄격한 사전 조건을 가지면 안 됨.


규칙 3: 자식 메서드는 부모의 사후 조건을 약화하면 안 됨

사후 조건 (Postconditions): 메서드 호출 후 보장하는 조건.

public class Parent {
    // 사후 조건: 양수 반환
    public int calculate() {
        return 100;  // 항상 양수
    }
}

// ❌ LSP 위반 — 사후 조건 약화
public class Child extends Parent {
    @Override
    public int calculate() {
        return -100;  // 음수 반환 ❌
    }
}

// 부모를 사용하던 코드
public void useParent(Parent p) {
    int result = p.calculate();
    System.out.println(Math.sqrt(result));  // 양수 가정 → 음수면 NaN
}

자식은 부모보다 약한 사후 조건을 가지면 안 됨.


규칙 4: 자식은 부모의 불변식을 깨면 안 됨

불변식 (Invariants): 객체가 항상 만족해야 할 조건.

public class Account {
    protected int balance;
    
    // 불변식: balance >= 0
    public void deposit(int amount) {
        balance += amount;
    }
    
    public void withdraw(int amount) {
        if (balance < amount) throw new IllegalStateException();
        balance -= amount;
    }
}

// ❌ LSP 위반 — 불변식 깨뜨림
public class OverdraftAccount extends Account {
    @Override
    public void withdraw(int amount) {
        balance -= amount;  // 음수 가능 ❌
    }
}

// 부모를 사용하던 코드
public void process(Account account) {
    account.withdraw(100);
    System.out.println(account.balance);  // 양수 가정
    if (account.balance > 0) {  // OverdraftAccount는 음수일 수 있음
        // ...
    }
}

자식은 부모의 불변식을 유지해야 함.


규칙 5: 자식은 새 예외를 던지면 안 됨

public class Parent {
    public void process() {
        // IllegalStateException 던질 수 있음
    }
}

// ❌ LSP 위반 — 새 예외
public class Child extends Parent {
    @Override
    public void process() {
        throw new UnsupportedOperationException();  // 새 예외 ❌
    }
}

예외 — Penguin 사례 와 동일.

자식은 부모가 던지지 않는 새 예외를 던지면 안 됨.


5가지 규칙 요약

"자식은 부모보다 약속을 지키되, 더 엄격하지도 더 약하지도 않게."

규칙자식의 행동
사전 조건약화는 OK, 강화는 X
사후 조건강화는 OK, 약화는 X
불변식유지해야 함
예외새 예외 X
시그니처호환 유지

실용적 LSP 적용 가이드 ⭐

가이드 1: "이 자식이 부모 자리에 들어가도 되나?"

상속 시 항상 자문:

public class Penguin extends Bird {
    // 질문: Penguin을 Bird가 사용되던 모든 곳에 넣어도 되나?
    // → fly() 호출 시 폭탄 → NO
    // → 상속 부적합
}

가이드 2: "행동 호환성" 이 진짜 is-a

// is-a 같지만 behaves-like-a X
class Stack extends ArrayList { }   // ❌
class Square extends Rectangle { }  // ❌
class Penguin extends FlyingBird { } // ❌

// 진짜 behaves-like-a
class VipCustomer extends Customer { } // ✅
class StandardFare extends Fare { }    // ✅

가이드 3: 의심스러우면 합성

LSP가 어렵게 느껴진다면:

// 상속 X
public class Stack {
    private final List<E> items = new ArrayList<>();
    // ArrayList의 위험한 메서드 노출 X
}

확신 없으면 합성 (Unit 1.1, 2.3 참고).


🏗️ 5. 내부 동작 원리

LSP는 설계 원칙 이라 직접적인 JVM 동작은 없습니다. 대신 다형성과 타입 시스템 관점 에서 봅니다.

컴파일 타임 vs 런타임 LSP 검증

컴파일러가 강제하는 것:

  • 메서드 시그니처 일치 (@Override)
  • 공변 반환 타입
  • 접근 제어자 약화 X (자식이 더 좁은 범위로 못 함)

컴파일러가 못 잡는 것 (런타임에 발견):

  • 사전 조건 강화
  • 사후 조건 약화
  • 불변식 위반
  • 새 예외 던지기
  • 동작의 의미적 차이

LSP의 대부분은 개발자의 책임.


Java가 자동 강제하는 LSP 규칙

1. 접근 제어자 — 자식은 부모보다 좁게 X

public class Parent {
    public void method() { ... }
}

public class Child extends Parent {
    private void method() { ... }  // ❌ 컴파일 에러
    // 자식이 더 좁은 접근 제어자 X
}

자바가 자동 강제 — 호환성 유지.

2. 예외 — 자식은 부모가 던지지 않는 checked exception X

public class Parent {
    public void process() throws IOException { ... }
}

public class Child extends Parent {
    @Override
    public void process() throws SQLException { ... }  // ❌ 컴파일 에러
}

public class Child2 extends Parent {
    @Override
    public void process() throws IOException, FileNotFoundException { ... }
    // ✅ FileNotFoundException은 IOException의 자식
}

checked exception은 자바가 자동 강제. unchecked 는 개발자 책임.

3. 공변 반환 타입 (Covariant Return)

public class Parent {
    public Animal create() { ... }
}

public class Child extends Parent {
    @Override
    public Dog create() { ... }  // ✅ Animal의 자식 반환 OK
}

자바가 허용하는 LSP 친화적 기능.


다형성 호출의 안전성

List<Bird> birds = List.of(new Sparrow(), new Penguin());

for (Bird bird : birds) {
    bird.fly();  // ⚠️ Penguin에서 폭탄
}

JVM 내부:
1. bird.fly() → 동적 바인딩
2. 실제 객체 (Penguin) 의 fly() 호출
3. Penguin.fly() 가 예외 던짐
4. 시스템 깨짐

JVM은 LSP 위반을 감지 못함. 단순히 메서드 호출만 함.


단위 테스트로 LSP 검증

LSP를 자동 검증하는 패턴:

public abstract class BirdContractTest<T extends Bird> {
    
    protected abstract T createBird();
    
    @Test
    void 모든_새는_날_수_있어야_함() {
        T bird = createBird();
        assertDoesNotThrow(() -> bird.fly());  // 어떤 자식도 통과해야
    }
}

public class SparrowContractTest extends BirdContractTest<Sparrow> {
    @Override
    protected Sparrow createBird() { return new Sparrow(); }
}

public class PenguinContractTest extends BirdContractTest<Penguin> {
    @Override
    protected Penguin createBird() { return new Penguin(); }
    // → 테스트 실패 ❌ → LSP 위반 발견
}

LSP 위반을 테스트로 자동 발견.


LSP와 SOLID 원칙들의 상호작용

[LSP] ←→ [OCP]
  - LSP 깨지면 OCP 무너짐
  - 자식이 다르게 동작하면 if-else 필요 → OCP 위반

[LSP] ←→ [DIP]
  - LSP가 보장돼야 DIP 안전
  - 추상화에 의존했는데 자식이 다르게 동작하면 의미 X

LSP는 다른 SOLID 원칙들의 안전 장치.


💻 6. 실전 코드 예시

예시 1: 결제 수단 — LSP 적용

// 모든 결제 가능
public interface PaymentMethod {
    void process(int amount);
    String getName();
}

// 환불 가능한 결제만
public interface RefundablePayment extends PaymentMethod {
    void refund(int amount);
}

@Component
public class CreditCardPayment implements RefundablePayment {
    @Override
    public void process(int amount) { ... }
    
    @Override
    public void refund(int amount) { ... }  // 환불 가능
    
    @Override
    public String getName() { return "신용카드"; }
}

@Component
public class GiftCardPayment implements PaymentMethod {  // RefundablePayment 미구현
    @Override
    public void process(int amount) { ... }
    
    @Override
    public String getName() { return "기프트카드"; }
    // refund 없음
}

// Service — 타입 자체로 안전성 보장
@Service
public class OrderService {
    public void processPayment(PaymentMethod payment, int amount) {
        payment.process(amount);  // 모든 결제 OK
    }
    
    public void cancelOrder(RefundablePayment payment, int amount) {
        payment.refund(amount);  // 환불 가능한 것만 ✅
    }
}

효과:

  • GiftCardPaymentcancelOrder 에 넘기면 컴파일 에러
  • 런타임 폭탄 X
  • 타입 시스템이 LSP 강제

예시 2: ILIC 운임 종류 — LSP 만족하는 상속

public abstract class Fare {
    protected Long id;
    protected int amount;
    protected FareStatus status;
    
    // 모든 운임이 따라야 할 계약
    public abstract int calculateTotal();
    
    public void submit() {
        if (status != FareStatus.DRAFT) throw new IllegalStateException();
        status = FareStatus.SUBMITTED;
    }
}

public class StandardFare extends Fare {
    @Override
    public int calculateTotal() { return amount; }
    // submit() 그대로 사용
}

public class UrgentFare extends Fare {
    private int urgentFee;
    
    @Override
    public int calculateTotal() { return amount + urgentFee; }  // 약속 지킴
    // submit() 그대로 사용
}

public class InternationalFare extends Fare {
    private double exchangeRate;
    
    @Override
    public int calculateTotal() { return (int)(amount * exchangeRate); }
}

LSP 만족:

  • 모든 자식이 calculateTotal() 을 정상 구현
  • 모든 자식이 submit() 의 계약 준수
  • → 어떤 자식이든 Fare 자리에 안전하게 사용
public void processFares(List<Fare> fares) {
    for (Fare fare : fares) {
        int total = fare.calculateTotal();  // 모두 안전
        fare.submit();                       // 모두 안전
    }
}

예시 3: ILIC Customer 등급 — LSP 만족

public abstract class Customer {
    protected String name;
    protected String email;
    
    // 모든 고객의 약속
    public abstract int calculateDiscountRate();  // 0 이상 100 이하 정수
    public abstract String getGreeting();          // null 아닌 문자열
}

public class NormalCustomer extends Customer {
    @Override public int calculateDiscountRate() { return 0; }
    @Override public String getGreeting() { return "안녕하세요"; }
}

public class VipCustomer extends Customer {
    @Override public int calculateDiscountRate() { return 20; }
    @Override public String getGreeting() { return "VIP 고객님"; }
}

public class PartnerCustomer extends Customer {
    @Override public int calculateDiscountRate() { return 30; }
    @Override public String getGreeting() { return "파트너님"; }
}

LSP 만족:

  • 모든 자식이 0 이상의 정수 반환 ✅
  • 모든 자식이 null 아닌 문자열 반환 ✅
  • → 부모 타입으로 받아 안전하게 처리

예시 4: LSP 위반 — Stack extends ArrayList ⭐

박승제님이 이전 학습 (Unit 1.1, 2.3) 에서 본 사례 재정리:

// ❌ LSP 위반
public class MyStack<E> extends ArrayList<E> {
    public void push(E item) { add(item); }
    public E pop() { return remove(size() - 1); }
}

// 사용
ArrayList<String> list = new MyStack<>();  // 부모 타입으로 받음
list.add("A");
list.add("B");
list.add(0, "C");  // 맨 앞에 추가 ⚠️
                    // Stack의 의도: LIFO만, 그러나 가능

// ArrayList 사용자가 가정한 동작:
// "마지막에 추가된 게 size()-1 위치"
// 그런데 MyStack은 LIFO 보장하려는데 add(0, ...)으로 깨짐

왜 LSP 위반?:

  • ArrayList 의 약속: "임의 위치 삽입/삭제 가능"
  • MyStack 은 그 약속을 인정하면서 동시에 LIFO를 강제하려 함
  • 두 약속이 충돌

해결 — 합성:

public class MyStack<E> {
    private final List<E> items = new ArrayList<>();
    // ArrayList의 약속을 노출 X
}

예시 5: LSP 친화적 알림 시스템

public interface NotificationChannel {
    void send(String message, String recipient);
    /**
     * 발송 가능 여부.
     * 사후 조건: boolean 반환 (예외 X)
     */
    boolean isAvailable();
}

public class EmailChannel implements NotificationChannel {
    @Override
    public void send(String message, String recipient) {
        // 이메일 발송
    }
    
    @Override
    public boolean isAvailable() {
        return emailServer.isReachable();  // 약속 준수
    }
}

public class SmsChannel implements NotificationChannel {
    @Override
    public void send(String message, String recipient) {
        // SMS 발송
    }
    
    @Override
    public boolean isAvailable() {
        return smsProvider.hasCredit();  // 약속 준수
    }
}

// ❌ LSP 위반 예시
public class BadChannel implements NotificationChannel {
    @Override
    public void send(String message, String recipient) {
        if (recipient == null) {
            // 부모는 null 처리 안 함
            // 자식이 새 예외 던짐 — LSP 위반
            throw new IllegalArgumentException("recipient null");
        }
    }
    
    @Override
    public boolean isAvailable() {
        // 부모는 boolean만 반환
        // 자식이 예외 던짐 — LSP 위반
        throw new RuntimeException("Not implemented");
    }
}

각 자식이 부모의 계약을 정확히 준수해야.


⚠️ 7. 주의사항 & 흔한 실수

실수 1: "is-a" 만 보고 상속

// ❌ 수학적 is-a
class Square extends Rectangle { }

// ❌ 분류상 is-a  
class Penguin extends Bird { }

// ❌ 자료구조 is-a
class Stack extends ArrayList { }

해결: behaves-like-a 인지 확인.


실수 2: 자식에서 새 예외 던지기

public class FileReader {
    public String read() throws IOException { ... }
}

public class CompressedFileReader extends FileReader {
    @Override
    public String read() throws IOException {
        // 압축 해제 실패 시?
        throw new RuntimeException("압축 해제 실패");  // ❌ 새 unchecked 예외
    }
}

해결: 부모의 예외 계약 안에서:

public class FileReader {
    public String read() throws ReadException { ... }  // 일반화
}

public class CompressedFileReader extends FileReader {
    @Override
    public String read() throws ReadException {
        try {
            // 압축 해제
        } catch (IOException e) {
            throw new ReadException(e);  // 부모 약속 안에서
        }
    }
}

실수 3: 자식에서 동작 의미 변경

public class Counter {
    private int count = 0;
    
    public void increment() { count++; }
    public int getCount() { return count; }
}

// ❌ 의미 변경
public class WeirdCounter extends Counter {
    @Override
    public void increment() {
        count += 2;  // 1씩 증가가 아닌 2씩 ❌
    }
}

// 사용자 가정 깨짐
public void countTo10(Counter c) {
    for (int i = 0; i < 10; i++) {
        c.increment();
    }
    System.out.println(c.getCount());  // 10 가정
                                        // WeirdCounter: 20 ❌
}

시그니처는 같아도 동작 의미가 달라지면 LSP 위반.


실수 4: 자식에서 부모 메서드 무력화

public class Vehicle {
    public void drive() { /* 운전 */ }
}

// ❌ 메서드 무력화
public class BrokenCar extends Vehicle {
    @Override
    public void drive() {
        // 아무것도 안 함 ❌
    }
}

메서드 호출했는데 아무 동작 안 하면 LSP 위반 (사후 조건 약화).


실수 5: 사전 조건 강화

public class Service {
    // 사전 조건: name은 null 가능
    public void process(String name) {
        if (name == null) name = "default";
        // ...
    }
}

// ❌ 사전 조건 강화
public class StrictService extends Service {
    @Override
    public void process(String name) {
        if (name == null) throw new IllegalArgumentException();  // ❌
        // ...
    }
}

// 부모 사용자
public void use(Service s) {
    s.process(null);  // 부모 가정: OK
                       // 자식: 폭탄 ❌
}

자식이 부모보다 더 까다로워지면 안 됨.


실수 6: instanceof로 LSP 위반 회피

public void processBird(Bird bird) {
    if (bird instanceof Penguin) {
        // 펭귄 특별 처리
    } else {
        bird.fly();  // 다른 새는 날기
    }
}

instanceof로 패치하는 것은 LSP 위반의 신호. 처음부터 타입 계층 재설계 필요.


실수 7: 단위 테스트 없이 상속

public class Bird {
    public void fly() { ... }
}

public class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException();
    }
}

// 테스트 없이 배포
// → 운영 환경에서 펭귄 등장 시 폭탄

해결: Bird 의 모든 자식에 같은 테스트 적용:

@Test
void 모든_새는_안전하게_날_수_있어야() {
    for (Bird bird : List.of(new Sparrow(), new Penguin(), ...)) {
        assertDoesNotThrow(() -> bird.fly());
    }
}

테스트 작성 시 LSP 위반 발견.


🔗 8. 연관 개념 맵

Phase 3 (SOLID) 내 흐름

[SRP] — 책임 분리
   ↓
[OCP] — 확장 가능
   ↓
[LSP] ★ ← 지금 여기 — OCP의 안전 장치
   ↓
[ISP] — 인터페이스 분리
   ↓
[DIP] — 추상화에 의존

LSP는 OCP의 안전망. OCP만 적용하면 위험할 수 있는 이유.


Phase 2와의 연결

Phase 2 학습LSP 적용
Unit 2.3 (상속)LSP가 상속의 진짜 규칙
Unit 2.4 (다형성)LSP 없는 다형성은 위험
Unit 2.5 (instanceof)instanceof 남발 = LSP 위반 신호

상속과 다형성을 안전하게 만드는 게 LSP.


LSP와 다른 SOLID

[SRP] ─┐
       ↓ 책임 분리
[OCP] ─┤
       ↓ 확장 가능
[LSP] ─┤ 안전 장치 ★
       ↓
[ISP] ─┤ 인터페이스도 LSP 친화적으로 분리
       ↓
[DIP] ─┘ 추상화 의존, LSP가 보장돼야 안전

LSP가 깨지면:

  • OCP가 무력화 (if-else 부활)
  • DIP가 위험 (추상화 의존이 의미 없어짐)
  • 다형성이 함정

Java 표준 라이브러리의 LSP 위반 사례 ⭐

자바 자체에도 LSP 위반이 있습니다:

1. Stack extends Vector

  • Vector의 임의 위치 메서드 노출
  • LIFO 의도와 충돌
  • → JDK 가 인정한 실수, Deque 권장

2. UnsupportedOperationException

  • Collections.unmodifiableList 의 add() 호출 시 예외
  • LSP 위반의 전형적 사례
  • 그러나 JDK가 의도적으로 사용 (내부 일관성을 위한 트레이드오프)

거장들도 LSP를 어길 때가 있음. 다만 의식적인 트레이드오프.


면접 단골 질문 매핑

질문이 Unit에서의 답
"LSP가 뭔가요?"자식이 부모를 안전하게 대체할 수 있어야 함
"LSP 위반의 예?"정사각형/직사각형, Stack/ArrayList, Penguin/Bird
"LSP를 어떻게 검증?"부모 사용 코드에 자식 넣어도 정상 동작 확인
"is-a vs behaves-like-a?"행동 호환성이 진짜 LSP 기준
"LSP와 OCP 관계?"LSP가 깨지면 OCP도 무너짐

📝 9. 핵심 요약 — 3줄 정리

1️⃣ LSP는 "자식이 부모의 자리를 안전하게 대체할 수 있어야" 의 원칙이다.

Barbara Liskov가 1987년 정립. 부모 클래스 참조를 사용하는 곳에 자식 객체를 넣어도 시스템이 정상 동작해야 함. 수학적 is-a 가 아닌 'behaves-like-a' (행동 호환성) 가 진짜 LSP 기준. 정사각형/직사각형, Stack/ArrayList, Penguin/Bird 가 클래식 위반 사례.

2️⃣ LSP는 5가지 규칙으로 자식을 통제한다.

① 메서드 시그니처 호환, ② 사전 조건 강화 X (자식이 더 까다롭게 X), ③ 사후 조건 약화 X (자식이 덜 보장하면 X), ④ 불변식 유지, ⑤ 새 예외 X. 이 규칙들의 본질: "자식은 부모보다 약속을 지키되, 더 엄격하지도 더 약하지도 않게."

3️⃣ LSP는 다형성과 OCP의 안전 장치다.

LSP가 깨지면 다형성이 함정이 되고 OCP가 무너진다 (if-else 부활). instanceof 남발이나 UnsupportedOperationException 던지기는 LSP 위반의 전형적 신호. 확신이 없으면 상속 대신 합성, 타입 자체로 행동을 표현 (Bird → FlyingBird 분리), 모든 자식에 같은 테스트 로 LSP 자동 검증이 실용적 적용법.


🎓 학습 자기 점검

기본 이해

  • LSP의 정의를 한 문장으로 설명할 수 있다
  • 정사각형/직사각형의 LSP 위반을 설명할 수 있다
  • is-a 와 behaves-like-a 의 차이를 안다
  • LSP 5가지 규칙을 나열할 수 있다

실전 적용

  • ILIC 코드의 LSP 위반을 식별할 수 있다
  • LSP를 만족하는 상속 구조를 설계할 수 있다
  • UnsupportedOperationException 사용을 피할 수 있다
  • 의심스러우면 합성으로 전환할 수 있다

면접 대비 (3-5분 답변)

  • "LSP가 뭔가요?" 답변 가능
  • "LSP 위반의 예시?" 답변 가능 (3가지 이상)
  • "LSP와 OCP의 관계?" 답변 가능
  • "ILIC에서 LSP를 어떻게 적용?" 답변 가능

자기 점검 — ILIC 적용

박승제님의 ILIC 코드를 점검:

LSP 위반 신호 ⚠️:

  • 자식 클래스에서 UnsupportedOperationException 사용
  • instanceof 로 특정 자식 검사 후 다른 처리
  • 자식이 부모의 메서드를 무력화 (빈 구현)
  • 자식의 동작 의미가 부모와 다름
  • 부모 자리에 자식을 넣으면 테스트 깨짐

3개 이상 해당 = LSP 위반 가능성 높음.


다음 Unit으로

  • ISP (인터페이스 분리 원칙) 을 학습할 준비 완료
  • "큰 인터페이스 vs 작은 인터페이스" 가 궁금하다
  • LSP가 ISP 와 어떻게 협력하는지 만날 준비 완료
profile
Software Developer

0개의 댓글