본문으로 건너뛰기

안동민 개발노트

본문 시작

빈 등록과 생성자 주입

BoardConfig가 저장소·시계·서비스를 빈으로 만들게 하고 Spring 컨테이너의 생성자 주입과 시작 시 실패를 실제 컨텍스트 테스트로 확인합니다.

Spring에서 빈(bean)은 Spring이 만들고 보관하는 객체입니다.

컨테이너는 빈을 만들고 필요한 빈끼리 연결하며 종료 시점까지 관리하는 공간입니다.

한 객체의 생성자에 필요한 다른 객체를 바깥에서 전달하는 방식을 의존관계 주입이라고 합니다.

앞 문서의 서비스 테스트는 필요한 객체를 직접 조립했습니다.

var service = new PostService(
        new MemoryPostRepository(),
        fixedClock);

이 방식은 틀리지 않습니다.

오히려 객체가 무엇을 필요로 하는지 가장 선명하게 보여 줍니다.

하지만 실제 애플리케이션에는 컨트롤러, 서비스, 저장소, 시계처럼 여러 객체가 있고 같은 저장소를 공유해야 합니다.

생성 위치가 여러 메인 메서드와 컨트롤러에 흩어지면 구현을 바꿀 때 사용 코드까지 찾아다녀야 합니다.

Spring 컨테이너에는 새로운 업무 규칙을 맡기지 않습니다.

이미 완성한 객체를 어떤 구현으로 만들고 어떻게 연결할지만 맡깁니다.


구성 클래스의 결정

BoardConfig는 다음 질문에 답합니다.

  1. PostRepository 역할에는 어떤 구현을 쓸 것인가?
  2. 애플리케이션의 기준 시간대는 무엇인가?
  3. PostService 생성자에 어떤 인스턴스를 전달할 것인가?
src/main/java/board/config/BoardConfig.java
package board.config;

import board.application.PostService;
import board.domain.PostRepository;
import board.infrastructure.MemoryPostRepository;

import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class BoardConfig {
    @Bean
    PostRepository postRepository() {
        return new MemoryPostRepository();
    }

    @Bean
    Clock postClock() {
        return Clock.systemUTC();
    }

    @Bean
    PostService postService(
            PostRepository repository,
            Clock postClock
    ) {
        return new PostService(repository, postClock);
    }
}

@Bean 메서드의 파라미터는 컨테이너가 해결할 의존성입니다.

postServicepostRepository()postClock()을 직접 호출하지 않고 팩토리 메서드 파라미터로 의존성을 받기 때문에 현재 구성에는 proxyBeanMethods = false가 맞습니다.

각 메서드는 독립적인 빈 팩토리이고 연결은 파라미터 주입으로 표현합니다.

Clock.systemUTC()PostService.register 호출마다 today를 한 번 계산할 때 날짜 경계를 UTC로 둡니다.

앞 문서에서 정한 것처럼 서비스는 그 날짜를 저장소 호출 전에 validateDate(today)에 전달합니다.

Spring Boot 시작 클래스가 board 패키지에 있고 구성 클래스가 그 아래에 있으므로 컴포넌트 스캔이 @Configuration을 찾습니다.

별도의 @Import는 필요하지 않습니다.


객체 그래프 조회

전체 Boot 서버를 띄우지 않고 AnnotationConfigApplicationContext로 구성 하나만 검증합니다.

src/test/java/board/config/BoardConfigTest.java
package board.config;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import board.application.PostService;
import board.domain.PostRepository;
import board.infrastructure.MemoryPostRepository;
import java.time.Clock;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.UnsatisfiedDependencyException;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

class BoardConfigTest {
    @Test
    void 서비스와_이름이_지정된_의존성_빈을_조회한다() {
        try (var context = new AnnotationConfigApplicationContext(
                BoardConfig.class)) {
            var service = context.getBean(PostService.class);
            var repository = context.getBean(PostRepository.class);
            var clock = context.getBean(Clock.class);

            assertThat(service).isNotNull();
            assertThat(repository).isSameAs(
                    context.getBean("postRepository"));
            assertThat(clock).isSameAs(
                    context.getBean("postClock"));
        }
    }

    @Test
    void 기본_스코프는_같은_인스턴스를_반환한다() {
        try (var context = new AnnotationConfigApplicationContext(
                BoardConfig.class)) {
            assertThat(context.getBean(PostService.class))
                    .isSameAs(context.getBean(PostService.class));
            assertThat(context.getBean(PostRepository.class))
                    .isSameAs(context.getBean(PostRepository.class));
        }
    }

    @Test
    void 시계가_없으면_컨텍스트_갱신이_실패한다() {
        assertThatThrownBy(() ->
                new AnnotationConfigApplicationContext(
                        MissingClockConfig.class))
                .isInstanceOf(UnsatisfiedDependencyException.class)
                .hasMessageContaining("postService")
                .hasRootCauseExactlyInstanceOf(
                        NoSuchBeanDefinitionException.class);
    }

    @Configuration(proxyBeanMethods = false)
    static class MissingClockConfig {
        @Bean
        PostRepository postRepository() {
            return new MemoryPostRepository();
        }

        @Bean
        PostService postService(
                PostRepository repository,
                Clock clock
        ) {
            return new PostService(repository, clock);
        }
    }
}

try-with-resources로 컨텍스트를 닫으면 등록된 종료 콜백도 실행됩니다.

지금 빈에는 닫을 외부 자원이 없지만 뒤에서 DataSource나 클라이언트 수명을 다룰 때 같은 패턴이 중요해집니다.

구성 테스트 실행
./gradlew test --tests board.config.BoardConfigTest
실행 결과
BoardConfigTest > 서비스와_이름이_지정된_의존성_빈을_조회한다() PASSED
BoardConfigTest > 기본_스코프는_같은_인스턴스를_반환한다() PASSED
BoardConfigTest > 시계가_없으면_컨텍스트_갱신이_실패한다() PASSED
BUILD SUCCESSFUL

정상 구성 테스트가 직접 관찰한 범위는 세 가지입니다.

  • 서비스 빈이 존재한다.
  • 리포지토리와 시계의 타입 조회·이름 조회가 각각 같은 인스턴스를 반환한다.
  • 같은 컨텍스트에서 서비스와 리포지토리를 반복 조회하면 같은 인스턴스를 반환한다.

테스트는 PostService 내부 필드를 꺼내 그 참조가 조회한 리포지토리·시계와 같은지 직접 비교하지 않습니다.

그 연결은 BoardConfig.postService의 팩토리 메서드 인자와 Spring의 의존성 해석 계약으로 설명하고, 실행 assertion의 관찰 범위와 섞지 않습니다.

기본 싱글톤은 JVM 전역 객체라는 뜻이 아니라 빈 정의마다, 해당 ApplicationContext 안에서 한 인스턴스를 재사용한다는 뜻입니다.

Clock 누락 테스트는 postService 생성 중 UnsatisfiedDependencyException이 발생하고 근본 원인이 NoSuchBeanDefinitionException임을 자동으로 확인합니다.

BoardConfig의 세 빈 정의를 ApplicationContext가 의존성 그래프로 해석해 저장소와 시계를 PostService 생성자에 전달하고, Clock 후보가 없으면 갱신 단계에서 실패하는 경계를 설명합니다.

DEPENDENCY DAG · PER CONTAINER

컨테이너는 세 빈 정의를 해석해 하나의 객체 그래프를 완성한다

흐름은 소스 줄 순서가 아니라 의존성 관계를 나타냅니다. 이 구성의 기본 non-lazy singleton들은 컨텍스트 갱신 중 미리 만들어지며, 서로 독립인 저장소와 시계의 전체 생성 순서는 계약하지 않습니다.

ASSEMBLY FLOW · FACTORY METHOD ARGUMENTS

세 application bean이 의존성 그래프로 연결된다

  1. BoardConfig 빈 정의 등록

    postRepository, postClock, postService 세 팩토리 메서드가 각각 빈 정의가 됩니다.

  2. ApplicationContext.refresh()

    컨테이너가 기본 non-lazy singleton을 미리 만들며 팩토리 메서드 인자를 후보 해석 규칙으로 해결합니다.

  3. 저장소와 시계 후보 해결

    PostRepositoryClock 빈은 각 빈 정의마다, 이 컨테이너 안에서 한 기본 singleton 인스턴스로 관리됩니다.

  4. PostService 생성자 호출

    선택된 저장소와 시계 참조가 팩토리 메서드 인자로 전달되고, 완성된 서비스 빈이 컨테이너에 보관됩니다.

proxyBeanMethods=false · 직접 @Bean 메서드를 부르지 않고 인자 주입으로 연결

EXECUTED ASSERTIONS · SUCCESS

관찰한 성공 경계

  • 서비스 빈이 존재합니다.
  • 저장소·시계의 타입 조회와 이름 조회가 각각 같은 인스턴스입니다.
  • 같은 컨텍스트에서 서비스·저장소의 반복 조회가 같은 인스턴스입니다.

서비스 내부 필드의 참조 동일성은 직접 assertion하지 않았고, singleton도 JVM 전역이 아니라 빈 정의당·컨테이너당 범위입니다.

REFRESH FAILURE · CLOCK CANDIDATES 0

시계 누락

Clock 후보가 없으면 postService 팩토리 인자를 해결하지 못해 컨텍스트 갱신이 실패합니다.

테스트는 의존성 해결 예외와 후보 없음 근본 원인을 확인합니다. HTTP 요청이 시작될 때까지 실패를 미루지 않습니다.

postClockPostService.register 호출마다 날짜를 한 번 계산할 기준을 제공합니다. 저장소 호출 전 시간 검증은 컨테이너가 아니라 PostService의 application 경계가 맡습니다.


생성자 주입과 빠른 실패

PostService의 두 필드는 final이고 생성자 파라미터입니다.

생성자 주입은 두 참조를 객체 생성 시점에 전달하고 final 필드로 유지할 수 있게 합니다.

다만 생성자와 final 자체가 null을 금지하지는 않습니다.

현재 PostService가 생성자에서 Objects.requireNonNull을 호출하기 때문에 직접 생성할 때도 누락된 협력자를 즉시 거부합니다.

앞의 MissingClockConfig처럼 컨테이너가 해결할 Clock 빈이 없으면 첫 HTTP 요청까지 기다리지 않고 컨텍스트 갱신 단계에서 실패합니다.

누락 테스트가 확인하는 예외 체인의 핵심
UnsatisfiedDependencyException:
Error creating bean with name 'postService'
No qualifying bean of type 'java.time.Clock' available

이 오류에는 세 정보가 있습니다.

  • 만들지 못한 빈: postService
  • 해결하지 못한 생성자 파라미터 타입: Clock
  • 후보 수: 0

필드 주입은 객체를 먼저 만든 뒤 리플렉션으로 값을 채웁니다.

따라서 컨테이너 주입 전의 평범한 생성만으로 필수 협력자가 채워진 상태를 만들기 어렵고, 그 필드를 final 계약으로 두기도 어렵습니다.

세터 주입은 API가 생성 후 재설정을 허용해야 하는 설계에서 선택할 수 있습니다.

Spring이 실행 중에 의존성을 자동으로 교체한다는 뜻은 아니며, 핵심 서비스의 필수 협력자에는 생성자 주입이 더 강한 계약입니다.


복수 빈의 모호성

기존 postClock 빈을 다음 두 빈으로 교체하고, 서비스 팩토리의 주입 파라미터 이름을 어느 빈 이름과도 같지 않은 clock으로 둡니다.

모호함을 만드는 구성
@Bean
Clock utcClock() {
    return Clock.systemUTC();
}

@Bean
Clock seoulClock() {
    return Clock.system(ZoneId.of("Asia/Seoul"));
}

@Bean
PostService postService(
        PostRepository repository,
        Clock clock
) {
    return new PostService(repository, clock);
}

PostService 생성자에는 Clock 하나만 필요하지만 후보는 둘입니다.

명시적 한정자나 기본 후보가 없을 때, Java의 -parameters처럼 파라미터 메타데이터가 보존된 환경에서는 주입 지점 이름과 빈 이름의 일치가 마지막 후보 좁히기로 사용될 수 있습니다.

여기서는 주입 지점 clockutcClock, seoulClock 어느 이름과도 일치하지 않으므로 NoUniqueBeanDefinitionException을 근본 원인으로 컨텍스트 갱신이 실패합니다.

다음 테스트는 그 실패 경계를 독립된 구성으로 고정합니다.

src/test/java/board/config/BoardConfigAmbiguityTest.java
package board.config;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

import board.application.PostService;
import board.domain.PostRepository;
import board.infrastructure.MemoryPostRepository;
import java.time.Clock;
import java.time.ZoneId;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
import org.springframework.beans.factory.UnsatisfiedDependencyException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

class BoardConfigAmbiguityTest {
    @Test
    void 일치하는_이름도_선택_단서도_없으면_시작이_실패한다() {
        assertThatThrownBy(() ->
                new AnnotationConfigApplicationContext(
                        AmbiguousClockConfig.class))
                .isInstanceOf(UnsatisfiedDependencyException.class)
                .hasMessageContaining("postService")
                .hasRootCauseExactlyInstanceOf(
                        NoUniqueBeanDefinitionException.class);
    }

    @Configuration(proxyBeanMethods = false)
    static class AmbiguousClockConfig {
        @Bean
        PostRepository postRepository() {
            return new MemoryPostRepository();
        }

        @Bean
        Clock utcClock() {
            return Clock.systemUTC();
        }

        @Bean
        Clock seoulClock() {
            return Clock.system(ZoneId.of("Asia/Seoul"));
        }

        @Bean
        PostService postService(
                PostRepository repository,
                Clock clock
        ) {
            return new PostService(repository, clock);
        }
    }
}

해결책은 선택의 의미에 따라 다릅니다.

  • 애플리케이션 전체의 기본 시계라면 한 빈만 남긴다.
  • 기본값과 예외가 공존한다면 @Primary로 기본을 표시한다.
  • “작성일 계산용”처럼 의미가 다르면 @Qualifier("publishedOn") 같은 의미 한정자를 생산자와 주입 지점에 공유한다.
  • 여러 시계를 함께 사용한다면 Map<String, Clock>으로 타입이 맞는 모든 빈을 이름과 인스턴스의 쌍으로 받은 뒤 애플리케이션 코드가 고른다.

지금은 작성일 정책이 UTC 하나이므로 빈을 하나만 유지하는 것이 가장 단순합니다.

미래에 회원 시간대를 지원할 때는 고정 Clock 여러 개보다 회원의 ZoneId를 입력받는 정책이 더 자연스러울 수 있습니다.

생성자, setter, 필드 주입의 객체 생성 계약을 비교하고, 단일 타입 후보와 Primary, 의미 한정자, Map 주입으로 여러 Clock 빈을 선택하는 규칙을 설명합니다.

INJECTION CONTRACT · CANDIDATE SELECTION

객체 생성 계약과 빈 선택 의미를 분리해 결정한다

생성자 주입은 협력자를 언제 전달할지 정하고, 후보 선택 규칙은 어떤 빈을 전달할지 정합니다. 두 결정을 하나의 애노테이션 효과처럼 섞지 않습니다.

이 문서의 핵심 서비스에 적용한 주입 방식 비교
방식 생성·사용 시 계약 이 문서의 판단
생성자 협력자를 생성 시 전달하고 필드를 final로 둘 수 있습니다. 값과 null 검증은 생성자 코드의 책임입니다. PostServiceObjects.requireNonNull과 함께 필수 저장소·시계 계약을 고정합니다.
setter 공개 API가 생성 후 재설정을 허용합니다. Spring이 실행 중 협력자를 자동으로 교체한다는 뜻은 아닙니다. 의도적으로 재설정 가능한 설계에만 사용하고 현재 핵심 서비스에는 쓰지 않습니다.
필드 평범한 생성 직후에는 컨테이너 주입 전 상태이며, 주입 필드를 final 계약으로 두기 어렵습니다. 생성자만으로 완전한 객체를 만들기 어렵고 단위 테스트와 필수 협력자 가시성이 약해집니다.

ONE TYPE CANDIDATE

타입 후보가 하나면 그대로 주입

현재 BoardConfigClock 하나처럼 다른 선택 단서가 필요 없는 가장 단순한 상태입니다.

DEFAULT AMONG MANY

@Primary로 넓은 기본값 표시

같은 타입 후보가 여럿이고 대부분의 주입 지점이 사용할 기본이 하나일 때 선택합니다.

SEMANTIC PURPOSE

@Qualifier로 사용 의미 표시

@Qualifier("publishedOn")처럼 생산자와 주입 지점이 의미를 공유합니다. 한정자 값은 빈 이름과 독립적일 수 있습니다.

ALL TYPE CANDIDATES

Map<String, Clock>으로 모두 받기

타입이 맞는 빈을 빈 이름→인스턴스로 주입한 뒤 애플리케이션 코드가 키를 선택합니다. 컨테이너의 자동 런타임 선택이 아닙니다.

명시적 한정자나 기본 후보가 없고 Java의 -parameters처럼 메타데이터가 보존되면 주입 지점 이름도 후보 좁히기에 쓰일 수 있습니다. 현재 실패 테스트의 clockutcClock·seoulClock과 모두 달라 복수 후보 오류가 납니다.


수동 구성과 컴포넌트 스캔

MemoryPostRepository@Repository를, PostService@Service를 붙여도 같은 객체 그래프를 만들 수 있습니다.

그러나 첫 장에서는 구현 선택을 한 파일에서 읽을 수 있도록 명시적 @Bean을 사용합니다.

선택 기준은 다음과 같습니다.

  • 우리 코드이며 구현이 하나이고 자동 발견이 자연스러운 컨트롤러는 스테레오타입 애노테이션이 편하다.
  • 외부 라이브러리 타입, 환경별 구현 선택, 생성 인자가 복잡한 객체는 @Bean이 의도를 잘 드러낸다.
  • 같은 역할 구현이 여럿이면 스캔으로 모두 등록한 뒤 우연히 고르기보다 구성에서 사용 조합을 명시한다.

스테레오타입으로 전환할 때는 대응하는 @Bean 정의를 제거합니다.

둘을 함께 두면 같은 인스턴스를 중복 보관하는 것이 아닙니다.

빈 이름이 다르면 별도 정의와 별도 인스턴스가 생겨 타입 후보가 모호해지고, 이름이 같으면 빈 이름 충돌 또는 override 정책의 영향을 받습니다.


테스트 전용 구성

서비스 단위 테스트는 여전히 Spring 없이 고정 Clock을 직접 전달하는 것이 가장 빠릅니다.

컨테이너 연결까지 확인해야 한다면 테스트 전용 구성을 추가합니다.

고정 시계를 포함한 테스트용 구성 조각
@Configuration(proxyBeanMethods = false)
@Import(BoardConfig.class)
static class FixedClockConfig {
    @Bean
    @Primary
    Clock fixedClock() {
        return Clock.fixed(
                Instant.parse("2026-07-13T09:00:00Z"),
                ZoneOffset.UTC);
    }
}

이 구성을 컨텍스트에 사용하면 기존 postClock과 함께 두 후보가 생기고 fixedClock@Primary 선택 대상이 됩니다.

다만 위 조각에는 컨텍스트를 열고 실제 주입 결과를 assertion하는 테스트가 없으므로 그 선택을 실행 증거로 주장하지 않습니다.

단순 서비스 규칙 테스트에 사용할 이유는 없습니다.

테스트마다 필요한 최소 경계를 고르는 습관이 전체 테스트 모음의 속도와 실패 가독성을 좌우합니다.


연습 문제

기존 postClockutcClock, seoulClock 두 빈으로 교체하고 주입 파라미터 이름을 clock으로 두어 실제 다중 후보 실패를 재현한 뒤, @Qualifier("publishedOn")으로 해결하세요.

@Primary 대신 한정자를 고른 이유도 한 문장으로 적습니다.

해설 보기

작성일 계산용 생산자와 주입 지점에 같은 의미 한정자를 둡니다.

@Bean
@Qualifier("publishedOn")
Clock utcClock() {
    return Clock.systemUTC();
}

@Bean
Clock seoulClock() {
    return Clock.system(ZoneId.of("Asia/Seoul"));
}

@Bean
PostService postService(
        PostRepository repository,
        @Qualifier("publishedOn") Clock clock
) {
    return new PostService(repository, clock);
}

@Primary는 “대부분의 주입 지점이 사용할 기본값”을 표현합니다.

여기서는 시계의 목적이 작성일 계산이라는 의미를 선택 지점에 남기고 싶으므로 한정자가 더 구체적입니다.

publishedOnutcClock이라는 빈 이름과 독립된 의미 표지입니다.

시계가 하나뿐이라면 한정자 자체가 불필요하므로 요구가 실제 생기기 전까지는 단일 빈 구성을 유지합니다.

컨테이너가 서비스·저장소·시계를 연결합니다.

다음 문서에서는 이 PostService를 REST 컨트롤러에 주입해 게시글 작성과 조회를 실제 HTTP 상태, Location 헤더, JSON 본문으로 완성합니다.