반응형
오브젝트 10 - 상속과 코드 재사용
10.1 상속과 중복 코드
10.1.1 DRY 원칙 (Don't Repeat Yourself)
- 중복 코드를 제거해야하는 가장 큰 이유는, 중복 코드는 변경을 방해한다.
- 중복 여부를 판단하는 기준은 변경이다. 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 이 코드는 중복이다.
- 신뢰할 수 있고 수정하기 쉬운 소프트웨어를 만드는 효과적인 방법 중 하나는 중복을 제거하는 것이다.
10.1.2 중복과 변경
중복코드 살펴보기
- 일반 요금제와 심야 할인 요금제가 필요하다.
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()));
}
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(
}
}
return result;
}
}
- 코드를 복사해서 만들면 구현 시간은 아주 짧아지지만, 중복코드가 많아져 이후 변경시 문제가 생길 수 있다.
중복 코드 수정하기
- 통화 요금에 부과할 세금을 계산하는 요구사항을 추가해보자.
- Phone 클래스와 NightlyDiscountPhone 에 taxRate를 추가하고 많은 코드들 중 중복 코드를 파악해 전부 수정해야 한다.
- 중복 코드는 항상 함께 수정되어야 하기 때문에 하나라도 빠트린다면 버그로 이어진다.
- 중복 코드는 새로운 중복 코드를 부른다.
10.1.3 중복 코드를 제거하는 법
1. 타입 코드 사용하기
- 두 클래스 사이의 중복 코드를 제거하는 한 가지 방법은 클래스를 하나로 합치는 것이다.
- 그러나 타입 코드를 사용하는 클래스는 낮은 응집도와 높은 결합도라는 문제를 가진다.
public class Phone {
private static final int LATE_NIGHT_HOUR = 22;
enum PhoneType { REGULAR, NIGHTLY }
private PhoneType type;
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;
...
...
}
}
}
2. 상속을 이용하기
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);
}
}
- 우리가 기대한 것은 10시 이전 요금과 10시 이후 요금을 더하는 것이다
- 그러나 상속을 이용한 코드에서는 10시 이전 요금에서 10시 이후의 요금을 차감하고 있다.
- 요구사항과 구현의 차이가 클수록 코드를 이해하기 어려워진다.
- 상속을 이용해 코드를 재사용하기 위해서는 부모 클래스의 개발자가 세웠던 가정이나 추론을 정확하게 이해해야 한다.
- 즉, 상속은 결합도를 높이고 이것은 코드를 수정하기 어렵게 만든다.
10.1.4 강하게 결합된 Phone과 NightlyDiscountPhone
- NightlyDiscountPhone의 calculateFee 메서드는 자신이 오버라이드한 Phone의 calculateFee 메서드가 모든 통화에 대한 요금의 총합을 반환한다는 사실에 기반하고 있다.
- 여기에 앞에서 설명했던 세금을 부과하는 요구사항을 추가한다면 어떻게 될까?
- 부모와 자식 클래스 모두 세금을 부과하는 로직을 추가해야한다.
- 이것은 두 클래스의 구현이 너무 강하게 결합되어있기 때문에 발생하는 문제다.
- 이처럼 상속 관계로 연결된 자식 클래스가 부모 클래스의 변경에 취약해지는 현상을 가리켜 취약한 기반 클래스 문제라고 부른다.
10.2 취약한 기반 클래스 문제
- 상속은 자식 클래스와 부모 클래스의 결합도를 높인다.
- 부모 클래스의 작은 변경에도 자식 클래스는 컴파일 오류와 실행 에러라는 고통에 시달릴 수 있다.
- 이처럼 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상을 취약한 기반 클래스 문제 라고 부른다.
- 상속은 자식 클래스를 점진적으로 추가해서 기능을 확장하는데는 용이하지만, 높은 결합도로 부모 클래스를 점진적으로 개선하는 것은 어렵다.
- 상속은 자식 클래스가 부모 클래스의 구현 세부사항에 의존하게 만들어 캡슐화를 약화시키고 결합도를 높인다.
10.2.1 불필요한 인터페이스 상속 문제
- 자바의 초기 버전에서 상속을 잘못한 대표적인 사례를 살펴보자.
1.stack
- stack이 vector를 상속받고 있기 때문에 임의의 위치에서 요소를 추가하거나 삭제할 수 있다.
- 따라서 맨 마지막 위치에서만 요소를 추가하거나 제거할 수 있도록 허용하는 stack의 규칙을 위반하게 된다.
2. properties
- Properties 클래스는 키와 값의 타입으로 오직 String 만 가질 수 있다.
- Hashtable의 인터페이스에 포함된 put 메서드를 이용하면 String 외의 키와 값을 Properties 에 저장할 수 있다.
10.2.2 메서드 오버라이딩의 오작용 문제
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);
}
}
- 위 클래스에서 addAll(Arrays.aslist("Java", "Ruby", "Scala")) 을 하면 addCount의 값이 3이 될거라고 예상할 것이다.
- 그러나 실제로는 부모 클래스의 addAll 메서드 안에서 add 메서드를 호출하기 때문에 addCount는 6이 된다.
- 클래스가 상속되길 원한다면 상속을 위해 클래스를 설계하고 문서화해라.
10.2.3 부모 클래스와 자식 클래스의 동시 수정 문제
- 음악 목록을 추가할 수 있는 플레이리스트를 구현하자.
- 플레이리스트에서 노래를 삭제할 수 있는 기능이 추가된 PersonalPlaylist도 필요하다.
public class Playlist {
private List<Song> tracks = new ArrayList<>();
public void append(Song song) {
getTracks().add(song);
}
public List<Song>getTracks() {
return tracks;
}
}
public class PersonalPlaylist extends Playlist {
public void remove(Song song) {
getTracks().remove(song);
}
}
- PersonalPlaylist를 구현하는 가장 빠른 방법은 상속이다.
- 그러나 요구사항이 Playlist에서 노래의 목록 뿐 아니라 가수별 노래의 제목도 관리하도록 변경되었다.
- 아래와 같이 Playlist의 append 메서드를 수정해야한다.
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;
}
}
- 그러나 위 수정 내용이 정상적으로 작동하려면 PersonalPlaylist 의 remove 메서드도 함께 수정해야한다.
- 이는 자식 클래스가 부모 클래스의 메서드를 오버라이딩하거나 불필요한 인터페이스를 상속받지 않았음에도 함께 수정해야하는 문제를 보여준다.
- 상속을 사용하면 자식 클래스가 부모 클래스의 구현에 강하게 결합되기 때문에 이 문제를 피하기 어렵다.
10.3 Phone 다시 살펴보기
- 상속으로 인한 피해를 최소화할 수 있는 방법을 찾아보자
- 문제 해결의 열쇠는 바로 추상화다.
10.3.1 추상화에 의존하자
- 부모 클래스와 자식 클래스 모두 추상화에 의존하도록 수정해야 한다.
- 코드 중복을 제거하기 위해 상속을 도입할 때 따르는 두가지 원칙이 있다.
- 두 메서드가 유사하다면 차이점을 메서드로 추출하라. 메서드 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있다.
- 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라.
10.3.2 차이를 메서드로 추출하라
- "변하는 것으로부터 변하지 않는 것을 분리하라", 또는 "변하는 부분을 찾고 이를 캡슐화 하라"
- 일반 요금제와 심야 할인 요금제의 calculateFee의 for문 안에 구현된 요금 계산 로직이 서로 다르다는 사실을 알 수 있다.
- 이 부분을 동일한 이름을 가진 메서드로 추출한다.
- 그 후 같은 코드를 부모 클래스로 올린다.
10.3.3 중복 코드를 부모 클래스로 올려라
- 부모 클래스를 만들고 상속받도록 수정하자
public abstract class AbstractPhone {}
public class Phone extends AbstractPhone { ... }
public class NightlyDiscountPhone extends 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;
}
}
abstract protected Money calculateCallFee(Call call);
}
public class Phone extends AbstractPhone {
private Money amount;
private Duration 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;
@Override
protected Money calculateCallFee(Call call) {
...
}
}
- 자식 클래스들 사이의 공통점을 부모 클래스로 옮김으로써 실제 코드를 기반으로 상속 계층을 구성할 수 있다.
- 이제 설계는 추상화에 의존하게 된다.
10.3.4 추상화가 핵심이다
- 공통 코드를 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가진다.
- Abstract Phone은 전체 통화 목록을 계산하는 방법이 바뀔 경우에만 변경된다.
- Phone은 일반 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만 변경된다.
- NightlyDiscountPhone은 심야 할인 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만 변경된다.
- 새로운 요금제를 추가하기도 쉽다. 새로운 클래스를 추가한 후 추상 클래스를 상속받은 뒤 calculateCallFee만 오버라이딩 하면 된다.
10.3.5 세금 추가하기
- 세금은 공통이므로 부모 클래스에 taxRate 변수를 추가하고 calculateFee 메서드에서 세금이 부과되도록 수정한다.
- 자식 클래스의 생성자에도 taxRate를 초기화하는 코드를 넣어야한다.
- 따라서 클래스 사이의 상속은 자식 클래스가 부모 클래스의 행동 뿐만 아니라 인스턴스 변수에도 결합되게 만든다.
10.4 차이에 의한 프로그래밍
- 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확자하는 방법을 차이에 의한 프로그래밍이라고 부른다.
- 차이에 의한 프로그래밍의 목표는 중복 코드를 제거하고 코드를 재사용하는 것이다.
- 코드를 재사용하면 코드 품질은 유지하면서 코드를 작성하는 노력과 테스트는 줄일 수 있다.
728x90
반응형
'스터디 > 오브젝트' 카테고리의 다른 글
오브젝트 12 - 다형성 (0) | 2024.04.02 |
---|---|
오브젝트 11 - 합성과 유연한 설계 (0) | 2024.03.26 |
오브젝트 09 - 유연한 설계 (0) | 2024.03.25 |
오브젝트 07 - 객체 분해 (1) | 2024.03.05 |
오브젝트 06 - 메시지와 인터페이스 (0) | 2024.02.26 |
댓글