본문으로 건너뛰기

안동민 개발노트

본문 시작

Spring 컨테이너 전환

같은 회원가입 객체 그래프의 생성과 연결을 Spring 컨테이너로 옮기고, 단일 생성자 주입·컨텍스트별 기본 singleton·후보 결정 실패를 Spring 7 API로 검증합니다.

앞 문서의 순수 Java BoardConfig는 저장소와 해시 구현을 직접 만들고 같은 참조를 두 서비스에 전달했습니다.

Spring으로 옮겨도 MemberRepositoryPasswordHasher 역할, 서비스 생성자, 회원가입·로그인 규칙은 바뀌지 않습니다.

달라지는 지점은 객체를 만들고 연결하는 구성 경계입니다.


같은 역할 그래프를 빈으로 등록

@Configuration@Bean 메서드를 처리할 구성 클래스임을 표시하고, 각 @Bean 메서드는 컨테이너가 관리할 객체의 생성 규칙을 제공합니다.

다음 구성은 메서드끼리 직접 호출하지 않고 필요한 빈을 매개변수로 받으므로 proxyBeanMethods = false를 사용합니다.

TestPasswordHasher는 test source에만 남기고, main 그래프는 앞 문서와 같은 PBKDF2 어댑터를 선택합니다.

board-core/src/main/java/board/BoardConfig.java
package board;

import board.member.LoginService;
import board.member.MemberRegistrationService;
import board.member.MemberRepository;
import board.member.MemoryMemberRepository;
import board.member.PasswordHasher;
import board.member.PasswordHashSettings;
import board.member.Pbkdf2PasswordHasher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public final class BoardConfig {
    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public PasswordHasher passwordHasher() {
        return new Pbkdf2PasswordHasher(
                PasswordHashSettings.productionDefaults());
    }

    @Bean
    public MemberRegistrationService memberRegistrationService(
            MemberRepository members,
            PasswordHasher passwordHasher
    ) {
        return new MemberRegistrationService(
                members, passwordHasher);
    }

    @Bean
    public LoginService loginService(
            MemberRepository members,
            PasswordHasher passwordHasher
    ) {
        return new LoginService(members, passwordHasher);
    }
}

refresh()가 실행되면 컨테이너는 기본적으로 지연되지 않은 singleton 빈을 만들면서 @Bean 매개변수의 의존성을 해결합니다.

이 구성에서는 하나의 MemberRepository 빈과 하나의 PasswordHasher 빈이 각 서비스 생성자에 전달됩니다.

그 참조 연결은 한 ApplicationContext 안의 빈 정의를 기준으로 하며 JVM 전역 객체라는 뜻이 아닙니다.

BoardConfig와 BoardApplication을 등록한 뒤 refresh가 같은 회원 저장소와 PBKDF2 해시 어댑터를 회원가입·로그인 서비스에 연결하고, 단일 생성자로 BoardApplication에 전달하는 Spring 객체 그래프를 설명합니다.

SPRING CONTAINER · SAME ROLE GRAPH

Spring은 같은 역할 그래프의 생성과 연결을 컨테이너로 옮긴다

역할과 업무 코드는 그대로 두고 구성 경계만 옮깁니다. 등록된 생성 규칙은 refresh()에서 해석되고, 완성된 애플리케이션은 이후 일반 Java 호출로 실행됩니다.

REGISTER → REFRESH → WIRED GRAPH

컨테이너 시작은 생성 규칙 등록과 의존성 해결을 분리한다

  1. 구성 클래스 등록

    context.register(...)BoardConfig.classBoardApplication.class를 컨텍스트에 등록합니다.

  2. 빈 정의 해석

    @Configuration과 네 @Bean 메서드가 저장소·해시 어댑터·두 서비스의 생성 규칙이 됩니다.

  3. refresh()에서 생성·연결

    컨테이너가 매개변수 타입으로 후보를 결정해 R#1과 H#1을 두 서비스 생성자에 전달합니다.

  4. 단일 생성자에 서비스 주입

    BoardApplication의 유일한 생성자가 회원가입·로그인 서비스를 받고 완성된 실행 객체가 됩니다.

  5. 진입점 뒤에는 일반 호출

    시작 경계에서 애플리케이션 빈을 얻은 뒤 서비스는 컨텍스트를 조회하지 않고 직접 협력합니다.

같은 역할 그래프에서 컨테이너가 선택하고 연결하는 대상
빈 생성 규칙 현재 구현 주입 지점 유지되는 경계
memberRepository() MemoryMemberRepository R#1 두 서비스의 MemberRepository 저장 기술은 구성에 남음
passwordHasher() Pbkdf2PasswordHasher H#1 두 서비스의 PasswordHasher 해시 정책 선택은 구성에 남음
두 서비스 메서드 현재 서비스 클래스 BoardApplication의 단일 생성자 업무 코드는 Spring API를 모름
명시적 컴포넌트 등록 BoardApplication 실행 진입점의 한 번의 조회 컨테이너 조회는 시작 경계에 한정

BEFORE · PURE JAVA CONFIG

구성 객체가 필드 참조를 직접 전달

앞 문서에서는 한 BoardConfig 인스턴스가 R#1·H#1을 만들고 서비스 생성자를 직접 호출했습니다.

NOW · EXPLICIT SPRING CONFIG

컨테이너가 빈 정의와 주입 지점을 연결

@Bean 메서드는 서로 직접 호출하지 않습니다. 매개변수 주입이 컨텍스트 안의 같은 역할 그래프를 연결합니다.

이 전환은 Spring Framework의 명시적 Java 구성입니다. Spring Boot 자동 설정이나 thread safety·transaction·persistence 보장은 이 그래프에 포함되지 않습니다.


단일 생성자 주입과 실행 경계

BoardApplication은 Spring의 ApplicationContext를 필드로 받지 않습니다.

명시적으로 등록된 @Component에 생성자가 하나뿐이면 @Autowired를 붙이지 않아도 그 생성자가 주입 지점이 됩니다.

진입점만 컨텍스트를 만들고 register → refresh → getBean 순서로 시작합니다.

업무 실행은 완성된 BoardApplication에서 일반 Java 메서드 호출로 이어집니다.

board-core/src/main/java/board/BoardApplication.java
package board;

import board.member.LoginService;
import board.member.Member;
import board.member.MemberRegistrationService;
import board.member.SignUpCommand;
import java.util.Objects;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.stereotype.Component;

@Component
public final class BoardApplication {
    private final MemberRegistrationService registration;
    private final LoginService login;

    public BoardApplication(
            MemberRegistrationService registration,
            LoginService login
    ) {
        this.registration = Objects.requireNonNull(
                registration, "registration must not be null");
        this.login = Objects.requireNonNull(
                login, "login must not be null");
    }

    public long registerAndAuthenticate(SignUpCommand command) {
        Objects.requireNonNull(command, "command must not be null");

        Member registered = registration.register(command);
        long loginId = login.authenticate(
                command.email(), command.rawPassword());

        if (registered.id() != loginId) {
            throw new IllegalStateException(
                    "composition returned different member ids");
        }
        return loginId;
    }

    public static void main(String[] args) {
        try (var context =
                     new AnnotationConfigApplicationContext()) {
            context.register(
                    BoardConfig.class, BoardApplication.class);
            context.refresh();

            long memberId = context.getBean(BoardApplication.class)
                    .registerAndAuthenticate(new SignUpCommand(
                            "member@example.com",
                            "안동민",
                            "secret-1234"));
            System.out.println("memberId=" + memberId);
        }
    }
}

getBean은 이 예제의 시작 경계에서 완성된 애플리케이션 객체를 한 번 얻는 데만 사용합니다.

서비스가 실행 중 컨테이너를 다시 조회하지 않으므로 업무 코드의 의존성은 생성자에 그대로 드러납니다.


하나로 결정되지 않으면 refresh에서 실패

단일 값 주입 지점은 타입과 선택 규칙을 적용한 결과 후보 하나가 결정될 때만 완성됩니다.

이 예제처럼 MemberRepository 후보가 하나면 주입에 성공합니다.

후보가 0개이면 가장 깊은 원인은 NoSuchBeanDefinitionException이고, 선택 기준 없이 후보가 복수이면 NoUniqueBeanDefinitionException입니다.

상위 예외나 상세 메시지 문자열은 구현 문맥에 따라 달라질 수 있으므로 테스트는 refresh() 실패의 root exception 타입만 검사합니다.

board-core/src/test/java/board/BoardConfigTest.java
package board;

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

import board.member.MemberRegistrationService;
import board.member.MemberRepository;
import board.member.MemoryMemberRepository;
import board.member.PasswordHasher;
import board.member.SignUpCommand;
import board.member.TestPasswordHasher;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

final class BoardConfigTest {
    @Test
    void 단일_생성자에_서비스를_주입해_같은_저장_상태를_사용한다() {
        try (var context = contextWith(
                BoardConfig.class, BoardApplication.class)) {
            long memberId = context.getBean(BoardApplication.class)
                    .registerAndAuthenticate(new SignUpCommand(
                            "member@example.com",
                            "회원",
                            "secret-1234"));

            assertThat(memberId).isPositive();
            assertThat(context.getBean(MemberRepository.class))
                    .isSameAs(context.getBean(MemberRepository.class));
        }
    }

    @Test
    void 기본_singleton은_컨텍스트별_빈_정의에_한정된다() {
        try (var first = contextWith(BoardConfig.class);
             var second = contextWith(BoardConfig.class)) {
            assertThat(first.getBean(MemberRepository.class))
                    .isNotSameAs(second.getBean(MemberRepository.class));
        }
    }

    @Test
    void 저장소_후보가_없으면_refresh가_실패한다() {
        try (var context =
                     new AnnotationConfigApplicationContext()) {
            context.register(MissingRepositoryConfig.class);

            assertThatThrownBy(context::refresh)
                    .hasRootCauseExactlyInstanceOf(
                            NoSuchBeanDefinitionException.class);
        }
    }

    @Test
    void 저장소_후보가_복수이고_선택_기준이_없으면_refresh가_실패한다() {
        try (var context =
                     new AnnotationConfigApplicationContext()) {
            context.register(AmbiguousRepositoryConfig.class);

            assertThatThrownBy(context::refresh)
                    .hasRootCauseExactlyInstanceOf(
                            NoUniqueBeanDefinitionException.class);
        }
    }

    private static AnnotationConfigApplicationContext contextWith(
            Class<?>... componentClasses
    ) {
        var context = new AnnotationConfigApplicationContext();
        context.register(componentClasses);
        context.refresh();
        return context;
    }

    @Configuration(proxyBeanMethods = false)
    static class MissingRepositoryConfig {
        @Bean
        PasswordHasher passwordHasher() {
            return new TestPasswordHasher();
        }

        @Bean
        MemberRegistrationService memberRegistrationService(
                MemberRepository missingRepository,
                PasswordHasher passwordHasher
        ) {
            return new MemberRegistrationService(
                    missingRepository, passwordHasher);
        }
    }

    @Configuration(proxyBeanMethods = false)
    static class AmbiguousRepositoryConfig {
        @Bean
        MemberRepository firstRepository() {
            return new MemoryMemberRepository();
        }

        @Bean
        MemberRepository secondRepository() {
            return new MemoryMemberRepository();
        }

        @Bean
        PasswordHasher passwordHasher() {
            return new TestPasswordHasher();
        }

        @Bean
        MemberRegistrationService memberRegistrationService(
                MemberRepository ambiguousRepository,
                PasswordHasher passwordHasher
        ) {
            return new MemberRegistrationService(
                    ambiguousRepository, passwordHasher);
        }
    }
}

테스트 대역은 실패 원인을 저장소 후보 수로 통제하기 위해 test-source 구성 안에서만 사용합니다.

0개 구성은 저장소만 빠뜨리고, 복수 구성은 이름이 다른 저장소 두 개를 등록하되 매개변수 이름과 일치하는 후보를 만들지 않습니다.

단일 MemberRepository 주입점이 후보 하나로 결정되면 refresh가 성공하고, 후보가 없거나 선택 기준 없이 복수이면 서로 다른 root exception으로 실패하며, 기본 singleton은 컨텍스트별 빈 정의에 한정됨을 설명합니다.

CANDIDATE RESOLUTION · CONTEXT BOUNDARY

단일 주입점은 후보 하나가 결정될 때만 완성된다

후보 결정과 객체 공유는 서로 다른 질문입니다. 먼저 단일 값 주입점에 넣을 후보를 하나로 결정하고, 그다음 기본 singleton 빈은 해당 컨텍스트와 빈 정의 안에서 공유됩니다.

SINGLE-VALUED INJECTION POINT

MemberRepository 하나를 요구하는 매개변수의 세 결과

refresh()에서 확인하는 후보 결정 결과
결정 가능한 후보 예제 구성 결과 root exception
0개 저장소 빈 누락 의존성을 채울 수 없어 refresh 실패 NoSuchBeanDefinitionException
1개 memberRepository() R#1을 서비스 생성자에 주입 없음
복수 선택 기준 없는 R#1·R#2 하나를 고를 수 없어 refresh 실패 NoUniqueBeanDefinitionException

테스트는 변할 수 있는 상세 메시지가 아니라 가장 깊은 예외의 정확한 타입을 검사합니다.

CONTEXT A · BEAN DEFINITION R

같은 컨텍스트

기본 singleton인 저장소 빈 R을 두 번 요청하면 Context A 안에서는 같은 R#A 참조를 얻습니다.

CONTEXT B · SAME CONFIG CLASS

다른 컨텍스트

같은 BoardConfig 클래스를 다시 등록해도 Context B는 별도의 R#B를 만듭니다. R#A와 R#B는 전역 객체가 아닙니다.

각 결론에 필요한 증거와 이 장의 한계
질문 증거 확인되는 범위 확인되지 않는 범위
후보가 결정되는가 0·1·복수 구성의 refresh() 단일 값 주입점의 성공과 root exception 예외 메시지 문자열의 안정성
저장소가 공유되는가 같은 컨텍스트에서 R을 두 번 조회 컨테이너별·빈 정의별 기본 singleton JVM·프로세스 전역 singleton
해시 빈이 같은가 현재 그래프의 한 빈 정의 이 컨텍스트가 선택한 주입 참조 포트의 identity 요구·저장 형식 호환
운영 성질이 생기는가 이 장에는 별도 증거 없음 명시적 생성과 연결만 확인 thread safety·transaction·persistence

prototype·lifecycle callback·반복 lookup과 다른 선택 규칙의 상세는 후속 문서의 별도 주제입니다.


기본 singleton이 보장하는 정확한 범위

관찰이 문서에서 말할 수 있는 것여기서 말하지 않는 것
같은 컨텍스트에서 같은 빈 정의를 두 번 조회같은 MemberRepository 인스턴스 반환JVM·프로세스 전체의 singleton
서로 다른 두 컨텍스트에서 같은 구성 등록서로 다른 저장소 인스턴스컨텍스트 사이의 회원 상태 공유
두 서비스에 같은 저장소 빈 주입순차 메모리 예제에서 가입 상태를 로그인에서 조회저장소의 thread safety·transaction·persistence
같은 해시 빈 주입현재 컨텍스트 그래프의 구현 선택모든 해시 구현의 객체 identity 요구·저장 형식 호환
단일 생성자 주입생성 시 필요한 서비스 참조가 완성서비스 자체의 동시 실행 안전성

Spring의 기본 singleton은 컨테이너별·빈 정의별 공유입니다.

@Bean을 붙였다는 사실만으로 스레드 안전성, 트랜잭션, 영속성, 기존 해시 migration이 생기지 않습니다.

이 문서는 Spring Framework의 명시적 Java 구성만 다루며 Spring Boot 자동 설정을 사용하거나 설명하지 않습니다.

prototype과 다른 스코프, lifecycle callback, 반복 lookup의 의미는 다음 문서 이후에 별도로 다룹니다.