오브젝트; 추상화에 의존하는 상속

🗓️

취약한 기반 클래스 문제

  • 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상을 취약한 기반 클래스 문제 라고 부른다.

이제 결합도의 개념을 상속에 적용해보자. 구현을 상속한 경우(extends) 파생 클래스는 기반 클래스에 강하게 결합되며, 이 둘 사이의 밀접한 연결은 바람직하지 않다. 설계자들은 이런 현상에 대해 “취약한 기반 클래스 문제”라는 명칭을 붙였다. 겉으로 보기에는 안전한 방식으로 클래스를 수정한 것 처럼 보이더라도 이 새로운 행동이 파생 클래스에게 상속될 경우 파생 클래스의 잘못된 동작을 초래할 수 있기 때문에 기반 클래스는 “취약하다”. 단순히 기반 클래스의 메서드들만을 조사하는 것만으로는 기반 클래스를 변경하는 것이 안전하다고 확신할 수 없다. 모든 파생 클래스들을 살펴봐야 한다. 나아가 기반 클래스와 파생 클래스를 사용하는 모든 코드가 새로운 코드로 인해 영향을 받지 않았는지 점검해야 한다. 핵심적인 기반 클래스에 대한 단순한 변경이 전체 프로그램을 불안정한 상태로 만들어버릴 수도 있다.

  • 취약한 기반 클래스 문제는 상속이란느 문맥 안에서 결합도가 초래하는 문제점을 가리키는 용어다. 즉, 상속 관계를 추가할 수록 전체 시스템의 결합도가 높아진다.
  • 객체지향의 기반은 캡슐화를 통한 변경의 통제다. 상속은 코드의 재사용을 위해 캡슐화의 장점을 희석시키고 구현에 대한 결합도를 높임으로써 객체지향이 가진 강력함을 반감시킨다.

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

java.util.Stack 클래스

  • Vector를 상속해 Stack을 만들었다.
Stack<String> stack = new Stack<>();

stack.push("아이유");
stack.push("이지금");
stack.push("이지은");
stack.add(0, "이지동"); //<<<

assertEquals("이지동", stack.pop()); // Error
  • 문제는 Vector에 포함된 get(), add(), remove()가 stack 자료구조와 관련 없지만, 포함이 되어 버렸다.
  • 마찬가지로Hashtable을 상속한 java.util.Properties가 있다.

상속을 위한 경고 2

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

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

  • HashSet에 강하게 결합된 InstrumentedHashSet도 있다. (이펙티브 자바)
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는 요소를 추가한 횟수를 기록하기 위해 addCount를 가진다. 부모 메소드 호출을 통해 요소를 추가한다.
InstrumentedHashSet<String> languages = new InstrumentedHashSet<>();
languages.addAll(Arrays.asList("아이유","이지금","이지동"));
  • 대부분은 위 코드를 실행한 후에 addCount의 값이 3이 될거라고 예상하지만 실제 값은 6이된다. 이유는 부모의 addAll()를 호출하기 때문이다.
  • addAll()을 제거하면 컬렉션을 파라미터로 전달하는 경우 자동으로 HashSetaddAll()이 호출되고 내부적으로 추가하려는 요소에 대해 InstrumentedHashSetadd()가 호출되어 예상했던 결과가 나올 것이다.
  • 그러나 이도 미래에 HashSet에 변화가 생긴다면 카운트에 누락이 생길 수 있다.
  • 정말 해결하려면 중복코드를 통해 바꿀 수 있겠지만 항상 부모의 맴버에 접근할 수 있다는 보장도 없다(private)
  • 서브클래스가 안전할 수 있게끔 클래스를 문서화 하려면 결국 클래스의 상세 구현 내역을 기술해야만 한다.

상속을 위한 경고 3

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

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

  • 음악 목록을 추가할 수 있는 플레이리스트를 구현했다.
public class Song {
    private String singer;
    private String title;

    public Song(String singer, String title) {
        this.singer = singer;
        this.title = title;
    }

    //getter..

}

public class Playlist {
    private List<Song> tracks = new ArrrayList<>();

    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);
    }
}
  • 여기에 append()를 구현한다.
public class Playlist {
    //...
    private Map<String, String> singers = new HashMap<>();



    public void append(Song song) {
        //..
        singers.put(song.getSinger(), song.getTitle());
    }

    // getter..
}
  • 만약 여기에 곡을 제거하는 기능이 있는 클래스를 상속으로 구현한다면, 부모의 기능이 변경 됐을때 자식 클래스도 변경되야 한다.
  • 자식 클래스가 부모 클래스의 메서드를 오버라이딩하거나 불필요한 인터페이스를 상속받지 않더라도, 부모 클래스를 수정할 때 자식 클래스를 함계 수정해야 한다.
  • 결합도는 다른 대상에 대해 알고 있는 자식의 양이다.
  • 상속은 자식 클래스가 부모 클래스의 내부에 대해 알도록 강요한다.
  • 따라서 상속은 결합도가 올라간다.

상속을 위한 경고 4

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

Phone 다시 살펴보기

추상화에 의존하자

  • NightlyDiscountPhone의 문제는 Phone에 강하게 결합되어 있다.
  • 자식 클래스가 부모 클래스의 구현이 아닌 추상화에 의존하게 하면 결합도를 낮출 수 있다.

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

  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 money = 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(regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds()));
            }
        }
        return result;
    }
}
  • 두 클래스의 메서드에서 다른 부분을 별도의 메서드로 추출하자.
public class Phone {
    //...

    public Money calculateFee() {
        Money money = 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());
}



    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) {
            result =  = result.plus(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());
        }
    }
} 
  • 이로써 calculateFee()클래스가 동일해졌다.

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

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);
}  
  • calculateFee()를 부모 클래스로 올렸다.
  • calculateCallFee()는 자식 클래스에서 오버라이딩 할 수 있도록 protected 선언한다.

추상화가 핵심이다

  • 공통 코드를 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가진다.
    • AbstractPhone : 전체 통화 목록을 계산하는 방법이 바뀔 경우에만 변경됨
    • Phone : 일반 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만 변경됨
    • NightlyDiscountPhone : 심야 할인 요금제의 통화 한 건을 계산하는 방식이 바뀔 경우에만 변경됨.
  • 이로써 단일 책임 원칙을 준수하기 때문에 응집도가 높아진다.
  • 각 자식 클래스는 부모 클래스의 내부 구현이 변경되더라도 영향을 받지 않는다. 그러므로 이 설계는 낮은 결합도를 유지한다.
  • 새로운 요금제를 추가할 때도 AbstractPhone을 상속받는 새로운 클래스를 만들고 calculateCallFee()를 오버라이딩 하면 된다. 그러므로 확장에 열려있고 수정에 닫혀있기 때문에 개방-폐쇄 원칙을 준수한다.

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

  • 명확한 이름으로 바꾸기.
public abstract class Phone { ... }

public class RegularPhone extends Phone { ... }

public class NightlyDiscountPhone extends 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 money = Money.ZERO;

        for(Call call : calls) {
            result =  = result.plus(calculateCallFee(call));
        }

        return result.plus(result.times(taxRate));
    }

    protected abstract Money calculateCallFee(Call call);
}

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;
    }
}
  • 클래스라는 도구는 메서드 뿐만 아니라 인스턴스 변수도 함께 포함한다. 따라서 클래스 사이의 상속은 부모클래스가 구현한 행동 뿐만 아니라 인스턴스 변수에 대해서도 자식 클래스가 결합되게 만든다.
  • 인스턴스 변수 목록이 변하지 않으면서 객체의 행동만 변경된다면 상속 계층에 속한 각 클래스들은 독립적으로 진화할 수 있다.
  • 인스턴스 변수가 추가된다면 책임을 아무리 잘 분리하더라도 상속 계층 전반에 변경을 유발한다.
  • 살펴본 것과 같이 상속으로 인한 클래스 사이의 결합을 피할 수 있는 방법은 없다. 행동을 변경하기 위해 인스턴스 변수를 추가하더라도 상속 게층 전체에 걸쳐 부작용이 퍼지지 않게 막아야 하는것이 중요하다.

차이에 의한 프로그래밍

  • 상속을 사용하면 이미 존재하는 클래스의 코드를 기반으로 다른 부분을 구현 함으로써 새로운 기능을 빠르게 추가할 수 있다.
  • 다른 부분만 추가하여 어플리케이션을 확장하는 것을 차이에 의한 프로그래밍 이라고 한다.
  • 상속은 이미 존재하는 클래스의 코드를 쉽게 재사용한다는 점에서 어플리케이션의 점진적 정의가 가능해 진다.
  • 차이에 의한 프로그래밍의 목표는 중복코드를 제거하고 코드를 재사용하는 것이다.
  • 재사용은 타이핑 수준의 문제를 해결해 주는것이 아니다. 재사용 가능한 코드는 ‘버그가 없는 코드’를 의미한다. 따라서 코드를 재사용 하면 코드의 품질은 유지하면서도 코드를 작성하는 노력과 테스트를 줄일 수 있다.