본문 바로가기
스터디/오브젝트

오브젝트 10 - 상속과 코드 재사용

by 디토20 2024. 3. 26.
반응형

 

 

 

 

 

 

 

 

오브젝트 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
반응형

댓글