들어가며

제목부터가 되게 중요해 보여서 흥미가 돋았다. 사실 안 중요한 챕터가 없는 것 같기도 하다 상속에 대해서는 잘 모르던 부분이라 조금 기대를 했다. 무엇보다 전 장이 짧고 쉬워서 자신이 생겼기 때문에 거침없이 10장에 들어갔다.

10장. 상속과 코드 재사용

상속과 중복 코드

DRY 원칙

중복 코드는 변경을 방해한다. 프로그램의 본질은 비즈니스와 관련된 지식을 코드로 변환하는 것이다. 이 지식은 항상 변하므로, 지식을 표현하는 코드 역시 변경해야 한다.

중복코드가 가지는 가장 큰 문제는 코드를 수정하는 데 필요한 노력을 몇 배로 증가시킨다는 것이다.

우선 어떤 코드가 중복인지를 찾아야 한다. 일단 중복 코드의 묶음을 찾았다면 찾아낸 모든 코드를 일관되게 수정해야 한다. 모든 중복 코드를 개별적으로 테스트해서 동일한 결과를 내놓는지 확인해야만 한다. 중복 코드는 수정과 테스트에 드는 비용을 증가시킬뿐만 아니라 시스템과 우리를 공황상태로 몰아넣을 수도 있다.

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

신뢰할 수 있고 수정하기 쉬운 소프트웨어를 만드는 효과적인 방법중 하나는 중복을 제거하는 것이다. 우리는 ‘반복하지 마라’라는 뜻의 Don’t Repeat Yourself, DRY 원칙을 따라야 한다.

<aside> ✏️

DRY 원칙

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

</aside>

중복과 변경

중복 코드 살펴보기

중복 코드의 문제점을 이해하기 위해 한 달에 한 번씩 가입자별로 전화 요금을 계산하는 간단한 애플리케이션을 개발해 보자. 전화 요금을 계산하는 규칙은 간단한데 통화 시간을 단위 시간당 요금으로 나눠 주면 된다.

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 이다.

Phone 인스턴스는 요금 계산에 필요한 세 가지 인스턴스 변수를 포함한다. 첫 번째는 단위요금을 저장할 amount, 두번째는 단위 시간을 저장하는 seconds, 세번째는 전체 통화 목록을 저장하고 있는 Call의 리스트인 calls 이다. calculateFee 메서드는 amount, seconds, calls를 이용해 전체 통화 요금을 계산한다.

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(); //=> Money.wons(60)

요구 사항은 항상 변한다. ‘심야 할인 요금제’라는 새로운 요금 방식을 추가해야 한다는 요구사항이 접수됐다. 심야 할인 요금제는 밤 10시 이후의 통화에 대해 요금을 할인해 주는 방식이다. 이제부터 Phone에 구현된 기존 요금제는 심야 할인 요금제와 구분하기 위해 ‘일반 요금제’라고 부르자.

이 요구사항을 해결할 수 있는 쉽고 가장 빠른 방법은 Phone의 코드를 복사해서 NightlyDiscountPhone이라는 새로운 클래스를 만든 후 수정하는 것이다.

public class NightlyDisocuntPhone {
	private static final int LATE_NIGHT_HOUR = 22;
	
	private Money nightlyAmount;
	private Money regulatAmount;
	private Duration seconds;
	priavate List<Call> calls = new ArrayList<>();
	
	public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds) {
		this.nightlyAmount = nightlyAmount;
		this.nightlyAmount = 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;
	}
}

심야 할인 요금제를 구현하는 NightlyDiscountPhone은 밤 10시 이전에 적용할 통화요금(regularAmount)과 밤 10시 이후에 적용할 통화요금(nightlyAmount), 단위시간(seconds)을 인스턴스 변수로 포함한다.

NightlyDiscountPhone은 밤 10시를 기준으로 regularAmount와 nightlyAmount 중에서 기준 요금을 결정한다는 점을 제외하고는 Phone과 거의 유사하다. Phone의 코드를 복사해서 NightlyDiscountPhone을 추가하는 방법ㄷ은 심야시간에 요금을 할인해야 한다는 요구사항을 아주 짧은 시간 안에 구현할 수 있게 해준다.

하지만 중복코드가 문제라는것을 앞서 서술했듯 구현시간을 절약한 대가로 우리는 중복코드가 존재한다는 시한폭탄을 안게 되었다.

중복 코드 수정하기

중복 코드가 코드 수정에 미치는 영향을 살펴보기 위해 새로운 요구사항을 추가해 보자. 이번에 추가할 기능은 통화 요금에 부과할 세금을 계산하는 것이다. 부과되는 세율은 가입자의 핸드폰마다 다르다고 가정하자. 현재 통화 요금을 계산하는 로직이 Phone과 NightlyDiscountPhone 양쪽 모두에 구현돼 있기 때문에 세금을 추가하기 위해서는 두 클래스를 함께 수정해야 한다.

Phone 클래스부터 수정하자. 가입자의 핸드폰별로 세율이 서로 달라야 하기 때문에 Phone은 세율을 저장할 인스턴스 변수인 taxRate를 포함해야 한다. taxRate의 값을 이용해 통화 요금에 세금을 부과하도록 Phone의 calculateFee 메서드를 수정하자.

public class Phone {
	...
	private double taxRate;
	
	public Phone(Money amount, Duration seconds, double taxRate){
		...
		this.taxRate = texRate;
	}
	
	public Money calculateFee() {
		Money result = Money.ZERO;
		
		for(Call call : calls) {
			result = result.plus(amount.times(call.getDuration().getSeconds() / secounds.getSeconds()));
		}
		
		return result.plus(result.times(taxRate));
	}
}

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

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

이 예제는 중복 코드가 가지는 단점을 잘 보여준다. 많은 코드 더미 속에서 어떤 코드가 중복인지를 파악하는 일은 쉬운 일이 아니다. 중복 코드는 항상 함께 수정돼야 하기 때문에 수정할 때 하나라도 빠트린다면 버그로 이어질 것이다.

한 발 양보해서 모든 중복 코드를 식별했고 함께 수정했다고 하자. 더 큰 문제는 중복 코드를 서로 다르게 수정하기가 쉽다는 것이다. Phone의 calculateFee 메서드에는 반환 시에 result에 plus 메서드를 호출해서 세금을 더했지만 NightlyDiscountPhone의 calculateFee 메서드에는 plus 대신 minus 메서드를 호출하고 있다는 사실을 눈치 챈 사람이 있는가?

살펴본 것처럼 중복 코드는 새로운 중복 코드를 부른다. 중복 코드를 제거하지 않은 상태에서 코드를 수정할 수 있는 유일한 방법은 새로운 중복 코드를 추가하는 것뿐이다.

민첩하게 변경하기 위해서는 중복 코드를 추가하는 대신 제거해야 한다. 기회가 생길 때마다 코드를 DRY하게 만들기 위해 노력하라.

타입 코드 사용하기

두 클래스 사이의 중복 코드를 제거하는 한 가지 방법은 클래스를 하나로 합치는 것이다. 다음과 같이 요금제를 구분하는 타입 코드를 추가하고 타입 코드의 값에 따라 로직을 분기시켜 Phone과 NightlyDiscountPhone을 하나로 합칠 수 있다. 하지만 타입 코드를 사용하는 클래스는 낮은 응집도와 높은 결합도라는 문제에 시달리게 된다!!!

public class Phone {
	private static final int LATE_NIGHT_HOUR = 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(nighltyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
				} else {
					result = result.plus(regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
				}
			}
		}
		
		return result;
	}
}

상속은 타입 코드를 사용하지 않고도 중복 코드를 관리할 수 있는 효과적인 방법이다.

상속을 이용해서 중복 코드 제거하기

상속의 기본 아이디어는 이미 존재하는 클래스와 유사한 클래스가 필요하다면 코드를 복사하지 말고 상속을 이용해 코드를 재사용하라는 것이다. 앞서 살펴봤듯이 NightlyDiscountPhone 클래스의 코드 대부분은 Phone 클래스의 코드와 거의 유사하기 때문에 NightlyDiscountPhone 클래스가 Phone 클래스를 상속받게 만들면 코드를 중복시키지 않고도 Phone 클래스의 코드 대부분을 재사용할 수 있다.

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(calll.getFrom().getHour() >= LATE_NIGHT_HOUR) {
				nightlyFee = nightlyFee.plus(getAmount().minus(nightlyAmount).times(call.getDuration().getSeconds() / getSeconds().getSeconds()));
			}
		}
		
		return result.minus(nightlyFee);
	}
}

super 참조를 통해 부모 클래스인 Phone 의 calculateFee 메서드를 호출해서 일반 요금제에 따라 통화요금을 계산한 후 이 값에서 통화 시작 시간이 10시 이후인 통화의 요금을 빼주는 부분은 Phone의 코드 재사용을 위하여 10시 이전의 통화 요금을 계산하는 경우에는 Phone에 구현된 로직을 재사용하고 10시 이후의 통화 요금을 계산하는 경우에 대해서만 NightlyDiscountPhone에서 구현하기로 결정한 것이다.