8장까지 쌓아 온 것은 원칙과 냄새였다. 이 장은 그 원칙을 실제 코드로 만들어 내는 도구들을 모은다. 관통하는 목표는 하나, 개방-폐쇄 원칙(OCP) — 확장에는 열려 있고 수정에는 닫혀 있는 설계다. 여기서 확장과 수정은 서로 다른 축이다. “새 정책을 추가”하는 것이 확장이고, “기존 코드를 건드리는 것”이 수정이다. OCP는 전자를 하는 동안 후자가 일어나지 않게 만든다. 예제는 계속 영화 예매 시스템 — Movie와 할인 정책이다.
추상화가 OCP를 만든다
수정에 닫히려면 변하는 부분을 추상화 뒤로 숨겨야 한다. Movie가 할인 종류를 직접 안다면, 종류가 늘 때마다 Movie를 수정해야 한다.
// before — Movie 가 할인 종류를 직접 분기한다. 종류가 늘면 여기를 또 고친다
public class Movie {
private MovieType movieType;
private Money discountAmount;
private double discountPercent;
public Money calculateMovieFee(Screening screening) {
switch (movieType) {
case AMOUNT_DISCOUNT:
return fee.minus(discountAmount);
case PERCENT_DISCOUNT:
return fee.minus(fee.times(discountPercent));
case NONE_DISCOUNT:
return fee;
}
throw new IllegalStateException();
}
}// after — 변하는 것(할인 계산)을 추상 클래스 뒤로 숨긴다
public class Movie {
private DiscountPolicy discountPolicy;
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}Movie는 이제 DiscountPolicy라는 추상화에만 의존한다. AmountDiscountPolicy, PercentDiscountPolicy, NoneDiscountPolicy — 그리고 앞으로 생길 어떤 정책이든 DiscountPolicy를 상속하면 Movie는 한 줄도 바뀌지 않는다. 확장(새 서브클래스)에는 열리고 수정(Movie)에는 닫혔다. 판단 기준: OCP의 대상은 “모든 변경”이 아니라 “예상되는 변경의 축”이다. 여기서는 할인 정책이 변할 축이라고 판단했기에 그 축만 추상화했다. 함정: 일어나지 않을 변경까지 미리 추상화하면 쓰지도 않을 유연성 때문에 코드만 복잡해진다(YAGNI).
개방-폐쇄 원칙의 두 축 — 컴파일 의존성과 런타임 의존성
OCP를 정확히 이해하려면 의존성을 두 종류로 나눠 봐야 한다. 하나는 컴파일 시점 의존성, 코드에 적힌 정적인 관계다. 다른 하나는 런타임 의존성, 실행 중에 실제로 메시지를 주고받는 객체 사이의 관계다. OCP의 비밀은 이 둘을 다르게 만드는 데 있다 — 컴파일 의존성은 고정한 채, 런타임 의존성만 변하게 하는 것.
public class Movie {
private DiscountPolicy discountPolicy; // 컴파일 의존성: 오직 DiscountPolicy 추상 타입
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}Movie의 코드는 오직 DiscountPolicy라는 추상 타입만 언급한다 — 이것이 고정된 컴파일 의존성이다. 그런데 실행 중에 discountPolicy에 담기는 객체는 AmountDiscountPolicy일 수도, PercentDiscountPolicy일 수도 있다. 이것이 변하는 런타임 의존성이다. 새 정책을 추가하는 확장은 런타임 의존성의 대상을 바꾸는 일이지 Movie의 컴파일 의존성을 건드리는 일이 아니다. 그래서 Movie는 수정에 닫힌 채로 확장에 열린다.
판단 기준: OCP를 가능하게 하는 유일한 무기가 추상화에 대한 의존이다. Movie가 구체 클래스(AmountDiscountPolicy)에 컴파일 의존하면 그 순간 런타임 의존성도 그 구체 클래스로 못 박힌다 — 둘을 다르게 만들 여지가 사라진다. 추상화에 의존할 때만 컴파일 의존성을 고정한 채 런타임 의존성을 갈아 끼울 수 있다. 함정: “인터페이스를 썼으니 OCP를 지켰다”고 착각하기 쉽다. 인터페이스를 두고도 그 구현체를 코드 안에서 new로 직접 만들면(다음 절) 컴파일 의존성이 다시 구체 클래스로 새어 나가 OCP가 깨진다.
생성과 사용을 분리한다
추상화에 의존해도, Movie가 구체 클래스를 직접 생성하면 다시 그 구체 타입에 묶인다. 사용하는 코드와 생성하는 코드는 다른 이유로 변한다 — 섞으면 안 된다.
// before — 추상화에 의존하는 듯 보이지만, 생성으로 다시 구체에 묶였다
public class Movie {
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee) {
this.discountPolicy = new AmountDiscountPolicy(...); // 구체 타입에 결합
}
}// after — 생성 책임을 밖으로 밀어내고, 사용만 남긴다(생성자 주입)
public class Movie {
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy; // 누가 만들지는 밖의 관심사
}
}Movie는 정책을 쓰기만 하고, 무엇을 쓸지는 밖에서 정한다. 이제 정책을 바꾸는 데 Movie를 열 필요가 없다. 판단 기준: 한 객체가 다른 객체를 생성까지 책임지면 결합이 두 겹(사용 + 생성)이 된다. 생성을 분리하면 결합이 한 겹으로 준다.
팩토리 — 생성 책임을 모은다
생성을 밖으로 밀어내면 “그럼 누가 만드나”라는 질문이 남는다. 클라이언트가 매번 그래프를 조립하면 클라이언트가 구체 타입을 다 알아야 한다. 생성 지식만 담당하는 순수한 가공물, FACTORY를 둔다.
public class Factory {
public Movie createAvatarMovie() {
return new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000),
new AmountDiscountPolicy(Money.wons(800), /* 조건들 */));
}
}
public class Client {
private Factory factory;
public Money getAvatarFee() {
Movie avatar = factory.createAvatarMovie(); // 생성 지식은 factory 안에만
return avatar.getFee();
}
}Client는 이제 Movie도, 어떤 DiscountPolicy인지도 몰라도 된다. 구체 타입을 아는 지식이 Factory 한 곳으로 모였다. 함정: 팩토리는 생성 지식을 모으는 것이지 없애는 게 아니다. 어딘가는 반드시 구체 타입을 알아야 한다 — 팩토리는 그 “어딘가”를 시스템의 한 지점으로 국소화할 뿐이다.
팩토리는 순수한 가공물이다
여기서 한 가지 의문이 생긴다. Factory는 도대체 무엇을 표현하는 객체인가. 영화 예매 도메인에는 “영화”, “상영”, “할인 정책”은 있어도 “팩토리”라는 개념은 없다. 손님에게 “팩토리가 뭐냐”고 물으면 아무도 답하지 못한다. Factory는 도메인에 존재하지 않는, 설계자가 발명한 객체다.
이런 객체를 순수한 가공물(pure fabrication) 이라 부른다. 도메인 개념을 표현하는 객체만으로 시스템을 짜다 보면, 책임을 둘 마땅한 도메인 객체가 없는 순간이 온다. “객체를 생성하는 책임”이 바로 그렇다 — Movie에게 맡기면 결합이 늘고, Client에게 맡기면 구체 타입 지식이 새어 나간다. 그래서 오직 그 책임만을 위해 도메인에 없던 객체를 하나 지어낸다.
// Factory 는 도메인 개념이 아니다 — 오직 '생성'이라는 책임을 담기 위해 발명한 객체다
public class Factory {
public Movie createAvatarMovie() { ... }
}판단 기준: 도메인 개념에 책임을 할당했을 때 결합도가 높아지거나 응집도가 낮아진다면, 도메인에 없더라도 그 책임만을 위한 인공 객체를 만드는 편이 낫다. 표현적 완결성(도메인을 그대로 반영)보다 낮은 결합과 높은 응집이 우선이다. 함정: 순수한 가공물을 남발하면 도메인 모델이 인공 객체에 파묻혀 오히려 이해가 어려워진다. 먼저 도메인 객체에 책임을 주려 시도하고, 그것이 설계를 망칠 때에만 가공물을 꺼낸다.
의존성 주입 대 서비스 로케이터
의존 객체를 외부에서 넣어 주는 방식이 의존성 주입(DI) 이다. 넣는 통로는 세 가지다.
// 1) 생성자 주입 — 객체가 생성되는 순간 의존성을 완성한다
public class Movie {
private DiscountPolicy discountPolicy;
public Movie(String title, DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
// 2) setter 주입 — 생성 후에 의존성을 넣거나 교체한다
public class Movie {
private DiscountPolicy discountPolicy;
public void setDiscountPolicy(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
// 3) 메서드 주입 — 협력이 필요한 그 메서드 호출 때만 넣는다
public class Movie {
public Money calculateMovieFee(Screening screening, DiscountPolicy discountPolicy) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}판단 기준: 생성자 주입이 기본값이다. 객체가 생성되는 시점에 필요한 의존성이 모두 완성되므로, 반쯤 초기화된 불완전한 객체가 존재할 여지가 없다. setter 주입은 실행 중에 의존성을 바꿔야 할 때 쓰지만, 주입을 깜빡하면 null인 채로 돌아다니는 객체가 생긴다. 메서드 주입은 의존성이 특정 메서드 호출에만 필요할 때의 특수 도구다. 함정: setter 주입은 유연해 보이지만 “이 객체를 쓰려면 먼저 setter를 불러야 한다”는 암묵적 순서 규약을 만든다. 이 규약은 컴파일러가 강제하지 못하므로 지키지 않으면 런타임에 터진다.
의존성을 넣는 또 다른 방식으로 서비스 로케이터 패턴이 있는데, 객체가 필요할 때 로케이터에게 물어서 꺼내 쓴다.
// 의존성 주입 — 의존성이 생성자 시그니처에 드러난다
public class Movie {
public Movie(..., DiscountPolicy discountPolicy) { ... }
}
// 서비스 로케이터 — 의존성이 코드 안에 숨는다
public class Movie {
public Movie(...) {
this.discountPolicy = ServiceLocator.discountPolicy(); // 시그니처엔 안 보임
}
}둘 다 결합을 낮추지만 결정적 차이가 하나 있다. 의존성이 드러나는가, 숨는가. DI에서는 Movie를 만들려면 DiscountPolicy가 필요하다는 사실이 생성자 시그니처에 그대로 적혀 있다. 컴파일러가 강제하고, 협력에 필요한 것이 인터페이스에 명시된다. 서비스 로케이터에서는 그 사실이 메서드 본문에 숨는다 — 시그니처만 봐서는 이 객체가 무엇에 의존하는지 알 수 없고, 로케이터에 등록을 빠뜨리면 컴파일은 통과하고 런타임에 터진다.
숨은 의존성은 세 지점에서 대가를 치른다. 첫째, 단위 테스트다. DI라면 테스트에서 가짜 DiscountPolicy를 생성자에 넣으면 그만이지만, 서비스 로케이터를 쓰면 테스트마다 전역 로케이터의 상태를 세팅하고 되돌려야 한다 — 게다가 로케이터는 전역 상태이므로 테스트 사이에 상태가 새어 순서 의존적인 깨짐을 낳는다. 둘째, 문서화다. DI의 생성자 시그니처는 그 자체로 “나는 이것들에 의존한다”는 명세지만, 로케이터는 본문을 다 읽어야만 의존성을 알 수 있다. 셋째, 컴파일러의 검증이다. DI는 의존성 누락을 컴파일 에러로 잡지만 로케이터는 런타임까지 미룬다. 판단 기준: 그래서 가능하면 DI가 우선이다. 서비스 로케이터는 프레임워크 제약 등으로 주입 자체가 불가능할 때의 차선책이다.
의존성 역전 원칙 — 추상화의 소유권
Movie가 DiscountPolicy에 의존하는 구조를 다시 보자. 여기엔 미묘하지만 중요한 방향성이 있다. 상위 수준의 정책(Movie가 요금을 계산한다)이 하위 수준의 세부(할인을 어떻게 계산하는가)에 의존하면 안 된다. 둘 다 추상화에 의존해야 한다 — 이것이 의존성 역전 원칙(DIP) 이다.
public class Movie { // 상위 수준 모듈
private DiscountPolicy discountPolicy; // 추상화에 의존
}
public interface DiscountPolicy { ... } // 추상화
public class AmountDiscountPolicy
implements DiscountPolicy { ... } // 하위 수준 모듈도 추상화에 의존전통적인 구조라면 상위인 Movie가 하위인 AmountDiscountPolicy를 직접 호출한다 — 의존성이 위에서 아래로 흐른다. DIP는 그 사이에 DiscountPolicy라는 추상화를 세워 흐름을 끊는다. 이제 Movie도 AmountDiscountPolicy도 추상화를 향해 의존한다. 의존성의 방향이 “역전”됐다는 이름은 여기서 나온다.
여기서 핵심 질문 하나. DiscountPolicy 인터페이스는 누구의 것인가. 답은 하위 수준(AmountDiscountPolicy)이 아니라 상위 수준 모듈(Movie) 이다. 인터페이스는 그것을 사용하는 클라이언트가 무엇을 필요로 하는지를 표현하므로, 사용하는 쪽에 속해야 한다. 이렇게 인터페이스를 사용하는 쪽 패키지에 두고 구현을 분리하는 것을 separated interface 라 한다. 그래야 상위 수준이 하위 수준의 구현이 바뀌든 교체되든 영향받지 않는, 진짜 독립된 재사용 단위가 된다. 함정: 인터페이스를 구현 클래스와 같은 패키지에 두면(AmountDiscountPolicy 옆에) 소유권이 하위로 넘어가, 상위 모듈이 하위 패키지에 의존하게 되어 역전이 무의미해진다.
유연성은 복잡성을 산다
이 장의 도구들 — 추상화, 팩토리, 주입 — 은 모두 유연성을 위한 것이다. 그러나 유연성은 공짜가 아니다. 인터페이스 한 겹, 팩토리 한 클래스, 주입 배선 한 줄마다 코드를 따라 읽어야 할 간접 단계가 늘어난다. Movie 하나를 이해하려던 사람이 DiscountPolicy → 구현체 → 팩토리 → 주입 지점을 모두 오가야 전체 그림을 본다.
// 유연하지만 무거운 구조 — 정책이 하나뿐이라면 이 모든 간접이 낭비다
Factory factory = new Factory();
Movie avatar = factory.createAvatarMovie(); // 어떤 정책인지 보려면 factory 를 열어야 한다판단 기준: 유연성은 변경이 실제로 일어나는 지점에만 넣는다. 할인 정책이 자주 바뀐다는 확신이 있을 때 그 축을 추상화하는 것이지, “언젠가 바뀔지도 모르니까”로 모든 축을 열어 두는 것이 아니다. 유연성과 가독성은 반비례하는 경우가 많다 — 정책이 영원히 하나라면, 추상화 없이 직접 쓴 코드가 더 좋은 설계다. 함정: 미래의 변경을 과대평가해 과하게 유연한 구조를 만들면, 오지 않은 변경을 위해 오늘의 모든 독자에게 복잡성이라는 세금을 물린다. 필요할 때, 필요한 만큼만 유연하게 만든다.
정리
- OCP는 확장(새 서브클래스)에 열고 수정(기존 코드)에 닫는 것. 컴파일 의존성은 추상화로 고정한 채 런타임 의존성만 갈아 끼워 만든다.
- 추상화에 의존해도 구체 타입을 직접 생성하면 다시 결합된다. 생성과 사용을 분리하라.
- 생성 책임은 팩토리로 국소화한다. 팩토리는 도메인에 없는 순수한 가공물 — 낮은 결합을 위해 발명한 인공 객체다.
- DI의 세 통로(생성자·setter·메서드) 중 생성자 주입이 기본값. 서비스 로케이터는 의존성을 숨겨 테스트·문서화·컴파일 검증에서 손해를 본다.
- DIP는 상위·하위가 모두 추상화에 의존하게 하고, 그 추상화(인터페이스)의 소유권은 상위 수준 모듈에 둔다(separated interface).
- 유연성은 복잡성을 산다. 변경이 실제로 일어나는 축에만, 필요한 만큼만 넣는다.
다음장으로 10장