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가 설명한다.
다형성을 안전하게 사용하기 위한 계약.
회사에 부장 김씨 가 있습니다. 김씨가 휴가를 가서 대리 박씨 가 그 자리를 대신합니다.
대리가 안전하게 가능한 조건:
만약 박씨가:
→ 박씨가 김씨의 자리를 안전하게 대체 할 수 있어야 진짜 대리.
자바에서:
당신이 친구에게 "과일 어떤 거든 가져와줘" 라고 부탁합니다.
그런데 만약 친구가 "독이 든 사과" 를 가져왔다면?
당신은 모든 과일을 안전하게 먹을 수 있다고 가정했는데, 자식 (독사과) 이 그 가정을 깨뜨림.
→ 이게 LSP 위반. 자식이 부모의 약속을 깨면 안 됨.
"부모를 사용하는 모든 곳에 자식을 넣어도 정상 동작해야 한다."
LSP의 핵심:
비유 정리:
| 비유 요소 | LSP 적용 |
|---|---|
| 부장 김씨 | 부모 클래스 |
| 대리 박씨 | 자식 클래스 |
| 안전한 대리 | LSP 만족 |
| 박씨가 업무 거부 | LSP 위반 |
| 독사과 | LSP 위반 자식 |
LSP 는 Barbara 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가 이런 함정을 정식으로 이론화.
다형성 (Unit 2.4) 이 작동하려면 LSP가 필수:
[다형성]: 부모 타입으로 자식 객체 사용
↓ 그러나
"자식이 부모처럼 동작" 보장 필요
↓
[LSP]: 자식이 부모의 계약을 지킴
↓
[안전한 다형성 ✅]
핵심 통찰:
"LSP는 다형성의 안전 계약이다."
LSP를 어기면 다형성이 위험한 도구로 변함. OCP가 가능해도 LSP가 깨지면 시스템이 깨짐.
상속의 흔한 오해:
정사각형 vs 직사각형:
→ 수학적 관계가 아닌 '행동 호환성' 이 LSP의 본질.
"LSP는 '상속을 했다면 부모의 계약을 지켜라' 의 원칙이다."
자식 클래스가 부모의 메서드 시그니처를 그대로 따른다고 해서 LSP를 만족하는 게 아니다. 부모를 사용하던 모든 코드가 자식을 사용해도 안전하게 동작 해야 한다.
LSP를 어기면 다형성이 위험해지고, OCP도 무너진다. 상속은 강력하지만 위험한 도구 — LSP는 그 위험을 통제하는 안전 장치.
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(); // 모두 안전 ✅
}
}
→ 타입 자체로 행동을 표현.
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; }
}
→ 상속 관계 없음. 각자 독립적인 도형.
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()); // 안전 ✅
}
UnsupportedOperationException 같은 런타임 폭탄자식 메서드는 부모 메서드의 시그니처를 따라야 함:
// 부모
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) 허용.
사전 조건 (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가 들어오면 💥
}
→ 자식은 부모보다 더 엄격한 사전 조건을 가지면 안 됨.
사후 조건 (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
}
→ 자식은 부모보다 약한 사후 조건을 가지면 안 됨.
불변식 (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는 음수일 수 있음
// ...
}
}
→ 자식은 부모의 불변식을 유지해야 함.
public class Parent {
public void process() {
// IllegalStateException 던질 수 있음
}
}
// ❌ LSP 위반 — 새 예외
public class Child extends Parent {
@Override
public void process() {
throw new UnsupportedOperationException(); // 새 예외 ❌
}
}
예외 — Penguin 사례 와 동일.
→ 자식은 부모가 던지지 않는 새 예외를 던지면 안 됨.
"자식은 부모보다 약속을 지키되, 더 엄격하지도 더 약하지도 않게."
| 규칙 | 자식의 행동 |
|---|---|
| 사전 조건 | 약화는 OK, 강화는 X |
| 사후 조건 | 강화는 OK, 약화는 X |
| 불변식 | 유지해야 함 |
| 예외 | 새 예외 X |
| 시그니처 | 호환 유지 |
상속 시 항상 자문:
public class Penguin extends Bird {
// 질문: Penguin을 Bird가 사용되던 모든 곳에 넣어도 되나?
// → fly() 호출 시 폭탄 → NO
// → 상속 부적합
}
// 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 { } // ✅
LSP가 어렵게 느껴진다면:
// 상속 X
public class Stack {
private final List<E> items = new ArrayList<>();
// ArrayList의 위험한 메서드 노출 X
}
→ 확신 없으면 합성 (Unit 1.1, 2.3 참고).
LSP는 설계 원칙 이라 직접적인 JVM 동작은 없습니다. 대신 다형성과 타입 시스템 관점 에서 봅니다.
컴파일러가 강제하는 것:
@Override)컴파일러가 못 잡는 것 (런타임에 발견):
→ LSP의 대부분은 개발자의 책임.
public class Parent {
public void method() { ... }
}
public class Child extends Parent {
private void method() { ... } // ❌ 컴파일 에러
// 자식이 더 좁은 접근 제어자 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 는 개발자 책임.
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를 자동 검증하는 패턴:
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] ←→ [OCP]
- LSP 깨지면 OCP 무너짐
- 자식이 다르게 동작하면 if-else 필요 → OCP 위반
[LSP] ←→ [DIP]
- LSP가 보장돼야 DIP 안전
- 추상화에 의존했는데 자식이 다르게 동작하면 의미 X
→ LSP는 다른 SOLID 원칙들의 안전 장치.
// 모든 결제 가능
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); // 환불 가능한 것만 ✅
}
}
효과:
GiftCardPayment 를 cancelOrder 에 넘기면 컴파일 에러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(); // 모두 안전
}
}
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 만족:
박승제님이 이전 학습 (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
}
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");
}
}
→ 각 자식이 부모의 계약을 정확히 준수해야.
// ❌ 수학적 is-a
class Square extends Rectangle { }
// ❌ 분류상 is-a
class Penguin extends Bird { }
// ❌ 자료구조 is-a
class Stack extends ArrayList { }
해결: behaves-like-a 인지 확인.
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); // 부모 약속 안에서
}
}
}
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 위반.
public class Vehicle {
public void drive() { /* 운전 */ }
}
// ❌ 메서드 무력화
public class BrokenCar extends Vehicle {
@Override
public void drive() {
// 아무것도 안 함 ❌
}
}
→ 메서드 호출했는데 아무 동작 안 하면 LSP 위반 (사후 조건 약화).
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
// 자식: 폭탄 ❌
}
→ 자식이 부모보다 더 까다로워지면 안 됨.
public void processBird(Bird bird) {
if (bird instanceof Penguin) {
// 펭귄 특별 처리
} else {
bird.fly(); // 다른 새는 날기
}
}
→ instanceof로 패치하는 것은 LSP 위반의 신호. 처음부터 타입 계층 재설계 필요.
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 위반 발견.
[SRP] — 책임 분리
↓
[OCP] — 확장 가능
↓
[LSP] ★ ← 지금 여기 — OCP의 안전 장치
↓
[ISP] — 인터페이스 분리
↓
[DIP] — 추상화에 의존
→ LSP는 OCP의 안전망. OCP만 적용하면 위험할 수 있는 이유.
| Phase 2 학습 | LSP 적용 |
|---|---|
| Unit 2.3 (상속) | LSP가 상속의 진짜 규칙 |
| Unit 2.4 (다형성) | LSP 없는 다형성은 위험 |
| Unit 2.5 (instanceof) | instanceof 남발 = LSP 위반 신호 |
→ 상속과 다형성을 안전하게 만드는 게 LSP.
[SRP] ─┐
↓ 책임 분리
[OCP] ─┤
↓ 확장 가능
[LSP] ─┤ 안전 장치 ★
↓
[ISP] ─┤ 인터페이스도 LSP 친화적으로 분리
↓
[DIP] ─┘ 추상화 의존, LSP가 보장돼야 안전
LSP가 깨지면:
자바 자체에도 LSP 위반이 있습니다:
Collections.unmodifiableList 의 add() 호출 시 예외→ 거장들도 LSP를 어길 때가 있음. 다만 의식적인 트레이드오프.
| 질문 | 이 Unit에서의 답 |
|---|---|
| "LSP가 뭔가요?" | 자식이 부모를 안전하게 대체할 수 있어야 함 |
| "LSP 위반의 예?" | 정사각형/직사각형, Stack/ArrayList, Penguin/Bird |
| "LSP를 어떻게 검증?" | 부모 사용 코드에 자식 넣어도 정상 동작 확인 |
| "is-a vs behaves-like-a?" | 행동 호환성이 진짜 LSP 기준 |
| "LSP와 OCP 관계?" | LSP가 깨지면 OCP도 무너짐 |
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 자동 검증이 실용적 적용법.
UnsupportedOperationException 사용을 피할 수 있다박승제님의 ILIC 코드를 점검:
LSP 위반 신호 ⚠️:
UnsupportedOperationException 사용instanceof 로 특정 자식 검사 후 다른 처리3개 이상 해당 = LSP 위반 가능성 높음.