BottleH Blog

Object - 10장 상속과 코드 재사용

    Tags

  • java
Object - 10장 상속과 코드 재사용 thumbnail

코드를 재사용하려는 강력한 동기 이면에는 중복된 코드를 제거하려는 욕망이 숨어 있다.

📖 10.1 상속과 중복 코드

중복 코드는 사람들의 마음속에 의심과 불신의 씨앗을 뿌린다.

🔖 10.1.1 DRY 원칙

중복 코드는 변경을 방해한다. 이것이 중복 코드를 제거해야 하는 가장 큰 이유다.

  • 중복 여부를 판단하는 기준은 변경이다.
  • 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 중복
  • DRY 원칙을 따르자.
    • Don't Repeat Yourself

    • 한 번, 단 한 번의 원칙
    • 단일 지점 제어 원칙
    • 모든 지식은 시스템 내에서 단일하고, 애매하지 않고, 정말로 믿을 만한 표현 양식을 가져야 한다.

🔖 10.1.2 중복과 변경

🎈 중복 코드 살펴보기

@RequiredArgsConstructor public class Call { @Getter private final LocalDateTime from; private final LocalDateTime to; public Duration getDuration() { return Duration.between(from, to); } }
  • 개별 통화 기간울 저장하는 Class
@Getter @RequiredArgsConstructor public class Phone { private final Money amount; private final Duration seconds; private 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(amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds())); } return result; } }
  • 위 요금제는 일반 요금제이다.
  • 여기서, 심야 할인 요금제(밤 10시 이후의 통화에 대해 할인)가 추가 되었다.
@RequiredArgsConstructor public class NightlyDiscountPhone { private static final int LATE_NIGHT_HOUR = 22; private final Money nightlyAmount; private final Money regularAmount; private final Duration seconds; private List<Call> calls = new ArrayList<>(); public Money calculateCallFee() { Money result = Money.ZERO; for (Call call : calls) { if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) { result = result.plus(nightlyAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds())); } else { result = result.plus(regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds())); } } return result; } }
  • 중복 코드가 생겼다!

🎈 중복 코드 수정하기

새로운 요구사항: 통화 요금에 부과할 세금 계산

@Getter @RequiredArgsConstructor public class Phone { private final Money amount; private final Duration seconds; private final double taxRate; private 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(amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds())); } return result.plus(result.times(taxRate)); } } @RequiredArgsConstructor public class NightlyDiscountPhone { private static final int LATE_NIGHT_HOUR = 22; private final Money nightlyAmount; private final Money regularAmount; private final Duration seconds; private final double taxRate; private List<Call> calls = new ArrayList<>(); public Money calculateCallFee() { Money result = Money.ZERO; for (Call call : calls) { if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) { result = result.plus(nightlyAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds())); } else { result = result.plus(regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds())); } } return result.minus(result.times(taxRate)); } }
  • 이처럼 중복코드는 새로운 중복코드를 부른다.
  • minus를 호출하고 있다.

🎈 타입 코드 사용하기

@Getter @RequiredArgsConstructor public class Phone { private static final int LATE_NIGHT_HOUR = 22; enum PhoneType {REGULAR, NIGHTLY} private final PhoneType type; private final Money amount; private final Money regularAmount; private final Money nightlyAmount; private final 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 regularAmount, Money nightlyAmount, Duration seconds) { this(PhoneType.NIGHTLY, Money.ZERO, regularAmount, nightlyAmount, seconds); } public Money calculateFee() { Money result = Money.ZERO; for (Call call : calls) { if (type == PhoneType.REGULAR) { result = result.plus(amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds())); } else { if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) { result = result.plus(nightlyAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds())); } else { result = result.plus(regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds())); } } } return result; } }
  • 낮은 응집도와 높은 결합도를 가진다.

🔖 10.1.3 상속을 이용해서 중복 코드 제거하기

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() { 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((double) call.getDuration().getSeconds() / getSeconds().getSeconds()))); } } return result.minus(nightlyFee); } }
  • 위 코드 처럼 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용하는 것은 쉽지 않다.
  • 자식 클래스의 작성자가 부모 클래스의 구현 방법에 대한 정확한 지식을 가져야 한다.

🔖 10.1.4 강하게 결합된 Phone과 NightlyDiscountPhone

새로운 요구사항: 세금 부과

@Getter @RequiredArgsConstructor public class Phone { private final Money amount; private final Duration seconds; private final double taxRate; private List<Call> calls = new ArrayList<>(); public Money calculateFee() { Money result = Money.ZERO; for (Call call : calls) { result = result.plus(amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds())); } return result.plus(result.times(taxRate)); } } public class NightlyDiscountPhone extends Phone { private static final int LATE_NIGHT_HOUR = 22; private Money nightlyAmount; public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds, double taxRate) { super(regularAmount, seconds, taxRate); this.nightlyAmount = nightlyAmount; } @Override public Money 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((double) call.getDuration().getSeconds() / getSeconds().getSeconds()))); } } return result.minus(nightlyFee.plus(nightlyFee.times(getTaxRate()))); } }
  • 중복 로직을 제거하기 위해 상속을 사용했음에도 중복 코드가 생겼다❗

자식 클래스의 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.

📖 10.2 취약한 기반 클래스 문제

  • 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상
  • 상속을 사용한다면 피할 수 없는 OOP의 근본적인 취약성
  • 캡슐화를 약화시키고 결합도를 높인다.

🔖 10.2.1 불필요한 인터페이스 상속 문제

Java 초기 버전의 대표적인 사례

  • java.util.Stack
    • Stack을 Vector의 자식 클래스로 구현
    • Stack이 규칙을 무너뜨릴 여지가 있는 위험한 Vector의 퍼블릭 인터페이스까지도 함께 상속받음.
  • java.util.Properties
    • Map의 조상인 Hashtable을 상속
    • String 타입 이외의 키와 값이라도 저장이 가능하게 되어버림.

상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.

🔖 10.2.2 메서드 오버라이딩의 오작용 문제

자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자식의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.

🔖 10.2.3 부모 클래스와 자식 클래스의 동시 수정 문제

@Getter @RequiredArgsConstructor public class Song { private final String singer; private final String title; } public class Playlist { @Getter private final List<Song> tracks = new ArrayList<>(); public void append(Song song) { getTracks().add(song); } } public class PersonalPlaylist extends Playlist { public void remove(Song song) { getTracks().remove(song); } }
  • 새로운 요구사항: 가수별 노래의 제목도 함께 관리
@Getter public class Playlist { private final List<Song> tracks = new ArrayList<>(); private final Map<String, String> singers = new HashMap<>(); public void append(Song song) { getTracks().add(song); singers.put(song.getSinger(), song.getTitle()); } } public class PersonalPlaylist extends Playlist { public void remove(Song song) { getTracks().remove(song); getSingers().remove(song.getSinger()); } }
  • 결합도란 다른 대상에 대해 알고 있는 지식의 양이다.

클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수 밖에 없다.

📖 10.3 Phone 다시 살펴보기

🔖 10.3.1 추상화에 의존하자

  • 부모 클래스와 자식 클래스 모두 추상화에 의존하도록 수정

코드 중복을 제거하기 위해 상속을 도입할 때 따르는 원칙

  1. 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출
  2. 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 옮겨라.

🔖 10.3.2 차이를 메서드로 추출하라

@Getter @RequiredArgsConstructor public class Phone { private final Money amount; private final Duration seconds; private List<Call> calls = new ArrayList<>(); 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((double) call.getDuration().getSeconds() / seconds.getSeconds()); } } @RequiredArgsConstructor public class NightlyDiscountPhone { private static final int LATE_NIGHT_HOUR = 22; private final Money nightlyAmount; private final Money regularAmount; private final Duration seconds; private List<Call> calls = new ArrayList<>(); public Money calculateCallFee() { 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((double) call.getDuration().getSeconds() / seconds.getSeconds()); } return regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } }

🔖 10.3.3 중복 코드를 부모 클래스로 올려라

public abstract class AbstractPhone { private final 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); } @Getter @RequiredArgsConstructor public class Phone extends AbstractPhone{ private final Money amount; private final Duration seconds; @Override protected Money calculateCallFee(Call call) { return amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } } @RequiredArgsConstructor public class NightlyDiscountPhone extends AbstractPhone { private static final int LATE_NIGHT_HOUR = 22; private final Money nightlyAmount; private final Money regularAmount; private final Duration seconds; @Override protected Money calculateCallFee(Call call) { if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) { return nightlyAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } return regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } }

🔖 10.3.4 추상화가 핵심이다

  • 세 클래스는 각각 하나의 변경 이유만을 가진다.
    • 단일 책임 원칙을 준수하므로 응집도가 높다.
  • 부모 클래스의 내부 구현이 변경되더라도 자식 클래스는 영향을 받지 않는다.
  • 상속 계층이 코드를 진화시키는 데 걸림돌이 된다면 추상화를 찾아내고 상속 계층 안의 클래스들이 그 추상화에 의존하도록 코드를 리팩터링

🔖 10.3.5 의도를 드러내는 이름 선택하기

public abstract class Phone { } public class RegularPhone extends Phone { } public class NightlyDiscountPhone extends Phone { }

🔖 10.3.6 세금 추가하기

@RequiredArgsConstructor public abstract class Phone { private final double taxRate; private List<Call> calls = new ArrayList<>(); 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); } @Getter public class RegularPhone extends Phone { private final Money amount; private final Duration seconds; public RegularPhone(double taxRate, Money amount, Duration seconds) { super(taxRate); this.amount = amount; this.seconds = seconds; } @Override protected Money calculateCallFee(Call call) { return amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } } public class NightlyDiscountPhone extends Phone { private static final int LATE_NIGHT_HOUR = 22; private final Money nightlyAmount; private final Money regularAmount; private final Duration seconds; public NightlyDiscountPhone(double taxRate, Money nightlyAmount, Money regularAmount, Duration seconds) { 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((double) call.getDuration().getSeconds() / seconds.getSeconds()); } return regularAmount.times((double) call.getDuration().getSeconds() / seconds.getSeconds()); } }
  • 책임을 아무리 잘 분리하더라도 인스턴스 변수의 추가는 종종 상속 계층 전반에 걸친 변경을 유발
  • 우리가 원하는 것은 행동을 변경하기 위해 인스턴스 변수를 추가하더라도 상속 계층 전체에 걸쳐 부작용이 퍼지지 않게 막는 것

📖 10.4 차이에 의한 프로그래밍

  • 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법
  • 중복 코드를 제거하고 코드를 재사용하는 것이 목표
  • 상속은 코드 재사용과 관련된 대부분의 경우에 우아한 해결 방법이 아니다.
    • 합성이 더 좋은 방법❗
Written by@BottleH
Back-End Developer

GitHub