잘 설계된 객체지향 애플리케이션은 작고 응집도 높은 객체들로 구성된다. 작고 응집도 높은 객체란 책임의 초점이 명확하고 한 가지 일만 잘 하는 객체를 의미한다.
객체지향 설계의 핵심은 협력을 위해 필요한 의존성은 유지하면서도 변경을 방해하는 의존성은 제거하는데 있다. 이런 관점에서 객체지향 설계란 의존성을 관리하는 것이고 객체가 변화를 받아들일 수 있게 의존성을 정리하는 기술이라고 할 수 있다.
어떤 객체가 협력하기 위해 다른 객체를 필요할 때 두 객체 사이에 의존성이 존재하게 된다. 의존성은 실행 시점과 구현 시점에 서로 다른 의미를 가진다.
PeriodCondtion 클래스의 isSatisfiedBy 메서드는 Screening 인스턴스에게 getStartTime 메시지를 전송한다.
public class PeriodCondition implements DiscountCondition {
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
public PeriodCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
this.dayOfWeek = dayOfWeek;
this.startTime = startTime;
this.endTime = endTime;
}
@Override
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 인스턴스가 존재해야 한다. 만약 Screening의 인스턴스가 존재하지 않거나 getStartTime 메시지를 이해할 수 없다면 PeriodCondition의 isSatisfiedBy 메서드는 예상했던 대로 동작하지 않을 것이다.
이처럼 어떤 객체가 예정된 작업을 정상적으로 수행하기 위해 다른 객체를 필요로 하는 경우 두 객체 사이에 의존성이 존재한다고 말한다. 의존성은 방향성을 가지며 항상 단방향이다. Screening이 변경될 때 PeriodCondtion이 영향을 받게 되지만 그 역은 성립하지 않는다.


의존성은 전이될 수 있다. Screening의 코드를 살펴보면 Screening이 Movie, LocalDateTime, Customer에 의존한다는 사실을 알 수 있다. 의존성 전이가 의미하는 것은 PeriodCondition이 Screening에 의존할 경우 PeriodCondition은 Screening이 의존하는 대상에 대해서도 자동적으로 의존하게 된다는 것이다. 다시 말해서 Screening이 가지고 있는 의존성이 Screening에 의존하고 있는 PeriodCondition으로도 전파된다는 것이다.

의존성은 함께 변경될 수 있는 가능성을 의미하기 때문에 모든 의존성이 전이되는 것은 아니다. 의존성이 전이될지 여부는 변경의 방향과 캡슐화의 정도에 따라 달라진다.
의존성은 전이될 수 있기 때문에 의존성의 종류를 직접 의존성과 간접 의존성으로 나누기도 한다. 직접 의존성이란 말 그대로 한 요소가 다른 요소에 직접 의존하는 경우를 가리킨다. 간접 의존성이랑 직접적인 관계는 존재하지 않지만 의존성 전이에 의해 영향이 전파되는 경우를 가리킨다.
런타임은 간단하다. 말 그대로 애플리케이션이 실행되는 시점을 가리킨다.
컴파일타임은 약간 미묘하다. 일반적으로 컴파일타임이란 작성된 코드를 컴파일하는 시점을 가리키지만 문맥에 따라서는 코드 그 자체를 가리키기도 한다. 컴파일타임 의존성이 바로 이런 경우에 해당한다.

여기서 중요한 것은 Movie 클래스에서 AmountDiscountPolicy, PercentDiscountPolicy 클래스로 향하는 어떤 의존성도 존재하지 않는다는 것이다.
하지만 런타임 의존성을 살펴보면 상황이 완전히 달라진다.

어떤 클래스의 인스턴스가 다양한 클래스의 인스턴스와 협력하기 위해서는 협력할 인스턴스의 구체적인 클래스를 알아서는 안 된다. 실제로 협력할 객체가 어떤 것인지 런타임에 해결해야 한다.
클래스가 사용될 특정한 문맥에 대해 최소한의 가정만으로 이뤄져 있다면 다른 문맥에서 재사용하기가 더 수월해진다. 이를 컨텍스트 독립성이라고 부른다.
클래스가 실행 컨텍스트에 독립적인데도 어떻게 런타임에 실행 컨텍스트에 적절한 객체들과 협력할 수 있을까?
컴파일타임 의존성을 실행 컨텍스트에 맞는 적절한 런타임 의존성으로 교체하는 것을 의존성 해결이라고 부른다. 의존성을 해결하기 위해서는 일반적으로 다음과 같은 세 가지 방법을 사용한다.
설계를 유연하고 재사용 가능하게 만들기로 결정했다면 의존성을 관리하는 데 유용한 몇 가지 원칙과 기법을 익힐 필요가 있다. 먼저 의존성과 결합도의 관계를 살펴보는 것으로 시작하자.
public class Movie {
private PercentDiscountPolicy percentDiscountPolicy;
}
이 코드는 Movie가 PercentDiscountPolicy에 의존하고 있다는 사실을 코드를 통해 명시적으로 드러낸다. Movie와 PercentDiscountPolicy 사이에 의존성이 존재하는 것은 문제가 아니다. 오히려 이 의존성이 객체 사이의 협력을 가능하게 만들기 때문에 존재 자체는 바람직한 것이다.
문제는 의존성의 정도다. 이 코드는 구체적인 클래스에 의존하게 만들기 때문에 다른 종류의 할인 정책이 필요한 문맥에서 Movie를 재사용할 수 있는 가능성을 없애 버렸다.
추상 클래스인 DiscountPolicy는 calculateDiscountAmount 메시지를 이해할 수 있는 타입을 정의함으로써 이 문제를 해결한다.
바람직한 의존성은 재사용성과 관련이 있고 설계를 재사용하기 쉽게 만드는 의존성이다.
일반적으로 추상화와 결합도의 관점에서 의존 대상을 다음과 같이 구분하는 거이 유용하다. 목록에서 아래쪽으로 갈수록 클라이언트가 알아야 하는 지식의 양이 적어지기 때문에 결합도가 느슨해진다.
아래 코드는 한 가지 실수로 인해 결합도가 불필요하게 높아졌다. 그 실수는 무엇일까?
public class Movie {
private DiscountPolicy discountPolicy;
// ...
public Movie(String title, Duration runningTime, Money fee) {
// ...
this.discountPolicy = new AmountDiscountPolicy(...);
}
}
생성자에서 구체 클래스인 AmountDiscountPolicy의 인스턴스를 직접 생성해서 대입하고 있다. 따라서 Movie는 추상 클래스인 DiscountPolicy 뿐만 아니라 구체 클래스인 AmountDiscountPolicy에도 의존하게 된다.

public class Movie {
private DiscountPolicy discountPolicy;
// ...
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
// ...
this.discountPolicy = new discountPolicy;
}
}
생성자의 인자로 선언하는 방법은 Movie가 DiscountPolicy에 의존한다는 사실을 Movie의 퍼블릭 인터페이스에 드러내는 것이다. 이것은 setter 메서드를 사용하는 방식과 메서드 인자를 사용하는 방식의 경우에도 동일하다. 모든 경우에 의존성은 명시적으로 퍼블릭 인터페이스에 노출된다. 이를 명시적인 의존성이라고 부른다.
반면 Movie의 내부에서 AmountDiscountPolicy의 인스턴스를 직접 생성하는 방식은 Movie가 DiscountPolicy에 의존한다는 사실을 감춘다. 다시 말해 의존성이 퍼블릭 인터페이스에 표현되지 않는다. 이를 숨겨진 의존성이라고 부른다.
의존성이 명시적이지 않으면
의존성은 명시적으로 표현돼야 한다. 의존성을 내부 구현에 숨겨두지 마라.
대부분의 언어에서는 클래스의 인스턴스를 생성할 수 있는 new 연산자를 제공한다. 하지만 new를 잘못 사용하면 클래스 사이의 결합도가 극단적으로 높아진다.
public class Movie {
private DiscountPolicy discountPolicy;
// ...
public Movie(String title, Duration runningTime, Money fee) {
// ...
this.discountPolicy = new AmountDiscountPolicy(...,
new SequenceCondition(),
new SequenceCondition(),
new PeriodCondition(),
new PeriodCondition(),
}
}
Movie가 AmountDiscountPolicy에게 더 강하게 결합되게 만든다. 엎친 데 덮친 격으로 SequenceCondition, PeriodCondition에도 의존하도록 만든다.
Movie가 더 많은 것에 의존할수록 점점 더 변경에 취약해진다. 이것이 높은 결합도를 피해야 하는 이유다.
코드에서 알 수 있는 것처럼 Movie가 DiscountPolicy에 의존해야 하는 유일한 이유는 calculateDiscountAmount 메시지를 전송하기 위해서다. 따라서 메시지에 대한 의존성 외의 모든 다른 의존성은 불필요한 의존성이다.
사용과 생성의 책임을 분리하고, 의존성을 생성자에 명시적으로 드러내고, 구체 클래스가 아닌 추상 클래스에 의존하게 함으로써 설계를 유연하게 만들 수 있다. 그리고 그 출발은 객체를 생성하는 책임을 객체 내부가 아니라 클라이언트로 옮기는 것에서 시작했다는 점을 기억하라.
클래스 안에서 객체의 인스턴스를 직접 생성하는 방식이 유용한 경우도 있다. 주로 협력하는 기본 객체를 설정하고 싶은 경우가 여기에 속한다. 예를 들어, Movie가 대부분의 경우에는 AmountDiscountPolicy의 인스턴스와 협력하고 가끔씩만 PercentDiscountPolicy의 인스턴스와 협력한다고 가정해보자. 이런 상황에서 모든 경우에 인스턴스를 생성하는 책임을 클라이언트로 옮긴다면 클라이언트들 사이에 중복 코드가 늘어나고 Movie의 사용성도 나빠질 것이다.
이 문제를 해결하는 방법은 기본 객체를 생성하는 생성자를 추가하고 이 생성자에서 DiscountPolicy의 인스턴스를 인자로 받는 생성자를 체이닝하는 것이다.
public class Movie {
private DiscountPolicy discountPolicy;
// ...
public Movie(String title, Duration runningTime, Money fee) {
this(title, runningTime, fee, new AmountDiscountPolicy(...));
}
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
추가된 생성자 안에서 AmountDiscountPolicy 클래스의 인스턴스를 생성한다는 것을 알 수 있다. 여기서 눈 여겨볼 부분은 첫 번째 생성자의 내부에서 두 번째 생성자를 호출한다는 것이다. 다시 말해 생성자가 체인처럼 연결된다.
이 방법은 메서드를 오버로딩하는 경우에도 사용할 수 있다.
public class Movie {
public Money calculdateMovieFee(Screening screening) {
return calculdateMovieFee(screening, new AmountDiscountPolicy(...));
}
public Money calculdateMovieFee(Screening screnning, DiscountPolicy discountPolicy) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
의존성이 불편한 이유는 그것이 항상 변경에 대한 영향을 암시하기 때문이다. 따라서 변경될 확률이 거의 없는 클래스라면 의존성이 문제가 되지 않는다. 자바라면 JDK에 포함된 표준 클래스가 이 부류에 속한다.
비록 클래스를 직접 생성하더라도 가능한 한 추상적인 타입을 사용하는 것이 확장성 측면에서 유리하다. 다양한 List 타입의 객체로 conditions를 대체할 수 있게 설계의 유연성을 높일 수 있다. 따라서 의존성에 의한 영향이 적은 경우에도 추상화에 의존하고 의존성을 명시적으로 드러내는 것은 좋은 설계 습관이다.
public abstract class DiscountPolicy {
private List<DiscountCondition> conditions = new ArrayList<>();
public void switchConditions(List<DiscountCondition> conditions) {
this.conditions = conditions;
}
}
Movie가 유연하다는 사실을 입증하기 위해 지금까지와 다른 컨텍스트에서 Movie를 확장해서 재사용하는 두 가지 예를 살펴보겠다. 하나는 할인 혜택을 제공하지 않는 영화의 경우이고, 다른 하나는 다수의 할인 정책을 중복해서 적용하는 영화를 처리하는 경우이다.
public class Movie {
private DiscountPolicy discountPolicy;
// ...
public Movie(String title, Duration runningTime, Money fee) {
this(title, runningTime, fee, null);
}
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
public Money calculateMovieFee(Screening screening) {
if (discountPolicy == null) {
return fee;
}
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
이 코드는 제대로 동작하지만 한 가지 문제가 있다. 지금까지의 Movie와 DiscountPolicy 사이의 협력 방식에 어긋나는 예외 케이스가 추가된 것이다. 그리고 이 예외 케이스를 처리하기 위해 Movie의 내부 코드를 직접 수정해야 했다. 어떤 경우든 코드 내부를 직접 수정하는 것은 버그의 발생 가능성을 높이는 것이라는 점을 기억하라.
해결책은 할인 정책이 존재하지 않는다는 사실을 예외 케이스로 처리하지 말고 기존에 Movie와 DiscountPolicy가 협력하던 방식을 따르도록 만드는 것이다. 다시 말해 할인 정책이 존재하지 않는다는 사실을 할인 정책의 한 종류로 간주하는 것이다.
public class NonDiscountPolicy extends DiscountPolicy {
// ...
}
두 번째 예는 중복 적용이 가능한 할인 정책을 구현하는 것이다. 여기서 중복 할인이란 금액, 비율 할인 정책을 혼합하는걸 의미한다.
이 문제 역시 NoneDiscountPolicy 처럼 중복 할인 정책을 할인 정책의 한 가지로 간주하는 것이다.
public class OverlappedDiscountPolicy extends DiscountPollicy {
private List<DiscountPolicy> discountPolicies = new ArrayList<>();
// ...
}
Movie를 재사용할 수 있었던 이유는 코드를 직접 수정하지 않고 협력 대상인 DiscountPolicy 인스턴스를 교체할 수 있었기 때문이다.