객체지향 프로그래밍의 장점 중 하나는 코드의 재사용이다. 객체지향은 코드를 재사용 하기 위해 새로운 코드를 추가한다. 객체지향에서 클래스를 재사용 하는 전통적인 방법은 클래스를 추가하는 것이다.
이 장에서는 클래스를 재사용하기 위해 클래스를 추가하는 대표적인 방법인 상속에 관한 내용이다. 상속 외에도 새로운 클래스의 인스턴스 내에 기존 클래스의 인스턴스를 포함시키는 합성이라는 방법도 있다.
상속과 중복 코드
중복 코드를 제거해야 하는 이유
DRY 원칙
- 중복 코드는 변경을 방해한다.
- 중복 코드는 코드를 수정하기 위해 어떤 코드가 중복인지 먼저 찾아야 하는 시간이 필요하다.
- 중복 여부를 판단하는 기준은 변경이다. 변경된 요구사항을 코드에 반영하기 위해 여러 코드를 함께 수정해야 한다면 그 코드는 중복 코드다.
- Don’t Repeat Yourself : 반복하지 마라, 모든 지식은 시스템 내에서 단일하고, 애마하지 않고, 정말고 믿을 만한 표현 양식을 가져야 한다.
- Onece 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;
}
}
Call
의 목록을 관리할 정보 전문가는Phone
이다.
public class Phone {
private Money amount;
private Duration seconds;
private List<Call> calls = new ArrayList<>();
public Phone(Money money, 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 money = Money.ZERO;
for(Call call : calls) {
result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
}
return result;
}
}
- 아래 코드는 ’10초당 5원’씩 부과되는 요금제에 가입한 사용자가 각각 1분 동안 두 번 통화를 한 경우 통화 요금을 계산하는 방법을 코드로 나타낸 것이다.
Phone phone = new Phone(Money.wons(5), Duration.ofSeconds(10));
phone.call(new Call(LocalDateTime.of(...),
new Call(LocalDateTime.of(...))));
phone.call(new Call(LocalDateTime.of(...),
new Call(LocalDateTime.of(...))));
phone.calculateFee(); // -> Money.wons(60);
- 여기서 ‘심야 할인 요금제’ 같은 요구사항이 등장 했을 때 보통은 아래와 같이 새로운 클래스를 작성한다.
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
사이에 중복 코드가 있기 때문에 코드를 변경해야 할 때 모두 수정해야 할 수 있다.
중복 코드 수정하기
public class Phone {
//...
private double taxRate;
public Phone(... 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 {
//...
priavte double taxRate;
public NightlyDiscountPhone(... 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.minus(result.times(taxRate));
}
}
- 중복 코드가 있으면 수정사항이 있을 때 마다 모든 클래스를 수정해야 한다.
- 중복 코드가 있으면 당장 파악은 편하지만 세부 사항은 여전히 파악이 어렵다 (두 클래스의
calculateFee()
의 return 참고)
타입 코드 사용하기
- 중복 코드를 제거하는 한가지 방법은 클래스를 하나로 합치는 것이다.
- 그러나 타입 코드를 사용하면 낮은 응집도와 높은 결합도라는 문제에 시달린다.
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 money, 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.amount = amount;
this.seconds = seconds;
//...
}
//...
public Money calculateFee() {
Money money = 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 niglhtlyAmount, 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.getForm().getHour() >= LATE_NIGHT_HOUR) {
nightlyFee = nightlyFee.plus(
getAmount().minus(nightlyAmount).times(
call.getDuration().getSeconds() / getSeconds().getSeconds()));
}
}
return result.minus(nightlyFee);
}
}
- 이렇게 작성하면 구현된 이유를 이해하기 위해 개발자가
Phone
의 코드를 재사용하기 위해 세운 가정을 이해해야 할 필요가 있다. - 이것은 개발자의 가정을 이해하기 전에 코드를 이해하기 어렵다는 점이 있다.
- 이처럼 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용하는 것은 쉽지 않다. 판단하기 위한 직관력이 떨어진다.
- 따라서 이렇게 상속을 하면 결합도를 높히고, 상속이 초래하는 부모 클래스와 자식 클래스 사이의 강한 결합이 코드를 수정하기 어렵게 만든다.
강하게 결합된 Phone과 NightlyDiscountPhone
- 앞서
NightlyDiscountPhone
가 강하게 결합된 이유는 부모 클래스의 메서드를 호출하고, 그 결과에 따른 사실에 기반하기 때문이다. - 아래 세금과 관련된 요구사항이 추가된다면..
public class Phone {
//...
private double taxRate;
public Phone(Money amount, Duration seconds, double taxRate) {
//..
}
public Money calculcateFee() {
//..
return result.plus(result.times(taxRate));
}
public double getTaxRate() {
return taxRate;
}
}
NightlyDiscountPhone
은 생성자에서 전달받은 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
과NightlyDiscountPhone
의 상속 계층이 가지는 문제가 명확해진다. 중복 코드를 제거하기 위해 상속을 했지만 또 다시 중복 코드를 만들어야 하는 것이다.
상속을 위한 경고 1
자식 클래스의 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.