개발자는 기록이 답이다
오브젝트 - 상속과 코드 재사용 본문
객체지향 프로그래밍의 장점 중 하나는 코드를 재사용하기가 용이하다는 것이다.
전통적인 패러다임 에서 코드를 재사용하는 방법은 코드를 복사한 후 수정하는 것이다.
객체지향은 조금 다른 방법을 취한다. 객체지향에서는 코드를 재사용하기 위해 '새로운 코드를 추가한다.
객체지향에서 코드는 일반적으로 클래스 안에 작성되기 때문에 객체지향에서 클래스를 재사용하는 전통적인 방법은 새로운 클래스를 추가하는 것이다.
클래스를 재사용하기 위해 새로운 클래스를 추가하는 방법
- 상속 : 재사용 관점에서 클래스 안에 정의된 인스턴스 변수와 메서드를 자동으로 새로운 클래스에 추가하는 방법
- 합성 : 새로운 클래스의 인스턴스 안에 기존 클래스의 인스턴스를 포함시키는 방법
(11장에서는 합성에 관해 자세히 살펴보고 상속과 합성의 장단점을 비교 할 예정)
코드를 재사용하려는 강력한 동기 이면에는 중복된 코드를 제거하려는 욕망이 숨어 있다.
따라서 상속에 대해 살펴보기 전에 중복 코드가 초래하는 문제점을 살펴보자.
1. 상속과 중복 코드
중복 코드는 사람을 혼동시킨다.
- 두 코드가 정말 동일한 것인가?
- 유사한 코드가 이미 존재하는데도 새로운 코드를 만든 이유는 무엇일까?
- 의도적으로 그렇게 한 것인 가 아니면 단순한 실수인가?
- 두 코드가 중복이기는 한 걸까?
- 중복을 없애도 문제가 없을까?
- 양쪽을 수정하기보다는 한쪽 코드만 수정하는 게 더 안전한 방법이 아닐까?
중복 코드는 우리를 주저하게 만들뿐만 아니라 동료들을 의심하게 만든다. 하지만 남겠지만 결정적인 이유는 따로 있다.
DRY 원칙
중복 코드는 변경을 방해한다.
프로그램의 본질은 비즈니스와 관련된 지식을 코드로 변환하는 것이다. 안타깝게도 이 지식은 항상 변한다. 그에 맞춰 지 식을 표현하는 코드 역시 변경해야 한다. 그 이유가 무엇이건 일단 새로운 코드를 추가하고 나면 언젠 가는 변경될 것이라고 생각하자.
중복 코드가 가지는 가장 큰 문제는 코드를 수정하는 데 필요한 노력을 몇 배로 증가시킨다.
- 우선 어떤 코드가 중복인지를 찾아야 한다.
- 중복 코드의 묶음을 찾았다면 찾아낸 모든 코드를 일관되게 수정해야 한다.
- 모든 중복 코드를 개별적으로 테스트해서 동일한 결과를 내놓는지 확인해야만 한다.
중복 코드는 수정과 테스트에 드는 비용을 증가시키고, 시스템과 팀원들을 혼랍스럽게 한다.
📌 중복 여부를 판단하는 기준 : 변경
- 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 이 코드는 중복이다.
- 함께 수정할 필요가 없다면 중복이 아니다.
- 중복 코드를 결정하는 기준은 코드의 모양이 아니다. 모양이 유사하다는 것은 단지 중복의 징후일 뿐이다.
- 중복 여부를 결정하는 기준은 코드가 변경에 반응하는 방식이다.
DRY'는 '반복하지 마라'라는 뜻의 Don't Repeat Yourself의 첫 글자를 모아 만든 용어
신뢰할 수 있고 수정하기 쉬운 소프트웨어를 만드는 효과적인 방법 중 하나는 중복을 제거하는 것이다.
프로그래머들은 DRY 원칙을 따라야 한다.
DRY 원칙 : 모든 지식은 시스템 내에서 단일하고, 애매하지 않고, 정말로 믿을 만한 표현 양식을 가져야 한다[Hun199]
DRY 원칙은 한 번, 단 한번(Once and Only Once) 원칙[Beck96] 또는 단일 지점 제어(Single-Point Control) 원칙[Glass06b]이라고도 부른다. 원칙의 이름이 무엇이건 핵심은 코드 안에 중복이 존재해서는 안 된다는 것이다.
※ 반대말은 WET 원칙으로 Write Everything Twice 또는 We Enjoy Typing의 약자다.
중복과 변경
1) 중복 코드 살펴보기
중복 코드의 문제점을 이해하기 위해 한 달에 한 번씩 가입자별로 전화 요금을 계산하는 간단한 애플리케이션을 개발해보자.
- 전화 요금을 계산하는 규칙은 간단한데 통화 시간을 단위 시간당 요금으로 나눠 주면 된다.
- e.g. 10초당 5원의 통화료를 부과하는 요금제에 가입돼 있는 가입자가 100초 동안 통화를 했다면
- 요금으로 100 / 10*5=50원이 부과된다.
먼저 개별 통화 기간을 저장하는 Call 클래스는 통화 시작 시간(from)과 통화 종료 시 간(to)을 인스턴스 변수로 포함한다.
package chapter10;
import java.time.Duration;
import java.time.LocalDateTime;
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다.
- 사용자가 '10초당 5원씩 부과되는 요금 제에 가입돼 있을 경우 amount의 값은 5원이 되고 seconds 의 값은 10초가 된다.
- 세 번째 인스턴스 변수 인 calls는 전체 통화 목록을 저장하고 있는 Call의 리스트다.
package chapter10;
import chapter9.movie.Money;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
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이라는 새로운 클래스를 만든 후 수정하는 것이다.
심야 할인 요금제를 구현하는 NightlyDiscountPhone은 아래의 인스턴스 변수를 포함한다.
- 밤 10시 이전에 적용할 통화요금(regularAmount)
- 밤 10시 이후에 적용할 통화요금(nightlyAmount)
- 단위시간(seconds)
package chapter10;
import chapter9.movie.Money;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
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;
}
}
예를 들어, 심야 할인 요금제가 10시 이전에는 10초당 5원이고 10시 이후에는 10초당 2원이라면 Seconds는 10초, regular Amounts5원, nightly Amount 2원의 값을 저장하고 있을 것이다.
NightlyDiscountPhone은 밤 10시를 기준으로 regular Amount 와 nightlyAmount 중에서 기준 요금을 결정한다는 점을 제외하고는 Phone과 거의 유사하다.
Phone 의 코드를 복사해서 NightlyDiscountPhone을 추가하는 방법은 심야 시간에 요금을 할인해야 한다는 요구사항을 아주 짧은 시간 안에 구현할 수 있게 해준다. 하지만 구현 시간을 절약한 대가로 지불해야 하는 비용은 예상보다 크다.
사실 Phone과 NightlyDiscount Phone 사이에는 중복 코드가 존재하기 때문에 언제 터질지 모르는 시한폭탄을 안고 있는 것과 같다.
2) 중복 코드 수정하기
중복 코드가 코드 수정에 미치는 영향을 살펴보기 위해 새로운 요구사항을 추가해 보자.
이번에 추가할 기능은 통화 요금에 부과할 세금을 계산하는 것이다. 부과되는 세율은 "가입자의 핸드폰마다 다르다"고 가정할 것이다.
현재 통화 요금을 계산하는 로직은 Phone과 NightlyDiscountPhone 양쪽 모두에 구현돼 있기 때문에 세금을 추가하기 위해서는 두 클래스를 함께 수정해야 한다.
Phone 클래스부터 수정하자. 가입자의 핸드폰별로 세율이 서로 달라야 하기 때문에 Phone은 세율을 제 장할 인스턴스 변수인 taxRate를 포함해야 한다. taxRate의 값을 이용해 통화 요금에 세금을 부과하도록 Phone의 calculateFee 메서드를 수정하자.
public class Phone {
...
private double taxRate;
public Phone(Money amount, Duration seconds, double taxRate) {
this.amount = amount;
this.seconds = seconds;
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.nightlyAmount = nightlyAmount;
this.regularAmount = regularAmount;
this.seconds = seconds;
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은 수정했지만 NightlyDiscountPhone은 수정하지 않은 채 코드가 배포됐다고 생각해보라.
- 심야 할인 요금제의 모든 가입자에게 세금이 부과되지 않는 장애가 발생할 것이다.
한 발 양보해서 모든 중복 코드를 식별했고 함께 수정했다고 하자. 더 큰 문제는 중복 코드를 서로 다르 게 수정하기가 쉽다는 것이다.
- Phone의 calculateree 메서드에서는 반환 시에 result에 plus 메서드를 호출해서 세금을 더했지만
- NightlyDiscountPhone의 calculateFee 메서드에서는 plus 대신 minus 메서드 를 호출하고 있다는 사실을 눈치 챈 사람이 있는가?
지금 살펴본 것처럼 중복 코드는 새로운 중복 코드를 부른다.
중복 코드를 제거하지 않은 상태에서 코드를 수정할 수 있는 유일한 방법은 새로운 중복 코드를 추가하는 것뿐이다.
더 큰 문제는 중복 코드가 늘어날 수록 애플리케이션은 변경에 취약해지고 버그가 발생할 가능성이 높아진다.
중복 코드가이 많아질수록 버그의 수는 증가하며 그에 비례해 코드를 변경하는 속도는 점점 더 느려진다.
민첩하게 변경하기 위해서는 중복 코드를 추가하는 대신 제거해야 한다.
기회가 생길 때마다 코드를 DRY하게 만들기 위해 노력하라.
3) 타입 코드 사용하기
두 클래스 사이의 중복 코드를 제거하는 한 가지 방법은 클래스를 하나로 합치는 것이다.
다음과 같이 요금제를 구분하는 타입 코드를 추가하고 타입 코드의 값에 따라 로직을 분기시켜 Phone과 NightlyDiscountPhone을 하나로 합칠 수 있다.
하지만 타입 코드를 사용하는 클래스는 낮은 응집도와 높은 결합도라는 문제에 시달리게 된다.
package chapter10;
import chapter9.movie.Money;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static javax.management.Query.times;
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 regularAmount, Money nightlyAmount, 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;
}
}
객체지향 프로그래밍 언어는 타입 코드를 사용하지 않고도 중복 코드를 관리할 수 있는 효과적인 방법을 제공한다.
너무 유명해서 객체지향 프로그래밍을 대표하는 기법으로 일컬어지기도 하는 상속이 바로 그것이다.
상속을 이용해서 중복 코드 제거하기
상속의 기본 아이디어는 매우 간단하다.
이미 존재하는 클래스와 유사한 클래스가 필요하다면 코드를 복사하지 말고 상속을 이용해 공통된 로직을 재사용하는 것이다.
앞에서 살펴본 것처럼 Nightly DiscountPhone 클래스의 코드 대부분은 Phone 클래스의 코드와 거의 유사하다.
따라서 NightlyDiscount Phone 클래스가 Phone 클래스를 상속받게 만들면 코드를 중복시키지 않고도 Phone 클래스의 코드 대부
분을 재사용할 수 있다.
package chapter10;
import chapter9.movie.Money;
import java.time.Duration;
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 (call.getFrom().getHour() >= LATE_NIGHT_HOUR){
nightlyFee = nightlyFee.plus(
getAmount().minus(nightlyAmount).times(
call.getDuration().getSeconds() / getSeconds().getSeconds()));
}
}
return result.minus(nightlyFee);
}
}
NightlyDiscountPhone 클래스의 calculateFee 메서드를 자세히 살펴보면 이상한 부분이 눈에 띌 것이다.
super 참조를 통해 부모 클래스인 Phone의 calculateFee 메서드를 호출해서 일반 요금제에 따라 통화요금을 계산한 후 이 값에서 통화 시작 시간이 10시 이후인 통화의 요금을 빼주는 부분이다.
이렇게 구현된 이유를 이해하기 위해서는 개발자가 Phone의 코드를 재사용하기 위해 세운 가정을 이해 하는 것이 중요하다.
NightlyDiscountPhone을 구현한 개발자는 Phone의 코드를 최대한 많이 재사용하고 싶었다.
개발자는 Phone이 구현하고 있는 일반 요금제는 1개의 요금 규칙으로 구성돼 있는 데 비해 NightlyDiscountPhone으로 구현할 심야 할인 요금제는 10시를 기준으로 분리된 2개의 요금제로 구성돼 있다고 분석했다.
10시 이전의 요금제는 Phone에 구현된 일반 요금제와 동일하다.
따라서 10시 이전의 통화 요금을 계산하는 경우에는 Phone에 구현된 로직을 재사용하고 10시 이후의 통화 요금을 계산하는 경우에 대해서만 NightlyDiscountPhone에서 구현하기로 결정한 것이다.
개발자는 10시 이전의 요금을 Phone에서 처리하기로 결정했고 그 결과 NightlyDiscountPhone의 생성자에서 10시 이전의 요금을 계산하는 데 필요한 regularAmount와 seconds를 Phone의 생성자에 전달한 것 이다.
그리고 부모 클래스의 calculateFee 메서드를 호출해서 모든 통화에 대해 10시 이전의 요금 규칙 을 적용해서 계산한 후 10시 이후의 통화 요금을 전체 요금에서 차감한 것이다. 값을 차감한 이유는 심 야 할인 요금제의 특성상 10시 이전의 요금이 10시 이후의 요금보다 더 비싸기 때문이다.
이해를 돕기 위해 심야 할인 요금제의 규칙이 다음과 같다고 해보자.
- 밤10시 이전 : 10초당 5원(regularAmount = 5원, seconds = 10초)
- 밤 10시 이후 : 10초당 2원(nightlyAmount=2원, seconds = 10초)
어떤 가입자가 두 번 통화했고 각 통화시간은 40초와 50초라고 가정하자. 이 통화가 밤 10시 이전에일어났다면 통화요금은 45원이 된다.
- (40초/10초*5원) + (50초/10초*5원) = 45원
만약 이 통화가 10시 이후에 일어났다면 통화요금은 18원이 된다.
- (40초/10초* 2원) + (50초/10초* 2원) 18원
만약 전체 통화 시간 중 처음 40초 동안은 10시 이전에, 나머지 50초 동안은 10시 이후에 이뤄졌다면통화요금은 다음과 같이 30원이 될 것이다.
- (40초/10초*5원) + (50/10초*2원) = 30원
30원을 구하는 또 다른 방법이 있다.
일단 40초와 50초 모두에 대해 10시 이전 기준으로 요금을 계산 한다.
그리고 10시 이전 기본 요금(원)에서 10시 이후 기본요금(원)을 뺀 후 이 값을 이용해서 10시이후의 통화 요금을 계산한다. 앞의 값에서 뒤의 값을 빼주면 요금을 구할 수 있다.
- (40초/10초*5원) + (50초/10초*5원) - (50초/10초*(5원-2원)) = 30원
이제 위 코드가 이해되는가? 이해가 안 되더라도 상관은 없다. 중요한 것은 개발자의 가정을 이해하기전에는 코드를 이해하기 어렵다는 점이다.
이 예를 통해 알 수 있는 것처럼 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용하 는 것은 생각처럼 쉽지 않다.
개발자는 재사용을 위해 상속 계층 사이에 무수히 많은 가정을 세웠을지도 모른다. 그리고 그 가정은 코드를 이해하기 어렵게 만들뿐만 아니라 직관에도 어긋날 수 있다.
우리가 기대한 것은 10시 이전의 요금에서 10시 이후의 요금을 차감하는 것이 아니라 10시 이전의 요금과 10시 이후의 요금을 더하는 것이다. 요구사항과 구현 사이의 차이가 크면 클수록 코드를 이해하 기 어려워진다. 잘못 사용된 상속은 이 차이를 더 크게 벌린다.
이 예제가 비현실적이라고 생각되는가? 그렇다. 비현실적이다. 이 코드가 비현실적인 이유는 지나치게 깔끔하고 그나마 이해하기 쉽기 때문이다. 실제 프로젝트에서 마주치게 될 코드는 여기서 설명한 예보다 훨씬 더 엉망일 확률이 높다. 여기서는 단지 두 클래스 사이의 상속 관계만 살펴봤지만 실제 프로젝 트에서 마주치게 될 클래스의 상속 계층은 매우 깊을 것이다.
깊고 깊은 상속 계층의 계단을 하나 내려 올 때마다 이해하기 어려운 가정과 마주하게 된다고 생각해보라.
4장에서 결합도를 하나의 모듈이 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 정도 로 정의했다.
이 예제에서 볼 수 있는 것처럼 상속을 이용해 코드를 재사용하기 위해서는 부모 클래스의 개발자가 세웠던 가정이나 추론 과정을 정확하게 이해해야 한다. 이것은 자식 클래스의 작성자가 부모클래스의 구현 방법에 대한 정확한 지식을 가져야 한다는 것을 의미한다.
따라서 상속은 결합도를 높인다. 그리고 상속이 초래하는 부모 클래스와 자식 클래스 사이의 강한 결합 코드를 수정하기 어렵게 만든다.
강하게 결합된 Phone과 NightlyDiscountPhone
부모 클래스와 자식 클래스 사이의 결합이 문제인 이유를 살펴보자.
NightlyDiscountPhone은 부모 클래 스인 Phone의 caculateree 메서드를 오버라이딩한다.
또한 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 호출한다.
NightlyDiscountPhone의 calculatefee 메서드는 자신이 오버라이딩한 Phone 의 calculateFee 메서드가 모든 통화에 대한 요금의 총합을 반환한다는 사실에 기반하고 있다.
하지만 앞에서 설명했던 세금을 부과하는 요구사항이 추가된다면 어떻게 될까?
Phone은 앞에서 구현했 던 것처럼 세율(taxRate)을 인스턴스 변수로 포함하고 calculateFee 메서드에서 값을 반환할 때 taxRate
를 이용해 세금을 부과해야 한다.
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;
}
}
NightlyDiscountPhone은 생성자에서 전달받은 taxRate를 부모 클래스인 Phone의 생성자로 전달해야 한다.
또한 Phone과 동일하게 값을 반환할 때 taxRate를 이용해 세금을 부과해야 한다.
public class NightlyDiscount Phone extends Phone {
public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount,
Duration seconds, double taxRate) {
super(regular Amount, seconds, taxRate);
...
}
@Override
public Money calculateFee() {
...
return result.minus(nightlyFee.plus(nightlyFee.times(getTaxRate())));
}
}
이제 Phone과 NightlyDiscountPhone 의 상속 계층이 가지는 문제점이 또렷해졌을 것이다.
NightlyDiscountPhone을 Phone 의 자식 클래스로 만든 이유는 Phone 의 코드를 재사용하고 중복 코드를 제거하기 위해서다.
하지만 세금을 부과하는 로직을 추가하기 위해 Phone을 수정할 때 유사한 코드를 NightlyDiscountPhone에도 추가해야 했다.
다시 말해서 코드 중복을 제거하기 위해 상속을 사용했음에 도 세금을 계산하는 로직을 추가하기 위해 새로운 중복 코드를 만들어야 하는 것이다.
이것은 NightlyDiscountPhone이 Phone의 구현에 너무 강하게 결합돼 있기 때문에 발생하는 문제다. 따라서 우리는 상속을 사용할 때 다음과 같은 경고에 귀 기울일 필요가 있다.
📌 상속을 위한 경고 1
자식 클래스의 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.
지금까지 살펴본 예제들은 자식 클래스가 부모 클래스의 구현에 강하게 결합될 경우 부모 클래스의 변 경에 의해 자식 클래스가 영향을 받는다는 사실을 잘 보여준다. 상속을 사용하면 적은 노력으로도 새로 운 기능을 쉽고 빠르게 추가할 수 있다. 하지만 그로 인해 커다란 대가를 치러야 할 수도 있다.
이처럼 상속 관계로 연결된 자식 클래스가 부모 클래스의 변경에 취약해지는 현상을 가리켜 취약한 기 반 클래스 문제라고 부른다. 취약한 기반 클래스 문제는 코드 재사용을 목적으로 상속을 사용할 때 발 생하는 가장 대표적인 문제다. 먼저 취약한 기반 클래스 문제가 발생하는 몇 가지 사례를 살펴본 후 다 시 NightlyDiscountPhone의 문제로 돌아오자.
2. 취약한 기반 클래스 문제
지금까지 살펴본 것처럼 상속은 자식 클래스와 부모 클래스의 결합도를 높인다.
이 강한 결합도로 인해 자식 클래스는 부모 클래스의 불필요한 세부사항에 엮이게 된다. 부모 클래스의 작은 변경에도 자식 클래스는 컴파일 오류와 실행 에러라는 고통에 시달려야 할 수도 있다.
불필요한 인터페이스 상속 문제
📌 상속을 위한 경고 2
상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.
메서드 오버라이딩의 오작용 문제
Hashset의 구현에 강하게 결합된 InstrumentedHashSet 클래스
InstrumentedHashset 은 Hashset의 내부에 저장된 요소의 수를 셀 수 있는 기능을 추가한 클래스로서 HashSet 의 자식 클래스로 구현돼 있다.
📌 상속을 위한 경고 3
자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우
부모 클래스가 자신의 메서드를 사용하는 방법에 자식클래스가 결합될 수 있다.
조슈아 블로치는 클래스가 상속되기를 원한다면 상속을 위해 클래스를 설계하고 문서화해야 하며, 그 렇지 않은 경우에는 상속을 금지시켜야 한다고 주장한다. 객체지향의 핵심이 구현을 캡슐화 하는 것인데도 이렇게 내부 구현을 공개하고 문서화하는 것이 옳은가?
설계는 트레이드오프 활동이라는 사실을 기억하라. 상속은 코드 재사용을 위해 캡슐화를 희생한다. 완 벽한 캡슐화를 원한다면 코드 재사용을 포기하거나 상속 이외의 다른 방법을 사용해야 한다.
부모 클래스와 자식 클래스의 동시 수정 문제
음악 목록을 추가할 수 있는 플레이리스트를 구현한다고 가정하자.
필요한 것은 음악 정보를 저장할 Song 클래스와 음악 목록을 저장할 Playlist 클래스다. 먼저 Song 클래스는 가수의 이름(singer)과 노래 제목(title)을 인스턴스 변수로 포함한다.
package chapter10;
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;
}
}
Playlist는 트랙에 노래를 추가할 수 있는 append 메서드를 구현한다.
package chapter10;
import java.util.ArrayList;
import java.util.List;
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의 코드를 재사용하는 것이다.
package chapter10;
public class PersonalPlaylist extends Playlist {
public void remove(Song song) {
getTracks().remove(song);
}
}
문제는 요구사항이 변경돼서 Playlist에서 노래의 목록뿐만 아니라 가수별 노래의 제목도 함께 관리해야 한다고 가정하자.
다음과 같이 노래를 추가한 후에 가수의 이름을 키로 노래의 제목을 추가하도록 Playlist의 append 메서드를 수정해야 할 것이다.
package chapter10;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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;
}
}
안타깝게도 위 수정 내용이 정상적으로 동작하려면 PersonalPlaylist의 remove 메서드도 함께 수정해야 한다.
만약 PersonalPlaylist를 수정하지 않는다면 Playlist의 tracks에서는 노래가 제거되지만 singers 에는 남아있을 것이기 때문이다.
따라서 Playlist와 함께 PersonalPlaylist를 수정해야 한다.
package chapter10;
public class PersonalPlaylist extends Playlist {
public void remove(Song song) {
getTracks().remove(song);
getSingers().remove(song.getSinger());
}
}
이 예는 자식 클래스가 부모 클래스의 메서드를 오버라이딩하거나 불필요한 인터페이스를 상속받지 않았음에도 부모 클래스를 수정할 때 자식 클래스를 함께 수정해야 할 수도 있다는 사실을 잘 보여준다.
상속을 사용하면 자식 클래스가 부모 클래스의 구현에 강하게 결합되기 때문에 이 문제를 피하기는 어렵다.
결합도란 다른 대상에 대해 알고 있는 지식의 양이다. 상속은 기본적으로 부모 클래스의 구현을 재사용한다는 기본 전제를 따르기 때문에 자식 클래스가 부모 클래스의 내부에 대해 속속들이 알도록 강요한다. 따라서 코드 재사용을 위한 상속은 부모 클래스와 자식 클래스를 강하게 결합시키기 때문에 함께수정해야 하는 상황 역시 빈번하게 발생할 수밖에 없는 것이다.
상속을 위한 경고 4
클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나
자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없다.
3. Phone 다시 살펴보기
지금까지 상속으로 인해 발생하는 취약한 기반 클래스 문제의 다양한 예를 살펴봤다. 이제 다시 phone 과 NightlyDiscountPhone의 문제로 돌아와 상속으로 인한 피해를 최소화할 수 있는 방법을 찾아보자, 취 약한 기반 클래스 문제를 완전히 없앨 수는 없지만 어느 정도까지 위험을 완화시키는 것은 가능하다. 문제 해결의 열쇠는 바로 추상화다.
추상화에 의존하자
NightlyDiscountPhone의 가장 큰 문제점은 Phone에 강하게 결합돼 있기 때문에 Phone 이 변경될 경우 함 께 변경될 가능성이 높다는 것이다. 이 문제를 해결하는 가장 일반적인 방법은 자식 클래스가 부모 클래스의 구현이 아닌 추상화에 의존하도록 만드는 것이다. 정확하게 말하면 부모 클래스와 자식 클래스 모두 추상화에 의존하도록 수정해야 한다.
차이를 메서드로 추출하라
가장 먼저 할 일은 중복 코드 안에서 차이점을 별도의 메서드로 추출하는 것이다.
이것은 흔히 말하는 “변하는 것으로부터 변하지 않는 것을 분리하라", 또는 "변하는 부분을 찾고 이를 캡슐화하라”라는 조 언을 메서드 수준에서 적용한 것이다.
중복 코드를 가진 Phone과 NightlyDiscountPhone 클래스에서 시작하자. Phone 클래스의 현재 모습은 다음과 같다.
package chapter10;
import chapter9.movie.Money;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
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 Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
}
return result;
}
}
NightlyDiscountPhone 클래스는 Phone과 유사하지만 calculateFee 메서드의 구현 일부와 인스턴스 변수 의 목록이 조금 다르다.
package chapter10;
import chapter9.movie.Money;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
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;
}
}
먼저 할 일은 두 클래스의 메서드에서 다른 부분을 별도의 메서드로 추출하는 것이다.
이 경우에는 calculateFee의 for 문 안에 구현된 요금 계산 로직이 서로 다르다는 사실을 알 수 있다.
이 부분을 동일한 이름을 가진 메서드로 추출하자.
이 메서드는 하나의 Call에 대한 통화 요금을 계산하는 것이므로 메서드의 이름으로는 calculateCallFee가 좋을 것 같다.
먼저 Phone에서 메서드를 추출하자.
package chapter10;
import chapter9.movie.Money;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
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 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());
}
}
NightlyDiscountPhone의 경우에도 동일한 방식으로 메서드를 추출하자.
package chapter10;
import chapter9.movie.Money;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
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) {
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());
}
}
}
두 클래스의 calculateFee 메서드는 완전히 동일해졌고 추출한 calculateCall Fee 메서드 안에 서로 다른 부분을 격리시켜 놓았다. 이제 같은 코드를 부모 클래스로 올리는 일만 남았다.
중복 코드를 부모 클래스로 올려라
부모 클래스를 추가하자.
목표는 모든 클래스들이 추상화에 의존하도록 만드는 것이기 때문에 이 클래스는 추상 클래스로 구현하는 것이 적합할 것이다.
새로운 부모 클래스의 이름은 AbstractPhone으로 하고 Phone과 NightlyDiscountPhone이 AbstractPhone을 상속받도록 수정하자.
public abstract class AbstractPhone {}
public class Phone extends AbstractPhone { ... }
public class NightlyDiscountPhone extends AbstractPhone { ... }
이제 Phone과 NightlyDiscountPhone의 공통 부분을 부모 클래스로 이동시키자.
공통 코드를 옮길 때 인스턴스 변수보다 메서드를 먼저 이동시키는 게 편한데, 메서드를 옮기고 나면 그 메서드에 필요한 메서 드나 인스턴스 변수가 무엇인지를 컴파일 에러를 통해 자동으로 알 수 있기 때문이다.
컴파일 에러를 바탕으로 메서드와 인스턴스 변수를 이동시키면 불필요한 부분은 자식 클래스에 둔 채로 부모 클래스 에 꼭 필요한 코드만 이동시킬 수 있다.
두 클래스 사이에서 완전히 동일한 코드는 calculateFee 메서드이므로 calculateFee 메서드를 Abstract Phone으로 이동시키고 Phone과 NightlyDiscountPhone에서 이 메서드를 제거하자.
package chapter10;
import chapter9.movie.Money;
public abstract class AbstractPhone {
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
result = result.plus(calculateCallFee(call));
}
return result;
}
}
calculateFee 메서드를 이동시키고 나면 calls인스턴스 변수와 calculateCallFee메소드가 존재하지 않는다는 에러가 발생한다.
먼저 Phone과 NightlyDiscountPhone에서 인스턴스 변수인 calls를 AbstractPhone으로 이동시키자.
calculateCallFee메서드는 calls인스턴스 변수와 양상이 좀 다르다.
Phone과 NightlyDiscountPhone의 calculateCallFee 메서드의 경우 시그니처는 동일하지만 내부 구현이 서로 다르기 때문이다.
따라서 메서드의 구현은 그대로 두고 공통 부분인 시그니처만 부모 클래스로 이동시켜야 한다.
시그니처만 이동시키는 것이므로 calculateCallFee 메서드를 추상 메서드로 선언하고 자식 클래스에서 오버라이딩할 수 있도록 protected로 선언하자.
package chapter10;
import chapter9.movie.Money;
import java.util.ArrayList;
import java.util.List;
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);
}
공통 코드를 모두 AbstractPhone으로 옮겼다. 이제 Phone에는 일반 요금제를 처리하는 데 필요한 인스턴스 변수와 메서드만 존재한다.
package chapter10;
import chapter9.movie.Money;
import java.time.Duration;
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());
}
}
NightlyDiscountPhone에는 심야 할인 요금제와 관련된 인스턴스 변수와 메서드만 존재하게 된다.
package chapter10;
import chapter9.movie.Money;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
public class NightlyDiscountPhone extends AbstractPhone {
private static final int LATE_NIGHT_HOUR = 22;
private Money nightlyAmount;
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 regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
}
}
지금까지 살펴본 것처럼 자식 클래스들 사이의 공통점을 부모 클래스로 옮김으로써 실제 코드를 기반으로 상속 계층을 구성할 수 있다. 이제 우리의 설계는 추상화에 의존하게 된다.
위로 올리기' 전략은 실패했더라도 "수정하기 쉬운 문제"를 발생시킨다. 문제는 쉽게 찾을 수 있고 쉽게 고칠 수 있다.
추상화하지 않고 빼먹은 코드가 있더라도 하위 클래스가 해당 행동을 필요로 할 때가 오면 이 문제는 바로 눈에 띈다.
모든 하위 클래스가 이 행동을 할 수 있게 만들려면 여러 개의 중복 코드를 양산하거나 이 행동을 상위 클래스로 올리는 수밖에 없다.
가장 초보적인 프로그래머라도 중복 코드를 양산하지 말라고 배웠기 때문에 나중에 누가 이 애플리케이션을 관리하는 이 문제는 쉽게 눈에 띈다. 위로 올리기에서 실수하더라도 추상화할 코 드는 눈에 띄고 결국 상위 클래스로 올려지면서 코드의 품질이 높아진다.... 하지만 이 리팩터링을 반대 방향으로 진행한다면, 다시 말해 구체적인 구현을 아래로 내리는 방식으로 현재 클래스를 구체 클래스에서 추상클래스로 변경하려한다면 작은 실수 한 번으로도 구체적인 행동을 상위 클래스에 남겨 놓게 된다.
추상화가 핵심이다.
공통 코드를 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가진다는 것에 주목하라.
- Abstract Phone은 전체 통화 목록을 계산하는 방법이 바뀔 경우에만 변경된다.
- Phone은 일반 요금제의 통화한 건 을 계산하는 방식이 바뀔 경우에만 변경된다.
- NightlyDiscountPhone은 심야 할인 요금제의 통화한 건을 계산하는 방식이 바뀔 경우에만 변경된다.
세 클래스는 각각 하나의 변경 이유만을 가진다. 이 클래스들은 단일 책임 원칙을 준수하기 때문에 응집도가 높다.
설계를 변경하기 전에는 자식 클래스인 NightlyDiscountPhone이 부모 클래스인 Phone의 구현에 강하게 결합돼 있었기 때문에 Phone의 구현을 변경하더라도 NightlyDiscountPhone도 함께 영향을 받았었다.
변경 후에 자식 클래스인 Phone과 NightlyDiscountPhone은 부모 클래스인 AbstractPhone 의 구체적인 구현에 의존하지 않는다. 오직 추상화에만 의존한다. 정확하게는 부모 클래스에서 정의한 추상 메서드인 calculateCallFee에만 의존한다.
calculateCallFee 메서드의 시그니처가 변경되지 않는 한 부모 클래스의 내부 구현이 변경되더라도 자식 클래스는 영향을 받지 않는다.
이 설계는 낮은 결합 도를 유지하고 있다.
사실 부모 클래스 역시 자신의 내부에 구현된 추상 메서드를 호출하기 때문에 추상화에 의존한다고 말할 수 있다.
의존성 역전 원칙 준수
- 요금 계산과 관련된 상위 수준의 정책을 구현하는 AbstractPhone이 세부적인 요금 계산 로직을 구현하는 Phone과 NightlyDiscountPhone에 의존하지 않고
- 그 반대로 Phone과 NightlyDiscountPhone이 추상화인 AbstractPhone에 의존하기 때문이다.
개방-폐쇄 원칙 준수
- 새로운 요금제를 추가하기도 쉽다.
- 새로운 요금제가 필요하다면 AbstractPhone을 상속받는 새로운 클래스를 추가한 후 calculateCallFee 메서드만 오버라이딩하면 된다. 다른 클래스를 수정할 필요가 없다.
- 현재의 설계는 확장에는 열려 있고 수정에는 닫혀 있기 때문이다.
지금까지 살펴본 모든 장점은 클래스들이 추상화에 의존하기 때문에 얻어지는 장점이다.
상속 계층이 코드를 진화시키는 데 걸림돌이 된다면
- 추상화를 찾아내고
- 상속 계층 안의 클래스들이 그 추상화에 의존하도록 코드를 리팩터링하라
- 차이점을 메서드로 추출하고 공통적인 부분은 부모 클래스로 이동하라.
의도를 드러내는 이름 선택하기
한가지 아쉬운 점은 클래스의 이름과 관련된 부분이다.
NightlyDiscountPhone이라는 이름은 심야 할인 요금제와 관련된 내용을 구현한다는 사실을 명확하게 전달한다.
그에 반해 Phone은 일반 요 금제와 관련된 내용을 구현한다는 사실을 명시적으로 전달하지 못한다.
게다가 NightlyDiscountphone과 Phone은 사용자가 가입한 전화기의 한 종류지만 AbstractPhone이라는 이름은 전화기를 포괄한다는 의미 를 명확하게 전달하지 못한다.
따라서 AbstractPhone은 Phone으로, Phone은 RegularPhone으로 변경하는것이 적절할 것이다.
public abstract class Phone { ... }
public class RegularPhone extends Phone { ... }
public class NightlyDiscountPhone extends Phone { ... }
세금 추가하기
수정된 코드는 이전 코드보다 더 쉽게 변경할 수 있을까? 실제로 해보기 전까지는 장담할 수 없다.
통화 요금에 세금을 부과하는 요구사항을 반영해 보고 효과를 판단해 보자.
세금은 모든 요금제에 공통으로 적용돼야 하는 요구사항이라는 사실을 기억하라.
따라서 공통 코드를 담고 있는 추상 클래스인 Phone을 수정하면 모든 자식 클래스 간에 수정 사항을 공유할 수 있을 것이다.
인스턴스 변수인 taxRate를 추가하고 요금에 세금이 부과되도록 calculateFee 메서드를 수정하자.
package chapter10;
import chapter9.movie.Money;
import java.util.ArrayList;
import java.util.List;
public abstract class Phone {
private double taxRate;
private List<Call> calls = new ArrayList();
public Phone(double taxRate) {
this.taxRate = taxRate;
}
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
result = result.plus(calculateCallFee(call));
}
return result.plus(result.times(taxRate));
}
abstract protected Money calculateCallFee(Call call);
}
자, 이것으로 모든 것이 끝난 걸까? 안타깝게도 그렇지는 않다.
우리는 Phone에 인스턴스 변수인 taxRate를 추가했고 두 인스턴스 변수의 값을 초기화하는 생성자를 추가했다.
이로 인해 Phone의 자식 클래스인 RegularPhone과 NightlyDiscountPhone의 생성자 역시 taxRate를 초기화하기 위해 수정해야 한다.
package chapter10;
import chapter9.movie.Money;
import java.time.Duration;
public class RegularPhone extends Phone {
...
public RegularPhone(Money amount, Duration seconds, double taxRate) {
super(taxRate); // 초기화
this.amount = amount;
this.seconds = seconds;
}
...
}
package chapter10;
import chapter9.movie.Money;
import java.time.Duration;
public class NightlyDiscountPhone extends Phone {
...
public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds, double taxRate) {
super(taxRate);
this.nightlyAmount = nightlyAmount;
this.regularAmount = regularAmount;
this.seconds = seconds;
}
...
}
클래스라는 도구는 메서드뿐만 아니라 인스턴스 변수도 함께 포함한다.
따라서 클래스 사이의 상속은 클래스가 부모 클래스가 구현한 행동뿐만 아니라 인스턴스 변수에 대해서도 결합되게 만든다.
인스턴스 변수의 목록이 변하지 않는 상황에서 객체의 행동만 변경된다면 상속 계층에 속한 각 클래스 들을 독립적으로 진화시킬 수 있다.
하지만 인스턴스 변수가 추가되는 경우는 다르다. 자식 클래스는 자신의 인스턴스를 생성할 때 부모 클래스에 정의된 인스턴스 변수를 초기화해야 하기 때문에 자연스럽게 부모 클래스에 추가된 인스턴스 변수는 자식 클래스의 초기화 로직에 영향을 미치게 된다. 결과적 으로 책임을 아무리 잘 분리하더라도 인스턴스 변수의 추가는 종종 상속 계층 전반에 걸친 변경을 유발 한다.
하지만 인스턴스 초기화 로직을 변경하는 것이 두 클래스에 동일한 세금 계산 코드를 중복시키는 것보 다는 현명한 선택이다.
8장을 주의깊게 읽었다면 객체 생성 로직이 변경됐을 때 영향을 받는 부분을 최 소화하기 위해 노력해야 한다는 사실을 잘 알고 있을 것이다. 객체 생성 로직의 변경에 유연하게 대응 할 수 있는 다양한 방법이 존재한다.
따라서 객체 생성 로직에 대한 변경을 막기보다는 핵심 로직의 중 을 막아라, 핵심 로직은 한 곳에 모아 놓고 조심스럽게 캡슐화해야 한다.
그리고 공통적인 핵심 로직 은 최대한 추상화해야 한다.
지금까지 살펴본 것처럼 상속으로 인한 클래스 사이의 결합을 피할 수 있는 방법은 없다. 상속은 어떤 방식으로든 부모 클래스와 자식 클래스를 결합시킨다. 메서드 구현에 대한 결합은 추상 메서드를 추가 함으로써 어느 정도 완화할 수 있지만 인스턴스 변수에 대한 잠재적인 결합을 제거할 수 있는 방법은 없다. 우리가 원하는 것은 행동을 변경하기 위해 인스턴스 변수를 추가하더라도 상속 계층 전체에 걸쳐
부작용이 퍼지지 않게 막는 것이다
'기술 서적 > OOP' 카테고리의 다른 글
오브젝트 9장 - 유연한 설계 (0) | 2024.01.20 |
---|---|
오브젝트 7장 - 객체 분해 (0) | 2024.01.05 |
오브젝트 4장 - 설계 품질과 트레이드오프(2) (0) | 2023.12.12 |
오브젝트 5장 - 책임 할당하기(2) (0) | 2023.12.09 |
오브젝트 5장 - 책임 할당하기(1) (1) | 2023.12.09 |