- 객체지향에서는 코드를 재사용하기 위해 코드 복사가 아닌 '새로운' 코드를 추가한다.
- 객체지향에서 클래스를 재사용하는 전통적인 방법은 새로운 클래스를 추가하는 것이다.
- 재사용 관점에서 상속이란 클래스 안에 정의된 인스턴스 변수와 메서드를 자동으로 새로운 클래스에 추가하는 구현 기법
- 1. 상속과 중복 코드
- 중복 코드는 개발을 주저하게 만들 뿐만 아니라 동료들을 의심하게 만든다.
- 이것만으로도 중복 코드를 제거해야 할 충분한 이유가 되고도 남겠지만 결정적인 이유는 따로 있다.
DRY 원칙
- 중복 코드는 변경을 방해한다. 이것이 중복 코드를 제거해야 하는 가장 큰 이유다.
- 중복 코드는 코드를 수정하는 데 필요한 노력을 몇 배로 증가시킨다.
- 우선 어떤 코드가 중복인지를 찾아야 한다.
- 중복 코드의 묶음을 찾았다면 찾아낸 모든 코드를 일관되게 수정해야 한다.
- 모든 중복 코드를 개별적으로 테스트까지 해야 한다.
- 코드 간의 중복 여부를 판단하는 기준은 변경이다.
- 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 이 코드는 중복이다.
- 함께 수정할 필요가 없다면 중복이 아니다.
- DRY 원칙: Don't Repeat Yourself, 동일한 지식을 반복하지도, 중복하지도 말라
중복과 변경
중복 코드 살펴보기
- 중복 코드의 문제점을 이해하기 위해 한 달에 한 번씩 가입자별로 전화 요금을 계산하는 간단한 애플리케이션을 개발해본다.
- 전화 요금을 계산하는 규칙 : 통화 시간을 단위 시간으로 나눈 후 단위 시간 당 요금을 곱해준다.
- 먼저 개별 통화 기간을 저장하는 Call 클래스가 필요하다.
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
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);
}
publc List<Call> getCalls() {
return calls;
}
public Money getMoney() {
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(2018, 1, 1, 12, 10, 0),
LocalDateTime(2018, 1, 1, 12, 11, 0)
));
phone.call(new Call(
LocalDateTime(2018, 1, 2, 12, 10, 0),
LocalDateTime(2018, 1, 2, 12, 11, 0)
));
phone.calculateFee();
- '심야 할인 요금제'라는 새로운 요금 방식을 추가해야 한다는 요구사항이 접수됐다고 해보자.
- 이 요구 사항을 해결할 수 있는 1차원적인 방법은 Phone의 코드를 복사해서 NightlyDiscountPhone이라는 새로운 클래스를 만든 후 수정하는 것이다.
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을 추가하는 방법은 심야 시간에 요금을 할인해야 한다는 요구사항을 아주 짧은 시간 안에 구현할 수 있게 해준다.
- 하지만 구현 시간을 절약한 대가로 지불해야 하는 비용은 예상보다 크다.
- Phone과 NightlyDiscountPhone 사이에는 중복 코드가 존재하기 때문에 위험하다.
중복 코드 수정하기
- 중복 코드가 코드 수정에 미치는 영향을 살펴보기 위해 새로운 요구사항을 추가해본다.
- 추가할 기능은 통화 요금에 부과할 세금을 계산하는 것이다.
- 부과되는 세율은 핸드폰마다 다르다고 한다.
- 현재 통화 요금을 계산하는 로직은 Phone과 NightlyDiscountPhone 양쪽 모두에 구현돼 있기 때문에 세금을 추가하지 위해서는 두 클래스를 함께 수정해야 한다.
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.getSeconds()
));
}
return result.plus(result.times(taxRate));
}
}
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.plus(result.times(taxRate));
}
}
- 이 예제는 중복 코드가 갖는 단점을 잘 보여준다.
- 많은 코드 더미 속에서 어떤 코드가 중복인지를 파악하는 일은 쉬운 일이 아니다.
- 중복 코드는 항상 함께 수정돼야 하가 때문에 수정할 때 하나라도 빠트린다면 버그로 이어질 것이다.
- 중복 코드는 새로운 중복 코드를 부른다. 중복 코드를 제거하지 않은 상태에서 코드를 수정할 수 있는 유일한 방법은 새로운 중복 코드를 추가하는 것뿐이다.
타입 코드 사용하기
- 두 클래스 사이의 중복 코드를 제거하는 한 가지 방법은 클래스를 하나로 합치는 것이다.
- 요금제를 구분하는 타입 코드를 추가하고 타입 코드의 값에 따라 로직을 분기시켜 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 nightlyAmount;
private Money regularAmount;
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, 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(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 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){
nigthlyFee = nightlyFee.plus(
// (시간당 할인 요금) * 통화 건의 통화 시간
getAmount().minus(nightlyAmount).times(
call.getDuration().getSeconds() / getSeconds().getSeconds();
);
);
}
}
return result.minus(nightlyFee);
}
}
- 상속하여 만든 코드는 개발자의 가정을 이해하기 전에는 이해하기가 어렵다.
- 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용하는 것은 생각처럼 쉽지 않다.
- 요구 사항과 구현 사이의 차이가 크면 클수록 코드를 이해하기가 어려워진다. 잘못된 상속은 이 차이를 더 크게 벌린다.
- 상속을 이용해 코드를 재사용하기 위해서는 부모 클래스의 개발자가 세웠던 가정이나 추론 과정을 정확하게 이해해야 한다.
- 이는 자식 클래스의 작성자가 부모 클래스의 구현 방법에 대한 정확한 지식을 가져야 한다는 것을 의미한다. => 상속은 결합도를 높인다.
강하게 결합된 Phone과 NightlyDiscountPhone
- NightlyDiscountPhone이 Phone을 상속하는 상황에서 세금을 부과하는 요구사항이 추가된다면
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())));
}
}
- 하나의 요구사항 추가로 인해 부모 클래스 Phone의 calculateFee 메서드와 NightlyDiscountPhone의 calculateFee 메서드 모두 수정해줘야 하므로, 두 메서드에서 중복 코드가 발생한다고 할 수 있다.
- NightlyDiscountPhone을 Phone의 자식 클래스로 만든 이유는 Phone의 코드를 재사용하고 중복 코드를 제거하기 위해서였다.
- 하지만 세금을 부과하는 로직을 추가하기 위해 Phone을 수정할 때 유사한 코드를 NightlyDiscountPhone에도 추가해야 했다.
- 코드 중복을 제거하기 위해 상속을 사용했음에도 세금을 계산하는 로직을 추가하기 위해 새로운 중복 코드를 만들어야 하는 것이다.
- 이것은 NightlyDiscountPhone이 Phone의 구현에 너무 강하게 결합돼 있기 때문에 발생하는 문제다.
- 상속을 사용하면 적은 노력으로도 새로운 기능을 쉽고, 빠르게 추가할 수는 있지만 그로 인한 부모 클래스와 자식 클래스 간의 높은 결합도가 커다란 대가를 치르게 할 수도 있다.
- 상속 관계로 연결된 자식 클래스가 부모 클래스의 변경에 취약해지는 현상을 가리켜 취약한 기반 클래스 문제라고 부른다.
- 취약한 기반 클래스 문제는 코드 재사용을 목적으로 상속을 사용할 대 발생하는 대표적인 문제다.
2. 취약한 기반 클래스 문제
- 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상을 취약한 기반 클래스 문제라고 한다.
- 이 문제는 상속을 사용한다면 피할 수 없는 객체지향 프로그래밍의 근본적인 취약성이다.
- 상속은 자식 클래스를 점진적으로 추가해서 기능을 확장하는 데는 용이하지만 높은 결합도로 인해 부모 클래스를 점진적으로 개선하는 것은 어렵게 만든다.
- 최악의 경우에는 모든 자식 클래스를 동시에 수정하고 테스트해야 할 수도 있다.
- 취약한 기반 클래스 문제는 캡슐화를 약화시키고 결합도를 높인다.
- 상속을 사용하면 부모 클래스의 퍼블릭 인터페이스가 아닌 구현을 변경하더라도 자식 클래스가 영향을 받기 쉬워진다.
- 상속은 코드의 재사용을 위해 캡슐화의 장점을 희석시키고 구현에 대한 결합도를 높임으로써 객체지향이 가진 강력함을 반감시킨다.
- 불필요한 인터페이스 상속 문제
- 자바 초기 버전에서 상속을 잘못 사용한 대표적인 사례는 java.util.Stack과 java.util.Properties이다.
- Stack은 가장 나중에 추가된 요소가 가장 먼저 추출되는 자료구조인 스택을 구현한 클래스
- Verctor는 임의의 위치에서 요소를 추출하고 삽입할 수 있는 리스트 자료 구조의 구현체(List의 초기 버전)
- 자바 컬렉션 프레임워크 초기 개발자들은 요소의 추가, 삭제 오퍼레이션을 제공하는 Vector를 재사용하기 위해 Stack을 Vector의 자식 클래스로 구현
- Stack이 Vector를 상속받았기 때문에 Stack의 퍼블릭 인터페이스에 Vector의 퍼블릭 인터페이스가 합쳐진다.
- Stack에게 상속된 Vector의 퍼블릭 인터페이스를 이용하면 임의의 위치에서 요소를 추가하거나 삭제할 수 있다.
- 따라서 Vector의 퍼블릭 인터페이스를 상속받은 현재의 Stack을 사용하면 스택의 규칙을 쉽게 위반할 수 있다.
- 문제의 원인은 Stack의 규칙을 무너뜨릴 여지가 있는 Vector의 퍼블릭 인터페이스까지도 함께 상속받았기 때문이다.
- java.util.Properties 클래스는 키와 값의 쌍을 보관한다는 점에서는 Map과 유사하지만 다양한 타입을 저장할 수 있는 Map과 달리 키와 값의 타입으로 오직 String만 가질 수 있다는 것이 원래 특징이다.
- 그런데 Properties는 Hashtable을 상속받는데, Hashtable은 자바에 제네릭이 도입되기 이전에 만들어졌기 때문에 컴파일러가 키와 값의 타입이 String인지 여부를 체크할 수 있는 방법이 없었기에
- Hashtable의 인터페이스에 포함돼 있는 put 메서드를 이용하면 String 타입 이외의 타입의 키와 값이라도 Properties에 저장할 수 있는 상태가 돼버렸다.
- 퍼블릭 인터페이스에 대한 고려없이 단순히 코드 재상용을 위해서만 상속을 이용하는 것은 위험하다.
- 단순히 코드를 재사용하기 위해 불필요한 오퍼레이션이 인터페이스에 스며들도록 방치해서는 안 된다.
- "상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다."
메서드 오버라이딩의 오작용 문제
- InstrumentedHashSet은 HashSet의 내부에 저장된 요소의 수를 셀 수 있는 기능을 추가한 클래스로서 HashSet의 자식 클래스로 구현돼 있다.
- InstrumetedHashSet은 HashSet의 구현에 강하게 결합된 클래스다.
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);
}
}
- InstrumentedHashSet은 요소를 추가한 횟수를 기록하기 위해 HashSet에는 없는 addCount라는 인스턴스 변수를 포함
- InstrumentedHashSet은 요소가 추가될 때마다 추가되는 요소의 개수만큼 addCount의 값을 증가시키는 기능을 위해 하나의 요소를 추가하는 add 메서드와 다수의 요소들을 추가하는 addAll 메서드를 오버라이딩한다.
- add 메서드와 addAll 메서드는 먼저 addCount를 증가시킨 후 super 참조를 이용해 부모 클래스의 메서드를 호출해서 실제로 요소를 추가하게 된다.
- InstrumentedHashSet의 코드만 봤을 때는 문제가 없어보이지만 부모 클래스 HashSet의 실제 addAll 메서드를 확인해보면 의도한대로 동작하지 않을 것임을 알 수 있다.
- InstrumentedHashSet의 addAll 메서드에서 부모 클래스 HashSet의 addAll 메서드를 호출하면 부모 클래스의 addAll 메서드는 자신에게 전달된 컬렉션 요소를 자신이 직접 저장하는 것이 아니라
- addAll 메서드의 내부로 전달된 컬레션의 요소 하나마다 매번 add 메서드의 인자로 전달하여 실제 요소의 저장은 add 메서드를 통해 이뤄지도록 하였다.
- 결국엔 InstrumentedHashSet의 addAll 메서드를 호출하면 컬렉션의 요소 개수만큼 InstrumentedHashSet의 add 메서드가 호출되어 addCount를 증가시키는 행위가 이중으로 일어나게 된다.
- 부모 클래스를 상속하고 부모 클래스에는 없는 새로운 기능을 추가하기 위해 메서드 오버라이딩을 했지만 부모 클래스의 구현에 발목이 잡혀버린 상태인 것이다.
- 따라서 InstrumentedHashSet은 부모 클래스인 HashSet의 구현에 강하게 결합된 상태라고 할 수 있다.
- "자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 묶여버릴 수 있다."
트레이드 오프 관점에서의 상속을 위한 설계
- 이런 문제로 인해 클래스가 상속되기를 원한다면 상속을 위해 클래스를 설계하고 문서화해야 하며, 그렇지 않은 경우에는 상속을 금지시켜야 한다고 주장하는 경우도 있다.
- 상속이 초래하는 문제점을 보완하면서 코드 재사용의 장점을 극대화하기 위해선 가치있는 주장이긴 하다.
- 그런데 상속을 위해 클래스의 내부 구현을 공개하고 문서화하는 것은 캡슐화를 위반한다.
- 다만 설계는 트레이드오프 활동이기에 고려해볼만한 방법이다.
- 상속의 문제점을 보완하면서 상속을 구현하려면 캡슐화를 희생하게 된다.
- 완벽한 캡슐화를 원한다면 코드 재사용을 포기하거나 상속 이외의 다른 방법을 사용해야 한다.
- 부모 클래스와 자식 클래스의 동시 수정 문제
- 음악 목록을 추가할 수 있는 플레이리스트를 구현한다고 가정
- 필요한 것은 음악 정보를 저장할 Song 클래스와 음악 목록을 저장할 PlayList 클래스다.
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;
}
}
public class Playlist {
private List<Song> tracks = new ArrayList<>();
public void append(Song song) {
getTracks().add(song);
}
public List<String> getTracks() {
return tracks;
}
}
- 플레이리스트에서 노래를 삭제할 수 있는 기능이 추가된 PersonalPlaylist가 필요하다고 가정
- PersonalPlaylist를 구혀하는 가장 빠른 방법은 상속을 통해 Playlist의 코드를 재사용하는 것이다.
public class PersonalPlaylist extends Playlist{
public void remove(Song song) {
getTracks().remove(song);
}
}
- 요구사항이 변경돼서 Playlist에서 노래의 목록뿐만 아니라 가수별 노래의 제목도 함께 관리해야 한다고 가정
- 노래를 추가한 후 가수의 이름을 키로 노래의 제목을 추가하도록 Playlist의 append 메서드를 수정
public class Playlist {
private List<Song> tracks = new ArrayList<>();
private Map<String, String> singers = new HashMap<>();
public void append(Song song) {
getTracks().add(song);
singers.put(song.getSinger(), song.getTitle());
}
public List<String> getTracks() {
return tracks;
}
public Map<String, String> getSingers() {
return singers;
}
}
- 위 수정 내용이 정상적으로 동작하려면 PersonalPlaylist의 remove 메서드도 함께 수정해야 한다.
public class PersonalPlaylist extends Playlist{
public void remove(Song song) {
getTracks().remove(song);
getSingers().remove(song.getSinger());
}
}
- 단순히 코드 재사용을 목적으로 한 상속은 부모 클래스와 자식 클래스를 강하게 결합시키기 때문에 함께 수정해야 하는 상황 역시 빈번하게 발생할 수밖에 없는 것이다.
- 슈퍼클래스의 작성자가 확장될 목적으로 특별히 그 클래스를 설계하지 않았다면 서브클래스는 슈퍼클래스와 보조를 맞춰서 진화해야 한다.
- "클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수 밖에 없다."
3. Phone 다시 살펴보기
추상화에 의존하자
- NightlyDiscountPhone의 가장 큰 문제점은 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 void call(Call call) {
calls.add(call);
}
publc List<Call> getCalls() {
return calls;
}
public Money getMoney() {
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;
}
}
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 문 안에 구현된 요금 계산 로직이 서로 다르다.
- 이 다른 부분을 동일한 이름을 가진 메서드로 추출한다.(하나의 통화에 대한 통화 요금 계산: 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 = 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 메서드는 완전히 동일해졌고 추출한 calculateCallFee 메서드 안에 서로 다른 부분을 격리시켜 놓았다. 이제 같은 코드를 부모 클래스로 올리는 일만 남았다.
- 중복 코드를 부모 클래스로 올려라
- 우선 부모 클래스를 추가하자.
- 목표는 모든 클래스들이 추상화에 의존하도록 만드는 것이기에 이 부모 클래스는 추상 클래스로 구현하는 것이 적합할 거이다.
public abstract class AbstractPhone {}
public class Phone extends AbstractPhone {...}
public class NightlyDiscountPhone extends AbstractPhone {...}
- Phone과 NightlyDiscountPhone의 공통 부분을 부모 클래스로 이동시키자.
- 공통 코드를 옮길 때 인스턴스 변수보다 메서드를 먼저 옮기는 것이 편하다.
- 메서드를 옮기고 나면 그 메서드에 필요한 메서드나 인스턴스 변수가 무엇인지를 컴파일 에러를 통해 자동으로 알 수 있다.
- 두 클래스 사이에서 완전히 동일한 코드는 calculateFee 메서드이므로 calculateFee 메서드를 AbstractPhone으로 이동시키고 Phone과 NightlyDiscountPhone에서 이 메서드를 제거한다.
public abstract class AbstractPhone {
public Money calculateFee() {
Money result = Money.ZERO;
for(Call call : calls) {
result = result.plus(calculateCallFee(call));
}
return result;
}
}
- calculateFee 메서드를 부모 클래스로 이동시키고 나면 calls가 존재하지 않는다는 에러가 발생
- 자식 클래스의 인스턴스 변수 calls를 AbstractPhone으로 이동시킨다.
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;
}
}
- calls를 이동시키고 나면 부모 클래스에서 calculateCallFee 메서드를 찾을 수 없다는 에러가 발생
- 자식 클래스 간의 calculateCallFee 메서드의 구현은 다르고 시그니처가 동일하기 때문에 시그니처만 AbstractPhone으로 이동시킨다.
- 시그니처만 이동시키는 것이므로 calculateCallFee 메서드를 추상 메서드로 선언하고 자식 클래스에서 오버라이딩할 수 있도록 protected로 선언한다.
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;
}
protected abstract Money calculateCallFee(Call call);
}
- Phone과 NightlyDiscountPhone의 중복 코드를 모두 AbstractPhone으로 옮겼다.
- 이제 Phone에는 일반 요금제를 처리하는 데 필요한 인스턴스 변수와 메서드만 존재하고
- NightlyDiscountPhone에는 심야 할인 요금제와 관련된 인스턴스 변수와 메서드만 존재하게 된다.
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()
)
}
}
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()
);
}
}
- 자식 클래스들 사이의 공통점을 부모 클래스로 옮김으로써 실제 코드를 기반으로 상속 계층을 구성할 수 있다.
추상화가 핵심이다
- 공통 코드를 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가진다.
- 세 클래스는 각각 하나의 변경 이유만을 가진다.
- 이들은 단일 책임 원칙을 준수하기 때문에 응집도가 높다.
- calculateCallFee 메서드의 시그니처가 변경되지 않는 한 부모 클래스의 내부 구현이 변경되더라도 자식 클래스들은 영향을 받지 않는다.
- 이 설계는 낮은 결합도를 유지하고 있다.
- 부모 클래스 역시 자신의 내부에 구현된 추상 메서드를 호출하기 때문에 추상화에 의존한다.
- 의존성 역전 원칙을 준수한다.
- 새로운 요금제를 추가하기도 쉽다.
- 새로운 요금제가 필요하다면 AbstractPhone을 상속받는 새로운 클래스를 추가하고 calculateCallFee 메서드만 오버라이딩하면 된다. 기존 코드는 수정이 필요 없다.
- 즉, 현재의 설계는 확장에는 열려 있고 수정에는 닫혀 있기 때문에 개방-폐쇄 원칙을 준수한다는 것이다.
의도를 드러내는 이름 선택하기
public abstract class Phone {...}
public class RegularPhone extends Phone {...}
public class NightlyDiscountPhone extends 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 {
private Money amount;
private Duration seconds;
public RegularPhone(Money amount, Duration seconds, double taxRate) {
super(taxRate);
this.amount = amount;
this.seconds = seconds;
}
@Override
protected Money calculateCallFee(Call call){
return amount.times(
call.getDuration().getSeconds() / seconds.getSeconds()
)
}
}
public class NightlyDiscountPhone extends Phone {
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,
double taxRate) {
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(
call.getDuration().getSeconds() / seconds.getSeconds()
);
}
return regularAmount.times(
call.getDuration().getSeconds() / seconds.getSeconds()
);
}
}
- 클래스라는 도구는 메서드뿐만 아니라 인스턴스 변수도 포함
- 클래스 사이의 상속은 자식 클래스가 부모 클래스가 구현한 행동뿐만 아니라 인스턴스 변수에 대해서도 결합되게 만든다.
- 인스턴스 변수의 목록이 변하지 않는 상황에서 객체의 행동만 변경된다면 상속 계층에 속한 각 클래스들을 독립적으로 진화시킬 수 있다.
- 하지만 인스턴스 변수가 추가되는 상황은 다르다.
- 부모 클래스에 추가된 인스턴스 변수는 자식 클래스의 초기화 로직에 영향을 미치게 된다.
- 책임을 아무리 잘 분리해도 인스턴스 변수의 추가는 종종 상속 계층 전반에 걸친 변경을 유발한다.
- 하지만 인스턴스 초기화 로직을 변경하는 것이 두 클래스에 동일한 세금 계산 코드를 중복시키는 것보다는 현명한 선택이다.
- 객체 생성 로직에 대한 변경을 막기보다는 핵심 로직의 중복을 막는 편이 좋다.
- 결국 상속으로 인한 클래스 사이의 결합을 피할 수 있는 방법은 없는 것이다.
- 상속은 어떤 식으로든 부모 클래스와 자식 클래스를 결합시킨다.
- 메서드 구현에 대한 결합은 추상 메서드를 추가함으로써 어느 정도 완화할 수 있지만 인스턴스 변수에 대한 잠재적인 결합을 제거할 수 있는 방법은 없다.
4. 차이에 의한 프로그래밍
- 차이에 의한 프로그래밍 : 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법
- 중복 코드를 제거하기 위해 최대한 코드를 재사용해야 한다.
- 재사용 가능한 코드란 심각한 버그가 존재하지 않는 코드다.
- 상속은 중복 코드를 제거하고 코드를 재사용할 수 있는 강력한 도구지만 잘못 사용할 경우 돌아온는 피해가 크다.
- 상속의 오용과 남용은 애플리케이션을 이해하고 확장하기 어렵게 만든다. 정말로 필요한 경우에만 상속을 사용해야 한다.
- 상속은 사실 코드 재사용과 관련된 대부분의 경우에 우아한 해결 방법이 아니다.
- 객체지향에 능숙한 개발자들은 상속의 단점을 피하면서도 코드를 재사용할 수 있는 더 좋은 방법이 있다는 사실을 안다. 바로 합성이다.