객체들이 협력하려면 서로를 알아야 하고, 안다는 것은 곧 의존한다는 것이다. 의존성 자체는 피할 수 없다 — 협력의 필연적 부산물이다. 문제는 의존성이 있느냐가 아니라 그것이 바람직한가다. 바람직한 의존성은 재사용과 변경을 돕고, 바람직하지 않은 의존성은 한 곳의 변경을 여러 곳으로 전파한다. 이 장은 나쁜 의존성을 좋은 의존성으로 바꾸는 기법을 다룬다.
컴파일타임 의존성과 런타임 의존성을 분리하라
Movie가 특정 할인 정책(AmountDiscountPolicy)을 직접 알면, 코드에 그 구체 클래스 이름이 박힌다 — 컴파일타임 의존성이다. 하지만 실행 중에 실제로 협력하는 대상은 그때그때 다를 수 있다 — 런타임 의존성이다. 이 둘이 일치하면 유연성이 사라진다.
// before — 구체 클래스에 컴파일타임 의존. 정책을 바꾸려면 Movie를 고쳐야 한다
public class Movie {
private AmountDiscountPolicy discountPolicy; // 구체 타입에 묶임
public Movie() {
this.discountPolicy = new AmountDiscountPolicy(...);
}
}
// after — 추상(인터페이스)에 의존. 실제 정책은 런타임에 결정된다
public class Movie {
private DiscountPolicy discountPolicy; // 추상 타입에 의존
public Movie(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy; // 무엇이 올지 Movie는 모른다
}
}Movie가 추상 타입 DiscountPolicy에만 의존하면, 컴파일타임에는 인터페이스만 알고 런타임에는 AmountDiscountPolicy든 PercentDiscountPolicy든 협력할 수 있다. 유연함은 바로 이 컴파일타임 구조와 런타임 구조의 거리에서 나온다.
판단 기준: 클래스 안에 다른 구체 클래스의 이름이 박혀 있으면 그만큼 굳은 것이다. 자주 바뀌거나 여러 대안이 있는 대상은 추상 타입으로 받는다. 함정: 모든 것을 인터페이스로 추상화하면 이해가 어려워진다. 변경 가능성이 낮고 대안이 없는 협력까지 추상화하는 건 유연성이 아니라 잡음이다.
new의 문제 — 생성이 곧 결합이다
new AmountDiscountPolicy(...)를 클래스 안에서 호출하는 순간, 그 클래스는 구체 타입과 생성 방법까지 안다. DiscountPolicy 타입으로 필드를 선언해 놓고도 안에서 new로 만들면, 결국 구체 클래스에 다시 묶인다. 추상화가 무의미해진다.
// before — 필드는 추상 타입이지만 안에서 new로 구체를 만든다(의존성이 다시 굳음)
public class Movie {
private DiscountPolicy discountPolicy = new AmountDiscountPolicy(...);
}
// after — 생성 책임을 밖으로 밀어낸다. Movie는 받아 쓰기만 한다
public class Movie {
private DiscountPolicy discountPolicy;
public Movie(DiscountPolicy discountPolicy) { // 사용과 생성을 분리
this.discountPolicy = discountPolicy;
}
}핵심은 사용과 생성의 분리다. Movie는 할인 정책을 사용할 뿐, 어떤 정책을 어떻게 만들지는 알 필요가 없다. 그 결정을 밖(생성 책임을 가진 조립자)으로 밀어내면 Movie는 정책 종류로부터 자유로워진다.
판단 기준: new는 “이 구체 클래스에 지금 여기서 묶이겠다”는 선언이다. 협력 대상을 바꿀 여지를 남기고 싶다면 그 new를 클래스 밖으로 옮긴다. 함정: 생성 자체가 사라지는 건 아니다. 어딘가는 new를 해야 한다 — 다만 그 책임을 사용하는 곳이 아니라 조립하는 곳(main, 팩토리, DI 컨테이너)에 모은다.
의존성 주입 — 필요한 것을 밖에서 넣어준다
생성을 밀어냈으면 이제 협력 대상을 외부에서 넣어줘야 한다. 이것이 **의존성 주입(Dependency Injection)**이다. 방법은 세 가지다.
// 1) 생성자 주입 — 객체 생성 시점에 필수 의존성을 못 박는다(가장 선호)
Movie movie = new Movie(new PercentDiscountPolicy(...));
// 2) setter 주입 — 실행 중에 협력 대상을 교체할 수 있다(선택적 의존성)
movie.setDiscountPolicy(new AmountDiscountPolicy(...));
// 3) 메서드 인자 주입 — 그 메서드 안에서만 잠깐 필요할 때
Money fee = movie.calculateFee(screening, new NoneDiscountPolicy());주입의 효과는 명확하다. Movie를 테스트할 때 진짜 정책 대신 가짜(mock/stub)를 넣을 수 있고, 정책을 바꿀 때 Movie 코드는 한 줄도 안 건드린다. 협력 대상을 고르는 결정이 조립하는 쪽으로 올라간다.
판단 기준: 생성 시점에 반드시 있어야 하고 이후 안 바뀌는 의존성은 생성자 주입, 실행 중 교체가 필요하면 setter 주입, 특정 연산에서만 쓰면 메서드 인자 주입. 함정: 생성자 인자가 지나치게 많아지면 그건 주입의 문제가 아니라 그 클래스가 너무 많은 책임을 진다는 신호다. 주입으로 가리지 말고 책임을 쪼갠다.
숨겨진 의존성을 드러내라
가장 위험한 의존성은 보이지 않는 의존성이다. Service.locator()나 전역 싱글턴에서 협력 대상을 꺼내 오면, 클래스의 시그니처만 봐서는 무엇에 의존하는지 알 수 없다.
// before — 숨은 의존성. 생성자만 봐선 무엇이 필요한지 알 수 없다
public class Movie {
private DiscountPolicy discountPolicy;
public Movie() {
this.discountPolicy = ServiceLocator.discountPolicy(); // 몰래 꺼내온다
}
}
// after — 드러난 의존성. 생성자가 필요한 것을 스스로 말한다
public class Movie {
private DiscountPolicy discountPolicy;
public Movie(DiscountPolicy discountPolicy) { // 명세가 곧 계약
this.discountPolicy = discountPolicy;
}
}의존성 주입(명시적)과 서비스 로케이터(숨김)는 겉보기엔 둘 다 결합을 낮추는 것 같지만 결정적 차이가 있다. 주입은 의존성을 인터페이스에 드러내 컴파일러와 사람 모두 알 수 있게 한다. 로케이터는 의존성을 구현 안에 숨겨, 실행해 보기 전엔 무엇이 필요한지 모른다. new Movie()가 컴파일은 되지만 로케이터에 정책을 등록 안 하면 런타임에 터진다.
판단 기준: 생성자와 메서드 시그니처만 읽어서 그 객체가 무엇에 의존하는지 알 수 있어야 한다. 숨겨진 의존성은 재사용을 어렵게 하고 테스트를 취약하게 만든다. 함정: 서비스 로케이터가 항상 나쁜 건 아니지만, 명시적 주입이 가능한 상황이라면 대개 주입이 낫다. 의존성은 숨길수록 관리하기 어려워진다.
정리
- 의존성은 협력의 필연적 부산물이다. 없앨 수 없으니 바람직하게 관리한다 — 재사용과 변경을 돕는 방향으로.
- 컴파일타임 의존성(구체 타입)과 런타임 의존성(실제 협력 대상)을 분리하면, 그 거리만큼 유연해진다. 추상 타입에 의존하라.
- 클래스 안의
new는 구체 타입과 생성 방법에 묶이는 것이다. 사용과 생성을 분리해new를 밖으로 밀어낸다. - 의존성 주입(생성자·setter·메서드 인자)으로 협력 대상을 외부에서 넣는다. 테스트 대역 교체와 정책 변경이 코드 수정 없이 가능해진다.
- 서비스 로케이터의 숨은 의존성보다, 시그니처에 드러난 명시적 주입이 낫다. 의존성은 숨길수록 다루기 어렵다.
다음장으로 9장