(10-11) 상속, 합성에 대해 살펴보고 두 구현 기법의 장점과 단점을 비교한다.
객체지향의 장점 중 하나는 코드 재사용이 용이하다는 것이다.
객체지향에서는 재사용을 위해서 코드를 추가한다.
이번에는 상속, 다음에는 합성에 대해 살펴보고 두 구현 기법의 장점과 단점을 비교한다.
일단은 우리를 혼란스럽게 하는 중복 코드의 문제점에 대해서 살펴본다.
중복 코드는 팀 내의 의심과 불신을 부추긴다.
이것만으로도 중복 코드를 제거할 이유는 충분하지만 더 큰 문제가 있다.
📌 중복 코드가 변경을 방해하는 이유
- 중복 코드가 어느 구간인지 찾아야 한다
- 모든 코드를 일관되게 수정해야 한다
- 중복 코드를 개별 테스트하고 동일한 결과를 반환하는지 확인해야 한다
→ 즉, 중복 코드는 수정과 테스트에 드는 비용을 증가시킨다.
신뢰할 수 있고 수정하기 쉬운 소프트웨어를 만드는 방법 중 하나는 중복을 제거하는 것이다.
프로그래머들은 DRY 원칙을 따라야 한다 (앤드류 헌트, 데이비드 토머스)
- DRY 원칙 (Don’t Repeat Yourself)
- 동일한 지식을 반복하지 마라
- 한 번, 단 한번 원칙
- 단일 지정 제어 원칙
한 달에 한 번 가입자 별로 전화 요금을 계산하는 애플리케이션으로 중복 코드에 대해 설명해본다.
전화 요금은 단위 시간 당 요금으로 나눈다 (10초 당 5원 부과)
요구사항 구현을 위해 Phone
클래스 내부에 요금을 계산하는 메서드를 만든다.
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
result = result.plus(amount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
}
return result;
}
이 애플리케이션에 새로운 요구사항이 추가되어 심야 할인 요금제라는 새로운 요금 방식을 추가하는 상황이 접수됐다. (밤 10시 이후의 통화에 대해 요금을 할인해주는 방식이다)
이 요구사항을 해결하는 가장 쉽고 빠른 방법은 Phone
복사 후 NightlyDiscountPhone
을 만들고 수정하는 것이다.
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
사이에 중복 코드가 존재하기 때문이다.
여기에 새로운 요구사항을 추가한다.
이번에 추가하는 기능은 통화 요금에 부과할 세금을 계산하는 것이다.
부과되는 세율은 가입자의 핸드폰마다 다르다고 가정한다. 서로 다른 클래스에 모두 구현되어 있으므로 세금 추가를 위해서 두 클래스를 함께 수정해야 한다.
public Money calculateFee() {
...
**return result.plus(result.times(taxRate));**
}
public Money calculateFee() {
...
**return result.minus(result.times(taxRate));**
}
여기서 중복 코드가 가지는 단점이 드러난다.
많은 코드 속에서 어떤 코드가 중복인지 파악하는 것은 쉬운 일이 아니다. 이러한 중복 코드는 항상 함께 수정되어야 하기 때문에 하나라도 빠트리면 버그로 이어지게 된다.
심지어 어디에는 plus()
, 어디에는 minus()
를 적용하고 있다. 중복 코드를 찾아내어 수정하더라도 그 수정이 잘못된다면 눈치채기가 어렵다.
→ 즉, 중복 코드는 새로운 중복 코드를 부른다. 중복 코드를 제거하지 않고 코드를 수정하는 방법은 새로운 중복 코드를 추가하는 것 뿐이다.
민첩하게 변경하기 위해서 중복 코드 추가 대신 제거를 선택하자. 기회가 생길때마다 DRY 원칙을 지켜라.
Phone
과 NightlyDiscountPhone
을 하나로 합칠 수 있다.하지만 타입 코드를 사용하면 낮은 응집도와 높은 결합도 문제에 시달리게 된다
⇒ 그 대신 중복 코드를 관리할 수 있는 더 효과적인 방법이 상속이다.
코드 예제를 살펴보면 조금 복잡하게 구현되었다. (일반/심야 각각 나눠서 계산하는게 아니라 다 일반 요금제로 계산하고 심야 시간에 계산한 값에서 빼준다)
→ 이런 구현은 개발자의 가정을 이해하기 전에 코드를 이해하기 어렵다.
즉, 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해서 재사용하는 것은 생각처럼 쉽지 않다.
상속을 이용해서 코드를 재사용하려면 부모 클래스의 개발자가 세웠던 가정 혹은 추론을 정확히 이해해야 한다.
부모 클래스와 자식 클래스 사이의 결합이 문제인 이유는 무엇일까?
현재의 상속 구조에서 세금을 부과하는 요구사항이 추가된다면 어떻게 될까?
Phone
은 앞에서 구현했던 세율을 인스턴스 변수로 포함하고 calculateFee()
메서드에서 값을 반환할 때 taxRate
를 이용해서 세금을 부과해야 한다.
그런데 NightlyDiscountPhone
에도 세금을 부과하는 유사한 코드를 추가해야 한다.
→ 중복 코드를 제거하고 코드를 재사용하기 위해서 상속을 사용했는데, 결국 새로운 중복 코드를 만들게 된다.
🔥 상속을 위한 경고 1
자식 클래스의 메서드 안에서 super 참조를 이용해서 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.
위와 같이 상속 관계로 연결된 자식 클래스가 부모 클래스 변경에 취약해지는 현상을 취약한 기반 클래스 문제라고 부른다.
취약한 기반 클래스 문제는 코드 재사용을 목적으로 상속을 사용할 때 발생하는 대표적인 문제다.
상속은 자식 클래스와 부모 클래스 사이의 결합도를 높인다.
→ 상속은 높은 결합도로 인해 부모 클래스의 점진적 개선을 어렵게 한다.
최악의 경우 모든 자식 클래스를 동시에 수정하고 다시 테스트해야 할 수 있다.
📌 즉, 취약한 기반 클래스 문제는 캡슐화를 약화시키고 결합도를 높인다.
상속은 자식 클래스가 부모 클래스의 구현 세부사항에 의존하도록 만들기 때문에 캡슐화를 약화시킨다.
상속을 사용하면 부모의 퍼블릭 인터페이스 뿐만 아니라 구현이 변경되어도 자식 클래스에서 영향받기 쉬워진다.
이는 객체지향의 기반인 캡슐화를 통한 변경의 통제를 희석하고 구현에 대한 결합도를 높인다.
부모 클래스에서 상속받은 메서드를 사용할 때 자식 클래스의 규칙이 위반될 수 있다.
FIFO인 Stack이 Vector를 상속받고 있다. Stack은 규칙을 무너뜨릴 여지가 있는 위험한 퍼블릭 인터페이스까지 함께 상속받았다.
인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 만들어야 한다.
퍼블릭 인터페이스에 대한 고려 없이 단순히 코드 재사용을 위해서 상속을 이용하는 것은 위험하다.
객체지향의 핵심은 객체들의 협력이다.
🔥 상속을 위한 경고 2
상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.
HashSet
을 상속받은 InstrumentedHashSet
에서 addAll()
의 문제점
→ 추가 횟수를 더할 때 중복 연산이 들어가서 의도와 다른 값이 저장된다
이 문제를 해결하기 위해서 HashSet
의 addAll()
구조를 빌려온다.
public calss 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
자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.
설계는 트레이드오프 활동이다 … 상속은 코드 재사용을 위해서 캡슐화를 희생한다.
완벽한 캡슐화를 적용하는 상속은 어렵다. 완벽한 캡슐화를 원한다면 코드 재사용을 포기하거나 상속 외의 다른 방법을 사용해야 한다.
부모 클래스의 수정으로 인해 자식 클래스가 수정되어야 할 수 있다.
집중할 부분은 자식 클래스가 부모 클래스의 메서드를 오버라이딩하거나 불필요한 인터페이스를 상속받지 않아도 부모 클래스 수정 시 자식 클래스를 함께 수정해야 할 수 있다는 것이다.
→ 상속을 사용하면 자식 클래스가 부모 클래스 구현에 강하게 결합되기 때문에 이 문제를 피하기 어렵다.
🔥 상속을 위한 경고 4
클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없다.
📌 상속을 위한 경고 모음
1. 자식 클래스의 메서드 안에서 super 참조를 이용해서 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.
2. 상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.
3. 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.
4. 클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없다.
상속으로 인한 문제(취약한 기반 클래스 문제)로 발생하는 피해를 최소화하기 위한 방법은 바로 추상화다.
상속의 문제를 해결하는 방법은 자식 클래스가 부모 클래스가 아니라 추상화에 의존하게 만드는 것이다.
정확히는 부모 클래스와 자식 클래스가 둘 다 추상화에 의존하도록 수정해야 한다.
변하는 것으로부터 변하지 않는 것을 분리하자.
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
result = result.plus(calculateCallFee(call));
}
return result.plus(result.times(taxRate));
}
public Money calculateCallFee(Call call) {
return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
}
public Money calculateFee() {
Money result = Money.ZERO;
for (Call call : calls) {
result = result.plus(calculateCallFee(call));
;
}
return result;
}
public Money calculateCallFee(Call call) {
if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
return nightlyAmount.plus(
nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())
);
} else {
return regularAmount.plus(
regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())
);
}
}
이제 공통된 코드는 부모로 올린다.
public abstract class AbstractPhone {
protected 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(calculateCallFee(call));
}
return result;
}
abstract protected Money calculateCallFee(Call call);
}
public class Phone extends AbstractPhone {
...
@Override
public Money calculateCallFee(Call call) {
return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
}
}
public class NightlyDiscountPhone extends AbstractPhone {
...
@Override
public Money calculateCallFee(Call call) {
if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
return nightlyAmount.plus(
nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())
);
}
return regularAmount.plus(
regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())
);
}
}
→ 자식 클래스들 사이의 공통점을 부모 클래스로 옮김으로써 실제 코드를 기반으로 상속 계층을 구성하라 수 있다. 이제 설계는 추상화에 의존하게 된다.
→ 즉, 각 클래스는 단일 책임 원칙(SRP)을 준수하며 낮은 결합도를 유지한다.
→ 개방 폐쇄 원칙(OCP)를 준수한다.
이런 것들은 클래스들이 추상화에 의존하기 때문에 얻어지는 장점이다.
클래스는 메서드뿐만 아니라 인스턴스 변수도 함께 포함한다.
따라서 클래스 사이의 상속은 자식 클래스가 부모 클래스가 구현한 행동뿐만 아니라 인스턴스 변수에 대해서도 결합되게 만든다.
즉 인스턴수가 그대로라면 행동을 변경했을 때 다른 클래스에 영향이 없지만, 부모의 인스턴스 변수가 변경되면 다른 클래스에 영향을 줄 수 있다.
차이에 의한 프로그래밍
차이에 의한 프로그래밍의 목표
→ 이에 대해 가장 유명한 방법은 상속이다.
그러나 상속을 코드 재사용의 목적으로 맹목적으로 사용하는 것은 위험하다. 잘못 사용할 경우에 돌아오는 피해 역시 크기 때문이다. → 합성이라는 더 좋은 대안이 있다.