devseop08 님의 블로그

[오브젝트] 상속과 코드 재사용 본문

Architecture/객체지향설계

[오브젝트] 상속과 코드 재사용

devseop08 2025. 7. 20. 03:42
  • 객체지향에서는 코드를 재사용하기 위해 코드 복사가 아닌 '새로운' 코드를 추가한다.
  • 객체지향에서 클래스를 재사용하는 전통적인 방법은 새로운 클래스를 추가하는 것이다.
  • 재사용 관점에서 상속이란 클래스 안에 정의된 인스턴스 변수와 메서드를 자동으로 새로운 클래스에 추가하는 구현 기법
  • 1. 상속과 중복 코드
  • 중복 코드는 개발을 주저하게 만들 뿐만 아니라 동료들을 의심하게 만든다.
  • 이것만으로도 중복 코드를 제거해야 할 충분한 이유가 되고도 남겠지만 결정적인 이유는 따로 있다.

DRY 원칙

  • 중복 코드는 변경을 방해한다. 이것이 중복 코드를 제거해야 하는 가장 큰 이유다.
  • 중복 코드는 코드를 수정하는 데 필요한 노력을 몇 배로 증가시킨다.
    • 우선 어떤 코드가 중복인지를 찾아야 한다.
    • 중복 코드의 묶음을 찾았다면 찾아낸 모든 코드를 일관되게 수정해야 한다.
    • 모든 중복 코드를 개별적으로 테스트까지 해야 한다.
  • 코드 간의 중복 여부를 판단하는 기준은 변경이다.
  • 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 이 코드는 중복이다.
  • 함께 수정할 필요가 없다면 중복이 아니다.
  • DRY 원칙: Don't Repeat Yourself, 동일한 지식을 반복하지도, 중복하지도 말라

중복과 변경

중복 코드 살펴보기
  • 중복 코드의 문제점을 이해하기 위해 한 달에 한 번씩 가입자별로 전화 요금을 계산하는 간단한 애플리케이션을 개발해본다.
  • 전화 요금을 계산하는 규칙 : 통화 시간을 단위 시간으로 나눈 후 단위 시간 당 요금을 곱해준다.
  • 먼저 개별 통화 기간을 저장하는 Call 클래스가 필요하다.
public class Call {
    private LocalDateTime from;
    private LocalDateTime to;

    public Call(LocalDateTime from, LocalDateTime to){
        this.from = from;
        this.to = to;
    }

    public Duration getDuration() {
        return Duration.between(from, to);
    }

    public LocalDateTime getFrom() {
        return from;
    }
}
  • 통화 요금을 계산할 객체가 필요하다.
  • 전체 통화 목록에 대해 알고 있는 정보 전문가에게 요금을 계산할 책임을 할당해야 한다.
  • Call의 목록을 관리할 정보 전문가 Phone
  • Phone 인스턴스는 요금 계산에 필요한 세 가지 인스턴스 변수를 포함
      1. 단위 요금을 저장하는 amount
      1. 단위 시간을 저장하는 seconds
      1. 전체 통화 목록을 저장하고 있는 Call의 리스트 calls
public class Phone {
    private Money amount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();

    public Phone(Money amount, Duration seconds) {
        this.amount = amount;
        this.seconds = seconds;
    }

    public void call(Call call) {
        calls.add(call);
    }

    publc List<Call> getCalls() {
        return calls;
    }

    public Money getMoney() {
        return amount;
    }

    public Duration getSeconds() {
        return seconds;
    }

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            result = result.plus(amount.times(
                        call.getDuration().getSeconds() / seconds.getSeconds()
                        ));
        }

        return result;
    }
}
  • Phone을 이용해 '10초당 5원'씩 부과되는 요금제에 가입한 사용자가 각각 1분 동안 두 번 통화를 한 경우의 통화 요금을 계산
Phone phone = new Phone(Money.wons(5), Duration.ofSeconds(10));
phone.call(new Call(
    LocalDateTime(2018, 1, 1, 12, 10, 0),
    LocalDateTime(2018, 1, 1, 12, 11, 0)
));
phone.call(new Call(
    LocalDateTime(2018, 1, 2, 12, 10, 0),
    LocalDateTime(2018, 1, 2, 12, 11, 0)
));
phone.calculateFee();
  • '심야 할인 요금제'라는 새로운 요금 방식을 추가해야 한다는 요구사항이 접수됐다고 해보자.
  • 이 요구 사항을 해결할 수 있는 1차원적인 방법은 Phone의 코드를 복사해서 NightlyDiscountPhone이라는 새로운 클래스를 만든 후 수정하는 것이다.
public class NightlyDiscountPhone {
    private static final int LATE_NIGHT_HOUR = 22;

    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();

    public NightlyDiscountPhone(Money nightlyAmount,
                                Money regularAmount, Duration seconds) {
        this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAmount;
        this.seconds = seconds;
    }

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            if(call.getFrom().getHour() >= LATE_NIGHT_HOUR){
                result = result.plus(nightlyAmount.times(
                         call.getDuration().getSeconds() / seconds.getSeconds()
                        ));
            } else {
                result = result.plus(regularAmount.times(
                         call.getDuration().getSeconds() / seconds.getSeconds()
                        ));
            }
        }

        return result;
    }
}
  • Phone의 코드를 복사해서 NightlyDiscountPhone을 추가하는 방법은 심야 시간에 요금을 할인해야 한다는 요구사항을 아주 짧은 시간 안에 구현할 수 있게 해준다.
  • 하지만 구현 시간을 절약한 대가로 지불해야 하는 비용은 예상보다 크다.
  • Phone과 NightlyDiscountPhone 사이에는 중복 코드가 존재하기 때문에 위험하다.
중복 코드 수정하기
  • 중복 코드가 코드 수정에 미치는 영향을 살펴보기 위해 새로운 요구사항을 추가해본다.
  • 추가할 기능은 통화 요금에 부과할 세금을 계산하는 것이다.
  • 부과되는 세율은 핸드폰마다 다르다고 한다.
  • 현재 통화 요금을 계산하는 로직은 Phone과 NightlyDiscountPhone 양쪽 모두에 구현돼 있기 때문에 세금을 추가하지 위해서는 두 클래스를 함께 수정해야 한다.
public class Phone {
    ...
    private double taxRate;

    public Phone(Money amount, Duration seconds, double taxRate) {
        ...
        this.taxRate = taxRate;
    }

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            result = result.plus(amount.times(
                        call.getDuration().getSeconds() / seconds.getSeconds()
                        ));
        }

        return result.plus(result.times(taxRate));
    }
}
public class NightlyDiscountPhone {
    ...
    private double taxRate;

    public NightlyDiscountPhone(Money nightlyAmount,
                                Money regularAmount, 
                                Duration seconds, double taxRate) {
        ...
        this.taxRate = taxRate
    }

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            if(call.getFrom().getHour() >= LATE_NIGHT_HOUR){
                result = result.plus(nightlyAmount.times(
                         call.getDuration().getSeconds() / seconds.getSeconds()
                        ));
            } else {
                result = result.plus(regularAmount.times(
                         call.getDuration().getSeconds() / seconds.getSeconds()
                        ));
            }
        }

        return result.plus(result.times(taxRate));
    }
}
  • 이 예제는 중복 코드가 갖는 단점을 잘 보여준다.
  • 많은 코드 더미 속에서 어떤 코드가 중복인지를 파악하는 일은 쉬운 일이 아니다.
  • 중복 코드는 항상 함께 수정돼야 하가 때문에 수정할 때 하나라도 빠트린다면 버그로 이어질 것이다.
  • 중복 코드는 새로운 중복 코드를 부른다. 중복 코드를 제거하지 않은 상태에서 코드를 수정할 수 있는 유일한 방법은 새로운 중복 코드를 추가하는 것뿐이다.
타입 코드 사용하기
  • 두 클래스 사이의 중복 코드를 제거하는 한 가지 방법은 클래스를 하나로 합치는 것이다.
  • 요금제를 구분하는 타입 코드를 추가하고 타입 코드의 값에 따라 로직을 분기시켜 Phone과 NightlyDiscountPhone을 하나로 합칠 수 있다.
  • 타입 코드를 사용하는 클래스는 낮은 응집도와 높은 결합도라는 문제에 시달리게 된다.
public class Phone {
    private static final int LATE_NIGHT_HOUR = 22;
    enum PhoneType { REGULAR, NIGHTLY }

    private PhoneType type;

    private Money amount;
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();

    public Phone(Money amount, Duration seconds) {
        this(PhoneType.REGULAR, amount, Money.ZERO, Money.ZERO, seconds);
    }

    public Phone(Money nightlyAmount, Money regularAmount, Duration seconds) {
        this(PhoneType.NIGHTLY, nightlyAmount, regularAmount, seconds);
    }

    public Phone(PhoneType type, Money amount, Money nightlyAmount,
                 Money regularAmount, Duration seconds) {
        this.type = type;
        this.amount = amount;
        this.regularAmount = regularAmount;
        this.nightlyAmount = nightlyAmount
        this.seconds = seconds;
    }

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            if(type == PhoneType.REGULAR){
                result = result.plus(
                         amount.times(
                           call.getDuration().getSeconds() / seconds.getSeconds()
                         ));
            }else {
                if(call.getFrom().getHour() >= LATE_NIGHT_HOUR){
                    result = result.plus(nightlyAmount.times(
                             call.getDuration().getSeconds() / seconds.getSeconds()
                            ));
                } else {
                    result = result.plus(regularAmount.times(
                             call.getDuration().getSeconds() / seconds.getSeconds()
                            ));
                }    
            }
        }

        return result;
    }    
}
  • 객체지행 프로그래밍 언어는 타입 코드를 사용하지 않고도 중복 코드를 관리할 수 있는 효과적인 방법을 제공한다.
  • 바로 상속이다.

상속을 이용해서 중복 코드 제거하기

public class NightlyDiscountPhone extends Phone {
    private static final int LATE_NIGHT_HOUR = 22;

    private Money nightlyAmount;

    public NightlyDiscountPhone(Money nightlyAmount, 
                                Money regularAmount, Duration seconds){
        super(regularAmount, seconds);
        this.nightlyAmount = nightlyAmount;                            
    }

    @Override
    public Money calculateFee() {
        // 부모 클래스의 calculateFee 호출
        Money result = super.calculateFee();

        Money nightlyFee = Money.ZERO; // 심야 통화 건에 대한 할인 오금 총계
        for(Call call : getCalls()) {
            if(call.getFrom().getHour() >= LATE_NIGHT_HOUR){
                nigthlyFee = nightlyFee.plus(
                    // (시간당 할인 요금) * 통화 건의 통화 시간 
                    getAmount().minus(nightlyAmount).times(
                       call.getDuration().getSeconds() / getSeconds().getSeconds();
                    );
                );
            }
        }

        return result.minus(nightlyFee);
    }
}
  • 상속하여 만든 코드는 개발자의 가정을 이해하기 전에는 이해하기가 어렵다.
  • 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용하는 것은 생각처럼 쉽지 않다.
  • 요구 사항과 구현 사이의 차이가 크면 클수록 코드를 이해하기가 어려워진다. 잘못된 상속은 이 차이를 더 크게 벌린다.
  • 상속을 이용해 코드를 재사용하기 위해서는 부모 클래스의 개발자가 세웠던 가정이나 추론 과정을 정확하게 이해해야 한다.
  • 이는 자식 클래스의 작성자가 부모 클래스의 구현 방법에 대한 정확한 지식을 가져야 한다는 것을 의미한다. => 상속은 결합도를 높인다.

강하게 결합된 Phone과 NightlyDiscountPhone

  • NightlyDiscountPhone이 Phone을 상속하는 상황에서 세금을 부과하는 요구사항이 추가된다면
public class Phone {
    ...
    private double taxRate;

    public Phone(Money amount, Duration seconds, double taxRate) {
        ...
        this.taxRate = taxRate;
    }

    public Money calculateFee() {
        ...
        return result.plus(result.times(taxRate));
    }

    public double getTaxRate() {
        return taxRate;
    }
}
public class NightlyDiscountPhone extends Phone {
    public NightlyDiscountPhone(Money nightlyAmount, 
                                Money regularAmount, 
                                Duration seconds, double taxRate){
        super(regularAmount, seconds, taxRate);        
        ...                
    }

    @Override
    public Money calculateFee() {
        return result.minus(nightlyFee.plus(nightlyFee.times(getTaxRate())));
    } 
}
  • 하나의 요구사항 추가로 인해 부모 클래스 Phone의 calculateFee 메서드와 NightlyDiscountPhone의 calculateFee 메서드 모두 수정해줘야 하므로, 두 메서드에서 중복 코드가 발생한다고 할 수 있다.
  • NightlyDiscountPhone을 Phone의 자식 클래스로 만든 이유는 Phone의 코드를 재사용하고 중복 코드를 제거하기 위해서였다.
  • 하지만 세금을 부과하는 로직을 추가하기 위해 Phone을 수정할 때 유사한 코드를 NightlyDiscountPhone에도 추가해야 했다.
  • 코드 중복을 제거하기 위해 상속을 사용했음에도 세금을 계산하는 로직을 추가하기 위해 새로운 중복 코드를 만들어야 하는 것이다.
  • 이것은 NightlyDiscountPhone이 Phone의 구현에 너무 강하게 결합돼 있기 때문에 발생하는 문제다.
  • 상속을 사용하면 적은 노력으로도 새로운 기능을 쉽고, 빠르게 추가할 수는 있지만 그로 인한 부모 클래스와 자식 클래스 간의 높은 결합도가 커다란 대가를 치르게 할 수도 있다.
  • 상속 관계로 연결된 자식 클래스가 부모 클래스의 변경에 취약해지는 현상을 가리켜 취약한 기반 클래스 문제라고 부른다.
  • 취약한 기반 클래스 문제는 코드 재사용을 목적으로 상속을 사용할 대 발생하는 대표적인 문제다.

2. 취약한 기반 클래스 문제

  • 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상을 취약한 기반 클래스 문제라고 한다.
  • 이 문제는 상속을 사용한다면 피할 수 없는 객체지향 프로그래밍의 근본적인 취약성이다.
  • 상속은 자식 클래스를 점진적으로 추가해서 기능을 확장하는 데는 용이하지만 높은 결합도로 인해 부모 클래스를 점진적으로 개선하는 것은 어렵게 만든다.
  • 최악의 경우에는 모든 자식 클래스를 동시에 수정하고 테스트해야 할 수도 있다.
  • 취약한 기반 클래스 문제는 캡슐화를 약화시키고 결합도를 높인다.
  • 상속을 사용하면 부모 클래스의 퍼블릭 인터페이스가 아닌 구현을 변경하더라도 자식 클래스가 영향을 받기 쉬워진다.
  • 상속은 코드의 재사용을 위해 캡슐화의 장점을 희석시키고 구현에 대한 결합도를 높임으로써 객체지향이 가진 강력함을 반감시킨다.
  • 불필요한 인터페이스 상속 문제
  • 자바 초기 버전에서 상속을 잘못 사용한 대표적인 사례는 java.util.Stack과 java.util.Properties이다.
  • Stack은 가장 나중에 추가된 요소가 가장 먼저 추출되는 자료구조인 스택을 구현한 클래스
  • Verctor는 임의의 위치에서 요소를 추출하고 삽입할 수 있는 리스트 자료 구조의 구현체(List의 초기 버전)
  • 자바 컬렉션 프레임워크 초기 개발자들은 요소의 추가, 삭제 오퍼레이션을 제공하는 Vector를 재사용하기 위해 Stack을 Vector의 자식 클래스로 구현
  • Stack이 Vector를 상속받았기 때문에 Stack의 퍼블릭 인터페이스에 Vector의 퍼블릭 인터페이스가 합쳐진다.
  • Stack에게 상속된 Vector의 퍼블릭 인터페이스를 이용하면 임의의 위치에서 요소를 추가하거나 삭제할 수 있다.
  • 따라서 Vector의 퍼블릭 인터페이스를 상속받은 현재의 Stack을 사용하면 스택의 규칙을 쉽게 위반할 수 있다.
  • 문제의 원인은 Stack의 규칙을 무너뜨릴 여지가 있는 Vector의 퍼블릭 인터페이스까지도 함께 상속받았기 때문이다.
  • java.util.Properties 클래스는 키와 값의 쌍을 보관한다는 점에서는 Map과 유사하지만 다양한 타입을 저장할 수 있는 Map과 달리 키와 값의 타입으로 오직 String만 가질 수 있다는 것이 원래 특징이다.
  • 그런데 Properties는 Hashtable을 상속받는데, Hashtable은 자바에 제네릭이 도입되기 이전에 만들어졌기 때문에 컴파일러가 키와 값의 타입이 String인지 여부를 체크할 수 있는 방법이 없었기에
  • Hashtable의 인터페이스에 포함돼 있는 put 메서드를 이용하면 String 타입 이외의 타입의 키와 값이라도 Properties에 저장할 수 있는 상태가 돼버렸다.
  • 퍼블릭 인터페이스에 대한 고려없이 단순히 코드 재상용을 위해서만 상속을 이용하는 것은 위험하다.
  • 단순히 코드를 재사용하기 위해 불필요한 오퍼레이션이 인터페이스에 스며들도록 방치해서는 안 된다.
  • "상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다."

메서드 오버라이딩의 오작용 문제

  • InstrumentedHashSet은 HashSet의 내부에 저장된 요소의 수를 셀 수 있는 기능을 추가한 클래스로서 HashSet의 자식 클래스로 구현돼 있다.
  • InstrumetedHashSet은 HashSet의 구현에 강하게 결합된 클래스다.
public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }
}
  • InstrumentedHashSet은 요소를 추가한 횟수를 기록하기 위해 HashSet에는 없는 addCount라는 인스턴스 변수를 포함
  • InstrumentedHashSet은 요소가 추가될 때마다 추가되는 요소의 개수만큼 addCount의 값을 증가시키는 기능을 위해 하나의 요소를 추가하는 add 메서드와 다수의 요소들을 추가하는 addAll 메서드를 오버라이딩한다.
  • add 메서드와 addAll 메서드는 먼저 addCount를 증가시킨 후 super 참조를 이용해 부모 클래스의 메서드를 호출해서 실제로 요소를 추가하게 된다.
  • InstrumentedHashSet의 코드만 봤을 때는 문제가 없어보이지만 부모 클래스 HashSet의 실제 addAll 메서드를 확인해보면 의도한대로 동작하지 않을 것임을 알 수 있다.
  • InstrumentedHashSet의 addAll 메서드에서 부모 클래스 HashSet의 addAll 메서드를 호출하면 부모 클래스의 addAll 메서드는 자신에게 전달된 컬렉션 요소를 자신이 직접 저장하는 것이 아니라
  • addAll 메서드의 내부로 전달된 컬레션의 요소 하나마다 매번 add 메서드의 인자로 전달하여 실제 요소의 저장은 add 메서드를 통해 이뤄지도록 하였다.
  • 결국엔 InstrumentedHashSet의 addAll 메서드를 호출하면 컬렉션의 요소 개수만큼 InstrumentedHashSet의 add 메서드가 호출되어 addCount를 증가시키는 행위가 이중으로 일어나게 된다.
  • 부모 클래스를 상속하고 부모 클래스에는 없는 새로운 기능을 추가하기 위해 메서드 오버라이딩을 했지만 부모 클래스의 구현에 발목이 잡혀버린 상태인 것이다.
  • 따라서 InstrumentedHashSet은 부모 클래스인 HashSet의 구현에 강하게 결합된 상태라고 할 수 있다.
  • "자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 묶여버릴 수 있다."
트레이드 오프 관점에서의 상속을 위한 설계
  • 이런 문제로 인해 클래스가 상속되기를 원한다면 상속을 위해 클래스를 설계하고 문서화해야 하며, 그렇지 않은 경우에는 상속을 금지시켜야 한다고 주장하는 경우도 있다.
  • 상속이 초래하는 문제점을 보완하면서 코드 재사용의 장점을 극대화하기 위해선 가치있는 주장이긴 하다.
  • 그런데 상속을 위해 클래스의 내부 구현을 공개하고 문서화하는 것은 캡슐화를 위반한다.
  • 다만 설계는 트레이드오프 활동이기에 고려해볼만한 방법이다.
  • 상속의 문제점을 보완하면서 상속을 구현하려면 캡슐화를 희생하게 된다.
  • 완벽한 캡슐화를 원한다면 코드 재사용을 포기하거나 상속 이외의 다른 방법을 사용해야 한다.
  • 부모 클래스와 자식 클래스의 동시 수정 문제
  • 음악 목록을 추가할 수 있는 플레이리스트를 구현한다고 가정
  • 필요한 것은 음악 정보를 저장할 Song 클래스와 음악 목록을 저장할 PlayList 클래스다.
public class Song {
    private String singer;
    private String title;

    public Song(String singer, String title) {
        this.singer = singer;
        this.title = title;
    }

    public String getSinger() {
        return singer;
    }

    public String getTitle() {
        return title;
    }
}
public class Playlist {
    private List<Song> tracks = new ArrayList<>();

    public void append(Song song) {
        getTracks().add(song);
    }

    public List<String> getTracks() {
        return tracks;
    }
}
  • 플레이리스트에서 노래를 삭제할 수 있는 기능이 추가된 PersonalPlaylist가 필요하다고 가정
  • PersonalPlaylist를 구혀하는 가장 빠른 방법은 상속을 통해 Playlist의 코드를 재사용하는 것이다.
public class PersonalPlaylist extends Playlist{
    public void remove(Song song) {
        getTracks().remove(song);
    }
}
  • 요구사항이 변경돼서 Playlist에서 노래의 목록뿐만 아니라 가수별 노래의 제목도 함께 관리해야 한다고 가정
  • 노래를 추가한 후 가수의 이름을 키로 노래의 제목을 추가하도록 Playlist의 append 메서드를 수정
public class Playlist {
    private List<Song> tracks = new ArrayList<>();
    private Map<String, String> singers = new HashMap<>();

    public void append(Song song) {
        getTracks().add(song);
        singers.put(song.getSinger(), song.getTitle());
    }

    public List<String> getTracks() {
        return tracks;
    }

    public Map<String, String> getSingers() {
        return singers;
    }
}
  • 위 수정 내용이 정상적으로 동작하려면 PersonalPlaylist의 remove 메서드도 함께 수정해야 한다.
public class PersonalPlaylist extends Playlist{
    public void remove(Song song) {
        getTracks().remove(song);
        getSingers().remove(song.getSinger());
    }
}
  • 단순히 코드 재사용을 목적으로 한 상속은 부모 클래스와 자식 클래스를 강하게 결합시키기 때문에 함께 수정해야 하는 상황 역시 빈번하게 발생할 수밖에 없는 것이다.
  • 슈퍼클래스의 작성자가 확장될 목적으로 특별히 그 클래스를 설계하지 않았다면 서브클래스는 슈퍼클래스와 보조를 맞춰서 진화해야 한다.
  • "클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수 밖에 없다."

3. Phone 다시 살펴보기

추상화에 의존하자

  • NightlyDiscountPhone의 가장 큰 문제점은 Phone에 강하게 결합돼 있기 때문에 Phone이 변경될 경우 함께 변경될 가능성이 높다는 것이다.
  • 부모 클래스와 자식 클래스 모두 추상화에 의존하도록 수정해야 한다.
  • 코드 중복을 제거하기 위해 상속을 도입할 때 따라야 하는 규칙
      1. 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출한다. 메서드 추출을 통해 두 메서드가 동일한 형태로 보이도록 만들 수 있다.
      1. 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식 클래스로 내리는 것보다 자식 클래스의 추상적인 메서드를 부모 클래스로 올리는 것이 재사용성과 응집도 측면에서 더 뛰어난 결과를 얻을 수 있다.

차이를 메서드로 추출하라

  • 먼저, 중복 코드 안에서 차이점을 별도의 메서드로 추출한다.
public class Phone {
    private Money amount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();

    public Phone(Money amount, Duration seconds) {
        this.amount = amount;
        this.seconds = seconds;
    }

    public void call(Call call) {
        calls.add(call);
    }

    publc List<Call> getCalls() {
        return calls;
    }

    public Money getMoney() {
        return amount;
    }

    public Duration getSeconds() {
        return seconds;
    }

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            result = result.plus(amount.times(
                        call.getDuration().getSeconds() / seconds.getSeconds()
                        ));
        }

        return result;
    }
}
public class NightlyDiscountPhone {
    private static final int LATE_NIGHT_HOUR = 22;

    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();

    public NightlyDiscountPhone(Money nightlyAmount,
                                Money regularAmount, Duration seconds) {
        this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAmount;
        this.seconds = seconds;
    }

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            if(call.getFrom().getHour() >= LATE_NIGHT_HOUR){
                result = result.plus(nightlyAmount.times(
                         call.getDuration().getSeconds() / seconds.getSeconds()
                        ));
            } else {
                result = result.plus(regularAmount.times(
                         call.getDuration().getSeconds() / seconds.getSeconds()
                        ));
            }
        }

        return result;
    }
}
  • calculateFee 메서드의 for 문 안에 구현된 요금 계산 로직이 서로 다르다.
  • 이 다른 부분을 동일한 이름을 가진 메서드로 추출한다.(하나의 통화에 대한 통화 요금 계산: calculateCallFee)
public class Phone {
    ...

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call: calls) {
            result = result.plus(calculateCallFee(call));
        }

        return result;
    }

    private Money calculateCallFee(Call call){
        return amount.times(
                        call.getDuration().getSeconds() / seconds.getSeconds()
                        )
    }
} 
public class NightlyDiscountPhone {
    ...

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            result = result.plus(calculateCallFee(call));
        }

        return result;
    }

    private Money calculateCallFee(Call call) {
        if(call.getFrom().getHour() >= LATE_NIGHT_HOUR){
            return nightlyAmount.times(
                     call.getDuration().getSeconds() / seconds.getSeconds()
                    );
        } else {
            return regularAmount.times(
                     call.getDuration().getSeconds() / seconds.getSeconds()
                    );
        }
    }
}
  • 두 클래스의 calculateFee 메서드는 완전히 동일해졌고 추출한 calculateCallFee 메서드 안에 서로 다른 부분을 격리시켜 놓았다. 이제 같은 코드를 부모 클래스로 올리는 일만 남았다.
  • 중복 코드를 부모 클래스로 올려라
  • 우선 부모 클래스를 추가하자.
  • 목표는 모든 클래스들이 추상화에 의존하도록 만드는 것이기에 이 부모 클래스는 추상 클래스로 구현하는 것이 적합할 거이다.
public abstract class AbstractPhone {}

public class Phone extends AbstractPhone {...}

public class NightlyDiscountPhone extends AbstractPhone {...}
  • Phone과 NightlyDiscountPhone의 공통 부분을 부모 클래스로 이동시키자.
  • 공통 코드를 옮길 때 인스턴스 변수보다 메서드를 먼저 옮기는 것이 편하다.
  • 메서드를 옮기고 나면 그 메서드에 필요한 메서드나 인스턴스 변수가 무엇인지를 컴파일 에러를 통해 자동으로 알 수 있다.
  • 두 클래스 사이에서 완전히 동일한 코드는 calculateFee 메서드이므로 calculateFee 메서드를 AbstractPhone으로 이동시키고 Phone과 NightlyDiscountPhone에서 이 메서드를 제거한다.
public abstract class AbstractPhone {
    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            result = result.plus(calculateCallFee(call));
        }

        return result;
    }
}
  • calculateFee 메서드를 부모 클래스로 이동시키고 나면 calls가 존재하지 않는다는 에러가 발생
  • 자식 클래스의 인스턴스 변수 calls를 AbstractPhone으로 이동시킨다.
public abstract class AbstractPhone {
    private List<Call> calls = new ArrayList<>();

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            result = result.plus(calculateCallFee(call));
        }

        return result;
    }
}
  • calls를 이동시키고 나면 부모 클래스에서 calculateCallFee 메서드를 찾을 수 없다는 에러가 발생
  • 자식 클래스 간의 calculateCallFee 메서드의 구현은 다르고 시그니처가 동일하기 때문에 시그니처만 AbstractPhone으로 이동시킨다.
  • 시그니처만 이동시키는 것이므로 calculateCallFee 메서드를 추상 메서드로 선언하고 자식 클래스에서 오버라이딩할 수 있도록 protected로 선언한다.
public abstract class AbstractPhone {
    private List<Call> calls = new ArrayList<>();

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            result = result.plus(calculateCallFee(call));
        }

        return result;
    }

    protected abstract Money calculateCallFee(Call call);
}
  • Phone과 NightlyDiscountPhone의 중복 코드를 모두 AbstractPhone으로 옮겼다.
  • 이제 Phone에는 일반 요금제를 처리하는 데 필요한 인스턴스 변수와 메서드만 존재하고
  • NightlyDiscountPhone에는 심야 할인 요금제와 관련된 인스턴스 변수와 메서드만 존재하게 된다.
public class Phone extends AbstractPhone{
    private Money amount;
    private Duration seconds;


    public Phone(Money amount, Duration seconds) {
        this.amount = amount;
        this.seconds = seconds;
    }

    @Override
    protected Money calculateCallFee(Call call){
        return amount.times(
                        call.getDuration().getSeconds() / seconds.getSeconds()
                        )
    }
}
public class NightlyDiscountPhone extends AbstractPhone {
    private static final int LATE_NIGHT_HOUR = 22;

    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;

    public NightlyDiscountPhone(Money nightlyAmount,
                                Money regularAmount, Duration seconds) {
        this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAmount;
        this.seconds = seconds;
    }

    @Override
    protected Money calculateCallFee(Call call){
        if(call.getFrom().getHour() >= LATE_NIGHT_HOUR){
            return nightlyAmount.times(
                     call.getDuration().getSeconds() / seconds.getSeconds()
                    );
        }

        return regularAmount.times(
                     call.getDuration().getSeconds() / seconds.getSeconds()
                    );

    }
}
  • 자식 클래스들 사이의 공통점을 부모 클래스로 옮김으로써 실제 코드를 기반으로 상속 계층을 구성할 수 있다.

추상화가 핵심이다

  • 공통 코드를 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가진다.
  • 세 클래스는 각각 하나의 변경 이유만을 가진다.
  • 이들은 단일 책임 원칙을 준수하기 때문에 응집도가 높다.
  • calculateCallFee 메서드의 시그니처가 변경되지 않는 한 부모 클래스의 내부 구현이 변경되더라도 자식 클래스들은 영향을 받지 않는다.
  • 이 설계는 낮은 결합도를 유지하고 있다.
  • 부모 클래스 역시 자신의 내부에 구현된 추상 메서드를 호출하기 때문에 추상화에 의존한다.
  • 의존성 역전 원칙을 준수한다.
  • 새로운 요금제를 추가하기도 쉽다.
  • 새로운 요금제가 필요하다면 AbstractPhone을 상속받는 새로운 클래스를 추가하고 calculateCallFee 메서드만 오버라이딩하면 된다. 기존 코드는 수정이 필요 없다.
  • 즉, 현재의 설계는 확장에는 열려 있고 수정에는 닫혀 있기 때문에 개방-폐쇄 원칙을 준수한다는 것이다.

의도를 드러내는 이름 선택하기

  • 의도에 맞게 클래스 이름들을 수정하자.
public abstract class Phone {...}

public class RegularPhone extends Phone {...}

public class NightlyDiscountPhone extends Phone {...}

세금 추가하기

public abstract class Phone {
    private double taxRate;
    private List<Call> calls = new ArrayList<>();

    public Phone(double taxRate) {
        this.taxRate = taxRate;
    }

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            result = result.plus(calculateCallFee(call));
        }

        return result.plus(result.times(taxRate));
    }

    protected abstract Money calculateCallFee(Call call);
}
public class RegularPhone extends Phone {
    private Money amount;
    private Duration seconds;


    public RegularPhone(Money amount, Duration seconds, double taxRate) {
        super(taxRate);
        this.amount = amount;
        this.seconds = seconds;
    }

    @Override
    protected Money calculateCallFee(Call call){
        return amount.times(
                        call.getDuration().getSeconds() / seconds.getSeconds()
                        )
    }
}
public class NightlyDiscountPhone extends Phone {
    private static final int LATE_NIGHT_HOUR = 22;

    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;

    public NightlyDiscountPhone(Money nightlyAmount,
                                Money regularAmount, Duration seconds,
                                double taxRate) {
        super(taxRate);
        this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAmount;
        this.seconds = seconds;
    }

    @Override
    protected Money calculateCallFee(Call call){
        if(call.getFrom().getHour() >= LATE_NIGHT_HOUR){
            return nightlyAmount.times(
                     call.getDuration().getSeconds() / seconds.getSeconds()
                    );
        }

        return regularAmount.times(
                     call.getDuration().getSeconds() / seconds.getSeconds()
                    );

    }
}
  • 클래스라는 도구는 메서드뿐만 아니라 인스턴스 변수도 포함
  • 클래스 사이의 상속은 자식 클래스가 부모 클래스가 구현한 행동뿐만 아니라 인스턴스 변수에 대해서도 결합되게 만든다.
  • 인스턴스 변수의 목록이 변하지 않는 상황에서 객체의 행동만 변경된다면 상속 계층에 속한 각 클래스들을 독립적으로 진화시킬 수 있다.
  • 하지만 인스턴스 변수가 추가되는 상황은 다르다.
  • 부모 클래스에 추가된 인스턴스 변수는 자식 클래스의 초기화 로직에 영향을 미치게 된다.
  • 책임을 아무리 잘 분리해도 인스턴스 변수의 추가는 종종 상속 계층 전반에 걸친 변경을 유발한다.
  • 하지만 인스턴스 초기화 로직을 변경하는 것이 두 클래스에 동일한 세금 계산 코드를 중복시키는 것보다는 현명한 선택이다.
  • 객체 생성 로직에 대한 변경을 막기보다는 핵심 로직의 중복을 막는 편이 좋다.
  • 결국 상속으로 인한 클래스 사이의 결합을 피할 수 있는 방법은 없는 것이다.
  • 상속은 어떤 식으로든 부모 클래스와 자식 클래스를 결합시킨다.
  • 메서드 구현에 대한 결합은 추상 메서드를 추가함으로써 어느 정도 완화할 수 있지만 인스턴스 변수에 대한 잠재적인 결합을 제거할 수 있는 방법은 없다.

4. 차이에 의한 프로그래밍

  • 차이에 의한 프로그래밍 : 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법
  • 중복 코드를 제거하기 위해 최대한 코드를 재사용해야 한다.
  • 재사용 가능한 코드란 심각한 버그가 존재하지 않는 코드다.
  • 상속은 중복 코드를 제거하고 코드를 재사용할 수 있는 강력한 도구지만 잘못 사용할 경우 돌아온는 피해가 크다.
  • 상속의 오용과 남용은 애플리케이션을 이해하고 확장하기 어렵게 만든다. 정말로 필요한 경우에만 상속을 사용해야 한다.
  • 상속은 사실 코드 재사용과 관련된 대부분의 경우에 우아한 해결 방법이 아니다.
  • 객체지향에 능숙한 개발자들은 상속의 단점을 피하면서도 코드를 재사용할 수 있는 더 좋은 방법이 있다는 사실을 안다. 바로 합성이다.

'Architecture > 객체지향설계' 카테고리의 다른 글

[오브젝트] 다형성  (2) 2025.08.01
[오브젝트] 합성과 유연한 설계  (1) 2025.07.21
[오브젝트] 유연한 설계  (0) 2025.07.16
[오브젝트] 의존성 관리하기  (1) 2025.07.10
[오브젝트] 객체 분해  (0) 2025.07.06