[오브젝트] #10. 상속과 코드 재사용

bien·2024년 11월 14일
0

오브젝트

목록 보기
10/13
  • 코드 재사용의 목적: 중복 코드 제거
  • 코드 재사용 방식 비교
    • 전통적인 방법
      • 기존 코드 복사 + 복사한 코드 수정
    • 객체지향
      • 코드는 클래스 안에 작성되며, 새로운 클래스를 추가하는 방식으로 재사용

객체지향의 코드 재사용

  • 방법1. 상속
    • 클래스 안의 인스턴스 변수와 메서드를 자동으로 새로운 클래스에 추가하는 구현기법
      class Child extends Parent {
      	// 부모 클래스의 기능을 자동으로 상속
      }
  • 방법2. 합성
    • 새로운 클래스의 인스턴스 안에 기존 클래스의 인스턴스를 포함시키는 방법.
      class Container {
      	private Component component; // 기존 클래스를 포함.
      }

01. 상속과 중복 코드

왜 중복 코드를 제거해야 할까?

DRY 원칙

중복 코드는 변경을 방해한다.

  • 프로그램의 본질은 비즈니스와 관련된 지식을 코드로 반환하는 것이다.
    • 이 지식은 항상 변한다.
    • 코드를 작성하면, 이는 언젠가는 변경될 것이라 생각하는 것이 현명하다.
  • 중복 코드는 코드 수정의 노력을 증가시킨다.
    1. 어떤 코드가 중복되는지 알아야 하며
    2. 중복 코드의 묶음을 모두 일관되게 변경해야 한다.
    3. 또한 모든 중복 코드의 변경이 일관된 결과를 반환하는지 확인해야 한다.
  • 중복 코드의 기준 = 변경
    • 중복 여부 결정 기준은, 코드가 변경에 반응하는 방식이다.
      • 요구사항 변경 시 두 코드를 함께 수정해야 한다면, 이 코드는 중복이다.
      • 모양의 유사함은 중복의 징후일 뿐이다.

DRY 원칙

신뢰할 수 있고 수정하기 쉬운 소프트웨어 만드는 방법 = 중복 제거하기

  • DRY 원칙(Don't Repeat Yourself): 반복하지 마라.
    • 한 번, 단 한번(Once and Only Once) 원칙, 단일 지점 제어(Single-Point Control) 원칙
    • 동일한 지식을 중복하지 말라.
      • 코드 안에 중복이 존재해서는 안 된다.

중복과 변경

💻 중복 코드 살펴보기

📋 요구사항

  • 전화요금 계산 애플리케이션
    • 전화요금 계산 규칙 : 통화 시간 / 단위 시간당 요금
      • ex) 10초당 5원 + 100초간의 통화 = 100 / 10 * 5 = 50원

Call.java

개별 통화시간 저장 클래스

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;
    }
}

Phone.java

전체 통화 목록 저장 클래스

public class Phone {
	private Money amount; // 단위 요금
    private Duration sconds; // 단위 시간 
    private List<Call> calls = new ArrayList<>(); // 전체 통화 목록
    
    public Phone(Money amount, Duration seconds) {
    	this.amount = amount;
        this.seconds = seconds;
    }
    
    public void call(Call call) {
    	call.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;
    }
}

클라이언트 코드

Phone을 이용해 10초당 5원씩 부과되는 요금제에 가입한 사용자가 1분동안 두번 통화를 한 경우의 통화요금

Phone phone = new Phone(Money.wons(5). Duration.ofSeconds(10));
phone.call(new Call(LocalDateTime.of(2018, 1, 1, 12, 10, 0),
					LocalDateTime.of*2018, 1, 1, 12, 11, 0)));
phone.call(new Call(LocalDateTime.of(2018, 1, 2, 12, 10, 0),
					LocalDateTime.of*2018, 1, 2, 12, 11, 0)));

phone.calculate(); // => Money.wons(60)

📋 요구사항 추가: 심야 할인 요금제

  • 심야 할인 요금제: 밤 10시 이후의 통화에 대해 요금을 할인해주는 방식
  • 기존의 요금제는 구분을 위해 "일반 요금제"로 호칭 변경

NightlyDiscountPhone.java

기준요금 결정의 로직을 제외하면 기존의 로직과 굉장히 유사해 복사 붙여넣기로 기능을 구현했다.

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()));
            }
         }
         
         retrun result;
    }
}

중복 코드 수정하기

📋 요구사항 추가: 통화요금에 세금 부과

  • 통화 요금에 부과할 세금을 계산.
    • 부과되는 세율은 가입자의 핸드폰마다 다르다.

Phone.java 수정

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.getSecondse()));
        }
        
        retrun result.plus(result.times(taxRate)); // 세율 계산로직 추가
    }
}

NightlyDiscountPhone.java 수정

public class NightlyDiscountPhone {
	...
    private double taxRate;
    
    public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seoncds, 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(
                	nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
            }
        }
        
        return result.minus(result.times(taxRate));
    }
}        

🚫 예제에서 확인할 수 있는 중복코드의 단점

  1. 많은 코드 속 어떤 코드가 중복인지 파악하는 일은 쉬운 일이 아니다.
    • 중복 코드는 항상 함께 수정되어야 하므로, 수정의 누락은 버그로 이어진다.
  2. 중복 코드를 서로 다르게 수정할 수 있다.
    • 예제에서 두 코드가 하나는 plus, 하나는 mius를 호출해 세금을 구했으나, 이 오류를 일일이 확인하는 것은 쉬운 일이 아니다.
  3. 중복 코드는 새로운 중복을 부른다.
    • 중복 코드를 제거하지 않고 코드를 수정하는 유일한 방법은, 새로운 중복코드를 추가하는 것 뿐이다.
    • 이 과정에서 일관성이 무너질 확률이 매우 높다.

타입 코드 사용하기

Phone.java

타입을 사용해 구현 시, 낮은 응집도와 높은 결합도라는 문제를 야기할 수 있다.

public class Phone {
	private static final int LATE_NIGHT_HOUR = 22;
    enum PhoneType { REGULAR, NIGHTLY }
    
    private PhoneType type;
    
    private Money amount;
    private Money regularAmount;
    private Money nightlyAmount;
    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, Money.ZERO, NightlyAmount, regularAmount, seconds);
    }
    
    public Phone(...) { ... }
    
    public Money calculateFee() {
    	Money result = Money.ZERO();
        
        for (Call call : calls) {
        	if (type == PhoneType.REGULAR) {
            	result = result.plus(
                	amount.times(call.getDuration().getSeonds() / 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;
    }
}

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

Phone.java

public class NightlyDiscountPhone extends Phone {
	private static final int LATE_NIGHT_HOUR = 22;
    
    private Money nightlyAmount;
    
    public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration secondes) {
    	super(regularAmount, seconds);
        this.nightlyAmount = nightlyAmount;
    }
    
    @Override
    public Money calculateFee() {
    	// 부모 클래스의 calculateFee 호출
        Money result = super.calcualteFee();
        
        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(nigthlyFee);
    }
}       
  • 금액 계산 로직: calculateFee
    • super를 통해 부모의 calcualteFee 메서드로 요금을 계산한 후, 10시 이후의 통화 요금을 빼준다.
  • 전제 조건
    • 심야 할인 요금제(NightlyDiscountPhone)는 2가지 요금 규칙으로 구성되어 있다.
      • 10시 이전에는 일반 요금제(Phone에 구현된)와 동일하다.
      • 10시 이후에 통화요금을 계산하는 경우에만 NightlyDiscountPhone에 구현한다.
    • 심야 할인 요금제의 특성상, 10시 이후의 가격이 이전보다 비싸므로, 값을 차감하는 방식으로 구현한다.
  • 예시
    • 할인 요금제의 규칙
      • 밤 10시 이전: 10초당 5원(regularAmount=5 원, seconds=10초)
      • 밤 10시 이후: 10초당 5원(regularAmount=5 원, seconds=10초)
    • 통화 기록
      1. 밤 10시 이전 40초
      2. 밤 10시 이후 50초
    • 현재 코드를 통한 금액 계산 방법
      • 일반요금으로 계산한 이후, 심야 요금제를 적용한 금액을 차감한다.
      • (40초/10초*5원) + (50초/10초*5원) - (50초/10초*(5원-2원)) = 30원
  • 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용하는 것은 쉽지 않다.
    • 개발자의 가정을 이해하기 전에는 코드를 이해하기가 쉽지 않다.
  • 상속은 결합도를 높인다.
    • 상속으로 야기된 부모, 자식 클래스간의 강한 결합이 코드 수정을 어렵게 만들 수 있다.

강하게 결합된 Phone과 NightlyDiscountPhone

왜 부모 자식 클래스간의 결합이 문제가 될까?

📋 요구사항 추가: 세금 계산 기능

금액 반환 시, 세율을 부과한 금액을 반환한다.

Phone.java

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;
    }

NightlyDiscountPhone.ajva

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())));
    }
}
  • NightlyDisocuntPhone은 중복 코드를 제거하기 위해 Phone을 상속받았다.
    • 그러나 세금을 부과하는 로직을 추가하기 위해, Phone을 수정하면서 NightlyDiscountPhone 역시 함께 변경해야 했다.
    • 즉, 코드 중복 제거를 위해 상속을 사용했음에도, 세금 계산 로직 추가를 위해 새로운 중복 코드를 만들어야 하는 것이다.

상속을 위한 경고1

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

  • 취약한 기반 클래스 문제
    • 상속 관계로 연결된 자식 클래스가 부모 클래스의 변경에 취약해지는 현상
    • 코드 재사용을 목적으로 상속을 사용할 때 발생하는 가장 대표적인 문제

02. 취약한 기반 클래스 문제

취약한 기반 클래스 문제가 발생하는 몇 가지 사례를 살펴보자

  • 상속
    • 자식 클래스와 부모 클래스의 결합도 상승
    • 자식 클래스가 부모 클래스의 구현 세부사항에 의존하게 한다.
      • 캡슐화 약화
    • 부모 클래스의 변경에 자식 클래스, 파생 클래스들이 크게 영향을 받을 수 있다.
  • 취약한 기반 클래스 문제(Fragile Base Class Problem, Brittle Base Class Problem)
    • 정의
      • 상속이라는 문맥 안에서 결합도가 초래하는 문제점을 지칭
      • 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상
      • 상속을 사용한다면 피할 수 없는 객체지향의 근본적인 취약성
        • 기반 클래스 변경 시, 기반 클래스의 안전한 변경 외에도, 모든 파생클래스들을 살펴보고 테스트해야 함.
    • 단점
      • 캡술화를 약화시키고 결합도를 높인다.
  • 객체지향과 상속
    • 객체: 구현과 관련된 세부사항을 퍼블릭 인터페이스안에 캡슐화한다.
      • 이 캡슐화를 통해 변경의 파급효과를 제어할 수 있다.
    • 그러나 상속을 사용하는 경우, 자식 클래스가 부모 클래스의 퍼블릭 인터페이스가 아닌 구현에 영향을 받게된다.
      • 즉, 상속은 코드의 재사용을 위해 캡슐화의 장점을 희석시키고 구현에 대한 결합도를 높여, 객체지향의 강력함을 반감시킨다.

상속이 가지는 문제점들을 구체적으로 알아보자.

1. 불필요한 인터페이스 상속 문제

  • 부모클래스에서 상속받은 메서드가, 자식 클래스의 규칙을 위반할 수 있다.
    • 예시1. java.util.Properties
    • 예시2. java.util.Stack

📋 Stack의 경우

자바의 초기 컬렉션 프레임워크 개발자들은 Vector를 재사용하기 위해 Stack을 자식클래스로 구현했다.

  • Vecotr: 임의의 위치에 요소를 추출 및 삽입할 수 잇는 리스트의 자료구조
  • Stack: 가장 나중에 추가된 요소가 가장 먼저 추출되는(Last In First Out, LIFO) 자료구조.

StackVector를 상속받음으로 인해서, StackVector의 퍼블릭 인터페이스에 접근이 가능하게 되었다.
이 점은 "맨 마지막 위치에서만 요소를 추가 및 제거 가능하다"라는 Stack의 규칙을 쉽게 위반하게 한다.

인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 만들어야 한다.

📋 Properties의 경우

  • Hashtable: 다양한 타입의 키와 값을 저장할 수 있다.
  • Properties: String의 키와 값 쌍만 저장 가능하다.

자바에 제네릭(generic)이 도입되기 이전에 만들어졌기 때문에, 컴파일러가 키와 값의 타입이 String인지 여부를 체크할 수 있는 방법이 없었다.
따라서 HashTableput 메서드를 통해 값을 저장하는 것이 가능했다.

Properties properties = new Properties();
properties.setProperty("Bjarne", "C++");
properties.setProperty("James", "Java");

properties.put("Dennis", 67);

assertEquals("C", properties.getProperty("Dennis")); // null 에러!

PropertiesgetProperty메서드의 반환값이 String이 아닐경우 null을 반환하기로 구현되어 있어 에러가 발생한다.

🚫 문제점

  • 퍼블릭 인터페이스에 대한 고려 없이 단순히 코드 재사용을 위해 상속받는 것은 위험하다.
    • 불필요한 오퍼레이션이 인터페이스에 스며들도록 방치해서는 안 된다.

상속을 위한 경고2

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

2. 메서드 오버라이딩의 오작용 문제

오버라이딩 시 자식클래스가 부모의 메서드 사용에 강하게 결합될 수 있다.

📋 예시

  • InstrumentedHashSet: 내부에 저장된 요소의 수를 셀 수 있는 기능을 추가한 클래스
    • HashSet의 자식클래스
    • addCount의 값을 증가시키고, 부모 클래스의 메서드로 요소를 추가한다.

InstrumentedHashSet.java

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);
    }
}

💣 오류 코드

InstrumentHashSet<String> languages = new InstrumentHashSet<>();
languages.addAll(Arrays.asList("Java", "Ruby", "Scala")); // addCounts = 6
  • 위 코드를 실행하면 addConuts의 값이 6이된다.
    • 부모 클래스인 HashSetaddAll메서드 안에서 add메서드를 호출하기 때문이다.
  • 해결 방법 1. InstrumentedHAshSetaddAll메서드를 제거한다.
    • 컬렉션 전달 시 자동으로 부모 클래스(HashSet)에서 add메서드가 호출되어 예상했던 결과가 나오게 된다.
    • 문제점: 이 방법은 이후에 HashSetadd메서드를 호출하지 않도록 변경되는 경우 다시 문제가 될 수 있다.
  • 해결 방법2. 미래의 수정을 감안
    • InstrumentedHashSetaddAll 메서드를 오버라이딩하고 추가되는 각 요소에 대해 한 번씩 add를 호출한다.
    • 이제 addAlladd를 전송하지 않도록 수정되어도 InstrumentedHashSet의 작동에는 영향이 없다.
    • 문제점: 오버라이딩된 addAll의 구현이 HashSet의 것과 동일하다.
      • 미래의 위험을 방지하기 위해 코드를 중복시켰다.
      • 부모의 소스코드에 대한 접근 권한이 없는 경우, 이같은 방법의 실현 자체가 불가능할 수도 있다.
public class 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

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

상속을 위한 클래스 문서화

  • 클래스가 상속되기를 원한다면, 상속을 위해 클래스를 설계하고 문서화해야 하며, 그렇지 않은 경우 상속을 금지시켜야 한다.
    (by. 조슈아 블로치)

    우선, 그런 클래스에서는 메서드 오버라이딩으로 인한 파급효과를 분명하게 문서화해야 한다. 달리 말해, 오버라이딩 가능한 메서드들의 자체 사용(self-use), 즉, 그 메서드들이 같은 클래스의 다른 메서드를 호출하는 지에 대해 반드시 문서화해야 한다. 이와는 반대로, 각각의 public이나 protected 메서드 및 생성자가 어떤 오버라이딩 가능한 메서들르 호출하는지, 어떤 순서로 하는지, 호출한 결과가 다음 처리에 어떤 영향을 주는지에 대해서도 반드시 문서화해야 한다. 더 일반적으로 말하면 오버라이딩 가능한 메서드를 호출할 수 있는 어떤 상황에 대해서도 문서화해야 한다는 것이다.

  • 객체지향의 핵심이 구현을 캡슐화 하는 것임에도 불구하고, 내부 구현을 공개하고 문서화 하는 것이 옳은가?

    그러나 잘 된 API 문서는 메서드가 무슨 일(what)을 하는지를 기술해야 하고, 어떻게 하는지(how)를 설명해서는 안 된다는 통념을 어기는 것일까? 그렇다, 어기는 것이다! 이것은 결국 상속이 캡슐화를 위반함으로써 초래된 불행인 것이다. 서브클래스가 안전할 수 있게끔 클래스를 문서화하려면 클래스의 상세 구현 내역을 기술해야 한다.

상속은 코드 재사용을 위해 캡슐화를 희생한다.
완벽한 캡슐화를 원한다면 코드 재사용을 포기하거나, 상속 이외의 다른 방법을 사용해야 한다.

3. 부모 클래스와 자식 클래스의 동시 수정 문제

Song.java

public class Song {
	private String singer;
    private String title;
    
    public Song(String singer, String title) {
    	this.singer = singer;
        this.title = tilte;
    }
    
    public String getSinger() {
    	return singer;
    }
    
    public String getTitle() {
    	return title;
    }
}        

Playlist.java

public class Playlist {
	private List<Song> tracks = new ArrayList<>();
    
    public void append(Song song) {
    	getTracks().add(song);
    }
    
    public List<Song> getTracks() {
    	return tracks;
    }
}

PersonalPlaylist.java

  • 플레이리스트에 노래 삭제 기능 추가
  • 상속을 통해 PlayList의 코드 재사용
public class PersonalPlaylist extends Playlist {
	public void remove(Song song) {
    	getTracks().remove(song);
    }
}

📋 요구사항 추가: 가수별 노래제목 관리기능

Playlist.java 수정

  • 노래제목과 가수를 저장할 Map 인스턴스 변수 추가
public class Playlist {
	private List<Song> tracks = new ArrayList<>();
    private Map<String, String> singers = new HashMap<>();
    
    public void append(Song song) {
    	tracks.add(song);
        signers.put(song.getSigner(), song.getTitle());
    }
    
    public List<Song> getTracks() {
    	return tracks;
    }
    
    public Map<String, String> getSingers() {
    	return singers;
    }
}

위 수정이 정상작동하려면 PersonalPlaylistremove도 함께 수정되어야 한다.
그렇지 않으면 Playlisttracks에서는 노래가 제거되지만 singers에는 남아있게 된다.

PersonalPlaylist.java 수정

public class PersonalPlaylist extends Playlist {
	pubic void remove(Song song) {
    	getTracks().remove(song);
        getSingers().remove(song.getSinger());
    }
}        

위의 예시는 자식 클래스가 "부모클래스의 메서드를 오버라이딩" 하거나, "불필요한 인터페이스를 상속"받지 않았음에도 부모클래스 수정에 자식 클래스를 함께 수정해야 하는 예시를 보여준다.
상속을 사용하면 자식 클래스가 부모 클래스의 구현에 강하게 결합되므로 이 문제를 피하기 어렵다.

📌 결론

  • 결합도: 다른 대상에 대해 알고 있는 지식의 양
    • 상속은 기본적으로 부모 클래스의 구현을 재사용함을 전제한다.
      • 따라서, 자식 클래스가 부모 클래스의 내부에 대해 자세히 알도록 강요한다.
  • 코드 재사용을 위한 상속은, 부모 클래스와 자식 클래스를 강하게 결합시키므로, 수정해야 하는 상황 역시 빈번하게 발생할 수 밖에 없다.

다시 말해, 서브 클래스는 올바른 기능을 위해 슈퍼클래스의 세부적인 구현에 의존한다. 슈퍼클래스의 구현은 릴리스를 거치면서 변경될 수 있고, 그에 따라 서브클래스의 코드를 변경하지 않더라도 깨질 수 있다. 결과적으로, 슈퍼클래스의 작성자가 확장될 목적으로 특별히 그 클래스를 설계하지 않았다면 서브클래스는 슈퍼클래스와보조를 맞춰서 진화해야 한다.

상속을 위한 경고 4

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


03. Phone 다시 살펴보기

상속으로 인한 피해를 최소화시킬 수 있는 방법을 찾아보자!
취약한 기반 클래스 문제를 완벽하게 없앨 순 없지만, 추상화를 이용해 위험을 완화시킬 수 있다.

추상화에 의존하자

  • 문제점: NightDiscountPhonePhone의 강한 결합이 Phone의 변경을 유도한다.
    • 해결법: 부모클래스와 자식 클래스가 모두 추상화에 의존하도록 수정한다.
  • 상속 도입 시 고려할 2가지 원칙
    1. 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라.
      메서드 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있다.
    2. 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라.

차이를 메서드로 추출하라

  • 중복 코드에서의 차이점을 별도의 메서드로 추출해라.
    • 변하는 것으로부터 변하지 않는 것을 분리해라, 변하는 부분을 찾고 이를 캡슐화하라 라는 조언을 메서드 수준에 적용한 것이다.

💻 리팩토링 전 코드

Phone.java

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()));
        }
        
        retrun result;
    }
}

NightlyDiscountPhone.java

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()));
            }
        }
    }
}

🔎 차이점 추출

calculateFeefor문 안에 구현된 요금 계산 로직이 서로 다르다.
이 부분을 동일한 이름을 가진 메서드로 추출하자.
이 메서드는 하나의 Call에 대한 통화 요금을 계산하는 것이므로 메서드 이름은 calculateCallFee로 지정한다.

Phone.java 수정

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());
    }

}

NightlyDiscountPhone.java

public class NightlyDiscountPhone {
	...

	public Money calculateFee() {
        Money result = Money.ZERO;

        for (Call call : calls) {
            result = 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());
        }
    }

}

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

1. 부모 클래스 추가

  • 모든 클래스들이 추상화에 의존하도록, 부모 클래스를 추상클래스로 구현
    • 이름은 AbstractPhone으로 지정
public abstract class AbstractPhone {}

public class Phone extends AbstractPhone { ... }

public class NightlyDiscountPhone extends AgstractPhone { ... }

2. 공통부분 부모 클래스로 이동

  • 공통 코드를 옮길 때 메서드를 먼저 이동시키는게 편하다.
    • 메서드를 옮기고 나면 해당 메서드에 필요한 메서드나 인스턴스 변수가 무엇인지 컴파일 에러를 통해 자동으로 알 수 있기 때문이다.
      • 컴파일 에러를 바탕으로 불필요한 부분은 자식 클래스에 남겨둔다.
      • 꼭 필요한 코드만 부모 클래스로 이동시킨다.

AbstractPhone.java

public class AbstractPhone {
    // 1. 메서드 요구 인스턴스: calls
    private List<Call> calls = new ArrayList<>();
    
    public Money calculateFee() {
        Money result = Money.ZERO;

        for (Call call : calls) {
            // 2. 메서드 요구 메서드: calculateCallFee
            result = result.plus(calculateCallFee(call));
        }

        return result;
    }
    
    abstract protected Money calculateCallFee(Call call);
}

공통된 메서드를 추상 클래스로 이동 시 2개의 컴파일 에러가 발생한다.

  1. calls 인스턴스
    • 자식 클래스로부터 부모 클래스로 이동시킨다.
  2. calcualteCallFee() 메서드
    • PhoneNightlyDiscountPhone에서 시그니처는 동일하지만 내부 구현이 서로 다르다.
      • 따라서, 시그니처만 이동시킨다.
      • calculateCallFee메서드를 추상 메서드로 선언하고, 자식 클래스에서 오버라이딩 하도록 protected로 선언한다.

3. 남은 자식클래스

Phone.java

package com.example.오브젝트;

public class Phone {
    private Money amount;
    private Duration seconds;


    public Phone(Money amount, Duration seconds) {
        this.amount = amount;
        this.seconds = seconds;
    }

    private Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }

}

NightlyDiscountPhone.java

package com.example.오브젝트;

import com.example.dddstudy.domain.Money;

import java.time.Duration;

public class NightlyDiscountPhone {
    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;
    }

    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());
        }
    }

}

4. 리팩터링 후의 상속 계층

'위로 올리기' 전략은 실패했더라도 수정하기 쉬운 문제를 발생시킨다. (...) 추상화하지 않고 빼먹은 코드가 있더라도 하위 클래스가 해당 행동을 필요로 할 때가 오면 이 문제는 바로 눈에 띈다. (...) 가장 초보적인 프로그래머라도 중복 코드를 양산하지 말라고 배웠기 때문에 나중에 누가 이 애플리케이션을 관리하든 이 문제는 쉽게 눈에 띈다. 위로 올리기에서 실수하더라도 추상화할 코드는 눈에 띄고 결국 상위 클래스로 올려지면서 코드의 품질이 높아진다 ... 하지만 이 리팩토링을 반대 방향으로 진행한다면, 다시 말해 구체적인 구현을 아래로 내리는 방식으로 현재 클래스를 구체 클래스에서 추상 클래스로 변경하려 한다면 작은 실수 한 번으로도 구체적인 행동을 상위 클래스에 남겨 놓게 된다.

추상화가 핵심이다

  • 공통 코드를 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가진다.
    • 세 클래스가 하나의 변경 이유만을 가지므로, 단일 책임 원칙을 준수한다.
      • AbtractPhone: 전체 통화 목록 계산 방법이 바뀔 때에만 변경
      • Phone: 일반 요금제의 통화 단위금액 변경 시 변경
      • NightlyDiscountPhone: 심야 할인 요금제의 단위가격 변경 시 변경
  • 변경 후 자식 클래스들은 부모 클래스의 구체적인 구현이 아닌 추상화에 의존한다.
    • 추상 메서드인 calculateFee가 변경되지 않는 한, 자식 클래스는 영향을 받지 않는다.
      • 낮은 결합도를유지한다.
  • 새로운 요금제 추가가 쉽다.

위의 장점들은 클래스들이 추상화에 의존하기에 얻어진 장점들이다.
상속 계층이 문제가 된다면, 추상화를 찾아재고 상속 계층 안의 클래스들이 그 추상화에 의존하도록 코드를 리팩터링 해라.
차이점을 메서드로 추출하고 공통적인 부분은 부모 클래스로 이동하라.

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

  • 클래스 이름과 관련된 아쉬움
    • ⭕️ NightlyDiscountPhone: 심야 할인 요금제와 관련된 내용을 구현한다는 사실을 명확하게 전달.
    • Phone: 일반 요금제와 관련된 내용을 구현한다는 사실을 명시적으로 전달하지 못함
      • RegularPhone으로 변경
    • AbstractPhone: (사용자가 가입한 전화기의 한 종류를 의미하는 다른 클래스와 달리) 전화기를 포괄한다는 의미를 명확히 전달하지 못함.
      • Phone으로 변경
public abstrac class Phone { ... }

public class RegularPhone extends Phone { ... }

public class NightlyDiscountPhone extends Phone { ... }

세금 추가하기

세금 추가하기라는 요구사항을 구현하면서, 수정된 코드가 더 변경이 용이한 코드인지 확인해보자!

📋 요구사항 추가: 모든 요금제에 세금계산 기능 추가

금액 반환 시, 세율을 부과한 금액을 반환한다.

모든 요금제에 공통 적용되는 요구사항이므로, 공통 코드를 담는 추상 클래스인 Phone을 수정해보자.

Phone.java

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);
}
  • 부모 클래스에 인스턴스 변수가 추가되어, 자식클래스에서도 해당 인스턴스 변수 값을 초기화해야 한다.

RegularPhone.java, NightlyDiscountPhone.java

public class RegularPhone extends Phone {
	...
    public RegularPhone(Money amount, Duration seconds, double taxRate) {
    	super(taxRate);
        this.amount = amount;
        this.seconds = seconds;
    }
    ...
}

public class NightlyDiscountPhone extends Phone {
	...
    public NightlyDiscountPhone(Money nightlyAmount, Money regularAmount, Duration seconds, double taxRate) {
    	super(taxRate);
        this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAMount;
        this.seconds = seconds;
    }
    ...
}
  • 클래스는 메서드와 인스턴스 변수가 함께 포함된다.
    • 메서드만 변경되는 경우, 상속 계층에 속한 각 클래스들을 독립적으로 진화시킬 수 있다.
    • 인스턴스 변수가 추가되는 경우, 자식 클래스의 인스턴스 생성 시 부모 클래스의 인스턴스 변수를 초기화해야 하므로 자식 클래스의 초기화 로직에 영향을 미치게 된다.
      • 책임을 아무리 잘 분리하더라도, 인스턴스 변수의 추가상승 계층 전반에 걸친 변경을 유발한다.
  • 인스턴스 초기화 로직을 변경하는 것이, 두 클래스에 동일한 세금 계산 코드를 중복시키는 것 보다 현명한 선택이다.
    • 객체 생성 로직의 변경에 유연하게 대응할 수 있는 다양한 방법이 존재한다.
    • 핵심 로직은 한 곳에 모아놓고 조심스럽게 캡슐화 해야 한다.
      • 공통적인 핵심 로직은 최대한 추상화되어야 한다.

📌 결론

상속으로 인한 클래스 사이의 결합을 피할 수 있는 방법은 없다.
메서드 구현에 대한 결합은 추상 메서드를 통해 완화가능하나, 인스턴스 변수에 대한 잠재적인 결합을 제거할 수 있는 방법은 없다.
행동을 변경하기 위해 인스턴스 변수를 추가하더라도 상속 계층 전체에 걸쳐 부작용이 퍼지지 않게 막는 것이다.


04. 차이에 의한 프로그래밍

  • 차이에 의한 프로그래밍(programming by difference)
    • 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법
    • 이미 존재하는 클래스의 코드를 쉽게 재사용할 수 있으므로, 애플리케이션의 점진적인 정의(incremental definition)가 가능해진다.
    • 목표
      1. 중복 코드 제거
      2. 코드 재사용
        • 두 목표는 동일한 행동을 가리키는 서로 다른 단어다. 항상 서로 병행된다.
        • 재사용 가능하며 중복이 제거된 코드는 심각한 버그가 존재하지 않는 코드다.
          • 코드의 품질은 유지하면서도, 코드 작성을 위한 노력과 테스트는 줄일 수 있다.

Reference

  • 오브젝트 | 조영호
profile
Good Luck!

0개의 댓글