작은 코드 단위가 깨끗해도 시스템 전체가 깨끗하다는 보장은 없다. 도시는 수도, 전기, 교통, 치안처럼 서로 다른 책임을 맡은 조직이 나뉘어 있기 때문에 돌아간다. 아무도 도시 전체의 세부를 다 알지 못하지만 각 부서가 자기 관심사를 책임지므로 도시는 적절한 추상화 수준에서 작동한다. 소프트웨어도 마찬가지다. 이 장의 관점은 하나로 모인다. 시스템의 조립(제작)과 실행(사용)을 분리하라.

제작과 사용의 분리

객체를 만드는 일과 객체를 사용하는 일은 성격이 다르다. 애플리케이션이 실행되려면 객체를 생성하고 의존성을 연결하는 준비 과정이 필요하다. 하지만 런타임의 비즈니스 로직이 그 준비 과정까지 함께 떠안으면 코드가 금방 복잡해진다. 흔한 예가 지연 초기화(lazy initialization)다.

// 준비와 실행이 섞인 코드
public class OrderProcessor {
    private Service service;
    public Service getService() {
        if (service == null) {
            service = new MyServiceImpl(todaysDefaults()); // 여기서 구체 타입에 묶인다
        }
        return service;
    }
}

간단하고 실용적으로 보이지만 문제가 있다. getServiceMyServiceImpl이라는 구체 타입과 그 생성자 인자에 영원히 묶인다. 테스트에서 대체 객체를 넣기 어렵고, “서비스를 제공하는 책임”과 “서비스를 만드는 책임”이 한 메서드에 섞인다. 판단 기준: 한 메서드 안에 new와 비즈니스 로직이 함께 있으면 관심사가 섞인 것이다. 함정: 지연 초기화가 성능 최적화처럼 보이지만, 이런 초기화가 시스템 곳곳에 흩어지면 전역적인 일관성이 사라지고 실제 의존 관계를 한눈에 볼 수 없게 된다.

시스템을 탄탄하게 만들려면 설정 논리와 실행 논리를 분리해야 한다. 객체 생성과 의존성 연결은 시작 단계에 몰아 처리하고, 애플리케이션의 핵심 코드는 이미 준비된 객체를 사용하도록 둔다.

main 분리

가장 단순한 방법은 main에서 시스템을 구성하는 것이다. main은 필요한 객체를 만들고 관계를 연결한 뒤 애플리케이션에 넘긴다. 그 이후 애플리케이션은 객체가 어떻게 생성되었는지 모른 채 자신의 일을 수행한다.

// main 쪽 — 생성과 조립을 전담
public static void main(String[] args) {
    Service service = new MyServiceImpl(loadConfig(args)); // 여기서만 new
    Application app = new Application(service);             // 완성된 객체를 넘긴다
    app.run();
}
 
// application 쪽 — 생성을 전혀 모른다
public class Application {
    private final Service service;
    public Application(Service service) { this.service = service; } // 받기만 한다
    public void run() { service.handle(...); }
}

핵심은 의존성의 방향이다. mainApplication을 알지만 Applicationmain을 모른다. 화살표는 언제나 main에서 애플리케이션 쪽으로 흐르고, 애플리케이션은 구체적인 생성 과정에 의존하지 않는다.

flowchart LR
    Main["main<br/>(생성·조립)"] -->|완성된 객체 전달| App["Application<br/>(사용)"]
    App -.->|의존성 방향: 알 필요 없음| Main
    style Main fill:#e8f0ff,color:#000
    style App fill:#eafbea,color:#000

판단 기준: 생성 책임이 한곳(main 또는 그 근처의 조립 모듈)에 모이면, 무엇이 무엇에 의존하는지 한 파일에서 읽을 수 있다. 함정: main이 커지는 것 자체는 문제가 아니다. 오히려 생성 로직이 애플리케이션 곳곳에 흩어지는 편이 훨씬 나쁘다.

팩토리

항상 main에서 모든 생성을 끝낼 수는 없다. 실행 중 특정 시점에 객체를 만들어야 할 때도 있다. 예를 들어 주문 처리 로직이 LineItem을 만들어 Order에 추가해야 한다고 하자. 애플리케이션은 “언제” 항목을 만들지 결정해야 하지만, 구체적으로 “어떤 클래스를 어떻게” 생성하는지는 몰라도 된다. 이 둘을 나누는 장치가 추상 팩토리다.

// 애플리케이션은 추상 팩토리에만 의존 — 생성 시점은 제어하되 생성 방식은 모른다
public interface LineItemFactory {
    LineItem make(String sku, int quantity);
}
 
public class OrderService {
    private final LineItemFactory factory;
    public OrderService(LineItemFactory factory) { this.factory = factory; }
    public void addItem(Order order, String sku, int qty) {
        order.add(factory.make(sku, qty)); // "언제" 는 여기서, "어떻게" 는 팩토리가
    }
}

팩토리는 생성 로직을 숨기면서도 애플리케이션이 생성 타이밍을 제어하게 해 준다. 판단 기준: 생성 시점의 결정과 생성 방식의 세부사항이 서로 다른 이유로 바뀐다면 팩토리로 나눌 대상이다. 단순한 객체 생성이 아니라 정책과 구현을 분리하는 장치로 봐야 한다.

의존성 주입 (DI / IoC)

의존성 주입(DI)은 객체가 직접 의존 대상을 만들지 않고 외부에서 주입받는 방식이다. 제어의 일부를 객체 밖으로 넘긴다는 점에서 제어의 역전(IoC, Inversion of Control)의 한 형태다. 객체는 자신이 사용할 인터페이스만 알고, 실제 구현은 조립 단계에서 결정된다.

// 객체는 인터페이스만 알고, 구현은 조립 단계에서 주입된다
public class BillingService {
    private final PaymentGateway gateway; // 인터페이스
    public BillingService(PaymentGateway gateway) {  // 스스로 new 하지 않는다
        this.gateway = gateway;
    }
    public Receipt charge(Money amount) { return gateway.pay(amount); }
}
 
// 운영 조립
new BillingService(new StripeGateway(apiKey));
// 테스트 조립 — 실제 결제 없이 통제된 구현을 주입
new BillingService(amount -> Receipt.approved(amount));

이 방식의 장점은 테스트에서 특히 분명하다. 실제 데이터베이스나 외부 API 대신 가짜 구현을 주입할 수 있고, 객체는 여전히 같은 방식으로 동작한다. Spring 같은 DI 컨테이너를 쓸 수도 있지만 핵심은 도구가 아니다. 판단 기준: 객체가 자기 협력자를 스스로 new 하지 않고 받기만 하는가. 함정: DI 컨테이너를 도입하면 다 해결된다고 오해하기 쉽다. 컨테이너는 조립을 자동화할 뿐, “생성과 사용을 분리한다”는 원칙을 대신 지켜 주지는 않는다. 원칙 없이 컨테이너만 쓰면 설정이 또 다른 미궁이 된다.

마무리

시스템이 커질수록 깨끗함은 함수나 클래스 내부만의 문제가 아니게 된다.

  • 제작과 사용을 분리한다. 비즈니스 로직 안에 new와 지연 초기화가 섞이면 관심사가 뒤엉킨 것이다.
  • 생성 책임을 main(또는 조립 모듈)에 모으고, 의존성 방향을 한 방향으로 유지한다.
  • 실행 중 생성이 필요하면 팩토리로 “언제”와 “어떻게”를 나눈다.
  • DI/IoC로 객체가 협력자를 받기만 하게 하면, 테스트와 구성 변경이 쉬워진다.

잘 구성된 시스템은 세부 구현이 바뀌어도 핵심 로직이 흔들리지 않는다.

다음장으로 12장