Spring Bean Container 정리 – Bean이란 무엇이며 Spring은 어떻게 관리하는가


Spring Bean Container가 어떻게 동작하는지 모르면, @Component@Bean의 차이를 물어보는 면접 질문 앞에서 막히거나, Bean이 싱글톤이라는 사실을 모르고 상태(State)를 필드에 저장해 멀티스레드 버그를 만들거나, @PostConstruct를 써야 하는 자리에 생성자를 쓰다가 의존성 주입이 완료되지 않은 상태에서 초기화 로직이 실행되는 장애를 겪게 됩니다. Spring이 “마법처럼” 동작한다고 느낀다면, 그 마법의 정체가 바로 IoC 컨테이너가 Bean을 등록·조합·관리하는 과정입니다. 이 글에서는 Bean의 정의부터 BeanDefinition 메타데이터, IoC 컨테이너의 두 종류(BeanFactory vs ApplicationContext), 컴포넌트 스캔 원리, Bean 생명주기 전체 흐름, 스코프 6가지, 그리고 실무 설계 원칙까지 예제 코드와 다이어그램으로 완벽하게 정리합니다.


목차

  1. Bean이란 무엇인가 – 일반 Java 객체와 무엇이 다른가
  2. Spring Container의 두 얼굴 – BeanFactory vs ApplicationContext
  3. Bean 등록 방법 4가지 – 컴포넌트 스캔부터 @Bean까지
  4. Bean 생명주기 완전 정복 – 생성부터 소멸까지 전 과정
  5. Bean 스코프 6가지 – 싱글톤·프로토타입·웹 스코프
  6. 전문가 관점 – BeanDefinition·조건부 등록·실무 설계 원칙

1. Bean이란 무엇인가 – 일반 Java 객체와 무엇이 다른가

Java 객체와 Spring Bean의 결정적 차이

Java에서 객체는 new 키워드로 직접 생성합니다. 반면 Spring Bean은 개발자가 직접 new로 생성하지 않고, Spring IoC 컨테이너가 생성·관리하는 객체입니다.

[일반 Java 객체 vs Spring Bean]

일반 Java 객체:
  개발자 → new UserService() → 힙(Heap) 메모리에 저장
  개발자가 생성·사용·소멸 직접 관리
  GC가 참조 없어지면 수거

Spring Bean:
  개발자 → @Service 선언 → Spring Container가 생성
  Spring Container → 싱글톤으로 관리 (기본값)
  Spring Container → 의존성 주입 처리
  Spring Container → 생명주기 콜백 실행
  애플리케이션 종료 시 Container가 소멸 관리

java

// ❌ 일반 Java 객체 – 개발자가 직접 생성·관리
public class OrderController {

    // 모든 의존성을 개발자가 직접 생성
    private UserService userService     = new UserService(new UserRepository());
    private OrderService orderService   = new OrderService(new OrderRepository(),
                                             new UserRepository()); // UserRepository 중복 생성!
    private EmailService emailService   = new SmtpEmailService();

    // 문제점:
    // 1. 의존성이 변경되면 이 코드도 모두 수정해야 함
    // 2. UserRepository가 두 번 생성됨 (메모리 낭비·상태 불일치)
    // 3. 테스트 시 실제 DB·SMTP 연결 필요
}

// ✅ Spring Bean – Container가 생성·관리
@RestController
@RequiredArgsConstructor
public class OrderController {

    // Container가 알아서 주입 (어떤 구현체인지 몰라도 됨)
    private final UserService userService;
    private final OrderService orderService;
    private final EmailService emailService;

    // 장점:
    // 1. 의존성 변경 시 이 코드 수정 불필요
    // 2. UserRepository는 Container가 싱글톤으로 1개만 관리
    // 3. 테스트 시 Mock 주입으로 외부 의존성 대체 가능
}

Bean의 3가지 핵심 특성

Spring Bean이 일반 객체와 다른 3가지 핵심 특성:

① 컨테이너 관리 (Container-Managed)
   생성·초기화·소멸을 Spring이 책임
   개발자는 "무엇이 필요한가"만 선언

② 싱글톤 기본값 (Singleton by Default)
   기본적으로 애플리케이션 전체에서 1개 인스턴스
   → 메모리 효율, 상태 공유 주의 필요

③ 의존성 그래프 자동 조립 (Dependency Graph Auto-Wiring)
   Bean 간 의존 관계를 Container가 자동 분석·주입
   → 개발자는 인터페이스에만 의존하면 됨

Bean은 왜 필요한가 – 엔터프라이즈 애플리케이션의 현실

중소 규모 웹 서비스도 수십~수백 개의 클래스가 복잡하게 얽혀 있습니다. Bean 없이 이 의존성 그래프를 직접 조립하면 어떻게 될까요?

java

// Bean 없이 수동 조립 – 실제 규모에서 얼마나 복잡해지는지
public class Application {
    public static void main(String[] args) {

        // 인프라 계층
        DataSource dataSource       = new HikariDataSource(hikariConfig());
        EntityManagerFactory emf    = createEntityManagerFactory(dataSource);
        TransactionManager txMgr    = new JpaTransactionManager(emf);

        // 레포지토리 계층
        UserRepository userRepo     = new UserRepositoryImpl(emf);
        OrderRepository orderRepo   = new OrderRepositoryImpl(emf);
        ProductRepository prodRepo  = new ProductRepositoryImpl(emf);

        // 외부 서비스
        EmailService emailSvc       = new SmtpEmailService(smtpConfig());
        SmsService smsSvc           = new TwilioSmsService(twilioConfig());
        PaymentGateway paymentGw    = new StripePaymentGateway(stripeKey());

        // 서비스 계층 (의존성 직접 조립)
        UserService userSvc         = new UserService(userRepo, emailSvc);
        ProductService productSvc   = new ProductService(prodRepo);
        InventoryService invSvc     = new InventoryService(prodRepo);
        PaymentService paymentSvc   = new PaymentService(paymentGw, txMgr);
        OrderService orderSvc       = new OrderService(
                                          orderRepo, userSvc, productSvc,
                                          invSvc, paymentSvc, emailSvc, smsSvc);

        // 컨트롤러 계층
        OrderController orderCtrl   = new OrderController(orderSvc, userSvc);
        UserController userCtrl     = new UserController(userSvc);
        // ... 수십 개 더

        // → 클래스 하나 추가할 때마다 이 조립 코드 전체 수정 필요
        // → 테스트를 위해 특정 의존성만 교체하는 것이 매우 어려움
    }
}

Spring Container는 이 복잡한 조립 과정을 단 몇 개의 어노테이션으로 자동화합니다.


2. Spring Container의 두 얼굴 – BeanFactory vs ApplicationContext

IoC 컨테이너란

**IoC(Inversion of Control, 제어의 역전)**는 객체의 생성·관리 권한을 개발자에서 프레임워크로 역전시키는 원칙입니다. Spring에서 이를 구현하는 핵심 컴포넌트가 IoC 컨테이너입니다.

[IoC 컨테이너의 역할]

입력:
  Bean 설정 정보 (@Component, @Configuration, XML 등)

처리:
  ① Bean 설계도(BeanDefinition) 생성
  ② Bean 인스턴스 생성 (reflection 활용)
  ③ 의존성 분석 및 주입
  ④ 생명주기 콜백 실행
  ⑤ Bean 저장·조회 기능 제공

출력:
  완전히 조립된 Bean 인스턴스

BeanFactory – 가장 기본적인 IoC 컨테이너

java

// BeanFactory 인터페이스 핵심 메서드
public interface BeanFactory {

    Object getBean(String name);                          // 이름으로 Bean 조회
    <T> T getBean(String name, Class<T> requiredType);   // 이름+타입으로 조회
    <T> T getBean(Class<T> requiredType);                // 타입으로 조회
    boolean containsBean(String name);                   // Bean 존재 확인
    boolean isSingleton(String name);                    // 싱글톤 여부 확인
    boolean isPrototype(String name);                    // 프로토타입 여부 확인
    Class<?> getType(String name);                       // Bean 타입 반환
}

BeanFactory의 핵심 특징은 **지연 초기화(Lazy Initialization)**입니다. getBean()이 호출되는 시점에 비로소 Bean을 생성합니다. 메모리 효율은 높지만, 구동 시점에 설정 오류를 발견하지 못하는 단점이 있습니다.

ApplicationContext – 실무에서 사용하는 컨테이너

ApplicationContextBeanFactory를 확장해 엔터프라이즈 기능을 추가한 컨테이너입니다. Spring Boot에서는 항상 ApplicationContext를 사용합니다.

[ApplicationContext가 BeanFactory에 추가한 기능]

BeanFactory (기본 IoC 기능)
  ↓ 확장
ApplicationContext
  ├── 국제화(MessageSource): 다국어 메시지 처리
  ├── 이벤트(ApplicationEventPublisher): 이벤트 발행·구독
  ├── 리소스(ResourceLoader): 파일·URL 등 외부 리소스 로딩
  ├── 환경변수(EnvironmentCapable): 프로파일·프로퍼티 접근
  └── AOP 통합: @Transactional, @Async 등 AOP 자동 처리

java

// ApplicationContext 구현체 종류
ApplicationContext ctx;

// ① XML 기반 (레거시)
ctx = new ClassPathXmlApplicationContext("applicationContext.xml");

// ② Java Config 기반 (Spring 3.x+)
ctx = new AnnotationConfigApplicationContext(AppConfig.class);

// ③ Spring Boot 자동 구성 (실무 표준)
// SpringApplication.run()이 내부적으로 생성
// 웹 환경: AnnotationConfigServletWebServerApplicationContext
// 비웹 환경: AnnotationConfigApplicationContext

// ApplicationContext 직접 접근 (테스트 또는 특수 상황)
@Component
public class BeanInspector implements ApplicationContextAware {

    private ApplicationContext applicationContext;

    @Override
    public void setApplicationContext(ApplicationContext ctx) {
        this.applicationContext = ctx;
    }

    public void printAllBeans() {
        String[] beanNames = applicationContext.getBeanDefinitionNames();
        Arrays.stream(beanNames).sorted()
            .forEach(name -> log.info("Bean: {}", name));
    }

    public <T> T getBean(Class<T> type) {
        return applicationContext.getBean(type);
    }
}

BeanFactory vs ApplicationContext 비교

구분BeanFactoryApplicationContext
초기화 시점지연(Lazy) – getBean() 호출 시즉시(Eager) – 컨테이너 시작 시
오류 감지런타임 (사용 시점)구동 시점 (빠른 실패)
국제화❌ 미지원✅ MessageSource
이벤트❌ 미지원✅ ApplicationEvent
AOP 통합❌ 수동✅ 자동
환경변수❌ 미지원✅ Environment
실무 사용거의 사용 안 함✅ 표준
대표 구현체DefaultListableBeanFactoryAnnotationConfigApplicationContext

3. Bean 등록 방법 4가지 – 컴포넌트 스캔부터 @Bean까지

방법 ① 컴포넌트 스캔 – 어노테이션 자동 등록

가장 일반적인 방법입니다. Spring이 지정 패키지를 탐색해 특정 어노테이션이 붙은 클래스를 자동으로 Bean으로 등록합니다.

[컴포넌트 스캔 처리 흐름]

@SpringBootApplication 선언 (com.example.myapp 패키지)
  ↓
컴포넌트 스캔 시작
  → com.example.myapp 패키지 및 모든 하위 패키지 탐색
  ↓
스캔 대상 어노테이션 탐지:
  @Component      → 일반 컴포넌트
  @Service        → 비즈니스 서비스 레이어
  @Repository     → 데이터 접근 레이어 (예외 변환 추가)
  @Controller     → MVC 컨트롤러
  @RestController → REST API 컨트롤러 (@Controller + @ResponseBody)
  @Configuration  → Bean 설정 클래스
  ↓
BeanDefinition 생성 (Bean 설계도)
  ↓
IoC 컨테이너에 등록

java

// 컴포넌트 스캔 어노테이션별 역할 차이
@Component("myComponent")      // Bean 이름 직접 지정 (기본: 클래스명 첫 글자 소문자)
public class GeneralComponent { ... }

@Service                       // @Component + 서비스 레이어 의미론
public class UserService { ... }
// → Bean 이름: "userService"

@Repository                    // @Component + 데이터 접근 계층
public class UserRepository { ... }
// → 추가 기능: DataAccessException 자동 변환
// (SQL 예외 → Spring 표준 데이터 접근 예외로 통일)

@Controller                    // @Component + 뷰 반환 컨트롤러
public class HomeController { ... }

@RestController                // @Controller + @ResponseBody
public class UserApiController { ... }
// → 반환값 자동으로 JSON 직렬화

// 컴포넌트 스캔 범위 커스터마이징
@Configuration
@ComponentScan(
    basePackages = { "com.example.order", "com.example.user" },
    excludeFilters = @ComponentScan.Filter(
        type = FilterType.ANNOTATION,
        classes = { Repository.class }  // Repository는 제외
    )
)
public class AppConfig { ... }

방법 ② @Configuration + @Bean – 수동 등록

라이브러리 클래스처럼 소스코드를 수정할 수 없거나, 생성 과정이 복잡한 객체를 Bean으로 등록할 때 사용합니다.

java

@Configuration
public class InfrastructureConfig {

    // 외부 라이브러리 Bean 등록 (RestTemplate 자체는 @Component 없음)
    @Bean
    public RestTemplate restTemplate() {
        HttpComponentsClientHttpRequestFactory factory =
            new HttpComponentsClientHttpRequestFactory();
        factory.setConnectTimeout(3_000);
        factory.setReadTimeout(10_000);
        return new RestTemplate(factory);
    }

    // 복잡한 초기화가 필요한 Bean
    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    }

    // @Bean 이름 명시적 지정 (기본: 메서드 이름)
    @Bean(name = "primaryDataSource")
    public DataSource dataSource() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");
        ds.setUsername("root");
        ds.setPassword("password");
        ds.setMaximumPoolSize(10);
        return ds;
    }

    // 환경별 조건부 Bean 등록
    @Bean
    @Profile("prod")                          // 운영 환경에서만 등록
    public EmailService prodEmailService() {
        return new SmtpEmailService(smtpConfig());
    }

    @Bean
    @Profile({"dev", "test"})                 // 개발·테스트 환경에서만
    public EmailService fakeEmailService() {
        return new FakeEmailService();        // 실제 이메일 발송 안 함
    }
}

// @Configuration vs @Component – Bean 메서드 호출 동작 차이
@Configuration  // CGLIB 프록시 적용 → 메서드 직접 호출 시에도 동일 Bean 반환
public class ProxyConfig {

    @Bean
    public ServiceA serviceA() {
        return new ServiceA(serviceB()); // serviceB() 직접 호출
    }

    @Bean
    public ServiceB serviceB() {
        return new ServiceB();
    }

    // serviceA()가 serviceB()를 직접 호출해도
    // CGLIB 프록시가 가로채 IoC 컨테이너의 동일 Bean 반환
    // → serviceB 인스턴스 1개만 생성 (싱글톤 보장)
}

// ⚠️ @Component 클래스 내 @Bean 메서드는 프록시 미적용
// → 직접 호출 시 매번 새 인스턴스 생성 (싱글톤 깨짐)
// → @Configuration 클래스에 @Bean 정의하는 것이 안전

방법 ③ XML 기반 설정 – 레거시 방식

xml

<!-- applicationContext.xml – 레거시 Spring 프로젝트에서 사용 -->
<beans xmlns="http://www.springframework.org/schema/beans">

    <!-- 기본 Bean 등록 -->
    <bean id="userService" class="com.example.UserService">
        <constructor-arg ref="userRepository"/>
        <constructor-arg ref="emailService"/>
    </bean>

    <bean id="userRepository" class="com.example.UserRepositoryImpl">
        <property name="dataSource" ref="dataSource"/>
    </bean>

    <!-- 외부 프로퍼티 주입 -->
    <bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource">
        <property name="jdbcUrl" value="${db.url}"/>
        <property name="username" value="${db.username}"/>
    </bean>
</beans>

현재 신규 프로젝트에서는 거의 사용하지 않습니다. XML 대신 Java Config(@Configuration)가 표준입니다.

방법 ④ 프로그래밍 방식 – 동적 등록

런타임에 조건에 따라 동적으로 Bean을 등록해야 할 때 사용합니다.

java

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(Application.class);
        app.addInitializers(context -> {
            // ApplicationContext 초기화 콜백에서 동적 Bean 등록
            if (context instanceof GenericApplicationContext gac) {
                gac.registerBean("dynamicService",
                    DynamicService.class,
                    () -> new DynamicService("runtime-config"),
                    bd -> bd.setScope("singleton")
                );
            }
        });
        app.run(args);
    }
}

// BeanDefinitionRegistryPostProcessor로 동적 등록
@Component
public class DynamicBeanRegistrar
        implements BeanDefinitionRegistryPostProcessor {

    @Override
    public void postProcessBeanDefinitionRegistry(
            BeanDefinitionRegistry registry) throws BeansException {

        // 특정 조건에 따라 Bean 동적 등록
        String[] pluginClasses = discoverPlugins(); // 플러그인 탐색

        for (String className : pluginClasses) {
            BeanDefinitionBuilder builder =
                BeanDefinitionBuilder.genericBeanDefinition(className);
            builder.setScope(BeanDefinition.SCOPE_SINGLETON);

            String beanName = generateBeanName(className);
            registry.registerBeanDefinition(beanName, builder.getBeanDefinition());
        }
    }

    @Override
    public void postProcessBeanFactory(
            ConfigurableListableBeanFactory beanFactory) throws BeansException {
        // 필요 시 BeanFactory 설정 추가
    }
}

4. Bean 생명주기 완전 정복 – 생성부터 소멸까지 전 과정

Bean의 생명주기를 이해하는 것은 초기화 타이밍 오류, 리소스 누수, @PostConstruct vs 생성자 선택 문제를 모두 해결하는 열쇠입니다.

Bean 생명주기 전체 흐름

[Spring Bean 완전한 생명주기]

① ApplicationContext 생성 시작
      ↓
② BeanDefinition 로딩
   (@Component 스캔, @Bean 메서드, XML 파싱)
      ↓
③ BeanFactoryPostProcessor 실행
   (BeanDefinition 수정 가능 – 프로퍼티 값 치환 등)
      ↓
④ Bean 인스턴스 생성
   리플렉션으로 생성자 호출 (기본 생성자 or @Autowired 생성자)
      ↓
⑤ 의존성 주입 (DI)
   생성자 주입: ④단계에서 동시 처리
   필드/Setter 주입: 이 단계에서 처리
      ↓
⑥ BeanPostProcessor – beforeInitialization()
   @Autowired 처리, AOP 프록시 준비 등
      ↓
⑦ 초기화 콜백 (3가지 방법, 우선순위 순)
   1) @PostConstruct 메서드 실행 ← 권장
   2) InitializingBean.afterPropertiesSet() 실행
   3) @Bean(initMethod = "init") 지정 메서드 실행
      ↓
⑧ BeanPostProcessor – afterInitialization()
   AOP 프록시 객체 생성 (이 단계에서 실제 프록시로 교체)
      ↓
⑨ Bean 사용 가능 상태
   ApplicationContext에 저장, @Autowired 주입 대상으로 활용
      ↓
⑩ ApplicationContext 종료 시작 (shutdown)
      ↓
⑪ 소멸 콜백 (3가지 방법, 우선순위 순)
   1) @PreDestroy 메서드 실행 ← 권장
   2) DisposableBean.destroy() 실행
   3) @Bean(destroyMethod = "close") 지정 메서드 실행
      ↓
⑫ Bean 소멸 완료

초기화 콜백 3가지 방법 비교

java

// 방법 1: @PostConstruct – 가장 권장하는 방식 (JSR-250 표준)
@Component
@RequiredArgsConstructor
@Slf4j
public class CacheService {

    private final RedisTemplate<String, Object> redisTemplate;
    private Map<String, Object> localCache;

    // 생성자: 의존성 주입만 담당 (초기화 로직 없음)
    // → 이 시점에는 redisTemplate이 아직 주입 전일 수 있음 (필드 주입 경우)

    @PostConstruct  // 의존성 주입 완료 후 호출 보장
    public void initialize() {
        log.info("[CacheService] 초기화 시작");
        // redisTemplate이 완전히 주입된 상태 → 안전하게 사용 가능
        localCache = new HashMap<>();
        preloadFrequentData();  // Redis에서 자주 사용하는 데이터 미리 로딩
        log.info("[CacheService] 초기화 완료 – {}개 항목 캐시됨", localCache.size());
    }

    private void preloadFrequentData() {
        // redisTemplate 사용 가능 (주입 완료 상태)
        Set<String> hotKeys = redisTemplate.keys("hot:*");
        if (hotKeys != null) {
            hotKeys.forEach(key ->
                localCache.put(key, redisTemplate.opsForValue().get(key)));
        }
    }
}

// 방법 2: InitializingBean 인터페이스 구현 (Spring 강한 결합)
@Component
public class DataSourceHealthChecker implements InitializingBean {

    @Autowired
    private DataSource dataSource;

    @Override
    public void afterPropertiesSet() throws Exception {
        // 의존성 주입 후 자동 호출
        try (Connection conn = dataSource.getConnection()) {
            log.info("[DB] 연결 확인 완료: {}", conn.getMetaData().getURL());
        }
    }
}

// 방법 3: @Bean(initMethod) – 외부 라이브러리에 유용
@Configuration
public class ExternalLibConfig {

    @Bean(initMethod = "start", destroyMethod = "stop")
    public HikariDataSource hikariDataSource() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:mysql://localhost/db");
        return ds;
        // start(): Bean 등록 후 자동 호출
        // stop():  애플리케이션 종료 시 자동 호출
    }
}

소멸 콜백 3가지 방법 비교

java

// 방법 1: @PreDestroy – 권장 (JSR-250 표준)
@Component
@Slf4j
public class SchedulerService {

    private ScheduledExecutorService scheduler;

    @PostConstruct
    public void start() {
        scheduler = Executors.newScheduledThreadPool(5);
        scheduler.scheduleAtFixedRate(this::runTask, 0, 1, TimeUnit.MINUTES);
        log.info("[Scheduler] 스케줄러 시작");
    }

    @PreDestroy  // 컨테이너 종료 직전 호출 → 리소스 정리
    public void stop() {
        if (scheduler != null && !scheduler.isShutdown()) {
            scheduler.shutdown();
            try {
                if (!scheduler.awaitTermination(30, TimeUnit.SECONDS)) {
                    scheduler.shutdownNow();
                }
            } catch (InterruptedException e) {
                scheduler.shutdownNow();
                Thread.currentThread().interrupt();
            }
        }
        log.info("[Scheduler] 스케줄러 안전 종료 완료");
    }
}

// 방법 2: DisposableBean 인터페이스 (Spring 강한 결합)
@Component
public class WebSocketManager implements DisposableBean {

    @Override
    public void destroy() throws Exception {
        // 웹소켓 연결 모두 종료
        closeAllConnections();
        log.info("[WebSocket] 모든 연결 종료 완료");
    }
}

@PostConstruct vs 생성자 – 언제 무엇을 써야 하는가

[초기화 로직 위치 선택 기준]

생성자에서 초기화:
  ✅ 의존성이 없는 단순 필드 초기화
  ✅ 불변 객체 구성 (final 필드)
  ❌ @Autowired 주입된 Bean 사용 불가 (필드/Setter 주입 경우)
  예: this.maxRetryCount = 3;

@PostConstruct에서 초기화:
  ✅ 주입된 의존성(Bean)을 사용해야 하는 초기화
  ✅ DB 연결 확인, 캐시 예열, 외부 API 연결 테스트
  ✅ 환경 변수(@Value)를 활용한 복잡한 초기화
  예: redisTemplate.keys("hot:*") → DB 조회 → 캐시 로딩

java

// 실제 장애 사례 – 생성자에서 @Autowired 필드 사용 시도
@Service
public class CacheService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate; // 필드 주입

    // ❌ 생성자 실행 시점에 redisTemplate은 아직 null!
    public CacheService() {
        // 필드 주입은 생성자 이후에 처리됨
        Set<String> keys = redisTemplate.keys("*"); // NullPointerException!
    }

    // ✅ @PostConstruct에서 처리
    @PostConstruct
    public void init() {
        // 이 시점에는 redisTemplate 주입 완료 → 안전
        Set<String> keys = redisTemplate.keys("*");
    }
}

5. Bean 스코프 6가지 – 싱글톤·프로토타입·웹 스코프

**Bean 스코프(Scope)**는 Bean 인스턴스의 생성 범위와 공유 방식을 정의합니다. 스코프를 잘못 이해하면 멀티스레드 버그, 메모리 누수, 상태 오염이 발생합니다.

스코프 ① Singleton – 기본값, 컨테이너 전체 1개

java

// 기본값이므로 @Scope 생략 가능
@Service
// @Scope("singleton")  ← 생략 가능
public class OrderService {

    // ⚠️ 싱글톤 Bean의 핵심 주의사항: 상태(Mutable State) 저장 금지!
    private List<Order> pendingOrders = new ArrayList<>();  // ❌ 위험!
    // → 모든 스레드가 같은 pendingOrders 공유 → 데이터 경합·오염

    private final OrderRepository orderRepository;  // ✅ 안전 (불변 의존성)

    // 싱글톤에서 안전한 방법: 메서드 로컬 변수 사용
    public OrderResult processOrder(OrderRequest request) {
        List<Order> localPending = new ArrayList<>();  // ✅ 스레드별 독립 변수
        // ...
        return OrderResult.of(localPending);
    }
}

// 싱글톤 스코프 동작 확인
@Component
@Slf4j
public class SingletonChecker implements ApplicationContextAware {

    private ApplicationContext ctx;

    @Override
    public void setApplicationContext(ApplicationContext ctx) {
        this.ctx = ctx;
    }

    @PostConstruct
    public void verify() {
        OrderService a = ctx.getBean(OrderService.class);
        OrderService b = ctx.getBean(OrderService.class);
        log.info("같은 인스턴스인가? {}", a == b);  // true
    }
}

스코프 ② Prototype – 요청마다 새 인스턴스

java

@Component
@Scope("prototype")         // 또는 @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ReportGenerator {

    private List<ReportSection> sections = new ArrayList<>();
    // → Prototype: 요청마다 새 인스턴스 → 상태 저장 안전

    public void addSection(ReportSection section) {
        sections.add(section);
    }

    public Report build() {
        return new Report(sections);
    }
}

// ⚠️ Prototype Bean을 Singleton에 주입 시 문제 발생
@Service
public class ReportService {

    @Autowired
    private ReportGenerator reportGenerator;
    // ❌ ReportService는 Singleton → reportGenerator도 1번만 주입됨
    // → 모든 요청이 같은 reportGenerator 공유 → Prototype 의미 없어짐

    // ✅ 해결책: ObjectProvider 사용
    private final ObjectProvider<ReportGenerator> reportGeneratorProvider;

    public ReportService(ObjectProvider<ReportGenerator> provider) {
        this.reportGeneratorProvider = provider;
    }

    public Report generateReport(ReportRequest request) {
        ReportGenerator generator = reportGeneratorProvider.getObject();
        // → 호출할 때마다 새 ReportGenerator 인스턴스 반환
        generator.addSection(buildSummary(request));
        generator.addSection(buildDetails(request));
        return generator.build();
    }
}

스코프 ③~⑥ 웹 스코프 – HTTP 요청·세션·애플리케이션·웹소켓

java

// ③ Request 스코프 – HTTP 요청 하나당 1개
@Component
@Scope(value = "request",
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {

    private String requestId = UUID.randomUUID().toString();
    private Long userId;
    private String clientIp;
    private Instant startTime = Instant.now();
    // → 요청 시작 시 생성, 요청 완료 후 자동 소멸

    // 모든 레이어에서 동일 요청 컨텍스트 공유 가능
    // (Singleton Bean에 주입해도 프록시가 현재 요청의 인스턴스 반환)
}

// 컨트롤러, 서비스, 레포지토리 어디서나 동일 요청 컨텍스트 접근
@RestController
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;
    private final RequestContext requestContext;  // 프록시 주입

    @PostMapping("/orders")
    public OrderDto createOrder(@RequestBody OrderRequest req) {
        requestContext.setUserId(getCurrentUserId());
        return orderService.placeOrder(req);
        // OrderService 내부에서도 같은 requestContext 접근 → 동일 userId
    }
}

// ④ Session 스코프 – HTTP 세션 하나당 1개
@Component
@Scope(value = "session",
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ShoppingCartSession implements Serializable {

    private List<CartItem> items    = new ArrayList<>();
    private BigDecimal totalPrice   = BigDecimal.ZERO;
    // → 로그인부터 로그아웃까지 세션 동안 유지

    public void addItem(CartItem item) {
        items.add(item);
        totalPrice = totalPrice.add(item.getPrice());
    }
}

// ⑤ Application 스코프 – ServletContext 전체에서 1개
@Component
@Scope(value = "application",
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public class GlobalStatistics {

    private final AtomicLong totalOrderCount = new AtomicLong(0);
    // → Singleton과 유사하지만 ServletContext 단위
    // → 멀티 ServletContext 환경(여러 웹앱)에서 차이 발생

    public void incrementOrderCount() {
        totalOrderCount.incrementAndGet();
    }
}

// ⑥ WebSocket 스코프 – WebSocket 연결 하나당 1개 (Spring 4.0+)
@Component
@Scope(value = "websocket",
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public class WebSocketSession {

    private String sessionId;
    private Long connectedUserId;
    private Instant connectedAt;
}

스코프별 생명주기 요약

스코프생성 시점소멸 시점공유 범위사용 예
singleton컨테이너 시작컨테이너 종료애플리케이션 전체서비스·레포지토리
prototypegetBean() 호출GC가 수거인스턴스별 독립상태 있는 객체
requestHTTP 요청 시작요청 완료요청 내요청 컨텍스트·로그
session세션 생성세션 만료세션 내장바구니·인증 정보
application앱 시작앱 종료서블릿 컨텍스트전역 통계
websocketWS 연결 시작WS 연결 종료WS 세션 내채팅 상태

6. 전문가 관점 – BeanDefinition·조건부 등록·실무 설계 원칙

BeanDefinition – Bean의 설계도

Spring Container는 Bean 클래스를 직접 저장하는 것이 아니라, Bean의 모든 설정 정보를 담은 **BeanDefinition(설계도)**를 먼저 만들고 이를 바탕으로 Bean을 생성합니다.

java

// BeanDefinition의 주요 정보
public interface BeanDefinition {

    String getBeanClassName();          // Bean 클래스명
    String getScope();                  // 스코프 (singleton, prototype, ...)
    boolean isLazyInit();               // 지연 초기화 여부
    String[] getDependsOn();            // 먼저 초기화되어야 할 Bean 이름
    boolean isAutowireCandidate();      // 자동 주입 후보 여부
    boolean isPrimary();                // @Primary 여부
    ConstructorArgumentValues getConstructorArgumentValues(); // 생성자 인자
    MutablePropertyValues getPropertyValues();  // 프로퍼티 값
    String getInitMethodName();         // 초기화 메서드
    String getDestroyMethodName();      // 소멸 메서드
}

// BeanDefinition 직접 조회 (디버깅·분석 목적)
@Component
@Slf4j
public class BeanDefinitionInspector implements ApplicationContextAware {

    private ConfigurableApplicationContext ctx;

    @Override
    public void setApplicationContext(ApplicationContext ctx) {
        this.ctx = (ConfigurableApplicationContext) ctx;
    }

    @PostConstruct
    public void printBeanDefinitions() {
        ConfigurableListableBeanFactory factory = ctx.getBeanFactory();

        Arrays.stream(factory.getBeanDefinitionNames())
            .filter(name -> name.startsWith("order"))
            .forEach(name -> {
                BeanDefinition bd = factory.getBeanDefinition(name);
                log.info("Bean: {}", name);
                log.info("  클래스: {}", bd.getBeanClassName());
                log.info("  스코프: {}", bd.getScope());
                log.info("  지연초기화: {}", bd.isLazyInit());
                log.info("  초기화메서드: {}", bd.getInitMethodName());
            });
    }
}

조건부 Bean 등록 – @Conditional·@ConditionalOnProperty

java

// @ConditionalOnProperty – 프로퍼티 값에 따른 조건부 등록
@Configuration
public class NotificationConfig {

    @Bean
    @ConditionalOnProperty(
        name = "notification.sms.enabled",
        havingValue = "true",
        matchIfMissing = false    // 프로퍼티 없으면 Bean 등록 안 함
    )
    public SmsService smsService() {
        return new TwilioSmsService();
    }

    @Bean
    @ConditionalOnProperty(name = "notification.email.provider",
                           havingValue = "sendgrid")
    public EmailService sendGridEmailService() {
        return new SendGridEmailService();
    }

    @Bean
    @ConditionalOnProperty(name = "notification.email.provider",
                           havingValue = "smtp",
                           matchIfMissing = true)  // 기본값: smtp
    public EmailService smtpEmailService() {
        return new SmtpEmailService();
    }
}

// @ConditionalOnMissingBean – 이미 동일 타입 Bean이 없을 때만 등록
@Configuration
public class FallbackConfig {

    @Bean
    @ConditionalOnMissingBean(EmailService.class)
    public EmailService fallbackEmailService() {
        // 다른 설정에서 EmailService를 등록하지 않았을 때만 기본 구현체 등록
        return new NoOpEmailService();
    }
}

// 커스텀 @Conditional – 복잡한 조건 처리
@Configuration
public class FeatureFlagConfig {

    @Bean
    @Conditional(FeatureFlagCondition.class)  // 커스텀 조건
    public NewPaymentService newPaymentService() {
        return new NewPaymentService();
    }
}

public class FeatureFlagCondition implements Condition {

    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        String flagValue = context.getEnvironment()
            .getProperty("feature.new-payment");
        return "enabled".equalsIgnoreCase(flagValue)
            && isBusinessHours();  // 영업 시간에만 활성화
    }

    private boolean isBusinessHours() {
        int hour = LocalTime.now().getHour();
        return hour >= 9 && hour < 18;
    }
}

@DependsOn – Bean 초기화 순서 제어

java

// Bean 초기화 순서를 명시적으로 지정해야 하는 경우
@Component
@DependsOn({"databaseMigrationService", "cacheWarmupService"})
// → 이 Bean이 생성되기 전에 반드시 두 Bean이 먼저 초기화됨
public class ApplicationReadinessProbe {

    @PostConstruct
    public void checkReady() {
        // DB 마이그레이션과 캐시 예열이 완료된 후 실행 보장
        log.info("[Readiness] 애플리케이션 준비 완료");
    }
}

@Component("databaseMigrationService")
public class DatabaseMigrationService {

    @PostConstruct
    public void runMigrations() {
        // Flyway/Liquibase 마이그레이션 실행
        log.info("[Migration] DB 마이그레이션 완료");
    }
}

Bean 설계 실무 체크리스트

[Spring Bean 설계 최종 체크리스트]

Bean 등록:
□ @Component vs @Bean 용도에 맞게 선택?
  (@Component: 직접 작성 클래스 / @Bean: 외부 라이브러리·복잡한 초기화)
□ @Configuration 클래스에서 @Bean 정의? (CGLIB 프록시 싱글톤 보장)
□ Bean 이름 충돌 없는가? (같은 이름 다른 패키지 클래스)

생명주기:
□ 의존성 주입 필요한 초기화는 @PostConstruct 사용?
□ 리소스 정리(@PreDestroy) 구현? (DB연결·스레드풀·파일핸들)
□ 생성자에서 @Autowired Bean 사용 시도 없는가?

스코프:
□ Singleton Bean에 Mutable State(가변 필드) 없는가?
□ Prototype Bean을 Singleton에 직접 주입하지 않는가?
  (ObjectProvider 또는 Scoped Proxy 사용)
□ 웹 스코프 Bean에 proxyMode 설정 완료?

조건부 등록:
□ 환경별 다른 Bean 필요 시 @Profile 또는 @ConditionalOnProperty 활용?
□ 기본 구현체는 @ConditionalOnMissingBean으로 유연하게 등록?

성능:
□ 무거운 초기화가 필요한 Bean은 @Lazy 고려?
□ Bean 수가 너무 많아 구동 시간이 긴 경우 지연 초기화 전략 수립?

결론

Spring Bean Container의 핵심은 세 가지입니다. 첫째, Bean은 단순한 Java 객체가 아니라 Spring IoC 컨테이너가 생성·관리·소멸을 책임지는 관리 객체이며, 이를 통해 의존성 조립의 복잡성이 사라집니다. 둘째, Bean 생명주기에서 @PostConstruct는 의존성 주입 완료 후 호출이 보장되므로 Bean을 사용하는 초기화 로직은 반드시 여기에 두어야 하고, @PreDestroy로 리소스를 안전하게 정리해야 합니다. 셋째, 기본 스코프인 싱글톤에서는 절대로 가변 상태를 필드에 저장하면 안 되며, 요청별 독립 상태가 필요하면 Prototype이나 Request 스코프를 ObjectProvider와 함께 활용해야 합니다.

지금 바로 현재 프로젝트의 싱글톤 Bean에서 가변 필드가 있는지 점검하고, @PostConstruct 없이 생성자에서 의존성을 사용하는 코드를 찾아 수정하는 것부터 시작해 보세요.

Comments

답글 남기기