[오브젝트] 서브클래싱과 서브타이핑(chap13)

KwonMoYang·2025년 7월 2일

📌 핵심 개념

상속을 사용하는 두 가지 목적을 이해하고, 올바른 상속 관계를 구성하는 방법을 학습한다.

  • 서브클래싱: 코드 재사용이 목적
  • 서브타이핑: 타입 계층 구성이 목적

1. 타입 계층

1.1 타입이란?

1.1.1 프로그래밍 관점의 타입

  • 심볼 (Symbol)
    심볼은 타입을 가리키는 이름

    public class Employee { ... }
    
    // "List"라는 심볼
    public interface List<E> { ... }
  • 내연 (Intension)
    해당 타입이 만족해야 하는 속성과 행동의 집합

      public class Bird {
        // 내연: Bird란 무엇인가?
        // - 날개를 가진다
        // - 부리를 가진다  
        // - 알을 낳는다
        // - 날 수 있다(?)
    
        private Wing wing;
        private Beak beak;
    
        public void layEgg() { }
        public void fly() { }
    }
  • 외연 (Extension)
    타입에 속하는 실제 객체들의 집합 - "이 타입의 인스턴스는 무엇인가?"

    Java// Bird 타입의 외연 = 모든 Bird 인스턴스들
    Bird sparrow = new Sparrow();
    Bird eagle = new Eagle();  
    Bird penguin = new Penguin();
    Bird ostrich = new Ostrich();
    
    // Set<Bird> = {sparrow, eagle, penguin, ostrich, ...}

1.1.2 객체지향 패러다임 관점에서의 타입

  • 객체가 수신할 수 있는 메시지의 종류를 정의하는 것
    즉, 동일한 퍼블릭 인터페이스를 가지면 동일한 타입이다. 외부에 제공되는게 같으면 된다.

    // 타입 = 퍼블릭 인터페이스
    public class Employee {
        private String name;
        private Money baseSalary;
    
        // 이 퍼블릭 메서드들이 Employee 타입을 정의
        public Money calculatePay(double taxRate) {
            return baseSalary.minus(baseSalary.times(taxRate));
        }
    
        public String getName() {
            return name;
        }
    }
    

1.2 타입 계층의 구성

// 슈퍼타입
public class Employee {
    protected String name;
    protected Money baseSalary;

    public Money calculatePay(double taxRate) {
        return baseSalary.minus(baseSalary.times(taxRate));
    }
}

// 서브타입들
public class SalariedEmployee extends Employee {
    public Money calculatePay(double taxRate) {
        return super.calculatePay(taxRate);
    }
}

public class HourlyEmployee extends Employee {
    private int timeCard;

    public Money calculatePay(double taxRate) {
        // 시간제 직원은 다른 계산 방식 적용
        return baseSalary.times(timeCard)
                         .minus(baseSalary.times(timeCard).times(taxRate));
    }
}

2. 서브클래싱 vs 서브타이핑

2.1 서브클래싱 (구현 상속)

목적: 코드의 재사용

// ❌ 나쁜 예 - 순수하게 코드 재사용만을 위한 상속
public class Stack<E> extends Vector<E> {
    public E push(E item) {
        addElement(item);
        return item;
    }

    public E pop() {
        E obj = peek();
        removeElementAt(size() - 1);
        return obj;
    }
}

// 문제점: Stack이 Vector의 모든 메서드를 노출
Stack<String> stack = new Stack<>();
stack.push("1st");
stack.push("2nd");
stack.add(0, "wrong!");  // Stack의 LIFO 원칙 위반!

2.2 서브타이핑 (인터페이스 상속)

목적: 다형성을 통한 유연한 설계

// ✅ 좋은 예 - 타입 계층 구성
public interface DiscountPolicy {
    Money calculateDiscountAmount(Screening screening);
}

public class AmountDiscountPolicy implements DiscountPolicy {
    private Money discountAmount;

    @Override
    public Money calculateDiscountAmount(Screening screening) {
        return discountAmount;
    }
}

public class PercentDiscountPolicy implements DiscountPolicy {
    private double percent;

    @Override
    public Money calculateDiscountAmount(Screening screening) {
        return screening.getMovieFee().times(percent);
    }
}

3. is-a 관계의 함정

3.1 단순한 is-a 관계의 문제

// "펭귄은 새다" - 구조적으로는 맞지만...
public class Bird {
    public void fly() {
        System.out.println("날고 있습니다.");
    }
}

public class Penguin extends Bird {
    @Override
    public void fly() {
        // 행동적으로 맞지 않음!
        throw new UnsupportedOperationException("펭귄은 날 수 없습니다.");
    }
}

// 클라이언트 코드가 깨짐
public void makeBirdFly(Bird bird) {
    bird.fly();  // Penguin이면 예외 발생!
}

3.2 올바른 타입 계층 설계

// 인터페이스 분리를 통한 해결
public interface Bird {
    void eat();
    void breathe();
}

public interface FlyingBird extends Bird {
    void fly();
}

public class Sparrow implements FlyingBird {
    public void eat() { /* 구현 */ }
    public void breathe() { /* 구현 */ }
    public void fly() { /* 구현 */ }
}

public class Penguin implements Bird {
    public void eat() { /* 구현 */ }
    public void breathe() { /* 구현 */ }
    // fly() 메서드가 없음 - 타입적으로 안전
}

4. 리스코프 치환 원칙 (LSP)

"서브타입은 언제나 기반 타입으로 교체할 수 있어야 한다"

4.1 LSP 위반 사례 - Rectangle과 Square

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) {
        super.setWidth(width);
        super.setHeight(width);  // 정사각형 불변식 유지
    }

    @Override
    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

// 클라이언트 관점에서 문제 발생
public void resize(Rectangle rectangle) {
    rectangle.setWidth(4);
    rectangle.setHeight(5);

    // Rectangle이면 20, Square면 25
    assert rectangle.getArea() == 20;  // Square일 때 실패!
}

4.2 LSP를 만족하는 설계

// 불변 객체로 설계하여 문제 해결
public class Rectangle {
    private final int width;
    private final int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

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

public class Square extends Rectangle {
    public Square(int side) {
        super(side, side);  // 생성 시점에만 제약 적용
    }
}

5. 계약에 의한 설계 (Design by Contract)

5.1 계약 규칙

  • 사전조건: 메서드를 호출하기 전에 만족해야 하는 조건 ⇒ 서브타입에서 더 약하게 (완화)
  • 사후조건: 메서드를 호출한 후에 만족해야 하는 조건 ⇒ 서브타입에서 더 강하게 (강화)
  • 불변식: 항상 유지
public class PaymentProcessor {
    // 사전조건: amount > 0
    // 사후조건: 결제 완료 시 true 반환
    public boolean processPayment(Money amount) {
        assert amount.isGreaterThan(Money.ZERO);  // 사전조건

        boolean result = doProcess(amount);

        assert result == isPaymentCompleted();  // 사후조건
        return result;
    }
}

public class VIPPaymentProcessor extends PaymentProcessor {
    @Override
    public boolean processPayment(Money amount) {
        // 사전조건 완화: 0원도 허용
        assert amount.isGreaterThanOrEqual(Money.ZERO);

        if (amount.equals(Money.ZERO)) {
            return processZeroPayment();  // VIP는 0원 결제 가능
        }

        return super.processPayment(amount);
    }
}

5.2 LSP에서 사전조건/사후조건 규칙이 나온 이유

핵심은 "클라이언트가 부모 타입으로 자식을 사용해도 문제없어야 한다" 는 것

클라이언트 관점에서 생각하기

// 클라이언트 코드
public class ClientCode {
    public void doSomething(PaymentService service) {
// 클라이언트는 PaymentService의 계약만 알고 있음// 사전조건: amount >= 1000원
        Money amount = Money.wons(1000);
        service.processPayment(amount);
    }
}

[]사전조건을 더 강하게 하면 안 되는 이유

// 부모 클래스
public class PaymentService {
// 사전조건: amount >= 1000원
    public void processPayment(Money amount) {
        if (amount.isLessThan(Money.wons(1000))) {
            throw new IllegalArgumentException("최소 1000원 이상");
        }
// 처리...
    }
}

// ❌ 잘못된 서브타입 - 사전조건을 더 강하게 함
public class PremiumPaymentService extends PaymentService {
// 사전조건: amount >= 10000원 (더 엄격!)
    @Override
    public void processPayment(Money amount) {
        if (amount.isLessThan(Money.wons(10000))) {
            throw new IllegalArgumentException("프리미엄은 최소 10000원 이상");
        }
// 처리...
    }
}

// 문제 발생!
PaymentService service = new PremiumPaymentService();
service.processPayment(Money.wons(5000));// 💥 예외 발생!// 클라이언트는 1000원 이상이면 된다고 알고 있었는데...

왜 문제가 되는가?

클라이언트는 부모의 계약(1000원 이상)만 알고 있는데, 자식이 더 엄격한 조건(10000원 이상)을 요구하면 클라이언트 코드가 깨집니다.

[]사전조건을 더 약하게 하는 것은 OK


java
// ✅ 올바른 서브타입 - 사전조건을 더 약하게 함
public class FlexiblePaymentService extends PaymentService {
// 사전조건: amount >= 0원 (더 관대!)
    @Override
    public void processPayment(Money amount) {
        if (amount.isLessThan(Money.ZERO)) {
            throw new IllegalArgumentException("음수는 불가");
        }

// 0원도 처리 가능 (포인트 적립 등)
        if (amount.equals(Money.ZERO)) {
            processZeroPayment();
            return;
        }

// 일반 처리
        super.processPayment(amount);
    }
}

// 문제 없음!
PaymentService service = new FlexiblePaymentService();
service.processPayment(Money.wons(5000));// ✅ 정상 작동
service.processPayment(Money.wons(0));// ✅ 추가 기능도 OK

6. DiscountPolicy와 SOLID 원칙

6.1 전체 구조

// 추상 클래스 - 템플릿 메서드 패턴
public abstract class DiscountPolicy {
    private List<DiscountCondition> conditions;

    public Money calculateDiscountAmount(Screening screening) {
        if (checkDiscountConditions(screening)) {
            return getDiscountAmount(screening);
        }
        return Money.ZERO;
    }

    private boolean checkDiscountConditions(Screening screening) {
        return conditions.stream()
                         .anyMatch(condition -> condition.isSatisfiedBy(screening));
    }

    protected abstract Money getDiscountAmount(Screening screening);
}

// 구체적인 할인 정책들
public class AmountDiscountPolicy extends DiscountPolicy {
    private Money discountAmount;

    @Override
    protected Money getDiscountAmount(Screening screening) {
        return discountAmount;
    }
}

public class PercentDiscountPolicy extends DiscountPolicy {
    private double percent;

    @Override
    protected Money getDiscountAmount(Screening screening) {
        return screening.getMovieFee().times(percent);
    }
}

6.2 SOLID 원칙 만족

DIP (의존성 역전 원칙)

public class Movie {
    private String title;
    private Money fee;
    private DiscountPolicy discountPolicy;  // 추상화에 의존

    public Money calculateMovieFee(Screening screening) {
        // 구체적인 할인 정책을 알 필요 없음
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

LSP (리스코프 치환 원칙)

// 모든 할인 정책은 동일한 방식으로 사용 가능
Movie movie1 = new Movie("영화1", Money.wons(10000), new AmountDiscountPolicy(...));
Movie movie2 = new Movie("영화2", Money.wons(10000), new PercentDiscountPolicy(...));

// 클라이언트는 구체적인 타입을 몰라도 됨
Money fee1 = movie1.calculateMovieFee(screening);
Money fee2 = movie2.calculateMovieFee(screening);

OCP (개방-폐쇄 원칙)

// 새로운 할인 정책 추가 - 기존 코드 수정 없음
public class CompositeDiscountPolicy extends DiscountPolicy {
    private List<DiscountPolicy> policies;

    @Override
    protected Money getDiscountAmount(Screening screening) {
        return policies.stream()
                      .map(policy -> policy.calculateDiscountAmount(screening))
                      .reduce(Money.ZERO, Money::plus);
    }
}

// Movie 클래스 수정 없이 사용
Movie movie = new Movie("복합할인", Money.wons(10000),
                       new CompositeDiscountPolicy(...));

7. LSP 위반이 OCP 위반으로 이어지는 사례

7.1 LSP 위반

// 특별 할인 - 조건 체크를 무시
public class SpecialDiscountPolicy extends DiscountPolicy {
    @Override
    public Money calculateDiscountAmount(Screening screening) {
        // 부모의 템플릿 메서드를 무시하고 재정의
        return discountAmount;  // 조건 체크 없이 항상 할인
    }
}

7.2 OCP 위반으로 연쇄

public class Movie {
    public Money calculateMovieFee(Screening screening) {
        // 특정 타입을 체크하는 코드 추가 = OCP 위반
        if (discountPolicy instanceof SpecialDiscountPolicy) {
            // 특별 처리 로직
            return fee.minus(discountPolicy.calculateDiscountAmount(screening));
        } else {
            // 일반 처리 로직
            Money discount = discountPolicy.calculateDiscountAmount(screening);
            return fee.minus(discount);
        }
    }
}

7.3 올바른 해결책

// LSP를 지키면서 요구사항 충족
public class NoneConditionDiscountPolicy extends DiscountPolicy {
    public NoneConditionDiscountPolicy(Money discountAmount) {
        // 항상 true인 조건을 추가하여 부모의 계약 준수
        super(Arrays.asList(new AlwaysTrueCondition()));
        this.discountAmount = discountAmount;
    }

    @Override
    protected Money getDiscountAmount(Screening screening) {
        return discountAmount;
    }
}

8. 실무 적용 방법

9.1 상속을 사용하기 전 체크리스트

  1. 목적이 무엇인가?
    • 코드 재사용 → 합성 고려
    • 타입 대체 → 상속 사용
  2. 행동이 호환되는가?
    • 클라이언트 관점에서 검증
    • 단순 구조가 아닌 행동 중심
  3. 리스코프 치환 원칙을 만족하는가?
    • 사전조건 약화 가능
    • 사후조건 강화 가능
    • 불변식 유지

9.2 인터페이스 분리

// 클라이언트별로 인터페이스 분리
public interface Flyer {
    void fly();
}

public interface Walker {
    void walk();
}

public interface Singer {
    void sing();
}

// 필요한 것만 구현
public class Sparrow implements Flyer, Walker, Singer {
    // 모든 능력 보유
}

public class Penguin implements Walker, Singer {
    // 날 수 없음
}

public class Airplane implements Flyer {
    // 걷거나 노래할 수 없음
}

💡 핵심 정리

상속의 올바른 사용

  1. 서브타이핑을 위해 상속을 사용하라
    • 코드 재사용이 목적이면 합성을 사용
  2. 행동 호환성을 검증하라
    • is-a 관계는 행동이 호환될 때만 성립
  3. 클라이언트 관점에서 설계하라
    • 클라이언트가 사용하는 방식이 기준
  4. SOLID 원칙은 함께 작동한다
    • 하나의 원칙 위반은 연쇄적으로 다른 원칙도 위반
profile
Dot Your moment.

0개의 댓글