여기서부터 두 번째 예제, 핸드폰 과금 시스템이 등장한다. 요금을 계산하는 Phone과, 심야에 더 싸게 매기는 NightlyDiscountPhone. 두 클래스는 통화 목록을 순회하며 요금을 더하는 골격이 똑같다 — 전형적인 중복이다. 코드 재사용의 가장 손쉬운 도구는 상속이고, 이 장은 그 상속을 실제로 적용해 본 뒤 어디서 무너지는지까지 끝까지 따라간다. 결론을 미리 말하면, 상속은 중복을 지우는 대신 부모와 자식을 강하게 묶는다. 그 결합이 어떻게 배신하는지가 이 장의 핵심이다.
DRY — 중복은 모양이 아니라 변경 이유다
중복을 지우려는 이유부터 짚자. DRY(Don’t Repeat Yourself) 원칙은 “모든 지식은 시스템 내에서 단 한 번만, 애매하지 않게, 권위 있게 표현되어야 한다”고 말한다. 중복이 나쁜 진짜 이유는 타이핑을 두 번 하기 때문이 아니라, 하나를 고칠 때 나머지도 빠짐없이 함께 고쳐야 하기 때문이다. 그중 하나를 놓치면 시스템은 조용히 모순에 빠진다.
여기서 핵심은 중복의 정의다. 중복인지 아닌지는 코드의 모양이 같은가가 아니라 함께 변경되는가, 같은 이유로 변경되는가로 판단한다.
// 모양은 비슷하지만 — 변경 이유가 다르면 중복이 아니다
double circleArea = 3.14 * r * r; // '원의 넓이 공식'이 바뀌면 변한다
double interestRate = 3.14; // '이자율'이 바뀌면 변한다 (우연히 같은 3.14)
// 모양은 달라도 — 같은 이유로 함께 변하면 중복이다
if (call.getFrom().getHour() >= 22) { ... } // '심야 시작 시각'이 바뀌면
String label = "심야 요금 (22시 이후)"; // 이 둘은 반드시 함께 바뀐다위쪽 두 3.14는 모양이 같아도 함께 변하지 않으니 중복이 아니다 — 억지로 상수 하나로 합치면 오히려 무관한 두 지식을 묶는 잘못된 결합이 된다. 아래쪽 22와 라벨은 모양이 달라도 “심야 시작 시각”이라는 하나의 지식을 나눠 표현하므로 중복이다. 판단 기준: 두 코드 조각이 항상 같은 이유로 함께 바뀌어야 한다면 중복이고 하나로 모아야 한다. 그렇지 않다면 모양이 같아도 남이다. 함정: 모양의 유사성만 보고 성급히 합치면(우연한 중복 제거) 서로 다른 이유로 변할 코드를 한 곳에 묶어, 나중에 한쪽만 바꿔야 할 때 억지로 분기를 끼워 넣게 된다.
중복에서 상속으로, 그리고 세금
아래 스텝 플레이어를 순서대로 본다. 두 클래스의 중복에서 시작해, 상속으로 중복을 지우고, 세금이라는 새 요구사항 하나에 그 중복이 되살아나는 데까지 간다.
public class Phone { private Money amount; private Duration seconds; private List<Call> calls = new ArrayList<>(); 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 Money nightlyAmount; private Money regularAmount; private Duration seconds; private List<Call> calls = new ArrayList<>(); public Money calculateFee() { Money result = Money.ZERO; for (Call call : calls) { if (call.getFrom().getHour() >= 22) result = result.plus(nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())); else result = result.plus(regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())); } return result; }}
상속이 배신하는 지점
두 번째 스텝까지는 상속이 훌륭해 보인다. calculateFee의 골격이 부모 한 곳에만 있고, 자식은 통화당 요금(calculateCallFee)만 다르게 정의한다. 전형적인 템플릿 메서드 구조다.
문제는 세 번째 스텝이다. “요금에 세금을 붙여 달라”는 새 요구사항 하나가 들어오자, taxRate 필드와 그 초기화가 부모와 자식 양쪽에 나타났다. 자식은 부모의 생성자를 호출해야 하므로 부모의 세금을 알아야 하고, 자기 자신도 세금을 저장한다. 우리가 상속으로 지웠던 바로 그 종류의 중복이 되살아났다.
이것이 취약한 기반 클래스 문제(fragile base class) 다. 자식은 부모의 구현에 의존한다 — 어떤 필드가 있는지, 생성자가 무엇을 받는지, 메서드가 언제 호출되는지까지. 그래서 부모를 고치면 자식이 조용히 깨지거나, 세금 예처럼 자식이 부모의 변경을 강제로 따라가야 한다. 판단 기준: 상속을 쓸 때는 “부모가 바뀌면 이 자식이 함께 바뀌어야 하는가”를 물어라. 답이 자주 “예”라면 그 상속은 결합이 너무 강하다. 함정: 상속은 컴파일 시점에 부모-자식 관계를 못박는다. 실행 중에 정책을 갈아 끼우거나 여러 정책을 조합할 수 없다 — 세금과 심야 할인을 함께 적용하려는 순간 클래스가 조합만큼 폭발한다. 이 두 함정을 한 번에 푸는 답이 다음 장의 합성이다.
취약한 기반 클래스의 교과서 사례 — InstrumentedHashSet
세금 예제는 취약함을 우리 코드로 겪은 것이지만, 자바 표준 라이브러리에는 이 문제의 고전이 박혀 있다. HashSet을 상속해 “지금까지 몇 개의 원소가 추가됐는지”를 세는 InstrumentedHashSet을 만들어 보자.
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);
}
}언뜻 완벽해 보인다. 그런데 원소 3개를 addAll로 넣으면 addCount는 3이 아니라 6이 된다. 이유는 HashSet.addAll의 내부 구현에 있다 — addAll은 내부에서 각 원소마다 add를 호출하도록 짜여 있다. 그런데 이 add는 동적 바인딩에 의해 우리가 오버라이드한 InstrumentedHashSet.add로 이어진다. 그래서 addAll에서 한 번(+3), 그 안이 부르는 add에서 또 한 번(+3), 이중으로 센다.
flowchart TD A["addAll(3개) 호출 → addCount += 3 (=3)"] A --> B["super.addAll 실행"] B --> C["HashSet.addAll 내부가 원소마다 add 호출"] C --> D["self 부터 탐색 → InstrumentedHashSet.add 실행"] D --> E["addCount++ 세 번 (=6) — 이중 계수"]
판단 기준: 자식이 올바르게 동작하려면 부모의 공개 시그니처만이 아니라 그 메서드가 내부에서 서로를 어떻게 호출하는지(self-use 패턴)까지 알아야 한다. 이 내부 호출 관계는 문서에 적히지 않는 구현 세부이고 언제든 바뀔 수 있다 — 자바 버전이 올라가 addAll이 더 이상 add를 부르지 않게 바뀌면, 이번엔 반대로 개수가 세어지지 않는다. 함정: 이 문제는 오버라이드를 잘못해서 생긴 게 아니다. addAll 오버라이드를 지우면 이번 버그는 사라지지만, 그것 역시 “부모가 add를 부른다”는 구현 세부에 의존한 해법이라 다음 버전에서 또 깨진다. 상속으로는 구현 의존에서 근본적으로 벗어날 수 없다.
불필요한 것까지 물려받는다 — Stack과 Properties
상속은 부모의 인터페이스를 통째로 물려받는다. 필요한 것만 골라 받을 수 없다. 자바 라이브러리에는 이 때문에 깨진 캡슐화의 흔적이 그대로 남아 있다.
// java.util.Stack 은 Vector 를 상속한다
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
stack.push(3);
stack.add(0, 999); // Vector 의 메서드 — 스택 중간에 값을 끼워 넣는다!
System.out.println(stack.pop()); // 3 — LIFO 규약이 깨졌다. 999 는 바닥에 있다Stack은 “마지막에 넣은 것이 먼저 나온다(LIFO)“를 보장해야 하는데, Vector를 상속한 탓에 add(int, E), remove(int), insertElementAt 같은 “임의 위치 접근” 메서드를 전부 물려받았다. 그 메서드들로 스택의 불변식을 언제든 깰 수 있다. Properties extends Hashtable도 같은 병이다 — Properties는 문자열 키/값만 담아야 하지만, Hashtable의 put(Object, Object)을 물려받아 아무 타입이나 넣을 수 있다.
Properties props = new Properties();
props.put("key", 42); // Hashtable.put — 컴파일도 통과한다
String v = props.getProperty("key"); // null! (String 이 아니라서 조용히 무시)판단 기준: 상속하려는 부모의 공개 메서드 중 자식의 규약을 깨뜨리는 것이 하나라도 있다면 그것은 잘못된 상속이다. 인터페이스를 골라 받고 싶다면 상속이 아니라 합성(부모를 필드로 감싸 필요한 메서드만 노출)을 써야 한다 — 이것이 다음 장의 해법이다. 함정: is-a로 말이 된다고 상속을 정당화하지 마라. “스택은 벡터의 일종이다”는 문장은 그럴듯하지만, 벡터의 인터페이스가 스택의 규약과 맞지 않으면 그 상속은 캡슐화를 깬다.
부모와 자식은 함께 수정된다 — Playlist와 PersonalPlaylist
취약함은 오작동으로만 오지 않는다. 정상 동작하는 코드에서도, 부모를 고칠 때마다 자식을 함께 고쳐야 하는 동시 수정 부담으로 온다. 노래 목록 Playlist와, 노래를 뺄 때 가수별 목록도 갱신하는 PersonalPlaylist를 보자.
public class Playlist {
private List<Song> tracks = new ArrayList<>();
private Map<String, String> singers = new HashMap<>();
public void append(Song song) {
tracks.add(song);
singers.put(song.getSinger(), song.getTitle());
}
}
public class PersonalPlaylist extends Playlist {
public void remove(Song song) {
getTracks().remove(song);
getSingers().remove(song.getSinger()); // 부모의 내부 구조(두 자료구조)를 자식이 안다
}
}이제 요구가 바뀌어 Playlist가 노래를 List 대신 다른 자료구조로 관리하게 됐다고 하자. Playlist.append만 고치면 될 것 같지만, PersonalPlaylist.remove도 부모가 tracks와 singers 두 자료구조를 쓴다는 사실에 의존하므로 함께 고쳐야 한다. 자식이 부모의 내부 구현을 훤히 알고 그 위에 코드를 얹었기 때문이다. 판단 기준: 자식이 부모의 protected 필드나 내부 자료구조에 손을 뻗는 순간, 부모의 구현 변경이 자식으로 전파된다. 상속의 결합은 이렇게 “부모를 바꾸면 자식도 열어야 하는” 유지보수 비용으로 나타난다.
추상화에 의존하라 — 세금 문제의 올바른 방향
지금까지 본 취약함의 공통 원인은 하나다. 자식이 부모의 구체적인 구현에 의존한다는 것. 그렇다면 부모의 구현이 아니라 추상화에 의존하게 만들면 결합을 낮출 수 있다. 세금 예제로 돌아가, 변하는 부분을 추상 메서드로 추출해 보자.
public abstract class Phone {
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은 이제 calculateCallFee의 구현을 모른다 — 오직 “통화 하나의 요금을 계산한다”는 추상적 약속에만 의존한다. Phone(일반)과 NightlyDiscountPhone(심야)은 각자 이 추상 메서드만 다르게 구현하고, 계산 골격은 건드리지 않는다. 자식은 부모의 필드나 내부 호출 순서가 아니라 추상 메서드라는 명시적 약속에만 기댄다. 판단 기준: 상속을 꼭 써야 한다면, 자식이 의존하는 지점을 부모의 구현이 아니라 부모가 노출한 추상 메서드로 좁혀라 — 결합의 표면적이 작아진다. 그러나 이것으로도 조합 폭발(세금 × 심야)은 풀리지 않는다. 그 답은 상속 자체를 합성으로 바꾸는 11장이다.
정리
- DRY: 중복은 모양이 같은 코드가 아니라 같은 이유로 함께 변하는 지식이다. 함께 변할 것만 모으고, 우연히 닮은 것은 남겨 둔다.
- 핸드폰 과금 예제 시작.
Phone과NightlyDiscountPhone은 요금 계산 골격이 중복된다. 상속 + 템플릿 메서드로 골격을 부모에 모으면 중복이 사라진다 — 여기까지는 좋다. - 그러나 세금 요구사항 하나에
taxRate가 부모·자식 양쪽에 생기며 중복이 부활한다. 취약한 기반 클래스 문제다. - 취약함의 얼굴들:
InstrumentedHashSet(부모의 self-use 내부 호출에 의존해 이중 계수),Stack extends Vector·Properties extends Hashtable(불필요한 인터페이스까지 물려받아 규약이 깨짐),Playlist/PersonalPlaylist(부모 내부 구조에 의존해 동시 수정). - 공통 원인은 자식이 부모의 구현에 의존한다는 것. 상속을 써야 한다면 추상 메서드로 의존 표면을 좁혀라. 그래도 조합 폭발은 남고, 그 답은 다음 장의 합성이다.
다음장으로 11장