Spring Bean Container가 어떻게 동작하는지 모르면, @Component와 @Bean의 차이를 물어보는 면접 질문 앞에서 막히거나, Bean이 싱글톤이라는 사실을 모르고 상태(State)를 필드에 저장해 멀티스레드 버그를 만들거나, @PostConstruct를 써야 하는 자리에 생성자를 쓰다가 의존성 주입이 완료되지 않은 상태에서 초기화 로직이 실행되는 장애를 겪게 됩니다. Spring이 “마법처럼” 동작한다고 느낀다면, 그 마법의 정체가 바로 IoC 컨테이너가 Bean을 등록·조합·관리하는 과정입니다. 이 글에서는 Bean의 정의부터 BeanDefinition 메타데이터, IoC 컨테이너의 두 종류(BeanFactory vs ApplicationContext), 컴포넌트 스캔 원리, Bean 생명주기 전체 흐름, 스코프 6가지, 그리고 실무 설계 원칙까지 예제 코드와 다이어그램으로 완벽하게 정리합니다.
목차
- Bean이란 무엇인가 – 일반 Java 객체와 무엇이 다른가
- Spring Container의 두 얼굴 – BeanFactory vs ApplicationContext
- Bean 등록 방법 4가지 – 컴포넌트 스캔부터 @Bean까지
- Bean 생명주기 완전 정복 – 생성부터 소멸까지 전 과정
- Bean 스코프 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 – 실무에서 사용하는 컨테이너
ApplicationContext는 BeanFactory를 확장해 엔터프라이즈 기능을 추가한 컨테이너입니다. 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 비교
| 구분 | BeanFactory | ApplicationContext |
|---|---|---|
| 초기화 시점 | 지연(Lazy) – getBean() 호출 시 | 즉시(Eager) – 컨테이너 시작 시 |
| 오류 감지 | 런타임 (사용 시점) | 구동 시점 (빠른 실패) |
| 국제화 | ❌ 미지원 | ✅ MessageSource |
| 이벤트 | ❌ 미지원 | ✅ ApplicationEvent |
| AOP 통합 | ❌ 수동 | ✅ 자동 |
| 환경변수 | ❌ 미지원 | ✅ Environment |
| 실무 사용 | 거의 사용 안 함 | ✅ 표준 |
| 대표 구현체 | DefaultListableBeanFactory | AnnotationConfigApplicationContext |
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 | 컨테이너 시작 | 컨테이너 종료 | 애플리케이션 전체 | 서비스·레포지토리 |
| prototype | getBean() 호출 | GC가 수거 | 인스턴스별 독립 | 상태 있는 객체 |
| request | HTTP 요청 시작 | 요청 완료 | 요청 내 | 요청 컨텍스트·로그 |
| session | 세션 생성 | 세션 만료 | 세션 내 | 장바구니·인증 정보 |
| application | 앱 시작 | 앱 종료 | 서블릿 컨텍스트 | 전역 통계 |
| websocket | WS 연결 시작 | 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 없이 생성자에서 의존성을 사용하는 코드를 찾아 수정하는 것부터 시작해 보세요.
답글 남기기
댓글을 달기 위해서는 로그인해야합니다.