본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
1장 : 회원가입과 게시판 시작

빈 등록과 생성자 주입

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여도 안전합니다. 각 메서드는 독립적인 빈 팩토리이고 연결은 파라미터 주입으로 표현합니다.

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 java.time.Clock;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import board.application.PostService;
import board.domain.PostRepository;

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));
        }
    }
}

try-with-resources로 컨텍스트를 닫으면 등록된 종료 콜백도 실행됩니다. 지금 빈에는 닫을 외부 자원이 없지만 뒤에서 DataSource나 클라이언트 수명을 다룰 때 같은 패턴이 중요해집니다.

구성 테스트 실행
./gradlew test --tests board.config.BoardConfigTest
실행 결과
BoardConfigTest > 저장소와_시계를_서비스에_연결한다() PASSED
BoardConfigTest > 기본_스코프는_같은_인스턴스를_반환한다() PASSED
BUILD SUCCESSFUL

정상 결과는 단순히 컨텍스트가 열렸다는 뜻보다 강합니다. 타입 조회와 이름 조회가 같은 리포지토리·시계 인스턴스를 반환했고, 서비스 빈도 기본 싱글톤으로 재사용됐다는 세 관찰이 함께 통과했습니다.

생성자 주입과 빠른 실패

PostService의 두 필드는 final이고 생성자 파라미터입니다. 완성되지 않은 서비스 객체는 만들 수 없습니다. 구성에서 Clock 빈을 삭제한 뒤 컨텍스트를 열면 첫 HTTP 요청까지 기다리지 않고 시작 단계에서 실패합니다.

Clock 빈을 지웠을 때 핵심 예외
UnsatisfiedDependencyException:
Error creating bean with name 'postService'
No qualifying bean of type 'java.time.Clock' available

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

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

필드 주입은 객체를 먼저 만든 뒤 리플렉션으로 값을 채웁니다. 테스트에서 평범한 생성자로 객체를 만들기 어렵고 필수 의존성을 final로 둘 수 없습니다. 세터 주입은 실행 중 의존성을 교체해야 하는 특별한 경우에는 쓸 수 있지만, 핵심 서비스의 필수 협력자에는 생성자 주입이 더 강한 계약입니다.

복수 빈의 모호성

시계 빈을 두 개 등록했다고 가정합니다.

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

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

PostService 생성자에는 Clock 하나만 필요하지만 후보는 둘입니다. 컨테이너는 이름을 추측해 조용히 고르지 않고 NoUniqueBeanDefinitionException을 포함한 시작 실패를 냅니다.

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

  • 애플리케이션 전체의 기본 시계라면 한 빈만 남긴다.
  • 기본값과 예외가 공존한다면 @Primary로 기본을 표시한다.
  • “작성일 계산용”처럼 의미가 다르면 @Qualifier("postClock")을 양쪽에 둔다.
  • 여러 시계를 함께 사용한다면 Map<String, Clock>을 받아 명시적으로 선택한다.

지금은 작성일 정책이 UTC 하나이므로 빈을 하나만 유지하는 것이 가장 단순합니다. 미래에 회원 시간대를 지원할 때는 고정 Clock 여러 개보다 회원의 ZoneId를 입력받는 정책이 더 자연스러울 수 있습니다.

수동 구성과 컴포넌트 스캔

MemoryPostRepository@Repository를, PostService@Service를 붙여도 같은 객체 그래프를 만들 수 있습니다. 그러나 첫 장에서는 구현 선택을 한 파일에서 읽을 수 있도록 명시적 @Bean을 사용합니다.

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

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

두 방식을 함께 쓸 수 있지만 같은 클래스 인스턴스를 스캔과 @Bean으로 중복 등록하지 않도록 주의합니다.

테스트 전용 구성

서비스 단위 테스트는 여전히 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과 함께 두 후보를 만들고 @Primary로 고른다는 동작까지 검증합니다. 단순 서비스 규칙 테스트에 사용할 이유는 없습니다. 테스트마다 필요한 최소 경계를 고르는 습관이 전체 테스트 모음의 속도와 실패 가독성을 좌우합니다.

연습 문제

Clock 빈을 두 개 등록해 실제 다중 후보 실패를 재현한 뒤, @Qualifier("postClock")으로 해결하세요. @Primary 대신 한정자를 고른 이유도 한 문장으로 적습니다.

해설 보기

구성 메서드와 주입 지점에 같은 한정자를 둡니다.

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

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

@Primary는 “대부분의 주입 지점이 사용할 기본값”을 표현합니다. 여기서는 시계의 목적이 작성일 계산이라는 의미를 선택 지점에 남기고 싶으므로 한정자가 더 구체적입니다. 시계가 하나뿐이라면 한정자 자체가 불필요하므로 요구가 실제 생기기 전까지는 단일 빈 구성을 유지합니다.

컨테이너가 서비스·저장소·시계를 연결합니다. 다음 문서에서는 이 PostService를 REST 컨트롤러에 주입해 게시글 작성과 조회를 실제 HTTP 상태, Location 헤더, JSON 본문으로 완성합니다.