상속을 사용하는 두 가지 목적을 이해하고, 올바른 상속 관계를 구성하는 방법을 학습한다.
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;
}
}
// 슈퍼타입
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));
}
}
목적: 코드의 재사용
// ❌ 나쁜 예 - 순수하게 코드 재사용만을 위한 상속
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 원칙 위반!
목적: 다형성을 통한 유연한 설계
// ✅ 좋은 예 - 타입 계층 구성
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);
}
}
// "펭귄은 새다" - 구조적으로는 맞지만...
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이면 예외 발생!
}
// 인터페이스 분리를 통한 해결
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() 메서드가 없음 - 타입적으로 안전
}
"서브타입은 언제나 기반 타입으로 교체할 수 있어야 한다"
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일 때 실패!
}
// 불변 객체로 설계하여 문제 해결
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); // 생성 시점에만 제약 적용
}
}
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);
}
}
핵심은 "클라이언트가 부모 타입으로 자식을 사용해도 문제없어야 한다" 는 것
클라이언트 관점에서 생각하기
// 클라이언트 코드
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
// 추상 클래스 - 템플릿 메서드 패턴
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);
}
}
public class Movie {
private String title;
private Money fee;
private DiscountPolicy discountPolicy; // 추상화에 의존
public Money calculateMovieFee(Screening screening) {
// 구체적인 할인 정책을 알 필요 없음
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
// 모든 할인 정책은 동일한 방식으로 사용 가능
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);
// 새로운 할인 정책 추가 - 기존 코드 수정 없음
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(...));
// 특별 할인 - 조건 체크를 무시
public class SpecialDiscountPolicy extends DiscountPolicy {
@Override
public Money calculateDiscountAmount(Screening screening) {
// 부모의 템플릿 메서드를 무시하고 재정의
return discountAmount; // 조건 체크 없이 항상 할인
}
}
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);
}
}
}
// 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;
}
}
// 클라이언트별로 인터페이스 분리
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 {
// 걷거나 노래할 수 없음
}