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).

생성과 사용을 분리한다

추상화에 의존해도, 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 한 곳으로 모였다. 함정: 팩토리는 생성 지식을 모으는 것이지 없애는 게 아니다. 어딘가는 반드시 구체 타입을 알아야 한다 — 팩토리는 그 “어딘가”를 시스템의 한 지점으로 국소화할 뿐이다.

의존성 주입 대 서비스 로케이터

의존 객체를 외부에서 넣어 주는 방식이 의존성 주입(DI) 이다. 생성자·수정자·메서드 인자로 넣는다. 대안으로 서비스 로케이터 패턴이 있는데, 객체가 필요할 때 로케이터에게 물어서 꺼내 쓴다.

// 의존성 주입 — 의존성이 생성자 시그니처에 드러난다
public class Movie {
    public Movie(..., DiscountPolicy discountPolicy) { ... }
}
 
// 서비스 로케이터 — 의존성이 코드 안에 숨는다
public class Movie {
    public Movie(...) {
        this.discountPolicy = ServiceLocator.discountPolicy(); // 시그니처엔 안 보임
    }
}

둘 다 결합을 낮추지만 결정적 차이가 하나 있다. 의존성이 드러나는가, 숨는가. DI에서는 Movie를 만들려면 DiscountPolicy가 필요하다는 사실이 생성자 시그니처에 그대로 적혀 있다. 컴파일러가 강제하고, 협력에 필요한 것이 인터페이스에 명시된다. 서비스 로케이터에서는 그 사실이 메서드 본문에 숨는다 — 시그니처만 봐서는 이 객체가 무엇에 의존하는지 알 수 없고, 로케이터에 등록을 빠뜨리면 컴파일은 통과하고 런타임에 터진다. 판단 기준: 숨은 의존성은 컴파일러의 도움을 못 받고, 테스트에서 가짜 객체를 갈아 끼우기도 어렵다. 그래서 가능하면 DI가 우선이다. 서비스 로케이터는 프레임워크 제약 등으로 주입 자체가 불가능할 때의 차선책이다.

정리

  • OCP는 확장(새 서브클래스)에 열고 수정(기존 코드)에 닫는 것. 변할 축을 추상화 뒤에 숨겨 만든다.
  • 추상화에 의존해도 구체 타입을 직접 생성하면 다시 결합된다. 생성과 사용을 분리하라.
  • 생성 책임은 팩토리로 국소화한다. 구체 타입을 아는 지식이 시스템의 한 지점에만 남는다.
  • DI와 서비스 로케이터의 차이는 “의존성이 시그니처에 드러나는가”. 드러나는 DI가 컴파일러와 테스트의 도움을 받으므로 기본값이다.

다음장으로 10장