[chapter 08] 의존성 관리하기
객체들이 서로 협력하기 위해서는 의존성이 필요하지만 과도한 의존성은 애플리케이션을 수정하기 어렵게 만든다. 객체지향 설계의 핵심은 협력을 위해 필요한 의존성은 유지하면서도 변경을 방해하는 의존성은 제거하는데 있다. 이런 관점에서 객체지향 설계란 의존성을 관리하는 것이고 객체가 변화를 받아들일 수 있게 의존성을 정리하는 기술이라고 할 수 있다.
01. 의존성 이해하기
1. 변경과 의존성
어떤 객체가 협력하기 위해 다른 객체를 필요로 할 때 두 객체 사이에 의존성이 존재하게 된다. 의존성은 실행 시점과 구현 시점에 서로 다른 의미를 가진다.
- 실행 시점: 의존하는 객체가 정상적으로 동작하기 위해서는 실행 시에 의존 대상 객체가 반드시 존재해야 한다.
- 구현 시점: 의존 대상 객체가 변경될 경우 의존하는 객체도 함께 변경된다.
의존성은 방향성을 가지며 항상 단방향이다.
2. 의존성 전이
한 클래스가 인자에 의존하게 될 경우, 그 인자의 인자에도 의존하게 된다는 것을 ‘의존성 전이(transitive dependency)’라고 한다. 의존성은 함께 변경될 수 있는 가능성을 의미하기 때문에 항상 의존성 전이가 일어난다고 할 수는 없다. 단지 변경에 의해 영향이 널리 전파될 수도 있다는 경고이다.
의존성은 직접 의존성과 간접 의존성으로 나뉜다.
- 직접 의존성: 한 요소가 다른 요소에 직접 의존하는 경우 (ex. 메소드의 인자)
- 간접 의존성: 직접적인 관계는 존재하지 않지만 의존성 전이에 의해 영향이 전파되는 경우. 코드안에 명시적으로 드러나지 않는다.
3. 런타임 의존성과 컴파일타임 의존성
- 런타임: 애플리케이션이 실행되는 시점.
- 컴파일타임: 일반적으로 작성된 코드를 컴파일하는 시점을 가리키지만 문맥에 따라서는 코드 그 자체를 가리키기도 한다.
컴파일타임 의존성이란 용어가 중요하게 생각하는 것은 시간이 아니라 우리가 작성한 코드의 구조를 말한다. 따라서 이 단어를 만났을 때, 정말 컴파일이 진행되는 시점을 가리키는 것인지 아니면 코드를 작성하는 시점을 가리키는 것인지 파악하는 것이 중요하다.
런타임 의존성과 컴파일타임 의존성이 다를 수 있다. 객체지향 애플리케이션에서 런타임의 주인공은 객체이다. 따라서 런타임 의존성이 다루는 주제는 객체사이의 의존성이다. 반면 코드 관점에서 주인공은 클래스다. 따라서 컴파일타임 의존성은 클래스 사이의 의존성이다. 유연하고 재사용 가한 코드를 설계하기 위해서 두 종류의 의존성을 서로 다르게 만들어야 한다. (p.258)
유연하고 재사용 가능한 설계를 창조하기 위해서는 동일한 소스코드 구조를 가지고 다양한 실행 구조를 만들 수 있어야 한다. 어떤 클래스의 인스턴스가 다양한 클래스의 인스턴스와 협력하기 위해서는 협력할 인스턴스의 구체적인 클래스를 알아서는 안된다. 실제로 협력할 객체가 어떤 것인지는 런타임에 해결해야 한다.
4. 컨텍스트 독립성
클래스가 사용될 특정한 문맥에 대해 최소한의 가정만으로 이뤄져 있다면(percentDiscountPolicy, amountDiscountPolicy가 아닌 DiscountPolicy)다른 문맥에서 재사용하기가 더 수월해진다. 이를 컨텍스트 독립성이라고 부른다.
5. 의존성 해결하기
컴파일타임 의존성은 구체적인 런타임 의존성으로 대체돼야 한다. 이처럼 컴파일타임 의존성을 실행 컨텍스트에 맞는 적절한 런타임 의존성으로 교체하는 것을 의존성 해결이라고 한다.
의존성 해결을 위한 3가지 방법
- 객체를 생성하는 시점에 생성자를 통해 의존성 해결
public Movie(DiscountPolicy discountPolicy){
this.discountPolicy = discountPolicy;
}
- 객체 생성 후 setter 메서드를 통해 의존성 해결
public void test(){
Movie avatar = new Movie();
avatar.setDiscountPolicy(new AmountDiscountPolicy());
}
setter 메서드를 사용하면 객체를 생성한 후에도 의존하고 있는 대상을 바꿀 수 있다. 하지만 객체가 생성된 후에 의존 대상을 설정하기 때문에 객체의 상태가 불완전할 수 있다.
Movie avatar = new Movie();
avatar.caculateFee();// NullPointerException!
avatar.setDiscountPolicy(new AmountDiscountPolicy());
더 좋은 방법은 항상 객체를 생성할 때 의존성을 해결해서 완전한 상태의 객체를 생성한 후, 필요에 따라 setter 메서드를 이용해 의존 대상을 변경할 수 있게 하는 것이다. 실무에서 가장 선호되는 방법이다.
Movie avatar = new Movie(..., new PercentDiscountPolicy());
avatar.setDiscountPolicy(new AmountDiscountPolicy());
- 메서드 실행 시 인자를 이용해 의존성 해결
Movie가 항상 할인 정책을 알 필요까지는 없고 가격을 계산할 때만 일시적으로 알아도 무방하다면 메서드의 인자를 이용해 의존성을 해결할 수도 있다.
public Movie calculateMovieFee(..., DiscountPolicy discountPolicy){
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
02. 유연한 설계
1.의존성과 결합도
의존성은 객체들의 협력을 가능하게 만드는 매개체라는 관점에서 바람직하지만 의존성이 과하면 문제가 된다. 어떤 의존성이 다양한 환경에서 클래스를 재사용할 수 없도록 제한한다면 그 의존성은 바람직하지 못한 것이다. 바람직한 의존성이란 컨텍스트에 독립적인 의존성을 의미하며 다양한 환경에서 재사용될 수 있는 가능성을 열어놓은 의존성을 의미한다.
의존성이 바람직 할 때 → 느슨한 결합도, 약한 결합도
의존성이 바람직 하지 못할 때 → 단단한 결합도, 강한 결합도
2. 지식이 결합을 낳는다
한 요소가 다른 요소에 대해 많은 정보를 알고 있을수록 두 요소는 강하게 결합된다. 반대로 한 요소가 다른 요소에 대해 더 적은 정보를 알고 있을수록 두 요소는 약하게 결합된다.
더 많이 알고 있다는 것은 더 적은 컨텍스트에서 재사용 가능하다는 것을 의미한다. 따라서 결합도를 느슨하게 만들기 위해서는 협력하는 대상에 대해 필요한 정보 외에 최대한 감추는 것이 중요하다.
3. 추상화에 의존하라
추상화를 사용하면 대상에 대해 알아야 하는 지식의 양을 줄일 수 있기 때문에 결합도를 느슨하게 유지할 수 있다.
추상화와 결합도 관점에서 의존 대상을 다음과 같이 구분하면 결합도를 느슨하게 유지할 수 있다. 아래로 갈수록 결합도가 낮은 것이다.
- 구체 클래스 의존성
- 추상 클래스 의존성
- 인터페이스 의존성
4. 명시적인 의존성
public Movie(){
this.discountPolicy = new AmountDiscountPolicy();
}
위의 예에서 Movie는 DiscountPolicy와 AmountDiscountPolicy에 의존하게 된다. 이 예에서 알 수 있는 것처럼 결합도를 느슨하게 만들기 위해서는 인스턴스 변수 타입을 추상 클래스나 인터페이스로 선언하는 것만으로는 부족하다. 클래스 안에서 구체 클래스에 대한 모든 의존성을 제거해야만 한다.
public Movie(DiscountPolicy discountPolicy){
this.discountPolicy = discountPolicy();
}
위의 예에서는 첫번째 예제의 문제를 생성자 인자를 통해 해결했다. discountPolicy를 명시적인 의존성, discountPolicy를 구현한 AmountDiscountPolicy는 숨겨진 의존성이라고 부른다.
의존성은 명시적으로 표현돼야 한다. 유연하고 재사용한 설계는 퍼블릭 인터페이스를 통해 의존성이 명시적으로 드러나는 설계다.
5. new는 해롭다
new를 잘못 사용하면 클래스 사이의 결합도가 극단적으로 높아진다.
결합도 측면에서 new가 해로운 이유
- new 연산자를 사용하기 위해서는 구체 클래스의 이름을 직접 기술해야 한다. 따라서 new를 사용하는 클라이언트는 추상화가 아닌 구체클래스에 의존할 수밖에 없기 때문에 결합도가 높아진다.
- new 연산자는 생성하려는 구체 클래스뿐만 아니라 어떤 인자를 이용해 클래스 생성자를 호출해야 하는지도 알아야 한다. 따라서 new를 사용하면 클라이언트가 알아야 하는 지식의 양이 늘어나기 때문에 결합도가 높아진다.
6. 가끔은 생성해도 무방하다
만약, Movie가 일반적으로 AmountDiscountPolicy의 인스턴스와 협력하고 가끔씩만 PercentDiscountPolicy의 인스턴스와 협력한다고 가정해보자. 이때 모든 경우에 인스턴스를 생성하는 책임을 클라이언트로 옮긴다면 클라이언트들 사이에 중복 코드가 늘어나고 Movie의 사용성도 나빠질 것이다.
이 경우 기본 객체를 생성하는 생성자를 추가하고 이 생성자에 DiscountPolicy의 인스턴스를 인자로 받는 생성자를 체이닝 하는 것이다.
public Movie(String title, Duration runningTime){
this(title, runningTime, new AmountDiscountPolicy());
}public Movie(DiscountPolicy discountPolicy, String title ...){
this.discountPolicy = discountPolicy();
}
이제 클라이언트는 AmountDiscountPolicy의 인스턴스와 협력하게 하면서도 컨텍스트에 적절한 DiscountPolicy의 인스턴스로 의존성을 교체할 수 있다.
7. 표준 클래스에 대한 의존은 해롭지 않다.
8. 컨텍스트 확장하기
(p.277)
9. 조합 가능한 행동
다양한 종류의 할인 정책이 필요한 컨텍스트에서 Movie를 재사용할 수 있었던 이유는 코드를 직접 수정하지 않고도 협력 대상인 DiscountPolicy 인스턴스를 교체할 수 있었기 때문이다.
유연하고 재사용 가능한 설계는 작은 객체들의 행동을 조합함으로써 새로운 행동을 이끌어 낼 수 있는 설계다. 훌륭한 객체지향 설계란 객체들의 조합을 선언적으로 표현함으로써 객체들이 무엇을 하는지 표현하는 설계다. 이 설계의 핵심은 의존성을 관리하는 것이다.