ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 오브젝트 5 - 상속 vs. 합성
    책책책 책을 읽읍시다/프로그래밍 2023. 2. 14. 22:15

    중복과 상속


    중복 코드는 변경을 방해한다. 이것이 중복 코드를 제거해야 하는 가장 큰 이유다. 중복 코드가 가지는 가장 큰 문제는 코드를 수정하는 데 필요한 노력을 몇 배로 증가시킨다는 것이다. 우선 어떤 코드가 중복인지를 찾아야 한다. 일단 중복 코드의 묶음을 찾았다면 찾아낸 모든 코드를 일관되게 수정해야 한다. 모든 중복 코드를 개별적으로 테스트해서 동일한 결과를 내놓는지 확인애야만한다. 중복 코드는 수정과 테스트에 드는 비용을 증가시킬뿐만 아니라 시스템과 우리를 공황상태로 몰아넣을 수도 있다.

    이번 예제는 한 달에 한 번씩 가입자별로 전화 요금을 계산하는 애플리케이션이다. 전화 요금을 계산하는 규칙은 통화 시간을 단위 시간당 요금으로 나눠주면 된다. 10초당 5원의 통화료를 부과하는 요금제에 가입돼 있는 가입자가 100초 동안 통화를 했다면 요금으로 100 / 10 * 5 = 50원이 부과된다.

    개별 통화 기간을 저장하는 Call, 통화 요금을 계산할 Phone이 필요하다.

    Call

    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

    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 void call(Call call) {
            calls.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;
        }
    }

    그리고 사용자가 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.calculateFee();

    그런데 여기서 심야 할인 요금제라는 새로운 요금 방식을 추가해달라는 요구사항이 접수되었다. 가장 빠른 방법은 Phone의 코드를 복사해서 NightlyDiscountPhone이라는 새로운 클래스를 만들거나, 타입 코드를 추가하고 Phone의 calculateFee 메서드에서 타입 코드에 따라 분기처리 하는 것이다. 이는 중복을 만들거나 높은 결합도와 낮은 응집도를 갖는 스파게티 코드를 만들 뿐이다. 객체지향 프로그래밍 언어는 타입 코드를 사용하지 않고도 중복 코드를 관리하는 상속을 제공한다.

     

    상속의 문제

    NightlyDiscountPhone 클래스가 Phone 클래스를 상속 받게 하자.

    public class NightlyDiscountPhone extends Phone {
        private static final int LATE_NIGHT_HOUR = 22;
        
        private Money nightlyAmount;
    
        public NightlyDiscountPhone(Money regularAmount, Duration seconds, Money nightlyAmount) {
            super(regularAmount, seconds);
            this.nightlyAmount = nightlyAmount;
        }
        
        @Override
        public Money calculateFee() {
            Money result = super.calculateFee();
            
            Money nightlyFee = Money.ZERO;
            for (Call call : getCalls()) {
                nightlyFee = nightlyFee.plus(
                        getAmount().minus(nightlyAmount).times(
                                call.getDuration().getSeconds() / getSeconds().getSeconds()
                        )
                );
            }
            
            return result.minus(nightlyFee);
        }
    }

    담당 개발자는 Phone의 코드를 재사용하기 위해 Phone의 calculateFee 메서드를 호출해서 일반 요금제에 따라 통화 요금을 계산한 후 이 값에서 통화 시작 시간이 10시 이후인 통화의 요금을 빼주도록 개발했다.

    문제 1. 강하게 결합된 Phone과 NightlyDiscountPhone

    세금을 부과하는 요구사항이 추가되었다. 세율(taxRate)을 인스턴스 변수로 포함하고 calculateFee 메서드에서 값을 반환할 때 taxRate를 이용하여 세금을 부과하도록 Phone을 변경하자.

    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 클래스에서 컴파일 에러가 난다. 여기서도 생성자에서 전달받은 taxRate를 부모 클래스인 Phone의 생성자로 전달해야 하기 때문이다. 게다가 오버라이딩한 calculateFee에서도 taxRate를 이용해 세금을 부과하는 코드를 추가해주어야 한다.

    public class NightlyDiscountPhone extends Phone {
        public NightlyDiscountPhone(Money regularAmount, Duration seconds, Money nightlyAmount, double taxRate) {
            super(regularAmount, seconds, taxRate);
            ...
        }
    
        @Override
        public Money calculateFee() {
            ...
            return result.minus(nightlyFee.plus(nightlyFee.times(getTaxRate())));
        }
    }

    NightlyDiscountPhone을 Phone의 자식 클래스로 만든 이유는 Phone의 코드를 재사용하고 중복 코드를 제거하기 위해서였지만, 세금을 부과하는 로직 추가를 위해 Phone을 수정할 때 유사한 코드를 NightlyDiscountPhone에도 추가하고 있다. 또 다른 중복 코드가 생긴 것이다. 이것은 NightlyDiscountPhone이 Phone의 구현에 너무 강하게 결합돼 있기 때문에 발생하는 문제다. 따라서 우리는 상속을 사용할 때 다음과 같은 경고에 귀 기울일 필요가 있다.

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

    이처럼 상속 관계로 연결된 자식 클래스가 부모 클래스의 변경에 취약해지는 현상을 가리켜 취약한 기반 클래스 문제라고 부른다. 상속 관계를 추가할수록 전체 시스템의 결합도가 높아진다는 사실을 알고 있어야 한다. 상속은 자식 클래스를 점진적으로 추가해서 기능을 확장하는 데는 용이하지만 높은 결합도로 인해 부모 클래스를 점진적으로 개선하는 것은 어렵게 만든다. 최악의 경우에는 모든 자식 클래스를 동시에 수정하고 테스트해야 할 수도 있다.

    취약한 기반 클래스 문제는 캡슐화를 약화시키고 결합도를 높인다. 상속은 자식 클래스가 부모 클래스의 구현 세부사항에 의존하도록 만들기 때문에 캡슐화를 약화시킨다[Snyder86]. 이것이 상속이 위험한 이유인 동시에 우리가 상속을 피해야 하는 첫 번째 애유다.

    객체를 사용하는 이유는 구현과 관련된 세부사항을 퍼블릭 인터페이스 뒤로 캡슐화할 수 있기 떄문이다. 캡슐화는 변경에 의한 파급효과를 제어할 수 있기 때문에 가치가 있다. 객체는 변경될지도 모르는 불안정한 요소를 캡슐화함으로써 파급효과를 걱정하지 않고도 자유롭게 내부를 변경할 수 있다.

    안타깝게도 상속을 사용하면 부모 클래스의 퍼블릭 인터페이스가 아닌 구현을 변경하더라도 자식 클래스가 영향을 받기 쉬워진다. 상속 계층의 상위에 위치한 클래스에 가해지는 작은 변경만으로도 상속 계층에 속한 모든 자손들이 급격하게 요동칠 수 있다.

    객체지향의 기반은 캡슐화를 통한 변경의 통제다. 삭속은 코드의 재사용을 위해 캡슐화의 장점을 희석시키고 구현에 대한 결합도를 높임으로써 객체지향이 가진 강력함을 반감시킨다.

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

    자바 초기 버전에서의 java.util.Stack 클래스는 부모 클래스에서 상속받은 메서드를 사용할 경우 자식 클래스의 규칙이 위반될 수 있다는 것을 보여주는 대표적인 사례이다. 자바의 초기 컬렉션 프레임워크 개발자들은 요소의 추가, 삭제 오퍼레이션을 제공하는 Vector를 재사용하기 위해 Stack을 Vector의 자식 클래스로 구현했다. LIFO 구조의 Stack이 자바에서 임의의 위치에서 요소를 조회하고, 추가하고, 삭제할 수 있는 get, add, remove 오퍼레이션이 제공된다.

    Stack<String> stack = new Stack<>();
    stack.push("1st");
    stack.push("2nd");
    stack.push("3rd");
    
    stack.add(0, "4th");
    
    assertEquals("4th", stack.pop()); // 에러!

    객체지향의 핵심은 객체들의 협력이다. 단순히 코드를 재사용하기 위해 불필요한 오퍼레이션이 인터페이스에 스며들도록 방치해서는 안 된다.

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

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

    InstrumentedHashSet은 HashSet 내부에 저장된 요소의 수를 셀 수 있는 기능을 추가한 클래스로서 HashSet의 자식 클래스로 아래와 같이 구현해보자.

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

    InstrumentedHashSet의 구현에는 아무런 문제가 없어 보인다. 하지만 다음 코드를 실행하면 예상과 다르게 나온다.

    InstrumentedHashSet<String> languages = new InstrumentedHashSet<>();
    languages.addAll(Arrays.asList("Java", "Ruby", "Scala"));

    addCount의 값은 3이 아닌 6이 나오는데, 부모 클래스인 HashSet의 addAll 메서드 안에서 add 메서드를 호출하기 때문이다.

    HashSet의 부모인 AbstractSet의 부모인 AbstractCollection의 addAll 메서드

    이 문제를 해결할 수 있는 방법은 InstrumentedHashSet의 addAll 메서드를 제거하는 것이다. 이러면 컬렉션을 파라미터로 전달하는 경우에는 자동으로 HashSet의 addAll 메서드가 호출되고 내부적으로 추가하려는 각 요소에 대해 InstrumentedHashSet의 add 메서드가 호출되어 예상했던 결과가 나올 것이다. 하지만 나중에 HashSet의 addAll 메서드가 add 메세지를 전송하지 않도록 수정된다면 addAll 메서드를 이용해 추가되는 요소들에 대한 카운트가 누락되는 또 다른 문제가 발생할 것이다. 미래의 수정까지 감안한 더 좋은 해결책은 InstrumentedHashSet의 addAll 메서드를 오버라이딩하고 추가되는 각 요소에 대해 한 번씩 add 메세지를 호출하는 것이다. 하지만 이는 위 이미지에 나온 AbstractCollection과 동일한 코드이다. 똥 피하려다 중복을 맞은 것이다. 이펙티브 자바의 조슈아 블로치는 클래스가 상속되기를 원한다면 상속을 위해 클래스를 설계하고 문서화해야 하며, 그렇지 않은 경우에는 상속을 금지시켜야 한다고 주장한다.

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

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

    음악 목록을 추가할 수 있는 플레이리스트를 구현한다고 가정하자. 필요한 것은 음악 정보를 저장할 Song 클래스와 음악 목록을 저장할 Playlist 클래스다.

    Song

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

    Playlist

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

    이제 플레이리스트에서 노래를 삭제할 수 있는 기능이 추가된 PersonalPlaylist가 요구사항으로 들어왔다. 역시 Playlist를 상속해 빠르게 구현해보자.

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

    문제는 요구사항이 변경돼서 Playlist에서 노래의 목록뿐만 아니라 가수별 노래의 제목도 함께 관리해야 된다는 것이다. 다음과 같이 노래를 추가한 후에 가수의 이름을 키로 노래의 제목을 추가하도록 Playlist의 append 메서드를 수정하자.

    public class Playlist {
        private List<Song> tracks = new ArrayList<>();
        private Map<String, String> singers = new HashMap<>();
    
        public void append(Song song) {
            getTracks().add(song);
            singers.put(song.getSinger(), song.getTitle());
        }
    
        public List<Song> getTracks() {
            return tracks;
        }
    
        public Map<String, String> getSingers() {
            return singers;
        }
    }

    안타깝게도 위 수정 내용이 정상적으로 동작하려면 PersonalPlaylist의 remove 메서드도 함께 수정해야 한다. 만약 PersonalPlaylist를 수정하지 않든다면 Playlist의 tracks에서는 노래가 제거되지만 singer에는 남아있을 것이기 때문이다. 따라서 Playlist와 함께 PersonalPlaylist를 수정해야 한다.

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

    이와 같이 상속을 사용하면 자식 클래스가 부모 클래스의 구현에 강하게 결합되기 때문에 이 문제를 피하기는 어렵다. 결합도란 다른 대상에 대해 알고 있는 지식의 양이다. 상속은 기본적으로 부모 클래스의 구현을 재사용한다는 기본 전제를 따르기 때문에 자식 클래스가 부모 클래스의 내부에 대해 속속들이 알도록 강요한다. 따라서 코드 재사용을 위한 상속은 부모 클래스와 자식 클래스를 강하게 결합시키기 때문에 함께 수정해야 하는 상황 역시 빈번하게 발생할 수밖에 없는 것이다.

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

    추상화에 의존하자

    저자가 코드 중복을 제거하기 위해 상속을 도입할 때 따르는 2가지 원칙을 소개한다.

    • 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라. 메서드 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있다[Feathers04].
    • 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식 클래스로 내리는 것보다 자식 클래스의 추상적인 메서드를 부모 클래스로 올리는 것이 재사용성과 응집도 측면에서 더 뛰어난 결과를 얻을 수 있다[Metz12].

    위 두 원칙에 의해 리팩터링한 결과는 아래와 같다.

    Phone(이전의 Phone은 RegularPhone이라는 클래스로 이름이 변경되고, 이 Phone은 RegularPhone과 NightlyDiscountPhone의 부모인 추상 클래스이다.)

    public abstract class Phone {
        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);
    }

    RegluarPhone

    public class RegularPhone extends Phone {
        private Money amount;
        private Duration seconds;
    
        public RegularPhone(Money amount, Duration seconds) {
            this.amount = amount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }

    NightlyDiscountPhone

    public class NightlyDiscountPhone extends Phone {
        private static final int LATE_NIGHT_HOUR = 22;
    
        private Money nightlyAmount;
        private Money regularAmount;
        private Duration seconds;
    
        @Override
        protected Money calculateCallFee(Call call) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
            }
    
            return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }

    이제 각 클래스는 서로 다른 각각 하나의 변경의 이유를 가진다. Phone은 전체 통화 목록을 계산하는 방법이 바뀔 경우에만, RegularPhone은 일반 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만, NightlyDiscountPhone은 심야 할인 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만 변경된다. 이 클래스들은 단일 책임 원칙을 준수하기 때문에 응집도가 높다. 변경 전에는 NightlyDiscountPhone이 부모 클래스인 RegularPhone의 구현에 강하게 결합돼 있었기 때문에 RegularPhone의 구현을 변경하더라도 NightlyDiscountPhone도 함께 영향을 받았다. 변경 후에는 NightlyDiscountPhone과 RegularPhone은 오직 추상 메서드인 calculateCallFee에만 의존한다. calculateCallFee 메서드의 시그니처가 벼녀경되지 않는한 부모 클래스의 내부 구현이 변경되더라도 자식 클래스는 영향을 받지 않는다. 이 설계는 낮은 결합도를 유지하고 있다.

    새로운 요금제가 필요하다면 Phone을 상속받은 클래스를 추가한 후 calculateCallFee 메서드만 오버라이딩하면 되기 때문에 확장이 쉽다.

    남아있는 문제

    통화 요금에 세금을 부과하는 요구사항이 다시 들어왔다. 공통 적용이기 때문에 추상 클래스인 Phone을 수정해야 한다.

    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));
        }
    
        abstract protected Money calculateCallFee(Call call);
    }

    인스턴스 변수인 taxRate와 이 값을 초기화하는 생성자도 추가하고, 요금에 세금이 부과되도록 calculateCallFee가 수정되었다. NightlyDiscountPhone과 RegularPhone 클래스에서 컴파일 에러가 뜬다. 자식 클래스에도 taxRate를 초기화하는 생성자를 추가해야하기 때문이다. 클래스라는 도구는 메서드뿐만 아니라 인스턴스 변수도 함께 포함한다. 따라서 클래스 사이의 상속은 자식 클래스가 부모 클래스가 구현한 행동뿐만 아니라 인스턴스 변수에 대해서도 결합되게 만든다. 인스턴스 변수의 목록이 변하지 않는 상황에서 객체의 행동만 변경된다면 상속 계층에 속한 각 클래스들을 독립적으로 진화시킬 수 있다. 하지만 인스턴스 변수가 추가되는 경우는 다르다. 자식 클래스는 자신의 인스턴스를 생성할 때 부모 클래스에 정의된 인스턴스 변수를 초기화해야 하기 때문에 자연스럽게 부모 클래스에 추가된 인스턴스 변수는 자식 클래스의 초기화 로직에 영향을 미치게 된다. 결과적으로 책임을 아무리 잘 분리하더라도 인스턴스 변수으 ㅣ추가는 종종 상속 게층 전반에 걸친 변경을 유발한다. 

    하지만 인스턴스 초기화 로직을 변경하는 것이 두 클래스에 동일한 세금 계산 코드를 중복시키는 것보다는 현명한 선택이다. 객체 생성 로직의 변경에 유연하게 대응할 수 있는 다양한 방법이 존재하기 떄문에 객체 생성 로직에 대한 변경을 막기보다는 핵심 로직의 중복을 막고 최대한 추상화하고 캡슐화해야 한다.

    이와 같이 상속으로 인한 클래스 사이의 결합을 피할 수 있는 방법은 없다. 우리가 원하는 것은 행동을 변경하기 위해 인스턴스 변수를 추가하더라도 상속 계층 전체에 걸쳐 부작용이 퍼지지 않게 막는 것이다.

     

    합성과 유연한 설계


    상속과 합성은 객체지향 프로그래밍에서 가장 널리 사용되는 코드 재사용 기법이다. 상속이 부모 클래스와 자식 클래스를 연결해서 부모 클래스의 코드를 재사용하는 데 비해 합성은 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용한다. 상속에서 부모 클래스와 자식 클래스 사이의 의존성은 컴파일타임에 해결되지만 합성에서 두 객체 사이의 의존성은 런타임에 해결된다. 상속 관계는 is-a관계라고 부르고 합성 관계는 has-a라고 부른다. 상속과 합성은 코드 재사용이라는 동일한 목적을 가진다는 점을 제외하면 구현 방법부터 변경을 다루는 방식에 이르기까지 모든 면에서 도드라진 차이를 보인다. 

    상속 관계는 클래스 사이의 정적인 관계인 데 비해 합성 관계는 객체 사이의 동적인 관계다. 코드 작성 시점에 결정한 상속 관계는 변경이 불가능하지만 합성 관계는 실행 시점에 동적으로 변경할 수 있기 때문이다. 또한 상속과 합성은 재사용의 대상이 다르다. 상속은 부모 클래스 안에 구현된 코드 자체를 재사용하지만 합성은 포함되는 객체의 퍼블릭 인터페이스를 재사용한다. 따라서 상속 대신 합성을 사용하면 구현에 대한 의존성을 인터페이스에 대한 의존성으로 변경할 수 있어 결합도가 낮아진다.

     

    상속을 합성으로 변경하기

    위에서 소개한 불필요한 인터페이스 상속, 메서드 오버라이딩 오작용, 부모 클래스와 자식 클래스의 동시 수정 문제를 합성으로 해결해보자.

    불필요한 인터페이스 상속 : java.util.Stack

    public class Stack<E> {
        private Vector<E> elements = new Vector<>();
        
        public E push(E item) {
            elements.add(item);
            return item;
        }
        
        public E pop() {
            if (elements.isEmpty()) {
                throw new EmptyStackException();
            }
            return elements.remove(elements.size() - 1);
        }
    }

    이제 Stack의 퍼블릭 인터페이스에는 불필요한 Vector의 오퍼레이션들이 포함되지 않아 Stack의 규칙을 준수한다. 클라이언트가 Stack을 잘못 사용하지 않을 수 있게 되었다.

    메서드 오버라이딩 오작용 : InstrumentedHashSet

    public class InstrumentedHashSet<E> implements Set<E> {
        private int addCount = 0;
        private Set<E> set;
    
        public InstrumentedHashSet(Set<E> set) {
            this.set = set;
        }
    
        @Override
        public boolean add(E e) {
            addCount++;
            return set.add(e);
        }
    
        @Override
        public boolean addAll(Collection<? extends E> c) {
            addCount += c.size();
            return set.addAll(c);
        }
    
        public int getAddCount() {
            return addCount;
        }
    
        @Override public int size() {
            return set.size();
        }
        @Override public boolean isEmpty() {
            return set.isEmpty();
        }
        @Override public boolean contains(Object o) {
            return set.contains(o);
        }
        @Override public Iterator<E> iterator() {
            return set.iterator();
        }
        @Override public Object[] toArray() {
            return set.toArray();
        }
        @Override public <T> T[] toArray(T[] a) {
            return set.toArray(a);
        }
        @Override public boolean remove(Object o) {
            return set.remove(o);
        }
        @Override public boolean containsAll(Collection<?> c) {
            return set.contains(c);
        }
        @Override public boolean retainAll(Collection<?> c) {
            return set.retainAll(c);
        }
        @Override public boolean removeAll(Collection<?> c) {
            return set.removeAll(c);
        }
        @Override public void clear() { set.clear(); }
    }

    InstrumentedHashSet은 HashSet이 제공하는 퍼블릭 인터페이스를 그대로 제공해야 하기 때문에 자바의 Set 인터페이스를 실체화하였다. 코드를 보면 Set의 오퍼레이션을 오버라이딩한 인스턴스 메서드에서 내부의 HashSet 인스턴스에게 동일한 메서드 호출을 그대로 전달한다는 것을 알 수 있다. 이를 포워딩(fowarding)이라 부르고 동일한 메서드를 호출하기 위해 추가된 메서드를 포워딩 메서드(forwarding method)[Bloch08]라고 부른다. 포워딩은 기존 클래스의 인터페이스를 그대로 외부에 제공하면서 구현에 대한 결합 없이 일부 작동 방식을 변경하고 싶은 경우에 사용할 수 있는 유용한 기법이다.

    부모 클래스와 자식 클래스의 동시 수정 문제: PersonalPlaylist

    public class PersonalPlaylist {
        private Playlist playlist = new Playlist();
        
        public void append(Song song) {
            playlist.append(song);
        }
        
        public void remove(Song song) {
            playlist.getTracks().remove(song);
            playlist.getSingers().remove(song.getSinger());
        }
    }

    안타깝게도 Playlist의 경우에는 합성으로 변경하더라도 가수별 노래 목록을 유지하기 위해 Playlist와 PersonalPlaylist를 함께 수정해야 하는 문제가 해결되지 않는다. 그래도 합성을 사용하면 향후에 Playlist 내부 구현을 변경하더라도 파급효과를 최대한 PersonalPlaylist 내부로 캡슐화할 수 있기 때문에 더 좋은 방법이다. PersonalPlaylist가 Playlist의 내부 구현이 아닌 퍼블릭 인터페이스에 의존하기 때문이다.

    상속으로 인한 조합의 폭발적인 증가

    상속으로 인해 결합도가 높아지면 코드를 수정하는 데 필요한 작업의 양이 과도하게 늘어나는 경향이 있다. 가장 일반적인 상황은 작은 기능들을 조합해서 더 큰 기능을 수행하는 객체를 만들어야 하는 경우다. 일반적으로 다음과 같은 두 가지 문제점이 발생한다.

    • 하나의 기능을 추가하거나 수정하기 위해 불필요하게 많은 수의 클래스를 추가하거나 수정해야 한다.
    • 단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수 있다.

    핸드폰 과금 시스템에 새로운 요구사항이 생겨 부가 정책이 추가되었다. 기존의 일반 요금제와 심야 할인 요금제에 부가 정책을 순서 상관없이 0~n개 붙일 수 있다. 아래와 같이 기본 정책과 부가 정책의 조합 가능한 수가 매우 많아진다.

    기본 정책 부가 정책
    일반 요금제 n/a n/a
    심야 할인 요금제 n/a n/a
    일반 요금제 세금 정책 n/a
    심야 할인 요금제 세금 정책 n/a
    일반 요금제 기본 요금 할인 정책 n/a
    심야 할인 요금제 기본 요금 할인 정책 n/a
    일반 요금제 기본 요금 할인 정책 세금 정책
    심야 할인 요금제 기본 요금 할인 정책 세금 정책
    일반 요금제 세금 정책 기본 요금 할인 정책
    심야 할인 요금제 세금 정책 기본 요금 할인 정책

    기본 정책에 세금 정책 조합하기

    RegularPhone 클래스를 상속받은 TexableRegularPhone을 만들자.

    public class TaxableRegularPhone extends RegularPhone {
        private double taxRate;
    
        public TaxableRegularPhone(Money amount, Duration seconds, double taxRate) {
            super(amount, seconds);
            this.taxRate = taxRate;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            Money fee = super.calculateFee();
            return fee.plus(fee.times(taxRate));
        }
    }

    부모 클래스의 메서드를 재사용하기 위해 super 호출을 사용하면 원하는 결과를 쉽게 얻을 수 있지만 자식 클래스와 부모 클래스의 결합도가 높아진다. 결합도를 낮추는 방법은 자식 클래스가 부모 클래스의 메서드를 호출하지 않도록 부모 클래스에 추상 메서드를 제공하는 것이다. 자신이 정의한 추상 메서드를 호출하고 자식 클래스가 이 메서드를 오버라이딩해서 부모 클래스가 원하는 로직을 제공하도록 수정하면 부모 클래스와 자식 클래스 사이의 결합도를 느슨하게 만들 수 있다. 이 방법은 자식 클래스가 부모 클래스의 구체적인 구현이 아니라 필요한 동작의 명세를 기술하는 추상화에 의존하도록 만든다.

    public abstract class Phone {
        private List<Call> calls = new ArrayList<>();
    
        public Money calculateFee() {
            Money result = Money.ZERO;
    
            for (Call call : calls) {
                result = result.plus(calculateCallFee(call));
            }
    
            return afterCalculated(result);
        }
    
        abstract protected Money calculateCallFee(Call call);
        abstract protected Money afterCalculated(Money fee);
    }

    Phone 클래스에 새로운 추상 메서드인 afterCalculated를 추가하여 자식 클래스에게 전체 요금을 계산한 후에 수행할 로직을 추가할 수 있는 기회를 제공하자.

    public class RegularPhone extends Phone {
        private Money amount;
        private Duration seconds;
    
        public RegularPhone(Money amount, Duration seconds) {
            this.amount = amount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee;
        }
    }

    일반 요금제를 구현하는 RegularPhone은 요금을 수정할 필요가 없기 때문에 afterCalculated 메서드에서 파라미터로 전달된 요금을 그대로 반환하도록 구현한다. 위 코드에서 알 수 있듯이 부모 클래스에 추상 메서드를 추가하면 모든 자식 클래스들이 추상 메서드를 오버라이딩해야 하는 문제가 발생한다. 자식 클래스 수가 많다면 꽤나 귀찮은 일이다.

    public class TaxableRegularPhone extends RegularPhone {
        private double taxRate;
    
        public TaxableRegularPhone(Money amount, Duration seconds, double taxRate) {
            super(amount, seconds);
            this.taxRate = taxRate;
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee.plus(fee.times(taxRate));
        }
    }

    TaxableRegularPhone을 RegularPhone이 계산한 요금에 세금을 부과하도록 afterCalculated 메서드를 오버라이딩하였다.

    이런 방식으로 TaxableNightlyDiscountPhone을 만들 수 있다. 

    public class TaxableNightlyDiscountPhone extends NightlyDiscountPhone {
        private double taxRate;
    
        public TaxableNightlyDiscountPhone(double taxRate) {
            this.taxRate = taxRate;
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee.plus(fee.times(taxRate));
        }
    }

    문제는 TaxableRegularPhone와 TaxableNightlyDiscountPhone가 부모 클래스의 이름을 제외하면 대부분의 코드가 거의 동일하다는 것이다. 자바를 비롯한 대부분의 객체지향 언어에서는 단일 상속만 지원하기 떄문에 상속으로 인해 발생하는 중복 코드 문제를 해결하기가 쉽지 않다.

    이번에는 기본 요금 할인 정책을 적용해보자. 일반 요금제와 기본 요금 할인 정책을 조합해 RegularPhone을 상속받는 RateDiscountableRegularPhone을 추가하면 된다.

    public class RateDiscountableRegularPhone extends RegularPhone {
        private Money discountAmount;
    
        public RateDiscountableRegularPhone(Money amount, Duration seconds, Money discountAmount) {
            super(amount, seconds);
            this.discountAmount = discountAmount;
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee.minus(discountAmount);
        }
    }

    이번에는 아래와 같은 경우를 생각해보자.

    • 세금 정책을 적용하고 기본요금 할인 정책을 적용
    • 기본요금 할인 정책을 적용하고 세금 정책을 적용

    TaxableAndRateDiscountableRegularPhone도 만들어야 되고 RateDiscountableAndTaxableRegularPhone도 만들어야 한다. 이와 같은 작업을 NightlyDiscountPhone에도 해주어야 한다. 귀찮은 작업 몇번 더해서 만들어진 코드는 아래와 같을 것이다.

    요금과 관련된 모든 기본 저액과 부가 정책의 조합이 가능한 상속 계층

    여기에 고정 요금제라는 새로운 기본 정책이 추가된다면 RegularPhone 옆에 NightlyDiscountPhone옆에 FixedRatePhone과 그 자식무리들 한쌍이 더 생길 것이고, 부가 정책이 또 생긴다면 기하급수적으로 클래스들이 늘어날 것이다.

    이처럼 상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우를 가리켜 클래스 폭발(class explosion)[Shalloway01] 문제 또는 조합의 폭발(combinational explosion) 문제라고 부른다. 클래스 폭발 문제는 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생하는 문제다. 컴파일타임에 결정된 자식 클래스와 부모 클래스 사이의 관계는 변경될 수 없기 때문에 자식 클래스와 부모 클래스의 다양한 조합이 필요한 상황에서 유일한 해결 방법은 조합의 수만큼 새로운 클래스를 추가하는 것뿐이다.

    합성 관계로 변경하기

    합성을 사용하면 구현 시점에 정책들의 관계를 고정시킬 필요가 없으며 실행 시점에 정책들의 관계를 유연하게 변경할 수 있게 된다. 상속이 조합의 결과를 개별 클래스 안으로 밀어 넣는 방법이라면 합성은 조합을 구성하는 요소들을 개별 클래스로 구현한 후 실행 시점에 인스턴스를 조립하는 방법이다.

    먼저 기본 정책과 부가 정책을 포괄하는 RatePolicy 인터페이스를 추가하자.

    public interface RatePolicy {
        Money calculateFee(Phone phone);
    }

    기본 정책을 만들텐데 일반 요금제와 심야 할인 요금제는 개별 요금을 계산하는 방식을 제외한 전체 처리 로직이 거의 동일하다. 이 중복 코드를 담을 추상 클래스 BasicRatePolicy를 추가하자.

    public abstract class BasicRatePolicy implements RatePolicy {
        @Override
        public Money calculateFee(Phone phone) {
            Money result = Money.ZERO;
    
            for (Call call : phone.getCalls()) {
                result = result.plus(calculateCallFee(call));
            }
    
            return result;
        }
    
        protected abstract Money calculateCallFee(Call call);
    }

    BasicRatePolicy를 상속 받은 일반 요금 정책과 심야할인 요금 정책은 아래와 같다.

    RegularPolicy

    public class RegularPolicy extends BasicRatePolicy {
        private Money amount;
        private Duration seconds;
    
        public RegularPolicy(Money amount, Duration seconds) {
            this.amount = amount;
            this.seconds = seconds;
        }
    
        @Override
        protected Money calculateCallFee(Call call) {
            return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }

    NightlyDiscountPolicy

    public class NightlyDiscountPolicy extends BasicRatePolicy {
        private static final int LATE_NIGHT_HOUR = 22;
    
        private Money nightlyAmount;
        private Money regularAmount;
        private Duration seconds;
    
        public NightlyDiscountPolicy(Money nightlyAmount, Money regularAmount, Duration seconds) {
            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(call.getDuration().getSeconds() / seconds.getSeconds());
            }
    
            return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }

    이제 기본 정책을 이용해 요금을 계산할 수 있도록 Phone을 수정하자.

    public class Phone {
        private RatePolicy ratePolicy;
        private List<Call> calls = new ArrayList<>();
    
        public Phone(RatePolicy ratePolicy) {
            this.ratePolicy = ratePolicy;
        }
    
        public List<Call> getCalls() {
            return calls;
        }
    
        public Money calculateFee() {
            return ratePolicy.calculateFee(this);
        }
    }

    Phone 내부에 RatePolicy에 대한 참조자가 포함돼 있는데 이게 바로 합성이다. Phone과 협력하는 요금 정책은 RatePolicy라는 인터페이스이고, 이는 컴파일타임 의존성을 런타임 의존성으로 바꿔주어 실행시점에 다양한 종류의 객체와 협력할 수 있도록 한다. 클라이언트 코드에서는 아래와 같이 필요한 요금 정책으로 Phone 인스턴스를 생성하면 된다.

    Phone regularPhone = new Phone(new RegularPolicy(Money.wons(10), Duration.ofSeconds(10)));
    Phone nightPhone = new Phone(new NightlyDiscountPolicy(Money.wons(5), Money.wons(10), Duration.ofSeconds(10)));

    부가 정책은 AdditionalRatePolicy라는 추상 클래스로 구현하자.

    public abstract class AdditionalRatePolicy implements RatePolicy {
        private RatePolicy next;
    
        public AdditionalRatePolicy(RatePolicy next) {
            this.next = next;
        }
    
        @Override
        public Money calculateFee(Phone phone) {
            Money fee = next.calculateFee(phone);
            return afterCalculated(fee);
        }
    
        abstract protected Money afterCalculated(Money fee);
    }

    AdditionalRatePolicy는 컴파일타임 의존성을 런타임 의존성으로 쉽게 대체할 수 있도록 RatePolicy 타입의 인스턴스를 인자로 받은 생성자를 사용한다. 생성자 내부에서는 next에 전달된 인스턴스에 대한 의존성을 주입한다. AdditionalRatePolicy의 calculateFee 메서드는 먼저 next가 참조하고 있는 인스턴스에게 calculateFee 메세지를 전송한 후 반환된 요금에 부가 정책을 적용하기 위해 afterCalculated 메서드를 호출한다.

    아래는 이를 바탕으로 세금 정책을 구현한 코드이다.

    public class TaxablePolicy extends AdditionalRatePolicy {
        private double taxRate;
    
        public TaxablePolicy(RatePolicy next, double taxRate) {
            super(next);
            this.taxRate = taxRate;
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee.plus(fee.times(taxRate));
        }
    }

    기본 요금 할인 정책을 추가하는 것도 간단하다.

    public class RateDiscountablePolicy extends AdditionalRatePolicy {
        private Money discountAmount;
    
        public RateDiscountablePolicy(RatePolicy next, Money discountAmount) {
            super(next);
            this.discountAmount = discountAmount;
        }
    
        @Override
        protected Money afterCalculated(Money fee) {
            return fee.minus(discountAmount);
        }
    }

    아래는 모든 계산과 관련된 모든 클래스 사이의 관계를 나타낸 것이다.

    기본 정책과 부가 정책을 조합할 수 있는 상속 구조

    클라이언트 코드에서는 아래와 같이 의존성 주입을 통해 요금 조합을 할 수 있다.

    Phone 기본과세금 = new Phone(
            new TaxablePolicy(new RegularPolicy(Money.wons(10), Duration.ofSeconds(10)), 0.05)
    );
    Phone 기본과세금과기본요금할인 = new Phone(
            new TaxablePolicy(new RateDiscountablePolicy(
                    new RegularPolicy(Money.wons(10), Duration.ofSeconds(10)), Money.wons(1000)),
                    0.05
            )
    );

    객체를 조합하고 사용하는 방식이 상속을 사용한 방식보다 더 예측 가능하고 일관성있다. 여기에 고정 요금제라는 기본 정책이 추가된다면 고정 요금제를 구현한 클래스 하나만 추가한 후 원하는 방식으로 조합하면 된다. 위의 상속처럼 부가정책 개수의 팩토리얼만큼 클래스를 만들 필요가 없다. 또한 요구사항을 변경할 때 오직 하나의 클래스만 수정해도 된다. 세금 정책을 변경한다면 상속 버전에서는 상속 계층 여기저기에 중복돼 있던 세금 정책 관련 코드를 수정해야 하지만, 합성 버전에서는 TaxablePolicy만 변경하면 된다. 단일 책임 원칙을 준수하고 있는 것이다.

    댓글

Designed by Tistory.