코드를 재사용하는 방법
- 전통적인 패러다임: 코드를 복사한 후 수정
- 객체지향 패러다임: 새로운 클래스 추가
- 상속: 클래스를 재사용하기 위해 새로운 클래스를 추가하는 가장 대표적인 방법
- 합성: 새로운 클래스의 인스턴스 안에 기존 클래스의 인스턴스를 포함시키는 방법
1. 상속과 중복 코드
DRY (반복하지 마라) 원칙
Once and Only Once 원칙 또는 Single-Point Control 원칙이라고도 부른다.
코드 안에 중복이 있어서는 안 된다는 것이다.
한 달에 한 번씩 가입자별로 전화 요금을 계산하는 코드를 예시로 보자.
class Call {
private from: number;
private to: number;
constructor(from: number, to: number) {
this.from = from;
this.to = to;
}
private getDruaction(): number {
return this.to - this.from;
}
private getFrom(): number {
return this.from;
}
}
통화 요금을 계산할 객체가 필요하다. Call의 목록을 관리할 정보 전문가는 Phone이다.
class Phone {
private amount: number;
private seconds: number;
private calls: Call[] = new Array();
constructor(amount: number, seconds: number) {
this.amount = amount;
this.seconds = seconds;
}
public call(call: Call): void {
this.calls.push(call);
}
public getCalls(): Call[] {
return this.calls;
}
public getAmount(): number {
return this.amount;
}
public getSeconds(): number {
return this.seconds;
}
public calculateFee(): number {
let result = 0;
for (const call of this.calls) {
result += this.amount * (call.getDuration() / this.seconds);
}
return result;
}
}
애플리케이션이 성공적으로 출시되고 시간이 흘러 '심야 할인 요금제'라는 새로운 요금 방식을 추가해야 된다고 하자.
이제 Phone에 구현된 기존 요금ㅇ제는 '일반 요금제'로 부르도록 하자.
이 요구사항을 해결하는 가장 쉽고 빠른 방법은 Phone의 코드를 복사해서 새로운 클래스를 만든 후 수정하는 것이다.
class NightlyDiscountPhone {
private LATE_NIGHT_SECONDS: number = 22 * 60 * 60;
private nightlyAmount: number;
private regularAmount: number;
private seconds: number;
private calls: Call[] = new Array();
constructor(nightlyAmount: number, regularAmount: number, seconds: number) {
this.nightlyAmount = nightlyAmount;
this.regularAmount = regularAmount;
this.seconds = seconds;
}
public calculateFee(): number {
let result = 0;
for (const call of this.calls) {
if (call.getFrom() >= this.LATE_NIGHT_SECONDS) {
result += this.nightlyAmount * (call.getDuration() / this.seconds);
} else {
result += this.regularAmount * (call.getDuration() / this.seconds);
}
}
return result;
}
}
NightlyDiscountPhone은 밤 10시를 기준으로 기준 요금을 결정하는 것 외에는 Phone과 유사하다.
둘 사이에는 중복 코드가 존재한다.
통화 요금에 세금이 부과된다고 할 때, 세금을 추가하기 위해서는 두 클래스를 함께 수정해야 한다.
중복 코드는 항상 함께 수정돼야 하기 때문에 하나라도 빠트리면 버그로 이어진다. 서로 다르게 수정해버릴 수도 있다.
중복 코드는 새로운 중복 코드를 부른다. 코드를 DRY하게 만들기 위해 노력해야 한다.
중복 코드를 제거하는 한 가지 방법은 클래스를 하나로 합치는 것이다.
요금제를 구분하는 타입 코드를 추가하고 로직을 분기시켜 구현할 수 있다.
그러나 타입 코드를 사용하는 클래스는 결국 낮은 응집도와 높은 결합도를 갖게 된다.
상속 이용하기
class NightlyDiscountPhone extends Phone {
private LATE_NIGHT_SECONDS: number = 22 * 60 * 60;
private nightlyAmount: number;
constructor(nightlyAmount: number, regularAmount: number, seconds: number) {
super(regularAmount, seconds);
this.nightlyAmount = nightlyAmount;
}
// override
public calculateFee(): number {
let result = super.calculateFee();
let nightlyFee = 0;
for (const call of this.getCalls()) {
if (call.getFrom() >= this.LATE_NIGHT_SECONDS) {
nightlyFee += this.nightlyAmount * (call.getDuration() / this.seconds);
}
}
return result - nightlyFee;
}
}
이상한 점이 있다. (10시 이전의 요금 + 10시 이후의 요금)이 아니라, (10시 이전의 요금 - 10시 이후의 요금)을 해주고 있다.
Phone의 일반 요금제는 1개의 요금 규칙으로 구성돼 있는 반면,
NightlyDiscountPhone의 심야 할인 요금제는 10시를 기준으로 분리된 2개의 요금제로 구성돼 있다고 분석한 것이다.
이는 개발자의 가정을 이해하기 전에는 코드를 이해하기 어렵다.
상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용하는 것은 쉽지 않다.
개발자는 재사용을 위해 상속 계층 사이에 무수히 많은 가정을 세웠을지도 모른다.
자식 클래스의 작성자는 부모 클래스의 구현 방법에 대한 정확한 지식을 가져야 한다. 따라서 상속은 결합도를 높인다.
만일 위 코드에 세금을 부과하는 로직을 추가한다면 또 다시 중복 코드가 발생하게 될 것이다.
자식 클래스의 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다.
상속 관계로 연결된 자식 클래스가 부모 클래스의 변경에 취약해지는 현상을 가리켜 취약한 기반 클래스 문제라고 한다.
2. 취약한 기반 클래스 문제
상속을 사용한다면 피할 수 없는 객체지향 프로그래밍의 근본적인 취약성이다.
겉으로는 안전한 방식으로 기반 클래스를 수정한 것처럼 보이더라도,
이 새로운 행동이 파생 클래스에게 상속될 경우 잘못된 동작을 초래할 수 있기 때문에 기반 클래스는 취약하다.
상속은 자식 클래스를 점진적으로 추가해서 기능을 확장하는 데는 용이하지만,
높은 결합도로 인해 부모 클래스를 점진적으로 개선하는 것은 어렵게 만든다.
취약한 기반 클래스 문제는 캡슐화를 약화시키고 결합도를 높인다.
- 자식 클래스가 부모 클래스의 구현 세부사항에 의존하도록 만든다.
- 부모 클래스의 퍼블릭 인터페이스가 아닌 구현을 변경하더라도 자식 클래스가 영향을 받기 쉬워진다.
1. 불필요한 인터페이스 상속 문제
자바의 초기 버전에서 상속을 잘못 사용한 대표적인 사례는 java.util.Properties와 java.util.Stack이다.
부모 클래스에서 상속받은 메서드를 사용할 경우 자식 클래스의 규칙이 위반될 수 있었다.
Stack은 LIFO 자료 구조인 스택을 구현한 클래스,
Vector는 임의의 위치에서 요소를 추출하고 삽입할 수 있는 리스트 자료 구조의 구현체다.
초기에는 요소의 추가/삭제를 제공하는 Vector를 재사용하기 위해 Stack을 Vector의 자식 클래스로 구현했다.
Vector는 임의의 위치에서 요소를 조회/추가/삭제하는 오퍼레이션을 제공하는 반면,
Stack은 맨 마지막 위치에서만 요소를 추가/제가하는 오퍼레이션을 제공한다.
그러나 Stack이 Vector를 상속받기 때문에 Stack의 퍼블릭 인터페이스에 Vector의 퍼블릭 인터페이스가 합쳐지고,
Stack에서 Vector의 인터페이스를 이용하면 임의의 위치에서 요소를 추가/삭제할 수 있다. Stack의 규칙을 쉽게 위반할 수 있는 것이다.
Properties 클래스는 키와 쌍을 보관한다는 점에서 Map과 유사하지만,
다양한 타입을 저장할 수 있는 Map과 달리 키와 값의 타입으로 오직 String만 가질 수 있다.
그러나 Properties는 Map의 조상인 Hashtable을 상속받는다.
자바에 제네릭이 도입되기 이전에 만들어졌기 때문에 컴파일러가 키와 값의 타입이 String인지 여부를 체크할 수 있는 방법이 없었다.
Hashtable의 인터페이스의 put을 이용하면 String 타입이 아니더라도 Properties에 저장할 수 있다.
인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 만들어야 한다.
퍼블릭 인터페이스에 대한 고려 없이 단순히 코드 재사용을 위해 상속을 이용하면 안 된다.
상속받은 부모 클래스의 메서드는 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.
2. 메서드 오버라이딩 오작용 문제
InstrumentedHashSet 클래스는 HashSet의 구현에 강하게 결합되어 있다.
InstrumentedHashSet은 HashSet의 내부에 저장된 요소의 수를 셀 수 있는 기능을 추가한 클래스로, 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<String> languages = new InstrumentedHashSet<>();
languages.addAll(Arrays.asList("Java", "Ruby", "Scala"));
실행 후 addCount의 값이 3이 될 것 같지만 실제로는 6이 된다. HashSet의 addAll 안에서 add 메서드를 호출하기 때문이다.
- InstrumentedHashSet의 addAll이 호출돼서 addCount에 3이 더해짐
- super.addAll이 호출되고 제어가 부모 클래스인 HashSet으로 이동함
- HashSet은 각각의 요소를 추가하기 위해 내부적으로 add를 호출함
- 결과적으로 InstrumentedHashSet의 add가 세번 호출되어 addCount에 3이 더해짐
InstrumentedHashSet의 addAll을 제거하면 해결되겠지만,
나중에 HashSet의 addAll이 add를 전송하지 않도록 수정된다면 addAll을 이용해 추가되는 요소들에 대한 카운트가 누락될 것이다.
더 좋은 해결책은 InstrumentedHashSet의 addAll를 오버라이딩하고 추가되는 각 요소에 대해 한 번씩 add를 호출하는 것이다.
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) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
}
그러나 오버라이딩된 addAll의 구현은 HashSet의 것과 동일하다. 즉, 코드가 중복된 것이다.
자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우, 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.
조슈아 블로치는 클래스가 상속되기를 원한다면 상속을 위해 클래스를 설계하고 문서화해야 하며,
그렇지 않은 경우에는 상속을 금지시켜야 한다고 주장한다. 그러나 API 문서화 또한 내부 구현을 공개하는 행위다.
설계는 트레이드오프 활동이다. 상속은 코드 재사용을 위해 캡슐화를 희생한다.
3. 부모 클래스와 자식 클래스의 동시 수정 문제
음악 목록을 추가할 수 있는 플레이리스트를 구현해보자.
class Song {
private id: number;
private singer: string;
private title: string;
constructor(id: number, singer: string, title: string) {
this.id = id;
this.singer = singer;
this.title = title;
}
public getId(): number {
return this.id;
}
public getSigner(): string {
return this.singer;
}
public getTitle(): string {
return this.title;
}
}
class Playlist {
private tracks: Song[] = new Array();
public append(song: Song): void {
this.getTracks().push(song);
}
public getTracks(): Song[] {
return this.tracks;
}
}
플레이리스트에서 노래를 삭제할 수 있는 기능이 추가된 PersonalPlaylist가 필요하다고 해보자.
class PersonalPlaylist extends Playlist {
public remove(song: Song): void {
const idx = this.getTracks().findIndex((item) => item.getId() === song.getId());
this.getTracks().splice(idx, 1);
}
}
요구사항이 변경돼서 Playlist에서 노래의 목록뿐만 아니라 가수별 노래의 제목도 함께 관리해야 한다고 해보자.
class Playlist {
private tracks: Song[] = new Array();
private singers: Map<string, string> = new Map();
public append(song: Song): void {
this.getTracks().push(song);
}
public getTracks(): Song[] {
return this.tracks;
}
public getSingers(): Map<string, string> {
return this.singers;
}
}
class PersonalPlaylist extends Playlist {
public remove(song: Song): void {
const idx = this.getTracks().findIndex((item) => item.getId() === song.getId());
this.getTracks().splice(idx, 1);
this.getSingers().delete(song.getSigner());
}
}
위 요구사항이 제대로 반영돼려면 Playlist와 PersonalPlaylist 모두 수정해야 한다.
부모 클래스의 메서드를 오버라이딩하거나 불필요한 인터페이스를 상속받지 않았음에도 함께 수정해야 할 수도 있다.
상속을 사용하면 자식 클래스가 부모 클래스의 구현에 강하게 결합된다.
클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나,
두 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없다.
3. Phone 다시 살펴보기
상속으로 인한 피해를 최소화해보자.
추상화에 의존하자
부모와 자식 모두 추상화에 의존하도록 하자. 코드 중복을 제거하기 위해 상속을 도입할 때 따르는 두 가지 원칙이 있다.
- 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라. 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있다.
- 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식 클래스로 내리는 것보다 자식 클래스의 추상적인 메서드를 부모 클래스로 올리는 것이 재사용성과 응집도 측면에서 더 뛰어나다.
차이를 메서드로 추출하라
중복 코드 안에서 차이점을 별도의 메서드로 추출하자.
변하는 것으로부터 변하지 않는 것을 분리하라. 변하는 부분을 찾고 이를 캡슐화하라.
class Phone {
private amount: number;
private seconds: number;
private calls: Call[] = new Array();
constructor(amount: number, seconds: number) {
this.amount = amount;
this.seconds = seconds;
}
public calculateFee(): number {
let result = 0;
for (const call of this.calls) {
result += this.amount * (call.getDuration() / this.seconds);
}
return result;
}
}
class NightlyDiscountPhone {
private LATE_NIGHT_SECONDS: number = 22 * 60 * 60;
private nightlyAmount: number;
private regularAmount: number;
private seconds: number;
private calls: Call[] = new Array();
constructor(nightlyAmount: number, regularAmount: number, seconds: number) {
this.nightlyAmount = nightlyAmount;
this.regularAmount = regularAmount;
this.seconds = seconds;
}
public calculateFee(): number {
let result = 0;
for (const call of this.calls) {
if (call.getFrom() >= this.LATE_NIGHT_SECONDS) {
result += this.nightlyAmount * (call.getDuration() / this.seconds);
} else {
result += this.regularAmount * (call.getDuration() / this.seconds);
}
}
return result;
}
}
두 클래스의 메서드에서 다른 부분을 별도의 메서드로 추출하자.
class Phone {
public calculateFee(): number {
let result = 0;
for (const call of this.calls) {
result += this.calculateCallFee(call);
}
return result;
}
private calculateCallFee(call: Call): number {
return this.amount * (call.getDuration() / this.seconds);
}
}
class NightlyDiscountPhone {
public calculateFee(): number {
let result = 0;
for (const call of this.calls) {
result += this.calculateCallFee(call);
}
return result;
}
private calculateCallFee(call: Call): number {
if (call.getFrom() >= this.LATE_NIGHT_SECONDS) {
return this.nightlyAmount * (call.getDuration() / this.seconds);
} else {
return this.regularAmount * (call.getDuration() / this.seconds);
}
}
}
중복 코드를 부모 클래스로 올려라
목표는 모든 클래스들이 추상화에 의존하도록 하는 것이다. 부모 클래스를 추상 클래스로 구현하자.
abstract class AbstractPhone {}
class Phone extends AbstractPhone { ... }
class NightlyDiscountPhone extends AbstractPhone { ... }
두 클래스 사이에서 완전히 동일한 코드는 calculateFee이다. 이 메서드를 AbstractPhone으로 이동시키자.
abstract class AbstractPhone {
private calls: Call[] = new Array();
public calculateFee() {
let result = 0;
for (const call of this.calls) {
result += this.calculateCallFee(call); // 에러
}
return result;
}
}
Phone과 NightlyDiscountPhone의 calculateCallFee는 시그니처는 동일하지만 내부 구현이 서로 다르다.
추상 메서드로 선언하고 자식 클래스에서 오버라이딩할 수 있도록 protected로 선언하자.
abstract class AbstractPhone {
private calls: Call[] = new Array();
public calculateFee() {
let result = 0;
for (const call of this.calls) {
result += this.calculateCallFee(call);
}
return result;
}
protected abstract calculateCallFee(call: Call): number;
}
공통 코드를 모두 옮겼으니 코드를 다시 작성해보자.
class Phone extends AbstractPhone {
private amount: number;
private seconds: number;
constructor(amount: number, seconds: number) {
super();
this.amount = amount;
this.seconds = seconds;
}
protected calculateCallFee(call: Call): number {
return this.amount * (call.getDuration() / this.seconds);
}
}
class NightlyDiscountPhone extends AbstractPhone {
private LATE_NIGHT_SECONDS: number = 22 * 60 * 60;
private nightlyAmount: number;
private regularAmount: number;
private seconds: number;
constructor(nightlyAmount: number, regularAmount: number, seconds: number) {
super();
this.nightlyAmount = nightlyAmount;
this.regularAmount = regularAmount;
this.seconds = seconds;
}
protected calculateCallFee(call: Call): number {
if (call.getFrom() >= this.LATE_NIGHT_SECONDS) {
return this.nightlyAmount * (call.getDuration() / this.seconds);
}
return this.regularAmount * (call.getDuration() / this.seconds);
}
}
자식 클래스들 사이의 공통점을 부모 클래스로 옮김으로써 실제 코드를 기반으로 상속 계층을 구성할 수 있다.
위로 올리기에서 실수하더라도 추상화할 코드는 눈에 띄고 결국 상위 클래스로 올려지면서 코드의 품질이 높아진다.
아래로 내리는 방식으로 현재 클래스를 구체에서 추상으로 변경하면 작은 실수 한 번으로도 구체적인 행동을 상위 클래스에 남겨 놓게 된다.
추상화가 핵심이다
공통 코드를 이동시킨 후 각 클래스는 서로 다른 변경의 이유를 가지게 되었다.
- AbstractPhone: 전체 통화 목록을 계산하는 방법이 바뀔 때
- Phone: 일반 요금제의 통화 한 건을 계산하는 방식이 바뀔 때
- NightlyDiscountPhone: 심야 할인 요금제의 통화 한 건을 계산하는 방식이 바뀔 때
이 클래스들은 단일 책임 원칙을 준수하기 때문에 응집도가 높다.
부모 클래스의 구체적인 구현에 의존하지 않고 오직 추상화에만 의존한다. 낮은 결합도를 유지한다.
부모 클래스도 자신의 추상 메서드를 호출하기 때문에 추상화에 의존한다.
상위 수준의 정책을 구현하는 AbstractPhone이 하위 수준의 정책을 구현하는 Phone과 NightlyDiscountPhone에 의존하지 않고,
그 반대로 의존하는 의존성 역전 원칙도 준수한다.
새로운 요금제를 추가하기도 쉽다. 확장에는 열려 있고 수정에는 닫혀 있는 개방-폐쇄 원칙도 준수한다.
이 모든 것은 클래스들이 추상화에 의존하기 때문에 얻어지는 것들이다.
의도를 드러내는 이름 선택하기
- NightlyDiscountPhone는 심야 할인 요금제와 관련된 내용을 구현한다는 사실을 명확하게 전달하는 반면, Phone은 그렇지 않다.
- NightlyDiscountPhone과 Phone은 사용자가 가입한 전화기의 한 종류지만 AbstractPhone은 전화기를 포괄한다는 의미를 명확하게 전달하기 못한다.
abstract class Phone {}
class RegularPhone extends Phone { ... }
class NightlyDiscountPhone extends Phone { ... }
세금 추가하기
세금은 모든 요금제에 공통으로 적용되는 요구사항이다.
공통 코드를 담고 있는 추상 클래스 Phone을 수정하면 자식 클래스 간에 수정 사항을 공유할 수 있을 것이다.
abstract class Phone {
private taxRate: number;
private calls: Call[] = new Array();
constructor(taxRate: number) {
this.taxRate = taxRate;
}
public calculateFee() {
let result = 0;
for (const call of this.calls) {
result += this.calculateCallFee(call);
}
return (result += result * this.taxRate);
}
protected abstract calculateCallFee(call: Call): number;
}
끝이 아니다. RegularPhone과 NightlyDiscountPhone의 생성자에서도 taxRate를 초기화시켜주어야 한다.
클래스 사이의 상속은 부모 클래스의 구현 뿐만 아니라 인스턴스 변수에 대해서도 결합되게 만든다.
인스턴스 변수으 ㅣ변화 없이 행동만 변경된다면 상속 계층에 속한 각 클래스들을 독립적으로 진화시킬 수 있다.
그러나 인스턴스 변수의 변경은 종종 상속 계층 전반에 걸친 변경을 유발한다.
하지만 인스턴스 초기화 로직을 변경하는 것이 동일한 세금 계산 코드를 중복시키는 것보다는 현명한 선택이다.
객체 생성 로직에 대한 변경을 막기보다는 핵심 로직의 중복을 막아라.
핵심 로직은 한 곳에 모아 놓고 캡슐화해야 한다. 공통적인 핵심 로직은 최대한 추상화해야 한다.
상속은 어떤 방식으로든 부모 클래스와 자식 클래스를 결합시킨다.
행동을 변경하기 위해 인스턴스 변수를 추가하더라도 상속 계층 전체에 걸쳐 부작용이 퍼지지 않게 막자.
4. 차이에 의한 프로그래밍
상속은 익숙한 개념을 이용해서 새로운 개념을 쉽고 빠르게 추가할 수 있게 한다.
이처럼 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법을 차이에 의한 프로그래밍이라고 한다.
상속을 이용하면 기존 클래스의 코드를 재사용할 수 있기 때문에 애플리케이션의 점진적인 정의가 가능해진다.
차이에 의한 프로그래밍의 목표는 중복 코드를 제거하고 코드를 재사용하는 것이다.
중복을 제거하기 위해서는 코드를 재사용 가능한 단위로 분해하고 재구성해야 한다.
코드를 재사용하기 위해서는 중복 코드를 제거해서 하나의 모듈로 모아야 한다.
재사용 가능한 코드란 심각한 버그가 존재하지 않는 코드다.
객체지향에서 이를 이룰 수 있는 가장 유명한 방법은 상속이다.
- 여러 클래스의 공통 중복 코드를 하나의 클래스로 모은다.
- 중복 코드를 옮긴 후, 옮겨진 클래스를 상속 관계로 연결한다.
그러나 코드를 재사용하기 위해 맹목적으로 상속을 사용하는 것은 위험하다.
상속의 오용과 남용은 애플리케이션을 이해하고 확장하기 어렵게 만든다. 정말로 필요한 경우에만 상속을 사용하라.
상속의 단점을 피하면서도 코드를 재사용할 수 있는 더 좋은 방법이 있다. 바로 합성이다.