작은 코드 단위가 깨끗해도 시스템 전체가 깨끗하다는 보장은 없다. 도시는 수도, 전기, 교통, 치안처럼 서로 다른 책임을 맡은 조직이 나뉘어 있기 때문에 돌아간다. 소프트웨어도 마찬가지다. 시스템 수준에서도 관심사를 나누고, 각 부분이 맡은 일을 분명히 해야 한다.

제작과 사용의 분리

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

아래처럼 지연 초기화를 사용하면 실제로 필요할 때까지 객체 생성을 미룰 수 있다.

public Service getService() {
    if (service == null) {
        service = new MyServiceImpl(...);
    }
    return service;
}

간단하고 실용적으로 보이지만, 이 방식은 MyServiceImpl에 대한 의존성을 호출 코드 안으로 끌어들인다. 테스트에서는 대체 객체를 넣기 어려워지고, 일반 로직과 생성 로직이 섞인다. 결국 메서드는 서비스를 제공하는 책임과 서비스를 만드는 책임을 동시에 갖게 된다.

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

main 분리

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

이 구조에서는 의존성의 방향이 main에서 애플리케이션 쪽으로 흐른다. 애플리케이션이 main을 알 필요도 없고, 구체적인 생성 과정에 의존할 필요도 없다. 생성 책임이 한쪽에 모이면 테스트와 변경도 쉬워진다.

팩토리

항상 main에서 모든 생성을 끝낼 수는 없다. 실행 중 특정 시점에 객체를 만들어야 할 때도 있다. 이때는 팩토리를 사용해 생성 책임을 분리할 수 있다.

예를 들어 주문 처리 로직이 LineItem을 만들어 Order에 추가해야 한다고 하자. 애플리케이션은 언제 항목을 만들지 결정해야 하지만, 구체적으로 어떤 클래스를 어떻게 생성하는지는 몰라도 된다. 이때 추상 팩토리를 두면 생성 시점의 결정과 생성 방식의 세부사항을 나눌 수 있다.

팩토리는 생성 로직을 숨기면서도 애플리케이션이 필요한 타이밍을 제어하게 해 준다. 단순한 객체 생성이 아니라 정책과 구현을 분리하는 장치로 볼 수 있다.

의존성 주입

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

이 방식의 장점은 테스트에서 특히 분명하다. 실제 데이터베이스나 외부 API 대신 가짜 구현을 주입할 수 있고, 객체는 여전히 같은 방식으로 동작한다. 운영 환경에서는 실제 구현을 넣고, 테스트 환경에서는 테스트용 구현을 넣는 식으로 구성을 바꿀 수 있다.

의존성 주입 컨테이너를 사용할 수도 있지만, 핵심은 도구가 아니다. 객체가 구체적인 생성 방식을 모르게 하고, 시스템의 조립과 실행을 분리하는 것이 핵심이다.

마무리

시스템이 커질수록 깨끗함은 함수나 클래스 내부만의 문제가 아니게 된다. 생성과 사용을 분리하고, 경계를 명확히 하며, 의존성의 방향을 관리해야 한다. 잘 구성된 시스템은 세부 구현이 바뀌어도 핵심 로직이 흔들리지 않는다.