잘 설계된 객체지향 어플리케이션은 작고 응집도 높은 객체들로 구성된다.
작고 응집도 높은 객체란 책임의 초점이 명확하고 한 가지 일만 잘하는 객체를
의미한다. 일반적으로 기능을 구현하기 위해선 객체 사이의 협력이 발생한다.
협력을 위해서는 의존성이 필요하지만 과도한 의존성은 애플리케이션 수정을
어렵게 만든다. 이 관점에서 객체지향 설계란 의존성을 관리하는 것이고 객체가
변화를 받아들일 수 있게 의존성을 정리하는 기술이라고 할 수 있다.
어떤 객체가 협력하기 위해 다른 객체를 필요로 할 때 두 객체 사이에
의존성이 존재하게 된다. 의존성은 실행 시점, 구현 시점에 다른 의미를
가진다.
예시로 PeriodCondition을 살펴보자. isSatisfiedBy메서드는
Screening 인스턴스에게 getStartTime메시지를 전송한다.
public class PeriodCondition implements DiscountCondition {
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
//...
public boolean isSatisfiedBy(Screening screening) {
return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0&&
endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
}
}
실행 시점에 PeriodCondition의 인스턴스가 정상적으로 동작하기 위해서는
Screening의 인스턴스가 존재해야 한다. 이처럼 어떤 객체가 예정된 작업을
정상적으로 수행하기 위해 다른 객체를 필요로 하는 경우 두 객체 사이에 의존성이
존재한다고 말한다. 의존성은 방향성을 가지며 항상 단방향이다.

한편, 두 요소 사이의 의존성은 의존되는 요소가 변경될 때 의존하는 요소도 함께
변경될 수 있다는 것을 의미한다.
PeriodCondition의 코드를 다시 살펴보면 의존성의 대상이 가지는 특성이
조금씩 다르다는 사실을 알 수 있다. DayOfWeek, LocalTime은 인스턴스
변수로 사용되고, Screening은 메서드 인자로 사용된다.
DiscountCondition은 인터페이스에 정의된 오퍼레이션들을 퍼블릭 인터페이스의
일부로 포함시키기 위해 의존한다.

의존성을 구분하기 위해 아래와 같이 구분하는 것이 편할 것이다.

다른 방식으로 표기했지만 의존성이 가지는 근본적인 함께 변경될 수 있다는
특성은 동일하다.
UML과 의존성
UML에서 실체화 관계, 연관 관계 등의 의존 관계는 의존성 개념을 포함한다.
이 장에서 다루는 '의존성'은 UML의 모든 관계가 가지는 공통적 특성으로 봐야한다.
의존성 전이(transitive dependency)가 의미하는 것은 PeriodCondition이
Screening에 의존할 경우 PeriodCondition은 Screening이 의존하는 대상에
대해서도 자동적으로 의존하게 된다는 것이다.

모든 경우에 의존성이 전파되는 것은 아니다. 실제 전이 여부는 변경의 방향과
캡슐화의 정도에 따라 달라진다. 의존성 전이는 영향이 널리 전파될 수 있다는
경고일 뿐이다.
전이가 가능하기에 의존성 종류를 직접 의존성과 간접 의존성으로
나누기도 한다. 직접 의존성은 PeriodCondition과 Screening의 관계같은
경우로 의존성이 PeriodCondition의 코드에 명시적으로 드러난다.
간접 의존성의 경우는 코드에 직접 드러나지 않는다.
의존성에 관련해서 런타임 의존성과 컴파일타임 의존성에 관해
이해할 필요가 있다.
런타임은 애플리케이션이 실행되는 시점을 가리킨다. 컴파일타임은 작성된 코드가
컴파일되는 시점을 가리키지만 문맥에 따라서는 코드 그 자체를 가리키기도 한다.
컴파일타임이라는 용어는 컴파일 진행 시점을 가리키는 지 아니면 코드를 작성하는
시점을 가리키는 것인지 파악하는 것이 중요하다.
OOP 어플리케이션에서 런타임의 주인공은 객체로, 런타임 의존성이 다루는 주제는
객체 사이의 의존성이다. 반면 코드 관점에서는 클래스가 주인공이다. 따라서
컴파일타임 의존성의 주제는 클래스 간 의존성이다.
중요한 것은 두 시점간 의존성이 다를 수 있다는 것이다. 유연하고 재사용 가능한
코드를 설계하기 위해서는 두 종류의 의존성을 서로 다르게 만들어야 한다.

위 예시에서 Movie는 비율 할인 정책, 금액 할인 정책 모두를 적용할 수 있게
설계해야 한다. 이에 따라 추상 클래스인 DiscountPolicy를 의존하도록
설정했다. 중요한 것은 Movie클래스에서 구체적인 클래스로 향하는 어떤
의존성도 존재하지 않는다는 것이다.
하지만 런타임 의존성을 살펴보면, 각 할인 정책을 적용하기 위해 직접적인
타입의 인스턴스와 협력해야 한다.

만약 Movie클래스가 특정 클래스에 대해서만 의존한다면 다른 클래스와 협력하는
것이 불가하다.
Movie의 인스턴스가 두 클래스와 함께 협력할 수 있도록 만드는 더 나은
방법은 Movie가 두 클래스 중 어떤 것도 알지 못하게 만드는 것이다.
두 클래스를 포괄하는 DiscountPolicy라는 추상 클래스에 의존토록
만들고 이 컴파일타임 의존성을 실행 시에 구체적인 타입의 인스턴스에 대한
런타임 의존성으로 대체해야 한다. 유연하고 재사용 가능한 설계를 위해서는
동일 코드 구조를 가지고 다양한 실행 구조를 만들 수 있어야 한다.
컴파일타임 구조와 런타임 구조 사이의 거리가 멀면 멀수록 설계가 유연해지고
재사용 가능해진다.
클래스는 자신과 협력할 객체의 구체적 클래스에 대해 알면 안 된다.
구체적 클래스를 알수록 그 클래스가 사용되는 특정 문맥에 강하게 결합되기
때문이다.
클래스가 특정 문맥에 강하게 결합될수록 다른 문맥에서 사용하기는
더 어려워진다. 클래스가 사용될 특정 문맥에 대해 최소한의 가정만으로 이뤄져
있다면 다른 문맥에서 재사용하기가 더 수월해진다. 이를 컨텍스트 독립성
이라고 부른다.
시스템을 구성하는 객체가 컨텍스트 독립적이라면 해당 시스템은 변경하기
쉽다. 여기서 컨텍스트 독립적이라는 말은 각 객체가 해당 객체를 실행하는
시스템에 관해 아무것도 알지 못한다는 의미다.
컴파일타임 의존성은 구체적인 런타임 의존성으로 대체돼야 한다.
컴파일타임 의존성을 실행 컨텍스트에 맞는 적절한 런타임 의존성으로 교체하는
것을 의존성 해결이라고 부른다. 의존성을 해결하기 위해 일반적으로
사용하는 세 가지 방법은 다음과 같다.
setter 메서드를 통해 의존성 해결Moive avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(...));
위 같은 의존성 주입을 위해 Moive의 생성자는 두 구체 클래스의 부모 클래스인
DiscountPolicy타입의 인자를 받는 생성자를 정의한다.
public class Movie {
public Moive(String title, Duration runningTime, Money fee, DiscountPolicy policy){ ... }
}
setter메서드를 이용해 의존성을 해결하는 방법도 있다.
Movie avartar = new Movie(...);
avartar.setDiscountPolicy(new AmountDiscountPolicy(...));
setter메서드를 이용하는 방법은 실행 시점에 의존 대상을 변경할 수 있기 때문에
설계를 좀 더 유연하게 만들 수 있다. 단점은 객체가 생성된 후에 필요한 의존 대상을
설정하기 때문에 객체를 생성하고 의존 대상을 설정하기 전까지는 객체의 상태가
불완전할 수 있다는 점이다.
Movie가 가격을 계산할 때만 일시적으로 할인 정책을 알아도 무방하다면 메서드의
인자를 통해 의존성을 해결할 수도 있다.
public class Movie {
public Money calculateMovieFee(Screening screening, DiscountPolicy discountPolicy) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
객체들이 협력하기 위해서는 서로의 존재와 수행가능한 책임을 알아야 한다.
이런 지식들이 객체 사이의 의존성을 낳으며, 객체들의 협력을 가능하게 만드는
매개체라는 관점에서는 바람직한 것이다. 하지만 의존성이 과하면 문제가 될 수
있다.
이를테면 Moive가 PercentDiscountPolicy에 직접 의존한다고
가정했을때 이 의존성은 객체 사이의 협력을 가능하게 만들기 때문에 존재
자체는 바람직하다.
문제는 의존성의 존재가 아니라 의존성의 정도다. 구체 클래스에 의존하게
만들기 때문에 다른 종류의 할인 정책이 필요한 문맥에서 Movie를
재사용할 수 없다.
해결 방법은 의존성을 바람직하게 만드는 것이다. DiscountPolicy와
calculateDiscountAmount메시지를 정의함으로써 이 문제를 해결할 수 있다.
이렇듯 바람직한 의존성은 재사용성과 관련이 있다. 어떤 의존성이
다양한 환경에서 사용될 수 있다면 그 의존성은 바람직한 것이다.
바람직한 의존성이란 컨텍스트에 독립적인 의존성을 의미하며 다양한 환경에서
재사용될 수 있는 가능성을 열어놓는 의존성을 의미한다.
결합도는 이러한 바람직한, 바람직하지 못한 의존성을 가리킬 때 사용되는 용어이다.
어떤 두 요소 사이에 존재하는 의존성이 바람직할 때 느슨한 결합도 또는 약한 결합도를
가진다고 말한다. 반면 바람직하지 못한 의존성은 단단한 결합도 또는 강한 결합도를
가진다고 말한다.
의존성은 두 요소 간 존재 유무를 설명하고, 결합도는 두 요소 간 존재하는 의존성의
정도를 의미한다.
한 요소가 다른 요소에 대해 더 많은 정보를 알고 있을수록 두 요소는 강하게 결합된다.
서로에 대해 알고 있는 지식의 양이 결합도를 결정한다. 더 많이 알수록 더 강하게
결합되고, 적은 컨텍스트들에서만 재사용 가능하게 된다.
결합도를 느슨하게 유지하려면 협력하는 대상에 대해 더 적게 알아야 한다.
이는 추상화를 통해 가능하다.
추상화는 어떤 양상, 세부사항, 구조를 좀 더 명확하게 이해하기 위해 특정 절차나
물체를 의도적으로 생략하거나 감춤으로써 복잡도를 극복하는 방법이다.
일반적으로 추상화와 결합도의 관점에서 의존 대상을 다음과 같이 구분하는 것이
유용하다. 목록에서 아래쪽으로 갈수록 클라이언트가 알아야 하는 지식의 양이
적어지기 때문에 결합도가 느슨해진다.
구체 클래스에 비해 추상 클래스는 클라이언트가 알아야 하는 지식의 양이 적기
때문에 결합도가 더 낮다. 하지만, 여전히 협력 대상이 속한 클래스 상속 계층이
무엇인지에 대해서는 알고 있어야 한다.
인터페이스에 의존하면 상속 계층을 모르더라도 협력이 가능해진다. 협력하는
객체가 어떤 메시지를 수신할 수 있는지에 대한 지식만을 남기기 때문에
추상 클래스 의존성보다 결합도가 낮다. 이것은 다양한 클래스 상속 계층에
속한 객체들이 동일한 메시지를 수신할 수 있도록 컨텍스트를 확장하는 것을
가능하게 한다.
중요한 것은 실행 컨텍스트에 대해 알아야 하는 정보를 줄일수록 결합도가
낮아진다는 것이다. 의존하는 대상이 더 추상적일수록 결합도는 더 낮아진다.
아래 코드는 한 가지 실수로 인해 불필요하게 결합도가 높아졌다.
public class Movie {
...
private DiscountPolicy discountPolicy;
public Moive(String title, Duration runningTime, Money fee) {
...
this.discountPolicy = new AmountDiscountPolicy(...);
}
}
인스턴스 변수인 discountPolicy는 DiscountPolicy타입으로 선언되어
있지만 생성자에서 구체 클래스인 AmountDiscountPolicy를 직접 생성해서
대입하고 있다. 따라서 Moive는 구체 클래스에 의존하게 된다.
결합도를 느슨하게 만들기 위해서는 인스턴스 변수의 타입을 추상 클래스,
인터페이스로 선언하는 것만으로는 부족하다. 클래스 안에서 구체 클래스에
대한 모든 의존성을 제거해야만 한다.
의존성을 해결하기 위해선 앞선 방법을 응용할 수 있다. 여기서의 트릭은
인스턴스 변수의 타입은 추상 클래스, 인터페이스로 정의하고 의존성을 해결할
때는 추상 클래스를 상속받거나 인터페이스를 구현한 구체 클래스를 전달하는
것이다.
의존성의 대상을 생성자의 인자로 전달받는 방법과 직접 생성하는 방법 사이의
가장 큰 차이점은 퍼블릭 인터페이스를 통해 할인 정책을 설정할 수 있는
방법을 제공하는지 여부다. 이와 같은 경우 의존성이 명시적으로 퍼블릭
인터페이스에 노출되기에 명시적인 의존성이라고 부른다.
반면 Movie의 내부에서 AmountDiscountPolicy의 인스턴스를 직접
생성하는 방식은 Moive가 DiscountPolicy에 의존한다는 사실을
감춘다. 한마디로, 의존성이 퍼블릭 인터페이스에 의해 표현되지 않는다.
이를 숨겨진 의존성이라고 부른다.
의존성이 명시적이지 않으면 의존성을 파악하기 위해 내부 구현을 살펴볼
수밖에 없다. 더 큰 문제는 의존성이 명시적이지 않으면 클래스를 다른
컨텍스트에서 재사용하기 위해 내부 구현을 직접 변경해야 한다는 것이다.
경계해야 할 것은 의존성 자체가 아니라 의존성을 감추는 것이다.
유연하고 재사용 가능한 설계란 퍼블릭 인터페이스를 통해 의존성이 명시적으로
드러나는 설계다.
new를 잘못 사용하면 클래스 간 결합도가 극단적으로 높아진다. 이 측면에서
new가 해로운 이유는 두 가지다.
new연산자를 사용하기 위해서는 구체 클래스 이름을 직접 기술해야 한다.new를 사용하는 클라이언트는 추상화가 아닌 구체 클래스에 의존해new연산자는 생성하려는 구체 클래스뿐만 아니라 어떤 인자를 통해 클래스의new를 이용하면 클라이언트가다음 코드를 보자
public class Movie {
// ...
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee){
// ...
this.discountPolicy = new AmountDiscountPolicy(Money.wons(800),
new SequenceCondition(1),
new SequenceCondition(10),
new PeriodCondition(DayOfWeek.MONDAY,
LocalTime.of(10, 0), LocalTime.of(11, 59)),
new PeriodCondition(DayOfWeek.THURSDAY,
LocalTime.of(10, 0), LocalTime.of(20, 59)));
}
}
Moive클래스가 AmountDiscountPolicy의 인스턴스를 생성하기 위해서는 생성자에
전달되는 인자를 알고 있어야 한다. 더불어 AmountDiscountPolicy가 참조하는
두 구체 클래스에도 의존하도록 만든다. Movie가 DiscountPolicy에 의존해야
하는 유일한 이유는 calculateDiscountAmount메시지를 전송하기 위해서다.
메시지에 대한 의존성 외의 모든 다른 의존성은 Movie의 결합도를 높이는 불필요한
의존성이다.
해결 방법은 인스턴스를 생성하는 로직과 생성된 인스턴스를 사용하는 로직을 분리하는
것이다. Movie는 인스턴스를 생성해서는 안된다. 외부로부터 AmountDiscountPolicy의
인스턴스를 전달받아야 한다.
public class Movie{
//...
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
//...
this.discountPolicy = discountPolicy;
}
}
이제 인스턴스를 생성하는 책임은 클라이언트가 처리한다. 사용과 생성의 책임을
분리하여 Movie의 겷바도를 낮추면 설계가 유연해질 수 있다.
클래스 안에서 객체의 인스턴스를 생성하는 경우는, 주로 협력하는 기본 객체를
설정할 때 유용하다. 예를 들어 Movie가 대부분의 경우 AmountDiscountPolicy와
협력하고 가끔씩만 PercentDiscountPolicy의 인스턴스와 협력한다고 가정해보자.
모든 경우 생성 책임을 클라이언트가 처리한다면 중복 코드가 늘어나고 Movie의
사용성도 나빠질 것이다.
문제의 해결법은 기본 객체를 생성하는 생성자를 추가하고 이 생성자에서
DiscountPolicy의 인스턴스를 인자로 받는 생성자를 체이닝하는 것이다.
public class Movie {
//...
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime) {
this(title, runningTime, new AmountDiscountPolicy(...));
}
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
//...
this.discountPolicy = discountPolicy;
}
}
이 코드에선 생성자가 체인처럼 연결된다. 추가된 간략한 생성자를 통해 구체적인
인스턴스와 협력하게 하면서도 컨텍스트에 적절한 인스턴스로 의존성을 교체할 수 있다.
이 방법은 메서드를 오버로딩하는 경우에도 사용할 수 있다.
public class Movie {
public Money calculateMovieFee(Screening screening) {
return calculateMovieFee(screening, new AmountDiscountPolicy(...));
}
public Money calculateMovieFee(Screening screening, DiscountPolicy discountPolicy) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
이 예는 설계가 트레이드오프 활동이라는 사실을 다시 상기시킨다. 구체 클래스에
의존하게 되더라도 클래스의 사용성이 더 중요하다면 결합도를 높이는 방향으로
코드를 작성할 수 있다.
변경될 확률이 거의 없는 클래스라면 의존성이 문제가 되지 않는다. 자바라면 JDK에
포함된 표준 클래스가 이 부류에 속한다.
public abstract class DiscountPolicy {
private List<DiscountCondition> conditions = new ArrayList<>();
}
한편, 의존성에 의한 영향이 적은 경우에도 추상화에 의존하고 의존성을 명시적으로
드러내는 것은 좋은 설계 습관이다.
public abstract class DiscountPolicy {
private List<DiscountCondition> conditions = new ArraysList<>();
public void switchConditions(List<DiscountCondition> conditions) {
this.conditions = conditions;
}
}
실제로 Movie가 유연하다는 사실을 입증하기 위해 지금까지와는 다른 컨텍스트에서
Movie를 확장해서 재사용하는 두 가지 예를 살펴보겠다.
첫째는 할인 혜택을 제공하지 않는 영화의 예매 요금을 계산하는 경우다. 쉽게
생각할 수 있는 방법은 discountPolicy에 어떤 객체도 할당하지 않는 것이다.
public class Movie {
public Movie(String title, Duration runningTime, Money fee) {
this(title, runningTime, fee, null);
}
public Money calculateMovieFee(Screening screening) {
if(discountPolicy == null){
return fee;
}
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
이 코드는 제대로 동작하지만 지금까지의 Movie와 DiscountPolicy 사이의
협력 방식에 어긋나는 예외 케이스가 추가된 것이다. 해결책은 할인 정책이 존재하지
않는다는 사실을 예외 케이스로 처리하지 않고 기존 협력 방식을 따르도록 만드는
것이다. 방법은 할인할 금액으로 0원을 반환하는 NonDiscountPolicy클래스를
추가하고 DiscountPolicy의 자식 클래스로 만드는 것이다.
public class NoneDiscountPolicy extends DiscountPolicy {
@Override
protected Money getDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
이제 NoneDiscountPolicy의 인스턴스를 Money의 생성자에 전달하면 된다.
두번째 예는 중복 적용이 가능한 할인 정책을 구현하는 것이다. 여기서 중복
할인이란 할인 정책을 혼합해서 적용할 수 있는 경우를 말한다.
가장 간단하게 Movie가 DiscountPolicy인스턴스들로 구성된 List를
인스턴스 변수로 갖게할 수 있지만, 이는 기존 협력 방식과 다른 예외 케이스를
만든다.
이 문제 역시 중복 할인 정책을 할인 정책의 한 가지로 간주하여 해결할 수 있다.
public class OverlappedDiscontPolicy extends DiscountPolicy {
private List<DiscountPolicy> discountPolicies = new ArrayList<>();
public OverlappedDiscountPolicy(DiscountPolicy...discountPolicies) {
this.discountPolicies = Arrays.asList(discountPolicies);
}
@Override
protected Money getDiscountAmount(Screening screening){
Money result = Money.ZERO;
for(DiscountPolicy each : discountPolicies) {
result = result.plus(each.calculateDiscountAmount(screening));
}
return result;
}
}
이제 Movie에 인스턴스를 생성해서 전달하여 사용할 수 있다.
결합도를 낮춤으로써 얻게 되는 컨텍스트의 확장이라는 개념이 유연하고 재사용
가능한 설계를 만드는 핵심이다.
다양한 종류의 할인 정책이 필요한 컨텍스트에서 Movie를 재사용할 수 있었던
이유는 코드를 직접 수정하지 않고도 협력 대상인 DiscountPolicy 인스턴스를
교체할 수 있었기 때문이다.
어떤 객체를 협력하느냐에 따라 객체의 행동이 달라지는 것은 유연하고 재사용
가능한 설계가 가진 특징이다.
이러한 설계는 객체가 어떻게 하는지를 장황하게 나열하지 않고도 객체들의 조합을
통해 무엇을 하는지를 표현하는 클래스들로 구성된다. 따라서 객체들의 연결 관계에
따라 객체의 행동을 쉽게 예상하고 이해할 수 있다.