객체들이 협력하려면 서로를 알아야 하고, 안다는 것은 곧 의존한다는 것이다. 의존성 자체는 피할 수 없다 — 협력의 필연적 부산물이다. 문제는 의존성이 있느냐가 아니라 그것이 바람직한가다. 바람직한 의존성은 재사용과 변경을 돕고, 바람직하지 않은 의존성은 한 곳의 변경을 여러 곳으로 전파한다. 이 장은 나쁜 의존성을 좋은 의존성으로 바꾸는 기법을 다룬다.

의존성이란 무엇인가

의존성을 정확히 정의하면 이렇다 — 어떤 객체 A가 변경될 때 다른 객체 B도 함께 변경될 수 있다면, B는 A에 의존한다. 의존성은 변경에 의해 영향받을 가능성을 담은 방향성 있는 관계다. MovieDiscountPolicy를 사용한다면, DiscountPolicy의 인터페이스가 바뀔 때 Movie도 바뀔 수 있으므로 MovieDiscountPolicy에 의존한다.

의존성에는 시점이 있다. 소스 코드에 타입 이름이 등장하는 구현(컴파일) 시점의 의존성과, 실행 중 실제로 메시지를 주고받는 실행 시점의 의존성이다. 이 둘은 일치할 수도, 어긋날 수도 있다. 좋은 설계는 이 둘을 의도적으로 어긋나게 만든다 — 구현 시점엔 추상 타입에만 의존하고, 실행 시점엔 다양한 구체 객체와 협력한다.

의존성은 또한 전이(transitive) 된다. A → B → C로 의존이 이어지면, C의 변경이 B를 거쳐 A까지 도달할 수 있다. 이 전이성 때문에, 직접 알지 못하는 먼 객체의 변경이 나에게 흘러들어 올 수 있다. 의존성 사슬이 길고 구체적일수록 변경에 흔들릴 표면적이 넓어진다.

// A(Movie) → B(DiscountPolicy) → C(DiscountCondition) 로 전이되는 의존
public class Movie {                    // A
    private DiscountPolicy policy;      // B에 의존
}
public class DiscountPolicy {           // B
    private List<DiscountCondition> conditions;   // C에 의존 → 전이로 A까지 영향 가능
}

전이 의존성을 줄이는 목표가 컨텍스트 독립성(context independence) 이다. 한 클래스가 자신이 사용될 특정한 문맥(어떤 구체 협력자와 함께 쓰이는지)에 대해 적게 알수록, 다른 문맥으로 재사용하기 쉽다. 구체 클래스에 의존하면 그 클래스가 사는 맥락까지 끌고 오지만, 추상 타입에 의존하면 맥락으로부터 자유로워진다. 유연하고 재사용 가능한 설계란 곧 컨텍스트 독립적인 설계다.

판단 기준: “이 클래스를 전혀 다른 프로젝트로 떼어 갈 수 있는가”를 물어 컨텍스트 독립성을 가늠한다. 특정 구체 협력자 없이는 컴파일조차 안 된다면 그 맥락에 묶인 것이다. 함정: 의존성을 0으로 만들 수는 없다. 목표는 제거가 아니라, 꼭 필요한 의존을 추상적이고 최소한으로 유지하는 것이다.

바람직하지 못한 의존성의 세 얼굴

모든 의존성이 문제는 아니다. 관리해야 할 것은 변경을 어렵게 만드는 바람직하지 못한 의존성이다. 이 장이 차례로 걷어내는 그 유형들을 먼저 이름 붙여 두면, 이후의 기법들이 각각 무엇을 겨냥하는지가 선명해진다.

  • 구체 클래스에 대한 의존. 인터페이스가 아니라 특정 구현 클래스(AmountDiscountPolicy)에 직접 묶이면, 협력 대상을 바꿀 여지가 사라진다. 그 구체 클래스가 바뀌거나 다른 구현으로 교체하고 싶을 때마다 코드를 열어야 한다.
  • 숨겨진 의존성. new나 전역 접근으로 의존 대상을 클래스 내부에 몰래 감춘 경우다. 시그니처만 봐선 무엇이 필요한지 알 수 없어, 실행해 보기 전엔 결합의 실체가 드러나지 않는다.
  • 생성에 대한 의존. 사용하는 객체가 협력 대상을 직접 생성하기까지 하면, 사용법뿐 아니라 생성 방법(어떤 인자로 어떻게 만드는지)까지 안다. new는 이 세 유형이 얽히는 지점이다 — 구체 클래스를 이름으로 부르며(구체 의존), 생성 방법을 알고(생성 의존), 그것을 내부에 감춘다(숨은 의존).

new가 결합도를 높이는 핵심 이유가 여기 있다. 한 줄의 new AmountDiscountPolicy(...)가 세 가지 나쁜 의존성을 한꺼번에 만든다. 이 장의 나머지는 이 세 얼굴을 각각 — 추상화로, 명시적 주입으로, 생성 책임의 분리로 — 벗겨 낸다.

판단 기준: 어떤 의존성을 만났을 때 “구체 타입에 묶였는가 / 시그니처에 드러나는가 / 생성까지 떠맡는가” 세 축으로 점검한다. 함정: 세 유형은 대개 함께 나타난다. 하나만 고치고 나머지를 남겨 두면 결합은 그대로다 — new를 없애되 추상 타입으로 받고 명시적으로 주입해야 세 가지가 함께 풀린다.

컴파일타임 의존성과 런타임 의존성을 분리하라

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에만 의존하면, 컴파일타임에는 인터페이스만 알고 런타임에는 AmountDiscountPolicyPercentDiscountPolicy든 협력할 수 있다. 유연함은 바로 이 컴파일타임 구조와 런타임 구조의 거리에서 나온다.

판단 기준: 클래스 안에 다른 구체 클래스의 이름이 박혀 있으면 그만큼 굳은 것이다. 자주 바뀌거나 여러 대안이 있는 대상은 추상 타입으로 받는다. 함정: 모든 것을 인터페이스로 추상화하면 이해가 어려워진다. 변경 가능성이 낮고 대안이 없는 협력까지 추상화하는 건 유연성이 아니라 잡음이다.

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 주입은 실행 중 언제든 협력 대상을 바꿀 수 있어 유연하지만, setter를 호출하기 전까지는 의존성이 비어 있어 NullPointerException의 위험을 안는다 — 객체가 “완성되지 않은 상태”로 돌아다닐 수 있다. 메서드 인자 주입은 그 메서드가 실행되는 동안에만 의존성이 필요할 때 쓴다 — 객체가 그 협력자를 필드로 계속 들고 있을 이유가 없을 때 적합하다. 다만 매번 인자로 넘겨야 하므로 호출부가 그 의존성을 알아야 한다.

판단 기준: 생성 시점에 반드시 있어야 하고 이후 안 바뀌는 의존성은 생성자 주입, 실행 중 교체가 필요하면 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()가 컴파일은 되지만 로케이터에 정책을 등록 안 하면 런타임에 터진다.

판단 기준: 생성자와 메서드 시그니처만 읽어서 그 객체가 무엇에 의존하는지 알 수 있어야 한다. 숨겨진 의존성은 재사용을 어렵게 하고 테스트를 취약하게 만든다. 함정: 서비스 로케이터가 항상 나쁜 건 아니지만, 명시적 주입이 가능한 상황이라면 대개 주입이 낫다. 의존성은 숨길수록 관리하기 어려워진다.

명시적 의존성이 테스트를 살린다

의존성을 명시적으로 드러냈을 때의 가장 실질적인 이득은 테스트 용이성에서 나타난다. 협력 대상을 밖에서 주입받는 객체는, 테스트에서 진짜 협력자 대신 통제 가능한 테스트 대역(test double) 을 끼워 넣을 수 있다.

// 숨은 의존성 — 테스트에서 정책을 갈아 끼울 방법이 없다
public class Movie {
    private DiscountPolicy policy = new AmountDiscountPolicy(...);  // 고정
}
// Movie를 테스트하려면 진짜 AmountDiscountPolicy와 그 조건까지 모두 세팅해야 한다
 
// 명시적 의존성 — 테스트에서 원하는 값을 반환하는 가짜를 주입한다
Movie movie = new Movie(screening -> Money.wons(500));  // 항상 500원 할인하는 stub
Money fee = movie.calculateFee(screening);
assertEquals(Money.wons(9500), fee);                    // 정책과 무관하게 Movie만 검증

숨은 의존성을 가진 객체는 그 의존 대상의 전체 협력 그래프를 함께 세워야만 테스트할 수 있다 — Movie를 테스트하려는데 할인 정책과 그 안의 조건들까지 모두 준비해야 한다. 명시적 주입은 이 사슬을 끊는다. 테스트하려는 객체만 남기고 나머지를 대역으로 대체하면, 검증의 초점이 흐려지지 않는다. 테스트하기 쉬운 설계가 곧 의존성이 잘 관리된 설계다.

판단 기준: “이 객체를 단독으로 테스트할 수 있는가”를 물으면 의존성 관리 수준이 드러난다. 협력자를 대역으로 바꿀 수 없다면 의존성이 숨어 있거나 굳어 있는 것이다. 함정: 테스트를 위해서만 인터페이스를 남발하지는 않는다. 대역 교체가 실제로 가치 있는 협력(외부 시스템, 비결정적 로직, 무거운 객체)에 집중한다.

조합 가능한 행동 — 유연한 설계의 정체

지금까지의 기법들은 결국 하나의 목표를 향한다. Movie가 구체 정책을 모르고 추상 타입에만 의존하면, 어떤 정책을 조립하느냐로 Movie의 행동을 바꿀 수 있다. Movie 코드는 한 줄도 고치지 않고, 조립하는 쪽에서 정책 객체를 갈아 끼우는 것만으로 금액 할인 영화도, 비율 할인 영화도, 할인 없는 영화도 만든다.

// 코드를 바꾸지 않고, 객체를 조합하는 것만으로 다른 행동을 만든다
Movie amountMovie  = new Movie(new AmountDiscountPolicy(Money.wons(800),
        new SequenceCondition(1), new PeriodCondition(MONDAY, ...)));   // 조건까지 조합
Movie percentMovie = new Movie(new PercentDiscountPolicy(0.1,
        new PeriodCondition(THURSDAY, ...)));
Movie noneMovie    = new Movie(new NoneDiscountPolicy());

여기서 핵심 통찰이 드러난다 — 유연하고 재사용 가능한 설계란 결국 의존성을 관리하는 일의 다른 이름이다. 잘 조립된 객체 그래프를 보면, 각 객체가 어떻게 행동할지가 그 객체의 코드가 아니라 런타임에 어떤 객체와 연결됐는가로 결정된다. 로직을 코드에 하드코딩하는 대신, 작은 객체들을 조합해 행동을 표현하는 것이다. 클래스를 새로 짜지 않고 이미 있는 객체들을 다르게 엮어 새 행동을 얻는다.

이것이 가능한 이유는 앞의 모든 기법 덕분이다. 추상 타입 의존이 조합의 자리를 열고, new를 밖으로 밀어낸 것이 조합할 권한을 조립자에게 주고, 명시적 주입이 어떤 조합이 일어나는지 드러낸다. 세 기법이 모여 “코드 수정 없이 행동을 조합하는” 유연성을 만든다.

판단 기준: 요구사항의 변화가 “새 조합”으로 표현될 수 있으면 그 설계는 유연하다. 새 정책이 필요할 때 클래스 하나 추가하고 조립만 바꾸면 되는지 확인한다. 함정: 조합 가능성을 극단으로 밀면 조립 코드가 복잡해지고, 무엇과 무엇이 연결됐는지 추적하기 어려워진다. 유연성에는 이해 비용이 따르므로 필요한 만큼만 연다.

표준 클래스에 대한 의존은 괜찮다

“구체 클래스에 의존하지 말라”는 원칙을 극단으로 밀면 String, Integer, List 같은 표준 클래스에까지 인터페이스를 씌우려 들게 된다. 그럴 필요 없다. 의존성의 나쁨을 결정하는 진짜 기준은 “구체 타입인가”가 아니라 “그것이 변경될 확률이 높은가” 다.

// 이런 의존은 걱정할 필요 없다 — 이 타입들이 바뀔 확률은 사실상 0이다
public class Movie {
    private String title;                  // String이 바뀔 일은 없다
    private List<DiscountCondition> conditions = new ArrayList<>();  // 표준 컬렉션
    private Money fee;                      // 안정된 값 객체
}

String이나 ArrayList의 인터페이스가 바뀌어 내 코드가 깨질 일은 현실적으로 없다. 이렇게 변경 가능성이 극히 낮은 안정된 요소에 대한 의존은 관리 대상이 아니다. 오히려 이런 것까지 추상화하면 코드에 불필요한 인터페이스만 늘어나 이해를 방해한다. 의존성 관리의 노력은 자주 바뀌고 대안이 여럿인 요소 — 할인 정책, 외부 연동, 도메인 규칙 — 에 집중한다.

판단 기준: 어떤 타입에 의존하기 전에 “이게 앞으로 바뀔 것 같은가”를 묻는다. 답이 “거의 안 바뀐다”면 구체 타입이어도 그냥 의존한다. 함정: 안정성과 구체성을 혼동하지 않는다. 표준 라이브러리는 구체적이지만 안정적이고, 우리 도메인의 정책 클래스는 구체적이면서 불안정하다. 관리해야 할 것은 후자다.

정리

  • 의존성은 A가 바뀔 때 B도 바뀔 수 있는 방향성 있는 관계다. 없앨 수 없으니 바람직하게 관리한다 — 재사용과 변경을 돕는 방향으로.
  • 의존성은 전이된다. 전이 사슬을 짧고 추상적으로 유지해 컨텍스트 독립성을 높이면, 다른 맥락으로 재사용하기 쉬워진다.
  • 컴파일타임 의존성(구체 타입)과 런타임 의존성(실제 협력 대상)을 분리하면, 그 거리만큼 유연해진다. 추상 타입에 의존하라.
  • 클래스 안의 new는 구체 타입과 생성 방법에 묶이는 것이다. 사용과 생성을 분리해 new를 밖으로 밀어낸다.
  • 의존성 주입(생성자·setter·메서드 인자)으로 협력 대상을 외부에서 넣는다. 테스트 대역 교체와 정책 변경이 코드 수정 없이 가능해진다.
  • 서비스 로케이터의 숨은 의존성보다, 시그니처에 드러난 명시적 주입이 낫다. 의존성은 숨길수록 다루기 어렵다.
  • 유연한 설계란 의존성 관리의 다른 이름이다. 코드를 고치지 않고 객체를 조합해 새 행동을 만든다.
  • 관리할 의존성은 “구체적인 것”이 아니라 “자주 바뀌는 것”이다. String·List 같은 안정된 표준 클래스 의존은 걱정하지 않는다.

다음장으로 9장