10장 : 상속과 코드 재사용

이번 장에서는 클래스를 재사용하기 위해 새로운 클래스를 추가하는 가장 대표적인 기법인 상속에 관해 살펴보기고 한다.

  • 재사용 관점에서 상속이란 클래스 안에 정의된 인스턴스 변수와 메서드를 자동으로 새로운 클래스에 추가하는 구현 기법이다.

객체지향에서는 상속 외에도 코드를 효과적으로 재사용할 수 있는 방법이 더 있다.

  • 새로운 클래스의 인스턴스 안에 기존 클래스의 인스턴스를 포함시키는 방법으로 흔히 합성이라고 부른다.

코드를 재사용하려는 동기 이면에 중복 코드 제거 욕망이 숨어 있다.

1. 상속과 중복 코드

✔️ DRY 원칙

중복 코드는 변경을 방해한다.

중복 여부 판단 기준은 변경이다. 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 이 코드는 중볻이다.

신뢰할 수 있고 수정하기 쉬운 소프트웨어를 만드는 효과적인 방법 중 하나는 중복을 제거하는 것이다.

DRY(Don’t Repeat Youtself)

  • 동일한 지식을 중복하지 마라

  • 모든 지식은 시스템 내에서 단일하고, 애매하지 않고, 정말로 믿을 만한 표현 양식을 가져야 한다.

  • 한 번, 단 한번(Once and Only Once) 원칙

  • 단일 지점 제어(Single-Point Control) 원칙

✔️ 중복과 변경

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;
	}
}
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);
	}
	
	public List<Call> getCalls() {
		return calls;
	}

	public Money getAmount() {
		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.of(2018, 1, 1, 12, 10, 0),
LocalDateTime.of(2018, 1, 1, 12, 11, 0)));

phone.call(new Call(LocalDateTime.of(2018, 1, 2, 12, 10, 0),
LocalDateTime.of(2018, 1, 2, 12, 11, 0)));

phone.calculateFee();

애플리케이션이 성공적으로 출시되고 시간이 흘러 ‘심야 할인 요금제’라는 새로운 요금 방식을 추가해야 한다는 요구사항이 접수됐다.

이 요구사항을 해결할 수 있는 쉽고도 가장 빠른 방법은 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.getForm().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 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));
	}
}

NightlyDiscountPhone도 동일한 방식으로 수정하자.

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.getForm().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.minus(result.times(taxRate));
	}
}

많은 코드 속에서 어떤 코드가 중복인지 파악하기 어렵다.

타입 코드 사용하기

두 클래스 사이의 중복 코드를 제거하는 한 가지 방법은 클래스를 하나로 합치는 것이다. 타입 코드 추가.

public class Phone {
	private static final int LATE_NIGHT_HOUT = 22;
	enum PhoneType { REGULAR, NIGHTLY } 

	private PhoneType type;

	private Money amount;
	private Money regularAmount;
	private Money nightlyAmount;
	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, Money.ZERO, 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.claculateFee();

		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);
	}
}

✔️ 강하게 결합된 Phone과 NightlyDiscountPhone

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())));
	}
}

자식 클래스의 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.

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

상속은 자식 클래스와 부모 클래스의 결합도를 높인다. 이 강한 결합도로 인해 자식 클래스는 부모 클래스의 불필요한 세부사항에 엮이게 된다.

⇒ 이처럼 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상을 취약한 기반 클래스 문제(Fragile Base Class Problem)라고 부른다.

취약한 기반 클래스 문제는 상속이라는 문맥 안에서 결합도가 초래하는 문제점을 가리키는 용어다. 상속 관계를 추가할수록 전체 시스템의 결합도가 높아진다는 사실을 알고 있어야 한다. 상속은 자식 클래스를 점진적으로 추가해서 기능을 확장하는 데는 용이하지만 높은 결합도로 인해 부모 클래스를 점진적으로 개선하는 것은 어렵게 만든다. 최악의 경우에는 모든 자식 클래스를 동시에 수정하고 테스트해야할 수도 있다.

취약한 기반 클래스 문제는 캡슐화를 약화시키고 결합도를 높인다. 상속은 자식 클래스가 부모 클래스의 구현 세부사항에 의존하도록 만들기 때문에 캡슐화를 약화시킨다. 이것이 상속이 위험한 이유인 동시에 우리가 상속을 피해야 하는 첫 번째 이유다.

객체를 사용하는 이유는 구현과 관련된 세부사항을 퍼블릭 인터페이스 뒤로 캡슐화할 수 있기 때문이다. 캡슐화는 변경에 의한 파급효과를 제어할 수 있기 때문에 가치 있다. 객체는 변경될지도 모르는 불안정한 요소를 캡슐화함으로써 파급효과를 걱정하지 않고도 자유롭게 내부를 변경할 수 있다.

안타깝게도 상속을 사용하면 부모 클래스의 퍼블릭 인터페이스가 아닌 구현을 변경하더라도 자식 클래스가 영향을 받기 쉬워진다. 상속 계층의 상위에 위치한 클래스에 가해지는 작은 변경만으로도 상속 계층에 속한 모든 자손들이 급격하게 요동칠 수 있다.

객체지향의 기반은 캡슐화를 통한 변경의 통제다. 상속은 코드의 재사용을 위해 캡슐화의 장점을 희석시키고 구현에 대한 결합도를 높임으로써 객체지향이 가진 강력함을 반감시킨다.

✔️ 불필요한 인터페이스 상속 문제

Stack이 규칙을 무너뜨릴 여지가 있는 위험한 Vector의 퍼블릭 인터페이스까지도 함께 상속받았기 때문이다.

Hashtable의 인터페이스에 포함돼 있는 put 메서드를 이용하면 String 타입 이외의 키와 값이라도 Properties에 저장할 수 있다.

상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.

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

HashSet의 구현에 강하게 결합된 InstrumentHashSet 클래스를 소개한다. InstrumentdHashSet은 HashSet의 내부에 저장된 요소의 수를 셀 수 있는 기능을 추가한 클래스로서 HashSet의 자식 클래스로 구현돼 있다.

public class InstrumentdHashSet<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);
	}
}
InstrumentHashSet<String> languages = new InstrumentedHashSet<>();
languages.addAll(Arrays.asList("Java", "Ruby", "Scala"));
  • 위 코드 실행 후 addCount의 값은 6이 나옴…

  • 부모 클래스인 HashSet의 addAll() 메서드 안에서 add 메서드를 호출하기 때문

InstrumentdhashSet의 addAll 메서드를 오버라이딩하고 추가되는 각 요소에 대해 한 번씩 add 메시지를 호출한다.

public class InstrumentedHashSet<E> extends HashSet<E> {
	@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;
	}
}

설계는 트레이드오프 활동이라는 사실을 기억하라. 상속은 코드 재사용을 위해 캡슐화를 희생한다. 완벽한 캡슐화를 원한다면 코드 재사용을 포기하거나 상속 이외의 다른 방법을 사용해야 한다.

✔️ 부모 클래스와 자식 클래스의 동시 수정 문제

음악 목록을 추가할 수 있는 플레이리스트 구현

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;
	}
}

이제 플레이리스트에서 노래를 삭제할 수 있는 기능이 추가된 PersonalPlaylist가 필요하다고 가정해보자. PersonalPlaylist를 구현하는 가장 빠른 방법은 상속을 통해 Playlist의 코드를 재사용하는 것이다.

public class PersonalPlaylist extends Playlist {
	public void remove(Song song) {
		getTracks().remove(song);
	}
}

노래 목록뿐 아니라 가수별 노래의 제목도 함께 관리해야 한다.

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 List<Song> getTracks() {
		return tracks;
	}

	public Map<String, String> getSingers() {
		return singers;
	}
}
public class PersonalPlaylist extends Playlist {
	public void remove(Song song) {
		getTracks().remove(song);
		getSingers().remove(song.getSinger());
	}
}

상속을 사용하면 자식 클래스가 부모 클래스의 구현에 강하게 결합되기 때문에 이 문제를 피하기는 어렵다.

클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수 밖에 없다.

3. Phone 다시 살펴보기

✔️ 추상화에 의존하자

NightkyDiscountPhone의 가장 큰 문제점은 Phone에 강하게 결합돼 있기 떄문에 PHone이 변경될 경우 함께 변경될 가능성이 높다는 것이다. 이 문제를 해결하는 가장 일반적인 방법은 자식 클래스가 부모 클래스의 구현이 아닌 추상화에 의존하도록 만드는 것이다. 정확하게 말하면 부모 클래스와 자식 클래스 모두 추상화에 의존하도록 수정해야 한다.

  • 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라. 메서드 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있다.

  • 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식 클래스로 내리는 것보다 자식 클래스의 추상적인 메서드를 부모 클래스로 올리는 것이 재사용성과 응집도 측면에서 더 뛰어난 결과를 얻을 수 있다

✔️ 차이를 메서드로 추출하라

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.taxRate = taxRate;
	}

	public Money caculateFee() {
		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 ArrayListM<>();

	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.getSecond()));
			} else {
					restul = result.plus(
						refulatAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
			}
		}
		return result;
	}
}

두 클래스의 메서드에서 다른 부분을 별도의 메서드로 추출해라.

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 {
	private static final int LATE_NIGHT_HOUR = 22;

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

	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) {
			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());
		}
	}
}

✔️ 중복 코드를 부모 클래스로 올려라

public abstract class Abstract {}

public class Phone extends AbstractPhone { ... }

public class NightlyDiscountPhone extends AbstractPhone { ... }

두 클래스 사이에서 완전히 동일한 코드는 calculateFee 메서드이므로 calculateFee 메서드를 AbstractPhone으로 이동시키고 Phone과 NightlyDiscountPhone에서 이 메서드를 제거하자

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;
	}
}

b

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;
	}
}

c

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);
}

d

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 amount;
	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 amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
	} 
}

✔️ 추상화가 핵심이다

공통 코드를 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가진다.

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

✔️ 세금 추가하기

클래스라는 도구는 메서드뿐만 아니라 인스턴스 변수도 함께 포함된다. 따라서 클래스 사이의 상속은 자식 클래스가 부모 클래스가 구현한 행동뿐만 아니라 인스턴스 변수에 대해서도 결합되게 만든다.

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

기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법을 차이에 의한 프로그래밍이라고 부른다.

Last updated