코드를 재사용하려는 강력한 동기 이면에는 중복된 코드를 제거하려는 욕망이 숨어 있다.
📖 10.1 상속과 중복 코드
중복 코드는 사람들의 마음속에 의심과 불신의 씨앗을 뿌린다.
🔖 10.1.1 DRY 원칙
중복 코드는 변경을 방해한다. 이것이 중복 코드를 제거해야 하는 가장 큰 이유다.
- 중복 여부를 판단하는 기준은 변경이다.
- 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 중복
- DRY 원칙을 따르자.
-
Don't Repeat Yourself
- 한 번, 단 한 번의 원칙
- 단일 지점 제어 원칙
- 모든 지식은 시스템 내에서 단일하고, 애매하지 않고, 정말로 믿을 만한 표현 양식을 가져야 한다.
-
🔖 10.1.2 중복과 변경
🎈 중복 코드 살펴보기
@RequiredArgsConstructor
public class Call {
@Getter
private final LocalDateTime from;
private final LocalDateTime to;
public Duration getDuration() {
return Duration.between(from, to);
}
}
- 개별 통화 기간울 저장하는 Class
@Getter
@RequiredArgsConstructor
public class Phone {
private final Money amount;
private final Duration seconds;
private List<Call> calls = new ArrayList<>();
public void call(Call call) {
calls.add(call);
}
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
result = result.plus(amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()));
}
return result;
}
}
- 위 요금제는 일반 요금제이다.
- 여기서, 심야 할인 요금제(밤 10시 이후의 통화에 대해 할인)가 추가 되었다.
@RequiredArgsConstructor
public class NightlyDiscountPhone {
private static final int LATE_NIGHT_HOUR = 22;
private final Money nightlyAmount;
private final Money regularAmount;
private final Duration seconds;
private List<Call> calls = new ArrayList<>();
public Money calculateCallFee() {
Money result = Money.ZERO;
for (Call call : calls) {
if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
result = result.plus(nightlyAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()));
} else {
result = result.plus(regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()));
}
}
return result;
}
}
- 중복 코드가 생겼다!
🎈 중복 코드 수정하기
새로운 요구사항: 통화 요금에 부과할 세금 계산
@Getter
@RequiredArgsConstructor
public class Phone {
private final Money amount;
private final Duration seconds;
private final double taxRate;
private List<Call> calls = new ArrayList<>();
public void call(Call call) {
calls.add(call);
}
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
result = result.plus(amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()));
}
return result.plus(result.times(taxRate));
}
}
@RequiredArgsConstructor
public class NightlyDiscountPhone {
private static final int LATE_NIGHT_HOUR = 22;
private final Money nightlyAmount;
private final Money regularAmount;
private final Duration seconds;
private final double taxRate;
private List<Call> calls = new ArrayList<>();
public Money calculateCallFee() {
Money result = Money.ZERO;
for (Call call : calls) {
if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
result = result.plus(nightlyAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()));
} else {
result = result.plus(regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()));
}
}
return result.minus(result.times(taxRate));
}
}
- 이처럼 중복코드는 새로운 중복코드를 부른다.
minus를 호출하고 있다.
🎈 타입 코드 사용하기
@Getter
@RequiredArgsConstructor
public class Phone {
private static final int LATE_NIGHT_HOUR = 22;
enum PhoneType {REGULAR, NIGHTLY}
private final PhoneType type;
private final Money amount;
private final Money regularAmount;
private final Money nightlyAmount;
private final 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 regularAmount, Money nightlyAmount, Duration seconds) {
this(PhoneType.NIGHTLY, Money.ZERO, regularAmount, nightlyAmount, seconds);
}
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
if (type == PhoneType.REGULAR) {
result = result.plus(amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()));
} else {
if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
result = result.plus(nightlyAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()));
} else {
result = result.plus(regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()));
}
}
}
return result;
}
}
- 낮은 응집도와 높은 결합도를 가진다.
🔖 10.1.3 상속을 이용해서 중복 코드 제거하기
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() {
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((double) call.getDuration().getSeconds() / getSeconds().getSeconds())));
}
}
return result.minus(nightlyFee);
}
}
- 위 코드 처럼 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용하는 것은 쉽지 않다.
- 자식 클래스의 작성자가 부모 클래스의 구현 방법에 대한 정확한 지식을 가져야 한다.
🔖 10.1.4 강하게 결합된 Phone과 NightlyDiscountPhone
새로운 요구사항: 세금 부과
@Getter
@RequiredArgsConstructor
public class Phone {
private final Money amount;
private final Duration seconds;
private final double taxRate;
private List<Call> calls = new ArrayList<>();
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
result = result.plus(amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()));
}
return result.plus(result.times(taxRate));
}
}
public class NightlyDiscountPhone extends Phone {
private static final int LATE_NIGHT_HOUR = 22;
private Money nightlyAmount;
public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds, double taxRate) {
super(regularAmount, seconds, taxRate);
this.nightlyAmount = nightlyAmount;
}
@Override
public Money 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((double) call.getDuration().getSeconds() / getSeconds().getSeconds())));
}
}
return result.minus(nightlyFee.plus(nightlyFee.times(getTaxRate())));
}
}
- 중복 로직을 제거하기 위해 상속을 사용했음에도 중복 코드가 생겼다❗
자식 클래스의 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.
📖 10.2 취약한 기반 클래스 문제
- 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상
- 상속을 사용한다면 피할 수 없는 OOP의 근본적인 취약성
- 캡슐화를 약화시키고 결합도를 높인다.
🔖 10.2.1 불필요한 인터페이스 상속 문제
Java 초기 버전의 대표적인 사례
java.util.Stack- Stack을 Vector의 자식 클래스로 구현
- Stack이 규칙을 무너뜨릴 여지가 있는 위험한 Vector의 퍼블릭 인터페이스까지도 함께 상속받음.
java.util.Properties- Map의 조상인 Hashtable을 상속
- String 타입 이외의 키와 값이라도 저장이 가능하게 되어버림.
상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.
🔖 10.2.2 메서드 오버라이딩의 오작용 문제
자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자식의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.
🔖 10.2.3 부모 클래스와 자식 클래스의 동시 수정 문제
@Getter
@RequiredArgsConstructor
public class Song {
private final String singer;
private final String title;
}
public class Playlist {
@Getter
private final List<Song> tracks = new ArrayList<>();
public void append(Song song) {
getTracks().add(song);
}
}
public class PersonalPlaylist extends Playlist {
public void remove(Song song) {
getTracks().remove(song);
}
}
- 새로운 요구사항: 가수별 노래의 제목도 함께 관리
@Getter
public class Playlist {
private final List<Song> tracks = new ArrayList<>();
private final Map<String, String> singers = new HashMap<>();
public void append(Song song) {
getTracks().add(song);
singers.put(song.getSinger(), song.getTitle());
}
}
public class PersonalPlaylist extends Playlist {
public void remove(Song song) {
getTracks().remove(song);
getSingers().remove(song.getSinger());
}
}
- 결합도란 다른 대상에 대해 알고 있는 지식의 양이다.
클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수 밖에 없다.
📖 10.3 Phone 다시 살펴보기
🔖 10.3.1 추상화에 의존하자
- 부모 클래스와 자식 클래스 모두 추상화에 의존하도록 수정
코드 중복을 제거하기 위해 상속을 도입할 때 따르는 원칙
- 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출
- 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 옮겨라.
🔖 10.3.2 차이를 메서드로 추출하라
@Getter
@RequiredArgsConstructor
public class Phone {
private final Money amount;
private final Duration seconds;
private List<Call> calls = new ArrayList<>();
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((double) call.getDuration().getSeconds() / seconds.getSeconds());
}
}
@RequiredArgsConstructor
public class NightlyDiscountPhone {
private static final int LATE_NIGHT_HOUR = 22;
private final Money nightlyAmount;
private final Money regularAmount;
private final Duration seconds;
private List<Call> calls = new ArrayList<>();
public Money calculateCallFee() {
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((double) call.getDuration().getSeconds() / seconds.getSeconds());
}
return regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds());
}
}
🔖 10.3.3 중복 코드를 부모 클래스로 올려라
public abstract class AbstractPhone {
private final List<Call> calls = new ArrayList<>();
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
result = result.plus(calculateCallFee(call));
}
return result;
}
protected abstract Money calculateCallFee(Call call);
}
@Getter
@RequiredArgsConstructor
public class Phone extends AbstractPhone{
private final Money amount;
private final Duration seconds;
@Override
protected Money calculateCallFee(Call call) {
return amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds());
}
}
@RequiredArgsConstructor
public class NightlyDiscountPhone extends AbstractPhone {
private static final int LATE_NIGHT_HOUR = 22;
private final Money nightlyAmount;
private final Money regularAmount;
private final Duration seconds;
@Override
protected Money calculateCallFee(Call call) {
if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
return nightlyAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds());
}
return regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds());
}
}
🔖 10.3.4 추상화가 핵심이다
- 세 클래스는 각각 하나의 변경 이유만을 가진다.
- 단일 책임 원칙을 준수하므로 응집도가 높다.
- 부모 클래스의 내부 구현이 변경되더라도 자식 클래스는 영향을 받지 않는다.
- 상속 계층이 코드를 진화시키는 데 걸림돌이 된다면 추상화를 찾아내고 상속 계층 안의 클래스들이 그 추상화에 의존하도록 코드를 리팩터링
🔖 10.3.5 의도를 드러내는 이름 선택하기
public abstract class Phone {
}
public class RegularPhone extends Phone {
}
public class NightlyDiscountPhone extends Phone {
}
🔖 10.3.6 세금 추가하기
@RequiredArgsConstructor
public abstract class Phone {
private final double taxRate;
private List<Call> calls = new ArrayList<>();
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
result = result.plus(calculateCallFee(call));
}
return result.plus(result.times(taxRate));
}
protected abstract Money calculateCallFee(Call call);
}
@Getter
public class RegularPhone extends Phone {
private final Money amount;
private final Duration seconds;
public RegularPhone(double taxRate, Money amount, Duration seconds) {
super(taxRate);
this.amount = amount;
this.seconds = seconds;
}
@Override
protected Money calculateCallFee(Call call) {
return amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds());
}
}
public class NightlyDiscountPhone extends Phone {
private static final int LATE_NIGHT_HOUR = 22;
private final Money nightlyAmount;
private final Money regularAmount;
private final Duration seconds;
public NightlyDiscountPhone(double taxRate, Money nightlyAmount, Money regularAmount, Duration seconds) {
super(taxRate);
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((double) call.getDuration().getSeconds() / seconds.getSeconds());
}
return regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds());
}
}
- 책임을 아무리 잘 분리하더라도 인스턴스 변수의 추가는 종종 상속 계층 전반에 걸친 변경을 유발
- 우리가 원하는 것은 행동을 변경하기 위해 인스턴스 변수를 추가하더라도 상속 계층 전체에 걸쳐 부작용이 퍼지지 않게 막는 것
📖 10.4 차이에 의한 프로그래밍
- 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법
- 중복 코드를 제거하고 코드를 재사용하는 것이 목표
- 상속은 코드 재사용과 관련된 대부분의 경우에 우아한 해결 방법이 아니다.
- 합성이 더 좋은 방법❗
