Spring Boot 초기화가 중요한 이유
애플리케이션 시작 시 캐시 워밍업, DB 마이그레이션 확인, 외부 서비스 연결 검증, 스케줄러 등록 등의 작업이 필요합니다. Spring Boot는 이를 위해 여러 초기화 메커니즘을 제공하지만, 각각의 실행 시점과 순서가 다릅니다. 잘못 선택하면 빈이 아직 준비되지 않았거나, 초기화 실패 시 앱이 정상 기동된 것처럼 보이는 문제가 생깁니다.
초기화 메커니즘 실행 순서
Spring Boot 시작 과정에서 각 초기화 포인트의 실행 순서입니다:
1. 생성자 주입 (Constructor Injection)
2. @PostConstruct
3. InitializingBean.afterPropertiesSet()
4. @Bean(initMethod = "init")
5. SmartInitializingSingleton.afterSingletonsInstantiated()
6. ApplicationListener<ContextRefreshedEvent>
7. SmartLifecycle.start()
8. ApplicationRunner.run() / CommandLineRunner.run()
9. ApplicationListener<ApplicationReadyEvent>
| 메커니즘 | 시점 | 모든 빈 준비? | 용도 |
|---|---|---|---|
| @PostConstruct | 빈 초기화 직후 | ❌ (자신만) | 필드 검증, 내부 상태 초기화 |
| InitializingBean | 프로퍼티 설정 후 | ❌ | @PostConstruct와 동일 (인터페이스 방식) |
| SmartInitializingSingleton | 모든 싱글톤 생성 후 | ✅ | 다른 빈 참조 필요한 초기화 |
| ContextRefreshedEvent | 컨텍스트 리프레시 완료 | ✅ | 컨텍스트 레벨 초기화 |
| SmartLifecycle | 컨텍스트 시작 시 | ✅ | 시작/중지가 쌍인 리소스 |
| ApplicationRunner | 앱 완전 기동 직전 | ✅ | 앱 시작 후 1회 작업 |
| ApplicationReadyEvent | 앱 완전 기동 후 | ✅ | 트래픽 수신 준비 완료 알림 |
@PostConstruct: 빈 자체 초기화
가장 간단하지만 다른 빈이 아직 초기화되지 않았을 수 있어 주의가 필요합니다:
@Component
public class CacheWarmer {
private final Map<String, Product> cache = new ConcurrentHashMap<>();
private final ProductRepository productRepository;
public CacheWarmer(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@PostConstruct
void warmUp() {
// ⚠️ 주의: 이 시점에 다른 빈의 @PostConstruct는
// 아직 실행되지 않았을 수 있음
List<Product> products = productRepository.findAll();
products.forEach(p -> cache.put(p.getSku(), p));
log.info("Cache warmed: {} products loaded", cache.size());
}
}
적합한 용도: 자기 자신의 필드 검증, 기본값 설정, 단순 내부 상태 초기화
부적합한 용도: 외부 서비스 호출, 다른 빈에 의존하는 복잡한 초기화
ApplicationRunner: 앱 시작 후 작업
모든 빈이 완전히 준비된 후 실행됩니다. 가장 권장되는 초기화 방법입니다:
@Component
@Order(1) // 여러 Runner 간 실행 순서 지정
public class DatabaseMigrationChecker implements ApplicationRunner {
private final DataSource dataSource;
@Override
public void run(ApplicationArguments args) throws Exception {
// ApplicationArguments로 커맨드라인 인자 접근
if (args.containsOption("skip-migration-check")) {
log.info("Migration check skipped");
return;
}
try (Connection conn = dataSource.getConnection()) {
DatabaseMetaData meta = conn.getMetaData();
log.info("DB connected: {} {}",
meta.getDatabaseProductName(),
meta.getDatabaseProductVersion());
}
}
}
@Component
@Order(2)
public class ExternalServiceHealthChecker implements ApplicationRunner {
private final RestClient restClient;
@Override
public void run(ApplicationArguments args) throws Exception {
// 외부 서비스 연결 검증
ResponseEntity<Void> response = restClient.get()
.uri("https://api.payment.com/health")
.retrieve()
.toBodilessEntity();
if (!response.getStatusCode().is2xxSuccessful()) {
log.warn("Payment service not healthy: {}",
response.getStatusCode());
}
}
}
// CommandLineRunner — 더 단순한 버전 (String[] args)
@Component
@Order(3)
public class DataSeeder implements CommandLineRunner {
private final UserRepository userRepository;
@Override
public void run(String... args) throws Exception {
if (userRepository.count() == 0) {
userRepository.save(new User("admin", "admin@example.com"));
log.info("Default admin user created");
}
}
}
SmartLifecycle: 시작/중지 쌍 관리
메시지 컨슈머, 스케줄러 등 시작과 중지가 쌍인 리소스에 적합합니다. Graceful Shutdown과 연계됩니다:
@Component
public class EventConsumerLifecycle implements SmartLifecycle {
private final KafkaConsumer<String, String> consumer;
private volatile boolean running = false;
private ExecutorService executor;
@Override
public void start() {
log.info("Starting event consumer...");
this.running = true;
this.executor = Executors.newSingleThreadExecutor();
this.executor.submit(this::consumeLoop);
}
@Override
public void stop() {
log.info("Stopping event consumer...");
this.running = false;
this.consumer.wakeup();
this.executor.shutdown();
}
// Graceful shutdown 지원
@Override
public void stop(Runnable callback) {
stop();
try {
executor.awaitTermination(30, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
callback.run(); // 완료 콜백 호출
}
@Override
public boolean isRunning() { return running; }
@Override
public int getPhase() {
return Integer.MAX_VALUE; // 가장 마지막에 시작, 가장 먼저 중지
}
@Override
public boolean isAutoStartup() { return true; }
private void consumeLoop() {
while (running) {
ConsumerRecords<String, String> records =
consumer.poll(Duration.ofMillis(100));
records.forEach(this::processRecord);
}
}
}
phase 값의 의미:
- 낮은 값 → 먼저 시작, 나중에 중지
- 높은 값 → 나중에 시작, 먼저 중지
- 인프라(DB 커넥션): phase=0, 비즈니스 로직(컨슈머): phase=MAX
EventListener: 이벤트 기반 초기화
Spring의 애플리케이션 이벤트를 활용한 초기화 패턴:
@Component
public class AppStartupListener {
// 컨텍스트 리프레시 완료 (Runner보다 먼저 실행)
@EventListener(ContextRefreshedEvent.class)
public void onContextRefreshed() {
log.info("Context refreshed - all beans ready");
}
// 앱 완전 기동 후 (Runner 실행 후)
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
log.info("Application fully started and ready");
// 슬랙 알림, 메트릭 전송 등
}
// 앱 시작 실패 시
@EventListener(ApplicationFailedEvent.class)
public void onStartupFailed(ApplicationFailedEvent event) {
log.error("Startup failed!", event.getException());
// 알림 발송, 정리 작업
}
}
초기화 실패 처리 전략
초기화 작업이 실패했을 때의 처리 전략이 중요합니다:
@Component
public class CriticalResourceInitializer implements ApplicationRunner {
private final RetryTemplate retryTemplate;
public CriticalResourceInitializer() {
// 재시도 설정: 3회, 2초 간격
this.retryTemplate = RetryTemplate.builder()
.maxAttempts(3)
.fixedBackoff(2000)
.retryOn(IOException.class)
.build();
}
@Override
public void run(ApplicationArguments args) throws Exception {
// 전략 1: 재시도 후 실패하면 앱 종료
try {
retryTemplate.execute(ctx -> {
connectToCriticalService();
return null;
});
} catch (Exception e) {
log.error("Critical service unavailable after retries", e);
throw e; // 예외를 throw하면 Spring Boot가 종료됨
}
}
}
@Component
public class OptionalResourceInitializer implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
// 전략 2: 실패해도 앱은 계속 실행 (degraded mode)
try {
loadOptionalCache();
} catch (Exception e) {
log.warn("Optional cache load failed, " +
"running in degraded mode", e);
}
}
}
비동기 초기화
시간이 오래 걸리는 초기화는 비동기로 처리하여 앱 시작 시간을 단축할 수 있습니다:
@Component
public class AsyncCacheWarmer {
private final AtomicBoolean cacheReady = new AtomicBoolean(false);
@Async
@EventListener(ApplicationReadyEvent.class)
public void warmCacheAsync() {
log.info("Starting async cache warm-up...");
// 수 분 걸리는 대용량 캐시 로드
loadMillionsOfRecords();
cacheReady.set(true);
log.info("Cache warm-up completed");
}
public boolean isCacheReady() {
return cacheReady.get();
}
}
이 패턴은 Spring @Async 비동기 처리와 함께 활용하면 효과적입니다.
선택 가이드
필드 검증, 내부 초기화 → @PostConstruct
앱 시작 후 1회 작업 → ApplicationRunner (✅ 권장)
시작/중지 쌍 리소스 → SmartLifecycle
다른 빈 모두 필요한 초기화 → SmartInitializingSingleton
앱 완전 기동 알림 → @EventListener(ApplicationReadyEvent)
초기화 실패 → 앱 종료 → Runner에서 예외 throw
초기화 실패 → degraded 모드 → Runner에서 예외 catch
마치며
Spring Boot의 초기화 메커니즘은 실행 시점이 핵심입니다. @PostConstruct는 빈 자체만 준비된 시점, ApplicationRunner는 모든 빈이 준비된 시점, SmartLifecycle는 시작/중지 쌍이 필요할 때 사용합니다. 대부분의 초기화 작업에는 ApplicationRunner + @Order 조합이 가장 안전하고 명확합니다. 실패 전략(종료 vs degraded)을 명확히 결정하고, 오래 걸리는 작업은 비동기로 분리하여 앱 시작 시간을 최적화하세요.