상속과 중복 코드중복 코드타입 코드 사용하기상속을 이용해서 중복 코드 제거하기상속을 사용할 때 생길 수 있는 문제상속 문제점 1. 불필요한 인터페이스 상속 문제상속 문제점 2. 메서드 오버라이딩의 오작용 문제상속 문제점3. 부모 클래스와 자식 클래스의 동시 수정 문제상속으로 인한 피해를 최소화 할 수 있는 방법차이를 메서드로 추출하라중복 코드를 부모 클래스로 올려라추상화가 핵심이다세금 추가하기
상속과 중복 코드
중복 코드
중복 코드 : 중복 여부를 판단하는 기준은 변경이다. 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 이 코드는 중복이다!!
DRY 원칙 (Do not Repeat Yourself)
모든 지식은 시스템 내에서 단일하고, 애매하지 않고, 정말로 믿을 만한 표현 양식을 가져야 한다.
핵심은 코드 안에 중복이 존재해서는 안 된다는 것
단점
- 중복 코드는 항상 함께 수정돼야 하기 때문에 수정할 때 하나라도 빠트린다면 버그로 이어진다.
- 만약 모든 중복 코드를 식별했고 함께 수정했다고 하더라도 더 큰 문제는 중복 코드를 서로 다르게 수정하기가 쉬움!
- 중복 코드는 새로운 중복 코드를 부른다. 중복 코드를 제거하지 않은 상태에서 코드를 수정할 수 있는 유일한 방법은 새로운 중복 코드를 추가하는 것 뿐임
타입 코드 사용하기
- 두 클래스 사이의 중복 코드를 제거하는 한 가지 방법은 클래스를 하나로 합치면서 타입 코드를 추가하는 것
- 그러나, 타입 코드를 사용하는 클래스는 낮은 응집도와 높은 결합도라는 문제에 시달리게 됨
- 하나의 타입의 로직을 바꾸기 위해서 다른 타입의 로직을 같이 건드려야 함 (단일 책임원칙 위반. 응집도 떨어짐)
- 하나의 이유로 바꾸어야 하는데 다른 것 까지 영향이 전해짐 → 높은 결합도
- 객체 지향에서 타입 코드를 사용하지 않고 중복 코드를 관리할 수 있는 효과적인 방법 → 상속
상속을 이용해서 중복 코드 제거하기
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) { nightlyFee = nightlyFee.plus( getAmount().minus(nightlyAmount).times( call.getDuration().getSeconds() / getSeconds().getSeconds())); } } return result.minus(nightlyFee); } }
- 위 예시를 통해 알 수 있는 것은 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용하는 것이 쉽지 않다는 점임. 개발자가 재사용을 위해 상속 계층 사이에 무수히 많은 가정을 세웠을지 모르고 그 가정은 코드를 이해하기 어렵게 만들뿐 아니라 직관에도 어긋날 수 있음
- 상속을 이용해 코드를 재사용하기 위해서는 부모 클래스의 개발자가 세웠던 가정이나 추론 과정을 정확하게 이해해야 한다. ↔ 자식 클래스의 작성자가 부모 클래스의 구현 방법에 대해 정확한 지식을 가져야 한다는 것을 의미 ⇒
상속은 결합도를 높임
상속을 사용할 때 생길 수 있는 문제
- 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상을
취약한 기반 클래스 문제
라고 부름
- 이 문제는 상속을 사용한다면 피할 수 없는 객체지향 프로그래밍의 근본적인 취약성임
- 상속은 자식 클래스를 점진적으로 추가해서 기능을 확장하는데는 용이하지만 높은 결합도로 인해 부모 클래스를 점진적으로 개선하는 것은 어렵게 만듦. 최악의 경우에는 모든 자식 클래스를 동시에 수정하고 테스트해야 할 수도 있다.
상속을 위한 경고
1. 취약한 기반 클래스 문제. 상속은 자식 클래스가 부모 클래스의 구현 세부사항에 의존하도록 만들기 때문에 캡슐화를 약화시킴
2. 상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있음(
java.util.Stack
, java.util.Properties
)
3. 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다 (InstrumentedHashSet
의 예시)
4. 클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수 밖에 없다.상속 문제점 1. 불필요한 인터페이스 상속 문제
- 자바 초기 버전에서 상속을 잘못 사용한 대표적인 사례, java.util.Properties, java.util.Stack

- 위와 같이 Stack이 Vector를 상속받기에 Stack에게 상속된 Vector의 퍼블릭 인터페이스를 이용하면 임의의 위치에 요소를 추가하거나 삭제할 수 있음
Stack<String> stack = new Stack<>(); stack.push("1st"); stack.push("2nd"); stack.push("3rd"); stack.add(0, "4th"); assertEquals("4th", stack.pop()); // 에러

- Properties는 key, value가 String 이어야만 하는데 Hashtable의 인터페이스를 활용할 수 있기에 다른 값을 넣을 수도 있음
Properties properties = new Properties(); properties.setProperty("Bjarn", "C++"); properties.setProperty("James Gosling", "Java"); properties.set("Dennis Ritchie", 67); assertEquals("C", properties.getProperty("Dennis Ritchie")); //에러
상속 문제점 2. 메서드 오버라이딩의 오작용 문제
public class InstrumentedHashSet<E> extends HashSet<E> { private int addCount = 0; public InstrumentedHashSet() { } public InstrumentedHashSet(int initCap, float loadFactor) { super(initCap, loadFactor); } @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); // } @Override public boolean addAll(Collection<? extends E> c) { boolean modified = false; for (E e : c) if (add(e)) modified = true; return modified; } public int getAddCount() { return addCount; } }
- 주석처리된 addAll로 작동을 하면 3개를 추가하면 addCount가 6이 됨. super.addAll에서 addCount를 또 더하기 때문에
- 조슈아 블로크는 클래스가 상속되기를 원한다면 상속을 위해 클래스를 설계하고 문서화해야 하며, 그렇지 않은 경우에는 상속을 금지시켜야 한다고 주장함
잘된 API 문서는 메서드가 무슨일을 하는지를 기술해야 하고, 어떻게 하는지를 설명해서는 안 된다는 통념을 어기는 것이 아닐까? (상속을 위한 클래스에 대한 자세한 문서가) 어기는 것이다! 이것은 결국 상속이 캡슐화를 위반함으로써 초래된 불행인 것이다. — Bloch (Effective Java)
상속 문제점3. 부모 클래스와 자식 클래스의 동시 수정 문제
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<Song> getTracks() { return tracks; } } public class PersonalPlaylist extends Playlist { public void remove(Song song) { getTracks().remove(song); } }
- 위와 같은 상황에서 요구사항이 변경되어 Playlist에서 노래 목록 뿐 아니라 가수별 노래의 제목도 함께 관리해야 한다고 하면 Playlist의 구현이 바뀌어야 하고 PersonalPlaylist의 remove 에 대한 구현도 같이 바뀌어야 함
- 이 예는 자식 클래스가 부모 클래스의 메서드를 오버라이딩하거나 불필요한 인터페이스를 상속받지 않았음에도 부모 클래스를 수정할 대 자식 클래스를 함께 수정해야 할 수도 있다는 사실을 잘 보여줌
상속으로 인한 피해를 최소화 할 수 있는 방법
1. 상속받은 자식 클래스 사이에 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라. 메서드 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있다
2. 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식 클래스로 내리는 것보다 자식 클래스의 추상적인 메서드를 부모 클래스로 올리기
- 문제 해결의 열쇠는 바로 추상화임
차이를 메서드로 추출하라
public class Phone extends AbstractPhone { ... public Money calculateFee() { Money result = Money.ZERO; for(Call call : calls) { result = result.plus(calculateCallFee(call)); } return result; } @Override protected Money calculateCallFee(Call call) { return amount.times(call.getDuration().getSeconds() / seconds.getSeconds()); } }
public class NightlyDiscountPhone extends AbstractPhone { ... public Money calculateFee() { Money result = Money.ZERO; for(Call call : calls) { result = result.plus(calculateCallFee(call)); } return result; } @Override protected 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 { private List<Call> calls = new ArrayList<>(); public Money calculateFee() { Money result = Money.ZERO; for(Call call : calls) { result = result.plus(calculateCallFee(call)); } return result; } abstract protected Money calculateCallFee(Call call); }
- 그 후 NightlyDiscountPhone과 Phone에서는 calculateCallFee 메서드를 구현
추상화가 핵심이다
- 공통 코드를 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가진다는 것에 주목하라!! ⇒ 단일 책임 원칙 준수 & 응집도 높음
AbstractPhone
은 전체 통화 목록을 계산하는 방법이 바뀔 경우에만 변경됨Phone
은 일반 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만 변경NightlyDiscountPhone
은 심야 할인 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만 변경
- 위와 같이 하면 의존성 역전 원칙도 준수. 상위 수준의 정책을 구현하는
AbstractPhone
이 세부적인 요금 계산 로직을 구현하는Phone
과NightlyDiscountPhone
에 의존하지 않고 그 반대이기 때문.
- 위와 같은 장점은 클래스들이 추상화에 의존하기 때문에 얻어지는 장점임
- 상속 계층이 코드를 진화시키는 데 걸림돌이 된다면 추상화를 찾아내고 상속 계층 안의 클래스들이 그 추상화에 의존하도록 코드를 리팩터링하기

세금 추가하기
- 세금은 모든 요금제에 공통으로 적용돼야 하는 요구사항이기에 공통 코드를 담고 있는 추상 클래스인
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); }
- 이것으로 다 끝난 것은 아님. Phone에 인스턴스 변수를 추가했기에 Phone의 자식 클래스의 생성자 역시 taxRate를 초기화하기 위해 수정이 필요함
- 인스턴스 변수의 목록이 변하지 않는 상황에서 객체의 행동만 변경된다면 상속 계층에 속한 각 클래스들을 독립적으로 진화시킬 수 있지만, 인스턴스 변수가 추가되는 경우는 다름. 자연 스럽게 부모 클래스에 추가된 인스턴스 변수는 자식 클래스의 초기화 로직에 영향을 미치게 됨
- 결과적으로 책임을 아무리 잘 분리하더라도 인스턴스 변수의 추가는 종종 상속 계층 전반에 걸친 변경을 유발함
- 하지만 인스턴스 초기화 로직을 변경하는 것이 두 클래스에 동일한 세금 계산 코드를 중복시키는 것보다는 현명한선택이다~~
- 객체 생성 로직의 변경은 유연하게 대응할 수 있는 다양한 방법이 존재함(생성자 주입, setter, 메서드 인자로 주입 등)
- 따라서 객체 생성 로직에 대한 변경을 막기보다는 핵심 로직의 중복을 막아라!