[Spring] 빈(Bean)

빈이란

자바 객체를 쓰려면 생성자에 어떤 값을 넣어야 하는지, 어떤 순서로 다른 객체를 먼저 만들어야 하는지까지 호출하는 쪽이 의존성을 파악하고 생성해야 한다. 스프링은 이 생성 책임을 통째로 컨테이너(Container)로 옮긴다.

우리는 타입만 선언하면 되고, 인스턴스를 언제 어떻게 만들지는 컨테이너가 결정한다. 즉, 생성의 제어권이 호출하는 코드에서 컨테이너로 넘어간다. 이렇게 컨테이너가 만들어서 관리하는 객체를 빈(Bean)이라 부른다.

스프링 컨테이너를 제어의 역전(IoC, Inversion of Control) 컨테이너라 부르는 이유도 여기에 있다. 객체를 만들 것인가 말 것인가, 언제 만들 것인가라는 제어권이 개발자에게서 컨테이너로 넘어갔기 때문이다.

구분직접 만든 객체스프링 빈
생성 주체호출하는 코드가 new로 직접 생성컨테이너가 대신 생성
생성 시점호출하는 시점컨테이너 기동 시(싱글톤) 또는 요청 시점(다른 스코프)
의존성 해결생성자 인자를 호출하는 쪽이 직접 채움컨테이너가 타입을 보고 채워 넣음
동일성매번 다른 인스턴스스코프가 보장하는 동일성(싱글톤이면 하나)
생명주기 관리참조가 사라지면 그걸로 끝초기화와 소멸 콜백을 컨테이너가 순서대로 호출
// 직접 만든 객체: 호출하는 코드가 의존성 순서를 전부 챙겨야 한다
fun createOrderService(): OrderService {
    val repository = OrderRepository(dataSource)
    val notifier = OrderNotifier(mailSender)
    return OrderService(repository, notifier)
}
 
// 스프링 빈: 타입만 선언하면 컨테이너가 만들어서 건네준다
@Component
class OrderService(
    private val orderRepository: OrderRepository,
    private val orderNotifier: OrderNotifier,
)

빈은 우리가 언제 어떻게 만들지 몰라도, 컨테이너가 타입으로 찾아 대신 건네주는 객체다. 그런데 컨테이너가 건네주는 순간까지 그 객체가 아무 일도 안 겪는 것은 아니다. 등록되고 인스턴스로 만들어지고 쓰이다가 사라지기까지, 빈에게는 정해진 순서가 있다.

빈의 생명주기

빈 하나가 등록부터 소멸까지 거치는 이 순서를 생명주기(Lifecycle)라 부른다. 스프링은 이 순서를 아홉 단계로 나누고, 각 단계마다 개발자가 끼어들 수 있는 훅(Hook)을 열어 둔다. BeanDefinition 등록, BeanFactoryPostProcessor, 인스턴스화, 의존성 주입, BeanPostProcessor(전), 초기화 콜백, BeanPostProcessor(후), 사용 준비 완료, 소멸 콜백 순서다.

BeanDefinition 등록 단계에서는 아직 객체가 없다. 클래스 이름, 스코프, 생성자 인자 같은 메타데이터만 저장소에 쌓인다. 그다음 BeanFactoryPostProcessor가 이 메타데이터를 조작한다. 가령 프로퍼티 값을 치환하는 PropertySourcesPlaceholderConfigurer도 이 단계에서 동작하는 BeanFactoryPostProcessor다.

메타데이터가 정해지면 컨테이너는 생성자나 팩토리 메서드로 실제 인스턴스를 만들고, 그 후 필드, 세터, 생성자 인자로 의존성을 채워 넣는다. 이 대순서를 실제로 지휘하는 메서드가 AbstractAutowireCapableBeanFactory#doCreateBean이고, 인스턴스를 만들고(createBeanInstance), 값을 채우고(populateBean), 초기화하는(initializeBean) 세 호출이 이 순서 그대로 적혀 있다.

📂 참고 소스: org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory#doCreateBean

// org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory#doCreateBean
protected Object doCreateBean(String beanName, RootBeanDefinition mbd, @Nullable Object[] args)
        throws BeanCreationException {
 
    // ① 인스턴스화: 생성자 또는 팩토리 메서드로 객체를 만든다
    BeanWrapper instanceWrapper = createBeanInstance(beanName, mbd, args);
    Object bean = instanceWrapper.getWrappedInstance();
    // ...(순환 참조 대비 조기 캐시 노출 로직 생략)
 
    Object exposedObject = bean;
    try {
        // ② 의존성 주입: 필드/세터/생성자 값을 채운다
        populateBean(beanName, mbd, instanceWrapper);
        // ③ 초기화: BeanPostProcessor와 init 콜백을 전부 통과시킨다
        exposedObject = initializeBean(beanName, exposedObject, mbd);
    }
    catch (Throwable ex) {
        // ...(예외를 BeanCreationException 으로 변환)
    }
    // ...(조기 노출된 싱글톤 참조와의 일치 검증 생략)
    return exposedObject;
}

이 세 호출이 아홉 단계 중 인스턴스화, 의존성 주입, 그리고 이어지는 초기화 전체를 감싸는 진입점이다.

initializeBean 안에서는 Aware 콜백, BeanPostProcessor(전), 초기화 콜백, BeanPostProcessor(후) 네 단계가 순서대로 실행된다. CommonAnnotationBeanPostProcessor라는 내장 후처리기가 BeanPostProcessor(전) 단계에서 @PostConstruct가 붙은 메서드를 호출하고, AbstractAutoProxyCreator 같은 후처리기는 BeanPostProcessor(후) 단계에서 AOP 프록시(Proxy)로 원본 인스턴스를 감싼다.

📂 참고 소스: org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory#initializeBean

// org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory#initializeBean
protected Object initializeBean(String beanName, Object bean, @Nullable RootBeanDefinition mbd) {
    invokeAwareMethods(beanName, bean);  // BeanNameAware, BeanFactoryAware 등
 
    Object wrappedBean = bean;
    if (mbd == null || !mbd.isSynthetic()) {
        wrappedBean = applyBeanPostProcessorsBeforeInitialization(wrappedBean, beanName);  // 전
    }
 
    try {
        invokeInitMethods(beanName, wrappedBean, mbd);  // InitializingBean/@PostConstruct/init-method
    }
    catch (Throwable ex) {
        throw new BeanCreationException(
                (mbd != null ? mbd.getResourceDescription() : null), beanName, ex.getMessage(), ex);
    }
    if (mbd == null || !mbd.isSynthetic()) {
        wrappedBean = applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);  // 후
    }
 
    return wrappedBean;
}

Aware 콜백이 BeanPostProcessor보다 먼저라는 것, 그리고 전과 후 사이에 invokeInitMethods 호출을 한다. 그 안에서 InitializingBean.afterPropertiesSet()과 @Bean(initMethod = ...)으로 지정한 커스텀 초기화 메서드 선택을 통해 빈 초기화를 어떻게 할지 선택한다.

📂 참고 소스: org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory#invokeInitMethods

// org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory#invokeInitMethods (핵심 발췌)
protected void invokeInitMethods(String beanName, Object bean, @Nullable RootBeanDefinition mbd)
        throws Throwable {
 
    boolean isInitializingBean = (bean instanceof InitializingBean);
    if (isInitializingBean && (mbd == null || !mbd.hasAnyExternallyManagedInitMethod("afterPropertiesSet"))) {
        // ...(트레이스 로그 생략)
        ((InitializingBean) bean).afterPropertiesSet();  // ① InitializingBean 이 먼저 불린다
    }
 
    if (mbd != null && bean.getClass() != NullBean.class) {
        String[] initMethodNames = mbd.getInitMethodNames();
        if (initMethodNames != null) {
            for (String initMethodName : initMethodNames) {
                if (StringUtils.hasLength(initMethodName) &&
                        !(isInitializingBean && "afterPropertiesSet".equals(initMethodName)) &&
                        !mbd.hasAnyExternallyManagedInitMethod(initMethodName)) {
                    invokeCustomInitMethod(beanName, bean, mbd, initMethodName);  // ② 커스텀 init-method
                }
            }
        }
    }
}

afterPropertiesSet()이 있으면 그것을 먼저 호출하고, 이름이 같지 않은 한 커스텀 init-method를 별도로 호출한다. 사용자 코드(가령 Configuration class)에서는 이 순서가 @PostConstruct, afterPropertiesSet(), @Bean(initMethod = ...) 순으로 진행된다.

이렇게 완성된 빈은 사용 준비 상태로 컨테이너에 보관된다. 싱글톤(Singleton) 스코프라면 컨테이너가 이 인스턴스를 계속 들고 있다가 컨테이너가 닫힐 때 @PreDestroy와 DisposableBean.destroy()를 순서대로 호출한다. 반면 프로토타입(Prototype) 스코프는 인스턴스화와 의존성 주입까지만 컨테이너가 책임지고, 그 이후로는 추적하지 않는다.

sequenceDiagram
    autonumber
    participant Factory as BeanFactory
    participant CAPP as CommonAnnotationBeanPostProcessor
    participant Bean as 빈 인스턴스
    participant Custom as 다른 BeanPostProcessor

    Factory->>Factory: 생성자/팩토리 메서드로 인스턴스화
    Factory->>Bean: 의존성 주입(필드/세터/생성자)
    Factory->>CAPP: postProcessBeforeInitialization(bean)
    CAPP->>Bean: @PostConstruct 메서드 호출
    CAPP-->>Factory: bean 반환
    Factory->>Bean: afterPropertiesSet() / 커스텀 init-method 호출
    Factory->>Custom: postProcessAfterInitialization(bean)
    Custom-->>Factory: 프록시로 감싼 bean 반환(AOP 등)
    alt singleton 스코프
        Factory->>Factory: 싱글톤 캐시에 보관
        Note over Factory: 컨테이너 close() 시 @PreDestroy/destroy() 호출
    else prototype 스코프
        Factory-->>Bean: 요청한 코드에 반환 후 추적 종료
        Note over Factory,Bean: 소멸 콜백은 호출되지 않는다
    end

빈의 생명주기는 등록부터 소멸까지, 컨테이너가 순서를 보장하는 콜백이다. 이 생명주기에서 어떤 스코프가 어디까지 관리되는지를 알아야 초기화 로직을 어디에 둘지와 자원 정리를 누가 책임질지를 정할 수 있다.

빈을 정의하는 방법

빈으로 등록하는 방법은 크게 두 가지다. 클래스에 스테레오타입 어노테이션(Stereotype Annotation)을 붙여 스캔되게 하거나, @Configuration 클래스 안에서 메서드로 직접 만들어 반환하는 것이다.

@Bean, 메서드로 만드는 빈

@Bean은 메서드 하나가 빈 하나를 만든다. 메서드 본문에 원하는 생성 로직을 코드로 그대로 쓸 수 있어서, 생성자 인자를 조합하거나 조건에 따라 다른 인스턴스를 반환하는 것도 가능하다. 특히 외부 라이브러리가 제공하는 클래스처럼 소스를 고칠 수 없는 대상에는 어노테이션(Annotation)을 붙일 방법이 없으므로, @Bean이 유일한 등록 경로가 된다.

@Configuration
class SecurityConfig {
 
    @Bean
    fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
}

@Component, 클래스 스캔으로 찾는 빈

@Component는 클래스 자체에 붙이는 표시다. @ComponentScan이 지정한 패키지 안을 훑다가 이 표시가 붙은 클래스를 찾으면, 그 클래스의 생성자를 보고 BeanDefinition을 만든다. 내가 직접 작성한 클래스이고 별다른 조합 로직 없이 그대로 등록하면 되는 경우에 적합하다.

@Component
class OrderNotifier(
    private val mailSender: MailSender,
) {
    fun notify(order: Order) {
        mailSender.send(order.customerEmail, "주문이 접수되었습니다")
    }
}

@Bean과 @Component, 언제 무엇을 쓰는가

두 방법의 차이는 누가 인스턴스화 로직을 쥐고 있는가다. 즉, @Component는 컨테이너가 생성자를 보고 알아서 만들고, @Bean은 개발자가 메서드 본문에 그 로직을 직접 쓴다.

상황선택근거
외부 라이브러리 클래스라 소스에 어노테이션을 못 붙인다@Bean클래스 정의를 건드리지 않고 인스턴스만 만들면 된다
내가 만든 클래스이고 스캔 대상 패키지 안에 있다@Component 계열생성자 의존성만 선언하면 컨테이너가 알아서 찾는다
생성 시점에 조건 분기나 값 조합이 필요하다@Bean메서드 본문에 임의의 코드를 쓸 수 있다
같은 인터페이스의 구현체를 여러 개 등록해야 한다@Bean 여러 개 또는 @Component + @Qualifier이름을 다르게 주거나 한정자로 구분한다
등록 여부를 설정값이나 다른 빈의 존재로 결정한다@Bean + @Conditional 계열@Component는 스캔되면 무조건 등록되어 조건부 등록에 맞지 않는다

contextd에서 빈이 등록되는 과정

ApplicationContext가 시작되면 refresh()라는 메서드를 진입하여 빈 등록이 시작된다. 가장 먼저 @ComponentScan으로 찾은 클래스와 @Configuration의 @Bean 메서드가 각각 BeanDefinition으로 바뀌어 하나의 레지스트리에 모이고, 이 시점까지는 실제 인스턴스가 하나도 만들어지지 않는다.

레지스트리가 채워지면 등록된 BeanFactoryPostProcessor들이 차례로 실행되며 정의 자체를 조정한다. 그다음 BeanPostProcessor 구현체들이 컨테이너에 등록되고, 마지막으로 싱글톤 빈들을 미리 만들어 두는 사전 인스턴스화 루프가 돈다. 이 루프 안에서 빈 하나마다 앞서 본 아홉 단계 생명주기가 그대로 반복된다.

등록된 빈이 주입되기까지

의존성 주입은 AutowiredAnnotationBeanPostProcessor가 생성자나 필드의 타입을 보고 레지스트리에서 후보를 찾는다. 후보가 하나면 그대로 주입하고, 여러 개면 @Primary가 붙은 빈을 우선하거나 @Qualifier로 지정한 이름을 찾는다.

// as-is(잘못된 패턴): 필드 주입은 컨테이너 없이 인스턴스를 만들 수 없고, 테스트에서 대역을 넣기도 번거롭다
@Component
class OrderService {
    @Autowired
    private lateinit var orderRepository: OrderRepository
}
 
// to-be(올바른 패턴): 생성자 주입은 객체가 만들어지는 시점에 의존성이 확정된다
@Component
class OrderService(
    private val orderRepository: OrderRepository,
)

빈 스코프

네 가지 스코프

스코프(Scope)는 빈 인스턴스를 몇 개까지, 언제까지 유지할지를 정하는 규칙이다. 스프링이 기본으로 제공하는 스코프는 네 가지이고, 웹 환경에서만 request와 session이 의미를 가진다.

스코프기본값 여부인스턴스 생성 시점특징
singleton기본값컨테이너 기동 시(사전 인스턴스화)컨테이너 전체에서 인스턴스 하나
prototype아니오getBean() 또는 주입 시점마다요청할 때마다 새 인스턴스, 소멸 콜백 미관리
request아니오(웹 전용)HTTP 요청마다요청이 끝나면 함께 소멸
session아니오(웹 전용)HTTP 세션마다세션이 끝나면 함께 소멸

싱글톤이 프로토타입을 주입받을 때

싱글톤은 컨테이너 기동 시 단 한 번 만들어지고 그 참조를 계속 들고 있다. 그런데 그 싱글톤이 생성자나 필드로 prototype 빈을 직접 주입받으면 이후로는 같은 프로토타입 인스턴스 하나를 계속 쓰게 된다. 그렇다면 매번 새 프로토타입 인스턴스를 받으려면 어떻게 해야 할까?

// 잘못된 패턴: 싱글톤이 생성자에서 prototype 빈을 한 번만 받으면, 이후 요청마다 같은 인스턴스를 돌려준다
@Component
class ReportPrinter(
    private val cursor: PrintCursor,  // prototype 스코프
) {
    fun print() = cursor.advance()
}
 
// 올바른 패턴: ObjectProvider 로 사용 시점마다 새 인스턴스를 조회한다
@Component
class ReportPrinter(
    private val cursorProvider: ObjectProvider<PrintCursor>,
) {
    fun print() = cursorProvider.getObject().advance()
}

prototype을 매번 새로 받고 싶다면, 주입 시점이 아니라 사용 시점에 조회해야 한다. ObjectProvider<T>나 스코프 프록시(Scoped Proxy)를 쓰면 getObject() 호출이 실제 조회 시점이 되므로, 싱글톤 안에서도 매번 새 프로토타입 인스턴스를 받을 수 있다.

@Configuration의 proxyBeanMethods

@Configuration 클래스는 기본값(proxyBeanMethods = true)일 때 CGLIB로 프록시 객체를 만들어 빈 메서드의 호출을 가로채어 싱글톤 상태를 유지시킨다. 그래서 한 @Configuration 클래스 안에서 다른 @Bean 메서드를 코드로 직접 호출해도 싱글톤이 유지된다.

proxyBeanMethods = false로 두면 싱글톤이 유지되지 않는다. 메서드를 호출할 때마다 메서드의 로직이 처음부터 다시 실행된다.

// 잘못된 패턴: proxyBeanMethods = false 에서는 dataSource() 호출이 순수 메서드 호출이라 매번 새로 만든다
@Configuration(proxyBeanMethods = false)
class DataSourceConfig {
    @Bean
    fun dataSource(): DataSource = HikariDataSource()
 
    @Bean
    fun jdbcTemplate(): JdbcTemplate = JdbcTemplate(dataSource())
}
 
// 올바른 패턴: 필요한 빈을 파라미터로 받으면 컨테이너가 넣어준 같은 인스턴스를 쓴다
@Configuration(proxyBeanMethods = false)
class DataSourceConfig {
    @Bean
    fun dataSource(): DataSource = HikariDataSource()
 
    @Bean
    fun jdbcTemplate(dataSource: DataSource): JdbcTemplate = JdbcTemplate(dataSource)
}

proxyBeanMethods를 false로 두면 프록시 객체 생성이 없어 컨테이너 기동이 빨라지지만, 메서드 간 직접 호출 시 싱글톤이 보장되지 않는다.

각종 빈 어노테이션

@Component, @Service, @Repository, @Controller, @RestController는 전부 컴포넌트 스캔의 대상이 된다는 점에서 같다. 소스를 열어보면 @Service, @Repository, @Controller는 @Component를 어노테이션으로 가진다.

@Configuration, @Bean

이 클래스는 안에 있는 @Bean 메서드들을 인식하여 스프링 컨테이너에 빈 객체로 등록한다.

@Component

@Component는 나머지 스테레오타입 어노테이션의 기반이다. 계층을 구분할 필요가 없는 유틸리티 클래스나, 아래 네 가지 어디에도 속하지 않는 빈은 @Component를 그대로 쓴다.

@Controller, @RestController

@Controller와 @RestController는 기능적으로 @Component와 동일하게 스캔되어 빈으로 등록된다. 다른 점은 RequestMappingHandlerMapping이 이 표시가 붙은 빈만 걸러내 URL 매핑 대상으로 삼는다는 것이다. @RestController는 여기에 @ResponseBody가 클래스에 붙어있다.

@Service

@Service는 기능적으로 @Component와 완전히 같다. 추가되는 동작은 없고, 이 빈이 비즈니스 로직을 담당한다는 의미만 표시한다. 이 마커를 이용하여 트랜잭션이나 로깅 같은 AOP 포인트컷을 계층 단위로 걸 수 있따.

@Repository

@Repository는 실제 동작이 하나 추가된다. PersistenceExceptionTranslationPostProcessor라는 BeanPostProcessor가 @Repository 빈을 프록시로 감싸고, 영속성 기술마다 다른 예외를 스프링의 DataAccessException 계열로 통일해서 던진다.

@Repository
class UserJdbcRepository(
    private val jdbcTemplate: JdbcTemplate,
) {
    fun save(user: User) {
        jdbcTemplate.update("INSERT INTO users(email) VALUES (?)", user.email)
    }
}
 
// 이메일 중복으로 유니크 제약을 위반하면
try {
    userRepository.save(user)
} catch (e: DataIntegrityViolationException) {
    // MySQL 드라이버가 던진 SQLIntegrityConstraintViolationException 이
    // PersistenceExceptionTranslationPostProcessor 의 프록시를 거치며 여기로 변환된다
}

그 결과 서비스 계층은 JDBC의 SQLException이나 JPA의 PersistenceException을 직접 알 필요가 없다. 구현 기술을 MyBatis에서 JPA로 바꿔도, 서비스 계층이 잡는 예외 타입은 그대로다.

어노테이션@Component와 기능이 같은가추가되는 동작
@ControllerRequestMappingHandlerMapping의 스캔 대상
@RestController예 + @ResponseBody반환값이 곧바로 응답 본문으로 직렬화
@Service없음(의미상 표시)
@RepositoryPersistenceExceptionTranslationPostProcessor가 예외를 변환

상황별 빈 사용 사례

외부 SDK 클라이언트 등록

AmazonS3나 결제 SDK(Software Development Kit)의 클라이언트 클래스는 외부 라이브러리가 제공하는 것이라 소스에 어노테이션을 붙일 수 없다. 이럴 때는 @Configuration 클래스 안에 @Bean 메서드를 두고, 빌더로 인스턴스를 만들어 한번 감싸서 사용할 수 있다.

// AmazonS3 클라이언트는 외부 라이브러리 클래스라 @Component 를 붙일 수 없다
@Configuration
class S3Config {
    @Bean
    fun amazonS3(): AmazonS3 =
        AmazonS3ClientBuilder.standard()
            .withRegion(Regions.AP_NORTHEAST_2)
            .build()
}

프로파일별 구현체 교체

결제 게이트웨이처럼 운영 환경에서는 실제 API를 호출해야 하지만 로컬이나 테스트에서는 호출하면 안 되는 대상이 있다. 인터페이스 하나에 구현체 두 개를 만들고 각각 @Profile로 활성 프로파일을 지정하면, 같은 타입을 주입받는 코드는 한 줄도 바뀌지 않는다.

interface PaymentGateway {
    fun charge(amount: Long): PaymentResult
}
 
@Profile("prod")
@Component
class TossPaymentGateway(
    private val client: TossApiClient,
) : PaymentGateway {
    override fun charge(amount: Long) = client.requestCharge(amount)
}
 
@Profile("local", "test")
@Component
class FakePaymentGateway : PaymentGateway {
    override fun charge(amount: Long) = PaymentResult.success(amount)
}

테스트에서의 교체

통합 테스트에서 외부 API를 실제로 호출하고 싶지 않다면, @TestConfiguration으로 테스트 전용 설정 클래스를 만들고 @Primary를 붙인 대역 빈을 등록한다. 그러면 우리는 운영 설정을 건드리지 않고도, 테스트 컨텍스트 안에서만 우선순위가 높은 빈을 받을 수 있다.

@TestConfiguration
class TestPaymentConfig {
    @Bean
    @Primary
    fun paymentGateway(): PaymentGateway = FakePaymentGateway()
}
 
@SpringBootTest
@Import(TestPaymentConfig::class)
class OrderServiceTest {
    @Autowired
    private lateinit var paymentGateway: PaymentGateway  // FakePaymentGateway 가 주입된다
}

조건부 등록

캐시처럼 설정값에 따라 켜고 끌 수 있어야 하는 빈은 @ConditionalOnProperty로 등록 여부를 결정한다. 해당 설정이 없거나 다른 값이면 대체 빈을 등록하는 @ConditionalOnMissingBean과 짝을 지어 두면, 설정을 하지 않은 환경에서도 컨테이너 기동이 실패하지 않는다.

@Configuration
class CacheConfig {
 
    @Bean
    @ConditionalOnProperty(name = ["app.cache.enabled"], havingValue = "true")
    fun cacheManager(): CacheManager = CaffeineCacheManager()
 
    @Bean
    @ConditionalOnMissingBean(CacheManager::class)
    fun noOpCacheManager(): CacheManager = NoOpCacheManager()
}
상황선택근거
외부 라이브러리 클래스라 소스를 못 고친다@Bean빌더나 생성자 호출을 메서드 본문에 직접 쓸 수 있다
내가 만든 클래스이고 역할이 컨트롤러/서비스/저장소 중 하나다해당 스테레오타입(@Controller, @Service, @Repository)계층을 드러내고, @Repository는 예외 변환까지 얻는다
환경마다 다른 구현체가 필요하다@Profile + 인터페이스활성 프로파일만 바꾸면 주입받는 코드는 그대로다
테스트에서만 다른 구현체가 필요하다@TestConfiguration + @Primary운영 설정을 건드리지 않고 테스트 컨텍스트에서만 우선순위를 바꾼다
설정값이나 다른 빈의 존재로 등록 여부가 갈린다@ConditionalOnProperty / @ConditionalOnMissingBean스캔은 조건을 모르므로 조건부 등록에는 @Bean 계열이 맞다
싱글톤인데 매번 새 인스턴스가 필요하다ObjectProvider 또는 scoped-proxy생성자 주입 시점이 아니라 사용 시점에 조회해야 스코프가 지켜진다