class Child extends Parent {
// 부모 클래스의 기능을 자동으로 상속
}
class Container {
private Component component; // 기존 클래스를 포함.
}
왜 중복 코드를 제거해야 할까?
신뢰할 수 있고 수정하기 쉬운 소프트웨어 만드는 방법 = 중복 제거하기
개별 통화시간 저장 클래스
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 sconds; // 단위 시간
private List<Call> calls = new ArrayList<>(); // 전체 통화 목록
public Phone(Money amount, Duration seconds) {
this.amount = amount;
this.seconds = seconds;
}
public void call(Call call) {
call.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.calculate(); // => 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()));
}
}
retrun 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.getSecondse()));
}
retrun result.plus(result.times(taxRate)); // 세율 계산로직 추가
}
}
public class NightlyDiscountPhone {
...
private double taxRate;
public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seoncds, 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(
nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
}
}
return result.minus(result.times(taxRate));
}
}
plus
, 하나는 mius
를 호출해 세금을 구했으나, 이 오류를 일일이 확인하는 것은 쉬운 일이 아니다.타입을 사용해 구현 시, 낮은 응집도와 높은 결합도라는 문제를 야기할 수 있다.
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(...) { ... }
public Money calculateFee() {
Money result = Money.ZERO();
for (Call call : calls) {
if (type == PhoneType.REGULAR) {
result = result.plus(
amount.times(call.getDuration().getSeonds() / 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 secondes) {
super(regularAmount, seconds);
this.nightlyAmount = nightlyAmount;
}
@Override
public Money calculateFee() {
// 부모 클래스의 calculateFee 호출
Money result = super.calcualteFee();
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(nigthlyFee);
}
}
calculateFee
super
를 통해 부모의 calcualteFee
메서드로 요금을 계산한 후, 10시 이후의 통화 요금을 빼준다.NightlyDiscountPhone
)는 2가지 요금 규칙으로 구성되어 있다.Phone
에 구현된)와 동일하다.NightlyDiscountPhone
에 구현한다.(40초/10초*5원) + (50초/10초*5원) - (50초/10초*(5원-2원)) = 30원
왜 부모 자식 클래스간의 결합이 문제가 될까?
금액 반환 시, 세율을 부과한 금액을 반환한다.
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())));
}
}
NightlyDisocuntPhone
은 중복 코드를 제거하기 위해 Phone
을 상속받았다.Phone
을 수정하면서 NightlyDiscountPhone
역시 함께 변경해야 했다.상속을 위한 경고1
자식 클래스의 메서드 안에서
super
참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다.super
호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.
취약한 기반 클래스 문제가 발생하는 몇 가지 사례를 살펴보자
상속이 가지는 문제점들을 구체적으로 알아보자.
java.util.Properties
java.util.Stack
자바의 초기 컬렉션 프레임워크 개발자들은 Vector
를 재사용하기 위해 Stack
을 자식클래스로 구현했다.
Stack
이 Vector
를 상속받음으로 인해서, Stack
이 Vector
의 퍼블릭 인터페이스에 접근이 가능하게 되었다.
이 점은 "맨 마지막 위치에서만 요소를 추가 및 제거 가능하다"라는 Stack
의 규칙을 쉽게 위반하게 한다.
인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 만들어야 한다.
Hashtable
: 다양한 타입의 키와 값을 저장할 수 있다.Properties
: String
의 키와 값 쌍만 저장 가능하다.자바에 제네릭(generic)이 도입되기 이전에 만들어졌기 때문에, 컴파일러가 키와 값의 타입이 String
인지 여부를 체크할 수 있는 방법이 없었다.
따라서 HashTable
의 put
메서드를 통해 값을 저장하는 것이 가능했다.
Properties properties = new Properties();
properties.setProperty("Bjarne", "C++");
properties.setProperty("James", "Java");
properties.put("Dennis", 67);
assertEquals("C", properties.getProperty("Dennis")); // null 에러!
Properties
의 getProperty
메서드의 반환값이 String이 아닐경우 null을 반환하기로 구현되어 있어 에러가 발생한다.
상속을 위한 경고2
상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.
오버라이딩 시 자식클래스가 부모의 메서드 사용에 강하게 결합될 수 있다.
InstrumentedHashSet
: 내부에 저장된 요소의 수를 셀 수 있는 기능을 추가한 클래스HashSet
의 자식클래스addCount
의 값을 증가시키고, 부모 클래스의 메서드로 요소를 추가한다.public class InstrumentedHashSet<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 InstrumentHashSet<>();
languages.addAll(Arrays.asList("Java", "Ruby", "Scala")); // addCounts = 6
addConuts
의 값이 6이된다.HashSet
의 addAll
메서드 안에서 add
메서드를 호출하기 때문이다.InstrumentedHAshSet
의 addAll
메서드를 제거한다.HashSet
)에서 add
메서드가 호출되어 예상했던 결과가 나오게 된다.HashSet
이 add
메서드를 호출하지 않도록 변경되는 경우 다시 문제가 될 수 있다.InstrumentedHashSet
의 addAll
메서드를 오버라이딩하고 추가되는 각 요소에 대해 한 번씩 add
를 호출한다.addAll
이 add
를 전송하지 않도록 수정되어도 InstrumentedHashSet
의 작동에는 영향이 없다.addAll
의 구현이 HashSet
의 것과 동일하다.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;
}
}
상속을 위한 경고3
자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.
우선, 그런 클래스에서는 메서드 오버라이딩으로 인한 파급효과를 분명하게 문서화해야 한다. 달리 말해, 오버라이딩 가능한 메서드들의 자체 사용(self-use), 즉, 그 메서드들이 같은 클래스의 다른 메서드를 호출하는 지에 대해 반드시 문서화해야 한다. 이와는 반대로, 각각의 public이나 protected 메서드 및 생성자가 어떤 오버라이딩 가능한 메서들르 호출하는지, 어떤 순서로 하는지, 호출한 결과가 다음 처리에 어떤 영향을 주는지에 대해서도 반드시 문서화해야 한다. 더 일반적으로 말하면 오버라이딩 가능한 메서드를 호출할 수 있는 어떤 상황에 대해서도 문서화해야 한다는 것이다.
그러나 잘 된 API 문서는 메서드가 무슨 일(what)을 하는지를 기술해야 하고, 어떻게 하는지(how)를 설명해서는 안 된다는 통념을 어기는 것일까? 그렇다, 어기는 것이다! 이것은 결국 상속이 캡슐화를 위반함으로써 초래된 불행인 것이다. 서브클래스가 안전할 수 있게끔 클래스를 문서화하려면 클래스의 상세 구현 내역을 기술해야 한다.
상속은 코드 재사용을 위해 캡슐화를 희생한다.
완벽한 캡슐화를 원한다면 코드 재사용을 포기하거나, 상속 이외의 다른 방법을 사용해야 한다.
public class Song {
private String singer;
private String title;
public Song(String singer, String title) {
this.singer = singer;
this.title = tilte;
}
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;
}
}
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);
signers.put(song.getSigner(), song.getTitle());
}
public List<Song> getTracks() {
return tracks;
}
public Map<String, String> getSingers() {
return singers;
}
}
위 수정이 정상작동하려면 PersonalPlaylist
의remove
도 함께 수정되어야 한다.
그렇지 않으면 Playlist
의 tracks
에서는 노래가 제거되지만 singers
에는 남아있게 된다.
public class PersonalPlaylist extends Playlist {
pubic void remove(Song song) {
getTracks().remove(song);
getSingers().remove(song.getSinger());
}
}
위의 예시는 자식 클래스가 "부모클래스의 메서드를 오버라이딩" 하거나, "불필요한 인터페이스를 상속"받지 않았음에도 부모클래스 수정에 자식 클래스를 함께 수정해야 하는 예시를 보여준다.
상속을 사용하면 자식 클래스가 부모 클래스의 구현에 강하게 결합되므로 이 문제를 피하기 어렵다.
다시 말해, 서브 클래스는 올바른 기능을 위해 슈퍼클래스의 세부적인 구현에 의존한다. 슈퍼클래스의 구현은 릴리스를 거치면서 변경될 수 있고, 그에 따라 서브클래스의 코드를 변경하지 않더라도 깨질 수 있다. 결과적으로, 슈퍼클래스의 작성자가 확장될 목적으로 특별히 그 클래스를 설계하지 않았다면 서브클래스는 슈퍼클래스와보조를 맞춰서 진화해야 한다.
상속을 위한 경고 4
클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없다.
상속으로 인한 피해를 최소화시킬 수 있는 방법을 찾아보자!
취약한 기반 클래스 문제를 완벽하게 없앨 순 없지만, 추상화를 이용해 위험을 완화시킬 수 있다.
NightDiscountPhone
과 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.seconds = seconds;
}
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
}
retrun 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 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()));
}
}
}
}
calculateFee
의 for
문 안에 구현된 요금 계산 로직이 서로 다르다.
이 부분을 동일한 이름을 가진 메서드로 추출하자.
이 메서드는 하나의 Call
에 대한 통화 요금을 계산하는 것이므로 메서드 이름은 calculateCallFee
로 지정한다.
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 {
...
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
result = 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());
}
}
}
AbstractPhone
으로 지정public abstract class AbstractPhone {}
public class Phone extends AbstractPhone { ... }
public class NightlyDiscountPhone extends AgstractPhone { ... }
public class AbstractPhone {
// 1. 메서드 요구 인스턴스: calls
private List<Call> calls = new ArrayList<>();
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
// 2. 메서드 요구 메서드: calculateCallFee
result = result.plus(calculateCallFee(call));
}
return result;
}
abstract protected Money calculateCallFee(Call call);
}
공통된 메서드를 추상 클래스로 이동 시 2개의 컴파일 에러가 발생한다.
calcualteCallFee()
메서드Phone
과 NightlyDiscountPhone
에서 시그니처는 동일하지만 내부 구현이 서로 다르다.calculateCallFee
메서드를 추상 메서드로 선언하고, 자식 클래스에서 오버라이딩 하도록 protected
로 선언한다.package com.example.오브젝트;
public class Phone {
private Money amount;
private Duration seconds;
public Phone(Money amount, Duration seconds) {
this.amount = amount;
this.seconds = seconds;
}
private Money calculateCallFee(Call call) {
return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
}
}
package com.example.오브젝트;
import com.example.dddstudy.domain.Money;
import java.time.Duration;
public class NightlyDiscountPhone {
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;
}
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());
}
}
}
'위로 올리기' 전략은 실패했더라도 수정하기 쉬운 문제를 발생시킨다. (...) 추상화하지 않고 빼먹은 코드가 있더라도 하위 클래스가 해당 행동을 필요로 할 때가 오면 이 문제는 바로 눈에 띈다. (...) 가장 초보적인 프로그래머라도 중복 코드를 양산하지 말라고 배웠기 때문에 나중에 누가 이 애플리케이션을 관리하든 이 문제는 쉽게 눈에 띈다. 위로 올리기에서 실수하더라도 추상화할 코드는 눈에 띄고 결국 상위 클래스로 올려지면서 코드의 품질이 높아진다 ... 하지만 이 리팩토링을 반대 방향으로 진행한다면, 다시 말해 구체적인 구현을 아래로 내리는 방식으로 현재 클래스를 구체 클래스에서 추상 클래스로 변경하려 한다면 작은 실수 한 번으로도 구체적인 행동을 상위 클래스에 남겨 놓게 된다.
AbtractPhone
: 전체 통화 목록 계산 방법이 바뀔 때에만 변경Phone
: 일반 요금제의 통화 단위금액 변경 시 변경NightlyDiscountPhone
: 심야 할인 요금제의 단위가격 변경 시 변경calculateFee
가 변경되지 않는 한, 자식 클래스는 영향을 받지 않는다.위의 장점들은 클래스들이 추상화에 의존하기에 얻어진 장점들이다.
상속 계층이 문제가 된다면, 추상화를 찾아재고 상속 계층 안의 클래스들이 그 추상화에 의존하도록 코드를 리팩터링 해라.
차이점을 메서드로 추출하고 공통적인 부분은 부모 클래스로 이동하라.
NightlyDiscountPhone
: 심야 할인 요금제와 관련된 내용을 구현한다는 사실을 명확하게 전달.Phone
: 일반 요금제와 관련된 내용을 구현한다는 사실을 명시적으로 전달하지 못함RegularPhone
으로 변경AbstractPhone
: (사용자가 가입한 전화기의 한 종류를 의미하는 다른 클래스와 달리) 전화기를 포괄한다는 의미를 명확히 전달하지 못함.Phone
으로 변경public abstrac class Phone { ... }
public class RegularPhone extends Phone { ... }
public class NightlyDiscountPhone extends Phone { ... }
세금 추가하기
라는 요구사항을 구현하면서, 수정된 코드가 더 변경이 용이한 코드인지 확인해보자!
금액 반환 시, 세율을 부과한 금액을 반환한다.
모든 요금제에 공통 적용되는 요구사항이므로, 공통 코드를 담는 추상 클래스인 Phone
을 수정해보자.
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));
}
protected abstract Money calculateCallFee(Call call);
}
public class RegularPhone extends Phone {
...
public RegularPhone(Money amount, Duration seconds, double taxRate) {
super(taxRate);
this.amount = amount;
this.seconds = seconds;
}
...
}
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;
}
...
}
상속으로 인한 클래스 사이의 결합을 피할 수 있는 방법은 없다.
메서드 구현에 대한 결합은 추상 메서드를 통해 완화가능하나, 인스턴스 변수에 대한 잠재적인 결합을 제거할 수 있는 방법은 없다.
행동을 변경하기 위해 인스턴스 변수를 추가하더라도 상속 계층 전체에 걸쳐 부작용이 퍼지지 않게 막는 것이다.