협력은 필수적이지만 과도한 협력은 설계를 곤경에 빠트릴 수 있다. 협력을 위해서는 의존성이 필요하지만 과도한 의존성은 애플리케이션을 수정하기 어렵게 만든다.
객체지향 설계의 핵심은 협력을 위해 필요한 의존성은 유지하면서도 변경을 방해하는 의존성은 제거하는데 있다. 이런 관점에서 객체지향 설계란 의존성을 관리하는 것이고 객체가 변화를 받아들일 수 있게 의존성을 정리하는 기술이라고 할 수 있다.
📖 8.1 의존성 이해하기
🔖 8.1.1 변경과 의존성
어떤 객체가 협력하기 위해 다른 객체를 필요로 할 때 두 객체 사이에 의존성이 존재하게 된다. 의존성은 실행 시점과 구현 시점에 서로 다른 의미를 가진다.
- 실행 시점: 의존하는 객체가 정상적으로 동작하기 위해서는 실행 시에 의존 대상 객체가 반드시 존재해야 한다.
- 구현 시점: 의존 대상 객체가 변경될 경우 의존하는 객체도 함께 변경된다.
@AllArgsConstructor
public class PeriodCondition implements DiscountCondition {
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
@Override
public boolean isSatisfiedBy(Screening screening) {
return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
!startTime.isAfter(screening.getStartTime().toLocalTime()) &&
!endTime.isBefore(screening.getStartTime().toLocalTime());
}
}
- 실행 시점에
PeriodCondition의 인스턴스가 정상적으로 동작하기 위해서는Screening의 인스턴스가 존재해야 한다.
이처럼 어떤 객체가 예정된 작업을 정상적으로 수행하기 위해 다른 객체를 필요로 하는 경우 두 객체 사이에 의존성이 존재한다고 말한다.
- 의존성은 방향성을 가지며 항상 단방향이다.
- 두 요소 사이의 의존성은 의존되는 요소가 변경될 때 의존하는 요소도 함께 변경될 수 있다는 것을 의미
- 의존성은 변경에 의한 영향의 전파 가능성을 암시
🔖 8.1.2 의존성 전이
- 의존성은 함께 변경될 수 있는 가능성을 의미하기 때문에 모든 경우에 의존성이 전이되는 것은 아니다.
- 의존성이 실제로 전이될지 여부는 변경의 방향과 캡슐화의 정도에 따라 달라진다.
- 의존성 전이는 변경에 의해 영향이 널리 전파될 수도 있다는 경고일 뿐이다.
직접 의존성
- 한 요소가 다른 요소에 직접 의존하는 경우
PeriodCondition이Screening에 직접 의존하는 경우
간접 의존성
- 직접적인 관계는 존재하지 않지만 의존성 전이에 의해 영향이 전파되는 경우
🔖 8.1.3 런타임 의존성과 컴파일타임 의존성
런타임 의존성
- 런타임: 애플리케이션이 실행되는 시점
- 객체 사이의 의존성
컴파일타임 의존성
- 컴파일타임: 코드를 컴파일하는 시점, 문맥에 따라서는 코드 그 자체
- 클래스 사이의 의존성
유연하고 재사용 가능한 설계를 창조하기 위해서는 동일한 소스코드 구조를 가지고 다양한 실행 구조를 만들 수 있어야 한다.
- 어떤 클래스의 인스턴스가 다양한 클래스의 인스턴스와 협력하기 위해서는 협력할 인스턴스의 구체적인 클래스를 알아서는 안 된다.
🔖 8.1.4 컨텍스트 독립성
- 클래스가 사용될 특정한 문맥에 대해 최소한의 가정만으로 이뤄져 있다면 다른 문맥에서 재사용하기가 더 수월해진다.
- 가능한 한 자신이 실행될 컨텍스트에 대한 구체적인 정보를 최대한 적게 알아야 한다.
- 더 다양한 컨텍스트에서 재사용될 수 있기 때문
시스템을 구성하는 객체가 컨텍스트 독립적이라면 해당 시스템은 변경하기 쉽다. 여기서 컨텍스트 독립적이라는 말은 각 객체가 해당 객체를 실행하는 시스템에 관해 아무것도 알지 못한다는 의미다.
🔖 8.1.5 의존성 해결하기
컴파일타임 의존성은 구체적인 런타임 의존성으로 대체돼야 한다.
- 의존성 해결: 컴파일타임 의존성을 실행 컨텍스트에 맞는 적절한 런타임 의존성으로 교체하는 것
- 객체를 생성하는 시점에 생성자를 통해 의존성 해결
- 객체 생성 후 setter 메서드를 통해 의존성 해결
- 객체를 생성한 이후에도 의존하고 있는 대상을 변경할 수 있는 가능성을 열어 놓고 싶은 경우에 유용
- 객체가 생성된 후에 협력에 필요한 의존 대상을 설정하기 때문에 객체를 생성하고 의존 대상을 설정하기 전까지는 객체의 상태가 불완전할 수 있다.
- 생성자 방식과 혼합해서 사용하는 걸 권장
- 메서드 실행 시 인자를 이용해 의존성 해결
- 메서드가 실행되는 동안만 일시적으로 의존 관계가 존재해도 무방할 때 사용
- 메서드가 실행될 때마다 의존 대상이 매번 달라져야 하는 경우에 유용
📖 8.2 유연한 설계
🔖 8.2.1 의존성과 결합도
의존성은 객체들의 협력을 가능하게 만드는 매개체라는 관점에서는 바람직한 것이다. 하지만 의존성이 과하면 문제가 될 수 있다.
바람직한 의존성은 재사용성과 관련이 있다.
- 어떤 의존성이 다양한 환경에서 클래스를 재사용할 수 없도록 제한한다면 그 의존성은 바람직하지 못한 것이다.
- 독립적인 의존성은 바람직한 의존성이고 특정한 컨텍스트에 강하게 결합된 의존성은 바람직하지 않은 의존성이다.
- 의존성이 바람직할 때 느슨한 결합도(loose coupling) 또는 약한 결합도(weak coupling)를 가진다고 말한다.
- 의존성이 바람직하지 못할 때 단단한 결합도(tight coupling) 또는 강한 결합도(strong coupling)를 가진다고 말한다.
🔖 8.2.2 지식이 결합을 낳는다
결합도의 정도는 한 요소가 자신이 의존하고 있는 다른 요소에 대해 알고 있는 정보의 양으로 결정
- 한 요소가 다른 요소에 대해 더 많은 정보를 알고 있을수록 강하게 결합
- 더 적은 컨텍스트에서 재사용 가능하다는 것을 의미
- 한 요소가 다른 요소에 대해 더 적은 정보를 알고 있을수록 두 요소는 약하게 결합
결합도를 느슨하게 만들기 위해서는 협력하는 대상에 대해 필요한 정보 외에는 최대한 감추는 것이 중요
🔖 8.2.3 추상화에 의존하라
추상화란 어떤 양상, 세부사항, 구조를 좀 더 명확하게 이해하기 위해 특정 절차나 물체를 의도적으로 생략하거나 감춤으로써 복잡도를 극복하는 방법
일반적으로 추상화와 결합도의 관점에서 의존 대상을 다음과 같이 구분하는 것이 유용하다. 아래로 갈수록 결합도가 느슨해진다.
- 구체 클래스 의존성
- 추상 클래스 의존성
- 상속 계층이 무엇인지 알아야 한다.
- 인터페이스 의존성
- 상속 계층을 모르더라도 협력이 가능해진다.
의존하는 대상이 더 추상적일수록 결합도는 더 낮아진다.
🔖 8.2.4 명시적인 의존성
public class Movie {
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee) {
this.discountPolicy = new AmountDiscountPolicy(...);
}
}
- 구체 클래스인
AmountDiscountPolicy의 인스턴스를 직접 생성해서 대입하고 있어 결합도가 불필요하게 높아졌다. - 숨겨진 의존성: 의존성이 퍼블릭 인터페이스에 표현되지 않는다.
public class Movie {
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
- 생성자를 사용해 의존성을 해결했다.
- 명시적인 의존성: 모든 경우에 의존성은 명시적으로 퍼블릭 인터페이스에 노출된다.
의존성을 구현 내부에 숨겨두지 마라. 명시적인 의존성을 사용해야만 퍼블릭 인터페이스를 통해 컴파일타임 의존성을 적절한 런타임 의존성으로 교체할 수 있다.
- 클래스가 다른 클래스에 의존하는 것은 부끄러운 것이 아니다❗️
- 경계해야 할 것은 의존성 자체가 아니라 의존성을 감추는 것이다.
🔖 8.2.5 new는 해롭다
new를 잘못 사용하면 클래스 사이의 결합도가 극단적으로 높아진다.
new연산자를 사용하기 위해서는 구체 클래스의 이름을 직접 기술해야한다. 즉, 구체 클래스에 의존할 수 밖에 없기에 결합도가 높아진다.new연산자는 생성하려는 구체 클래스뿐만 아니라 어떤 인자를 이용해 클래스의 생성자를 호출해야 하는지도 알아야 한다. 즉, 지식의 양이 늘어나기 때문에 결합도가 높아진다.
사용과 생성의 책임을 분리하고, 의존성을 생성자에 명시적으로 드러내고, 구체 클래스가 아닌 추상 클래스에 의존하게 함으로써 설계를 유연하게 만들 수 있다.
- 객체를 생성하는 책임을 객체 내부가 아니라 클라이언트로 옮겨라❗️
🔖 8.2.6 가끔은 생성해도 무방하다
주로 협력하는 기본 객체를 설정하고 싶은 경우 클래스 안에서 객체의 인스턴스를 직접 생성하는 방식이 유용하다.
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;
}
}
- 생성자 체이닝을 활용
- 메서드 오버로딩에도 사용 가능
모든 결합도가 모이는 새로운 클래스를 추가함으로써 사용성과 유연성이라는 두 마리 토끼를 잡을 수 있는 경우도 있다.
🔖 8.2.7 표준 클래스에 대한 의존은 해롭지 않다
의존성이 불편한 이유는 그것이 항상 변경에 대한 영향을 암시하기 때문
- 변경될 확률이 거의 없는 클래스라면 의존성이 문제가 되지 않는다.
public abstract class DiscountPolicy {
private List<DiscountCondition> conditions = new ArrayList<>();
public void switchConditions(List<DiscountCondition> conditions) {
this.conditions = conditions;
}
}
ArrayList의 코드가 수정될 확률은 0에 가깝기 때문에 인스턴스를 직접 생성하더라도 문제가 되지 않는다.- 클래스를 직접 생성하더라도 가능한 한 추상적인 타입을 사용하는 것이 확장성 측면에서 유리하다.
- 인터페이스
List를 사용하여 다양한 객체로 대체가능하도록 설계
- 인터페이스
🔖 8.2.8 컨텍스트 확장하기
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))
}
}
- 예외 케이스가 추가되어 내부 코드가 수정되었다.
- 버그 발생 가능성 ⬆️
public class NoneDiscountPolicy extends DiscountPolicy {
@Override
protected Money getDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
- 내부 코드를 수정하지 않아도 된다.
설계를 유연하게 만들 수 있었던 이유는 Movie가 DiscountPolicy라는 추상화에 의존하고, 생성자를 통해 DiscountPolicy에 대한 의존성을 명시적으로 드러냈으며, new와 같이 구체 클래스를 직접적으로 다뤄야 하는 책임을 Movie 외부로 옮겼기 때문이다.
- 결합도를 낮춤으로써 얻게 되는 컨텍스트 확장이라는 개념이 유연하고 재사용 가능한 설계를 만드는 핵심
🔖 8.2.8 조합 가능한 행동
어떤 객체와 협력하느냐에 따라 객체의 행동이 달라지는 것은 유연하고 재사용 가능한 설계가 가진 특징이다. 유연하고 재사용 가능한 설계는 응집도 높은 책임들을 가진 작은 객체들을 다양한 방식으로 연결함으로써 애플리케이션의 기능을 쉽게 확장할 수 있다.
훌륭한 객체지향 설계란 객체가 어떻게 하는지를 표현하는 것이 아니라 객체들의 조합을 선언적으로 표현함으로써 객체들이 무엇을 하는지를 표현하는 설계다.
설계를 창조하는 데 있어서의 핵심은 의존성을 관리하는 것이다.
