목차
- ☆ 표시가 붙은 부분은 스터디 중 나온 얘기 혹은 제 개인적인 생각이나 제가 이해한 방식을 적어놓은 것으로, 책에 나오지 않는 내용입니다. 따라서 책에서 말하고자 하는 바와 다를 수 있습니다.
- 모든 이미지의 출처는 오브젝트(조용호 저) 책 입니다.
CHAPTER 10. 상속과 코드 재사용
⚈ 코드 재사용
- 전통적인 패러다임 : 코드를 복사한 후 수정
- 객체지향 : 코드를 재사용하기 위해 '새로운' 코드를 추가. 객체지향에서 클래스를 재사용하는 전통적인 방법은 새로운 클래스를 추가하는 것.
⚈ 이번 장에서는 상속을 통한 코드 재사용을 알아보고, 11장에서 코드를 효과적으로 재사용할 수 있는 합성을 알아본 후 상속과 합성의 장단점을 비교하게 된다.
01 상속과 중복 코드
⚈ 중복 코드는 사람들의 마음속에 의심과 불신의 씨앗을 뿌린다.
- ☆ ㄹㅇㅋㅋ 단순 복붙해둔 중복코드 보면 다른 부분은 보고싶지도 않음.
⚈ 중복 여부를 판단하는 기준은 변경이다.
- 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 이 코드는 중복이다.
- ☆ 똑같이 생긴걸 복붙한걸 중복 코드라고 생각했는데, 이 책에서 말하는 중복의 기준을 보니 확실히 책의 말이 맞는 것 같다. 동일한 이유로 수정이 되는 부분이라면 중복이 맞지!
- DRY 원칙 : Don't Repeat Yourself - 동일한 지식을 중복하지 말라. 모든 지식은 시스템 내에서 단일하고, 애매하지 않고, 정말로 믿을 만한 표현 양식을 가져야 한다.
⚈ 중복 코드는 서로 다르게 수정하기가 쉽다
- 많은 코드 더미 속에서 어떤 코드가 중복인지를 파악하는 일은 쉬운 일이 아니다.
- 중복 코드는 항상 함께 수정돼야 한다.
- 중복 코드는 새로운 중복 코드를 부른다.
- 중복 코드를 제거하지 않은 상태에서 코드를 수정할 수 있는 유일한 방법은 새로운 중복 코드를 추가하는 것뿐이다.
- ☆ 변경되는 부분과 안되는 부분을 잘 파악해서 디자인 패턴을 적용하자!
⚈ 타입 코드 사용하기
- 두 클래스 사이의 중복 코드를 제거하는 한 가지 방법은 클래스를 하나로 합치는 것이다.
- 타입 코드를 추가하고 타입 코드의 값에 따라 로직을 분기시키는 방식
- 낮은 응집도와 높은 결합도라는 문제에 시달리게 된다.
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) {
nightlyFee = nightlyFee.plus(
getAmount().minus(nightlyAmount).times(
call.getDuration().getSeconds() / getSeconds().getSeconds()));
}
}
return result.minus(nightlyFee);
}
}
⚈ 위 코드로 알 수 있는 상속을 통한 코드 재사용의 문제점
- 위 코드의 부모 클래스인 Phone의 calculateFee()는 아래와 같다.
public Money calculateFee() {
Money result = Money.ZERO;
for(Call call : calls) {
result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
}
return result;
}
- 저걸 재사용하기 위해 상속을 통해 짰더니 minus를 통한 계산 식이 복잡하게 짜여졌다. -> 개발자의 가정을 이해하기 전에는 코드를 이해하기 어렵다.
- 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용하는 것은 생각처럼 쉽지 않다. 또한 직관에도 어긋날 수 있다.
- 상속을 이용해 코드를 재사용하기 위해서는 부모 클래스의 개발자가 세웠던 가정이나 추론 과정을 정확하게 이해해야 한다.
- 따라서 상속은 결합도를 높인다. 상속이 초래하는 부모 클래스와 자식 클래스 사이의 강한 결합이 코드를 수정하기 어렵게 만든다.
⚈ 자식 클래스의 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.
- 위 코드에 추가로 세금을 부과하는 요구사항이 추가되었다고 해보자. 그럼 아래와 같이 변경될 것이다.
// Phone에 추가된 세금 요구사항 로직(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에 추가된 세금 요구사항 로직(taxRate)
@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.plus(nightlyFee.times(getTaxRate())));
}
- Phone의 코드를 재사용하고 중복 코드를 제거하기 위해 Phone의 자식 클래스로 NightlyDiscountPhone을 만든건데, 세금을 부과하는 로직을 추가하기 위해 Phone을 수정할 때 유사한 코드를 NightlyDiscountPhone에도 추가해야 했다. 즉 새로운 중복 코드를 만들었다.
- 이것은 NightlyDiscountPhone이 Phone의 구현에 너무 강하게 결합돼 있기 때문에 발생하는 문제다.
- ☆ 부모클래스를 알고 있어야 하는 것도 캡슐화를 어기는 것이다. 부모가 바뀌면 자식도 바뀌어야 하니
02 취약한 기반 클래스 문제
⚈ 위에서 본 것 처럼 상속 관계로 연결된 자식 클래스가 부모 클래스의 변경에 취약해지는 현상을 가리켜 취약한 기반 클래스 문제(Fragile Base Class Problem, Brittle Base Class Problem)라고 한다.
- 상속을 사용한다면 피할 수 없는 객체지향 프로그래밍의 근본적인 취약성이다.
- 상속이라는 문맥 안에서 결합도가 초래하는 문제점을 가리키는 용어다. 상속 관계를 추가할수록 전체 시스템의 결합도가 높아진다는 사실을 알고 있어야 한다.
- 객체를 사용하는 이유는 구현과 관련된 세부사항을 퍼블릭 인터페이스 뒤로 캡슐화할 수 있기 때문이다. 캡슐화는 변경에 의한 파급효과를 제어할 수 있기 때문에 가치있다.
- 안타깝게도 상속을 사용하면 부모 클래스의 퍼블릭 인터페이스가 아닌 구현을 변경하더라도 자식 클래스가 영향을 받기 쉬워진다. 상속은 코드의 재사용을 위해 캡슐화의 장점을 희석시키고 구현에 대한 결합도를 높임으로써 객체지향이 가진 강력함을 반감시킨다.
⚈ 불필요한 인터페이스 상속 문제
- 자바의 Stack은 Vector를 상속받는다. Vector의 퍼블릭 인터페이스를 이용하면 임의의 위치에서 요소를 추가하거나 삭제할 수 있다. 따라서 Stack의 규칙을 쉽게 위반할 수 있다.
- 아래와 같은 코드는 스택 구조의 규칙에 따르면 3이 출력되야 하지만, 실제론 2가 출력된다.
Stack<Integer> stack = new Stack<>();
stack.push(0);
stack.push(1);
stack.push(2);
stack.add(0, 3);
System.out.println(stack.pop());
- 상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.
⚈ 메서드 오버라이딩의 오작용 문제
- 이하의 코드는 set에 요소가 추가된 횟수를 기록하기 위해 HashSet을 상속해 작성된 코드이다.
- addAll에 3개의 요소를 집어넣을 경우 예상은 addCount가 3이 되는 것이지만, 실제론 6이 된다. 부모 클래스인 HashSet의 addAll에서 add를 호출하기 때문이다.
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) {
addCouhnt += c.size();
return super.addAll(c);
}
}
- 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.
- 조슈아 블로치 : 클래스가 상속되기를 원한다면 상속을 위해 클래스를 설계하고 문서화해야 하며, 그렇지 않은 경우에는 상속을 금지시켜야 한다.
- 객체지향의 핵심이 구현을 캡슐화하는 것인데도 이렇게 내부 구현을 공개하고 문서화하는 것이 옳은가?
- 설계는 트레이드오프 활동이다.
- 상속은 코드 재사용을 위해 캡슐화를 희생한다. 완벽한 캡슐화를 원한다면 코드 재사용을 포기하거나 상속 이외의 다른 방법을 사용해야 한다.
⚈ 부모 클래스와 자식 클래스의 동시 수정 문제
- 음악 목록을 추가할 수 있는 플레이리스트를 구현한다고 가정하자.음악 정보를 저장할 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<Song> getTracks() {
return tracks;
}
}
- 이제 노래를 삭제할 수 있는 기능이 추가된 PersonalPlaylist가 필요하다고 해보자. 상속을 통해 Playlist의 코드를 재사용하는게 가장 빠른 방법일 것이다.
public class PersonalPlaylist extends Playlist {
public void remove(Song song) {
getTracks().remove(song);
}
}
- 문제는 요구사항이 변경돼서 Playlist에서 노래의 목록뿐만 아니라 가수별 노래의 제목도 함께 관리해야 한다고 가정하자. 그럼 PlayList와 PersonalPlaylist는 아래와 같이 변경될 것이다.
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;
}
}
----
public class PersonalPlaylist extends Playlist {
public void remove(Song song) {
getTracks().remove(song);
getSingers().remove(song.getSinger());
}
}
- 위의 예를 통해 자식 클래스가 부모 클래스의 메서드를 오버라이딩하거나 불필요한 인터페이스를 상속받지 않았음에도 부모 클래스를 수정할 때 자식 클래스를 함께 수정해야 할 수도 있다는 사실을 알 수 있다.
- 코드 재사용을 위한 상속은 부모 클래스와 자식 클래스를 강하게 결합시키기 때문에 함께 수정해야 하는 상황 역시 빈번하게 발생할 수박에 없다.
- 클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없다.
03 Phone 다시 살펴보기
⚈ 상속으로 인한 피해를 최소화할 수 있는 방법을 찾아보자. 취약한 기반 클래스 문제를 완전히 없앨 수는 없지만 어느 정도까지 위험을 완화시키는 것은 가능하다.
⚈ 열쇠는 추상화다. 추상화에 의존하자.
- 부모 클래스와 자식 클래스 모두 추상화에 의존하도록 해야 한다.
⚈ 코드 중복을 제거하기 위해 상속을 도입할 때 따르는 두 가지 원칙
- 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라.
- 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라.
⚈ 기존 Phone, NightlyDiscountPhone
public class Phone {
private Money amount;
private Duration seconds;
private List<Call> calls = new ArrayList<>();
private double taxRate;
public Phone(Money amount, Duration seconds, double taxRate) {
this.amount = amount;
this.seconds = seconds;
this.taxRate = taxRate;
}
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.plus(result.times(taxRate));
}
}
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<>();
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));
}
}
⚈ 차이를 메서드로 추출 후 중복 코드를 부모 클래스로 올린 코드
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);
}
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());
} else {
return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
}
}
}
⚈ 자식 클래스들 사이의 공통점을 부모 클래스로 옮김으로써 실제 코드를 기반으로 상속 계층을 구성할 수 있다. 이제 우리의 설계는 추상화에 의존하게 된다.
- '위로 올리기' 전략은 실패했더라도 수정하기 쉬운 문제를 발생시킨다.
- 위로 올리기에서 실수하더라도 추상화할 코드는 눈에 띄고 결국 상위 클래스로 올려지면서 코드의 품질은 높아진다.
⚈ 추상화가 핵심이다.
- 위처럼 공통 코드를 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가진다. (☆ 즉, 중복이 없어졌다.)
- AbstractPhone은 전체 통화 목록을 계산하는 방법이 바뀔 경우에만 변경된다.
- Phone은 일반 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만 변경된다.
- NightlyDiscountPhone은 심야 할인 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만 변경된다.
- 세 클래스는 각각 하나의 변경 이유만을 가진다 -> 단일 책임 원칙을 준수하기 때문에 응집도가 높다.
- 또한 caculateCallFee 메서드의 시그니처가 변경되지 않는한 부모 클래스의 내부 구현이 변경되더라도 자식 클래스는 영향을 받지 않는다 -> 낮은 결합도를 유지하고 있다.
- 새로운 요금제를 추가하기도 쉽다. 새로운 요금제가 필요하다면 AbstractPhone을 상속받는 새로운 클래스를 추가한 후 caculateCallFee 메서드만 오버라이딩하면 된다. -> OCP (개방-폐쇄 원칙) 역시 준수한다.
- 위의 모든 장점은 클래스들이 추상화에 의존하기 때문에 얻어지는 장점이다.
⚈ 의도를 드러내는 이름 선택하기
- 기존 Phone은 '일반 요금제'와 관련된 내용을 구현한다는 사실을 명시적으로 전달하지 못한다. 또한 AbstractPhone은 전화기를 포괄한다는 의미를 명확하게 전달하지 못한다. 따라서 이하와 같이 변경하는 것이 적절할 것이다.
- AbstractPhone -> Phone
- Phone -> RegularPhone
- NighlyDiscountPhone -> 그대로
⚈ 이번에 '01'에서 봤던 것 처럼 세금 요구사항을 추가하는건 더 쉬워졌을까?
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());
} else {
return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
}
}
}
- 책임을 아무리 잘 분리하더라도 인스턴스 변수의 추가는 종종 상속 계층 전반에 걸친 변경을 유발한다.
- 하지만 인스턴스 초기화 로직을 변경하는 것이 두 클래스에 동일한 세금 계산 코드를 중복시키는 것보다는 현명한 선택이다.
- 객체 생성 로직의 변경에 유연하게 대응할 수 있는 다양한 방법이 존재한다 (8장, 9장 내용이다.) 따라서 객체 생성 로직에 대한 변경을 막기보다는 핵심 로직의 중복을 막아라. 핵심 로직은 한 곳에 모아 놓고 조심스럽게 캡슐화해야 한다. 그리고 공통적인 핵심 로직은 최대한 추상화해야 한다.
- 상속으로 인한 클래스 사이의 결합을 피할 수 있는 방법은 없다. 상속은 어떤 방식으로든 부모 클래스와 자식 클래스를 결합시킨다.
04 차이에 의한 프로그래밍
⚈ 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법을 차이에 의한 프로그래밍(programming by difference)이라고 부른다.
- 차이에 의한 프로그래밍의 목표는 중복 코드를 제거하고 코드를 재사용하는 것이다.
⚈ 중복 코드는 악의 근원이다.
- 코드를 재사용하기 위해서는 중복 코드를 제거해서 하나의 모듈로 모아야 한다.
- 중복 코드 제거와 코드 재사용은 동일한 행동을 가리키는 서로 다른 단어다.
- 중복 코드를 제거하기 위해 최대한 코드를 재사용해야 한다.
⚈ 상속 (Inheritance)
- 객체지향에서 중복 코드를 제거하고 코드를 재사용할 수 있는 가장 유명한 방법이다.
- 상속은 너무나도 매력적이기 때문에 객체지향에 갓 입문한 프로그래머들은 이에 도취된 나머지 모든 설계에 상속을 적용하려고 시도하곤 한다.
- 상속이 코드 재사용이라는 측면에서 매우 강력한 도구이지만, 강력한 만큼 잘못 사용할 경우 돌아오는 피해 역시 크다.
- 상속의 오용과 남용은 애플리케이션을 이해하고 확장하기 어렵게 만든다.
- 정말로 필요한 경우에만 상속을 사용하라.
⚈ 합성 (Composition)
- 상속은 코드 재사용과 관련된 대부분의 경우에 우아한 해결 방법이 아니다.
- 상속의 단점을 피하면서도 코드를 재사용할 수 있는 더 좋은방법이 합성이다.
- ☆ 11장에서 살펴볼 내용임.
'Study > 오브젝트' 카테고리의 다른 글
[오브젝트] 12장. 다형성 (0) | 2023.01.14 |
---|---|
[오브젝트] 11장. 합성과 유연한 설계 (0) | 2023.01.04 |
[오브젝트] 9장. 유연한 설계 (0) | 2022.12.22 |
[오브젝트] 8장. 의존성 관리하기 (0) | 2022.12.22 |
[오브젝트] 7장. 객체 분해 (0) | 2022.12.08 |
댓글