빈 초기화 순서: @DependsOn·@Lazy·@Order로 기동을 통제하기
들어가며
Bean A가 B보다 먼저 만들어져야 한다. 필드 하나 공유해서 내부 캐시를
미리 채워둬야 하는데, 그냥 @Order(1)을 A에,
@Order(2)를 B에 붙여두면 되겠거니 하고 앱을 띄워봤다.
그런데 로그를 보면 순서가 바뀌어 있거나, 심지어 B가 먼저 올라와 있다.
@Order는 그런 일을 하지 않는다 — 이 글은
그 오해에서 시작한다.
스프링 컨테이너는 대부분의 경우 의존성 그래프를 스스로 해석해서 순서를 정한다. A가 생성자로 B를 받으면 B가 먼저 만들어지고, 그걸로 끝이다. 개발자가 순서를 명시적으로 제어해야 하는 상황은 생각보다 드물고, 꺼내야 할 때는 무슨 도구가 어떤 층위에서 동작하는지를 정확히 알아야 한다. 잘못된 도구를 쓰면 앱은 아예 뜨지도 않거나, 더 나쁘게는 뜨긴 뜨는데 특정 순간에만 터진다.
이 주제를 공부할 때 특히 막히기 쉬운 지점 다섯 가지가 있다.
@Order는 빈 생성 순서가 아니다 — 컬렉션 주입 시 리스트 인덱스 결정용이고, 싱글톤 기동 순서와는 직교한다@DependsOn은@PostConstruct내부의 비동기 작업까지 보장하지 않는다 — 동기 초기화 완료까지만이다@Lazy로 순환을 해결하다가 Boot 2.6에서 폭발한다 —allow-circular-references=false가 기본값이 되었다spring.main.lazy-initialization=true를 운영에 켜면 첫 요청에서 터진다 — 숨어 있던 설정 오류가 시동 대신 런타임에 드러난다SmartLifecycle.stop(Runnable)에서callback.run()을 빼먹으면 30초 타임아웃이 걸린다 — K8sterminationGracePeriodSeconds를 넘기고 강제 종료된다
이 글은 기본 → 제어 도구 → 순환 → 기동·종료 훅 →
실무 순으로, Spring Framework 6.x(Java 17+) 기준으로 정리한다.
시리즈 3편이 한 빈의 생애
단계(@PostConstruct, @PreDestroy,
BeanPostProcessor)를 다뤘다면, 이 6편은 여러 빈
사이의 순서를 다룬다.
목차
- 1) 기본 초기화 순서: 의존성 그래프 자동 해석
- 2) @DependsOn: side-effect 기반 강제 선행
- 3) @Lazy: 두 층위의 지연
- 4) @Order, Ordered, @Priority: 컬렉션 주입 순서 전용
- 5) 순환 의존성: 언제 허용되고 언제 터지는가
- 6) ApplicationRunner / CommandLineRunner: refresh 후 1회 훅
- 7) SmartLifecycle: Phase로 기동·종료 순서 직접 통제
- 8) 실무에서 이렇게 읽고 쓴다
- 9) 한 줄 정리
1) 기본 초기화 순서: 의존성 그래프 자동 해석
1-1) 컨테이너는 스스로 순서를 정한다
스프링 공식 레퍼런스는 ApplicationContext의 동작을
이렇게 설명한다.
"An ApplicationContext implementation eagerly creates and configures all singleton beans as part of the initialization process."
즉 컨테이너가 뜨면 싱글톤 빈은 기본적으로 전부 eager하게
만들어진다. 그리고 그 "전부"에는 순서가 있다. 컨테이너는 등록된
BeanDefinition을 훑어 **의존성 그래프(dependency graph)**를
만들고, 그 그래프를 위상 정렬(topological sort)해서 필요한 놈부터 차례로
생성한다.
@Service
public class ServiceA {
private final RepositoryB b;
// 생성자 주입이므로 RepositoryB가 먼저 만들어져야만
// ServiceA의 생성자를 호출할 수 있다
public ServiceA(RepositoryB b) {
this.b = b;
}
}
@Repository
public class RepositoryB {
public RepositoryB() {
// B가 A보다 먼저 여기 도달한다. 보장됨.
}
}ServiceA가 RepositoryB를 생성자로 받는
순간, 컨테이너는 다른 선택지가 없다.
RepositoryB를 먼저 만들지 않고는 ServiceA를
인스턴스화할 방법이 없기 때문이다. 이건 @DependsOn 같은
추가 어노테이션 없이도 자연스럽게 보장된다.
1-2) 대부분의 경우 명시 제어는 필요 없다
이 섹션의 핵심 주장 하나. 순서 제어 도구를 꺼내기 전에 먼저
물어라 — 이 순서 문제는 DI 그래프로 자연스럽게 풀리지 않는가?
풀리지 않는 경우에만 @DependsOn, @Lazy,
SmartLifecycle 같은 도구가 필요하다.
아래 다이어그램은 eager 초기화 시 기본 순서와, 뒤에서 다룰
@Lazy 지연이 어떻게 달라지는지를 타임라인으로 보여준다.
RepositoryB와 ServiceA는 refresh 중에
생성되지만, LazyCache는 첫 참조 시점까지
미뤄진다. 이 차이가 §3
@Lazy에서 다룰 주제다.
1-3) 의존성 그래프로 못 푸는 세 가지 상황
그럼 언제 그래프로 못 푸는가. 대표적으로 세 가지다.
- side-effect 선행 — A가 B를 코드로 참조하지는 않지만, B가 먼저 초기화되면서 만든 부수 효과(DB 스키마 마이그레이션, 드라이버 등록 등)가 A에 필요한 경우
- 순환 의존성 — A가 B를, B가 A를 필요로 하는 경우 (설계 실패지만 현실에서 종종 생김)
- 기동 후 1회성 작업 — 모든 빈이 다 준비된 뒤에 한 번만 실행할 작업 (데이터 프리로딩, 워밍업 등)
첫 번째는 §2 @DependsOn, 두 번째는 §3 @Lazy와 §5 순환, 세 번째는 §6 Runner와 §7 SmartLifecycle에서 다룬다.
2) @DependsOn: side-effect 기반 강제 선행
2-1) 공식 정의와 용도
@DependsOn은 대상 빈이 만들어지기 전에
반드시 먼저 생성되어야 할 빈을 문자열 이름으로 지정하는 어노테이션이다.
Spring 공식 Javadoc은 이렇게 정의한다.
"Beans on which the current bean depends. Any beans specified are guaranteed to be created by the container before this bean."
"guaranteed to be created before this bean" — 이 문장이
@DependsOn의 전부다. 대상 빈이 인스턴스화되기 전에 지정된
빈들이 먼저 만들어진다는 것, 그리고 파괴 순서도 역순으로 보장된다는
것(단 싱글톤 스코프 한정).
@Component
@DependsOn("flywayMigrator")
public class ReportGenerator {
// flywayMigrator가 먼저 만들어지면서 DB 스키마를 갱신한다.
// ReportGenerator는 그 결과에 의존하지만, 코드상으로는
// flywayMigrator를 직접 참조할 필요가 없다.
}ReportGenerator는 FlywayMigrator를
주입받지 않는다. 필드도 없고 생성자 인자도 없다.
그런데도 마이그레이션이 끝난 뒤에야 만들어져야 한다 — 이것이 side-effect
의존이고, @DependsOn이 쓰이는 자리다.
2-2) 생성자 주입이 가능하면 @DependsOn을 쓰지 마라
여기서 틀리기 쉬운 지점이 있다. @DependsOn은
side-effect 선행이 필요할 때만 쓰는 것이고, 만약 A가
B의 메서드나 상태를 실제로 사용한다면 그건 그냥
생성자 주입으로 푸는 게 맞다.
// 피하기: 주입 가능한데 @DependsOn으로 풀기
@Component
@DependsOn("repositoryB")
public class ServiceA {
@Autowired private RepositoryB b;
}
// 선호: 생성자 주입
@Component
public class ServiceA {
private final RepositoryB b;
public ServiceA(RepositoryB b) { this.b = b; }
}왜인가. 첫째, @DependsOn은 문자열 빈
이름에 의존하므로 리팩터링할 때 컴파일러가 잡아주지 않는다.
둘째, 의존 관계가 코드에 드러나지 않아서 누가 누구를 쓰는지 읽기
어렵다. 셋째, DI 그래프가 자연스럽게 형성되면
@DependsOn은 중복 표현이 된다.
2-3) 오해 주의: @PostConstruct 내부의 비동기까지 보장하지 않는다
여기가 가장 많이 걸리는 함정이다. @DependsOn이 보장하는
건 선행 빈의 동기 초기화 완료까지다. 그 빈의
@PostConstruct 안에서 @Async 메서드를
부르거나, 별도 ExecutorService에 작업을 던져놓고 리턴했다면
— 그 비동기 작업은 아직 진행 중이어도
@DependsOn의 보장은 이미 끝났다고 본다.
@Component("warmup")
public class Warmup {
@PostConstruct
public void init() {
// 이 작업은 별도 스레드에서 돌고, init()은 즉시 리턴한다
executor.submit(this::heavyCachePreload);
}
}
@Component
@DependsOn("warmup")
public class Consumer {
// Warmup.init()이 리턴하면 Consumer는 바로 만들어진다.
// heavyCachePreload()는 아직 안 끝났을 수 있다.
}이 구조에서 Consumer가 Warmup이 채워놓은
캐시를 읽으려고 하면 비어 있는 상태를 만날 수 있다.
해결책은 두 가지다. 첫째, 애초에 @PostConstruct에서
비동기를 쓰지 말고 동기로 완료할 것. 둘째, 정말 비동기가 필요하다면
SmartLifecycle이나 ApplicationRunner로 옮겨서
기동 단계를 명시적으로 관리할 것. §6과
§7에서
각각 다룬다.
3) @Lazy: 두 층위의 지연
3-1) 층위 1: 빈 정의 레벨의 지연
@Lazy는 두 개의 전혀 다른 층위에서
동작한다. 이걸 구분하지 못하면 @Lazy가 왜 먹는지 안 먹는지
끝없이 헷갈린다.
첫 번째 층위는 **빈 정의(bean definition)**에 붙이는
@Lazy다. @Component, @Service,
@Bean 옆에 붙이면 해당 빈은 컨테이너 refresh 시점에
만들어지지 않고, 누군가 처음으로 참조할 때 만들어진다.
@Component
@Lazy
public class HeavyCache {
public HeavyCache() {
// refresh 시점이 아니라 첫 참조 시점에 실행된다
loadFromDisk();
}
}시동 시간을 줄이거나, 특정 프로파일에서만 실제로 쓰는 빈이 있을 때 유용하다.
3-2) 층위 2: 주입 지점 레벨의 지연 (프록시)
두 번째 층위가 더 중요하다. **주입 지점(injection point)**에
@Lazy를 붙이면 스프링은 실제 빈 대신
프록시를 주입한다. 프록시는 처음 메서드가 호출될 때 진짜 빈을
resolve하고 호출을 위임한다.
@Service
public class ServiceA {
private final ServiceB b;
// 생성자 파라미터에 @Lazy → ServiceB 프록시가 주입된다
// ServiceA가 만들어지는 시점에는 ServiceB가 아직 없어도 된다
public ServiceA(@Lazy ServiceB b) {
this.b = b;
}
public void doSomething() {
// 이 호출이 발생하는 순간 프록시가 실제 ServiceB를 찾아 호출
b.handle();
}
}두 층위의 차이를 표로 정리하면 이렇다.
| 층위 | 붙이는 위치 | 효과 | 주 용도 |
|---|---|---|---|
| 빈 정의 | @Component, @Bean |
첫 참조까지 생성 연기 | 시동 시간 단축 |
| 주입 지점 | @Autowired 파라미터 |
lazy 프록시 주입 | 순환 의존성 해결, 조기 바인딩 회피 |
3-3) 핵심 함정: eager 빈이 Lazy 빈을 생성자 주입하면 Lazy가 무력화된다
@Lazy를 빈 정의에 붙여도, 그 빈을 생성자
주입하는 non-lazy 빈이 있으면 결국 eager하게 만들어진다. 당연한
얘기다 — non-lazy 빈이 refresh 중에 생성되려면 생성자 인자가 필요하고,
그 인자가 lazy 빈이면 그때 만들어질 수밖에 없다.
@Component
@Lazy
public class LazyBean { }
@Component
public class EagerBean {
// LazyBean이 @Lazy지만, 여기서 생성자로 받으면
// EagerBean이 만들어지는 순간 LazyBean도 만들어진다
public EagerBean(LazyBean b) { }
}@Lazy를 정말 살리려면 주입 지점에도
@Lazy를 붙여서 프록시로 받거나, 아니면
ObjectProvider<LazyBean>을 받아 호출 시점에
getObject()로 꺼내야 한다.
3-4) 오해 주의 1: Boot 2.6+ 순환 금지
Spring Boot 2.6부터
spring.main.allow-circular-references의 기본값이
**false**로 바뀌었다. 공식 릴리스 노트 표현은 이렇다.
"Circular references are prohibited by default."
Boot 2.5 이전에는 세터/필드 주입 순환이 조용히 돌아갔고, 많은
프로젝트가 이걸 의도치 않게 쓰고 있었다. 2.6으로
업그레이드한 첫날 애플리케이션이
BeanCurrentlyInCreationException을 뱉으며 시동에 실패하는
사례가 속출했다. @Lazy로 순환을 해결하던 패턴이
폭발한 것도 이 맥락이다. §5 순환에서 다시
다룬다.
3-5) 오해 주의 2: 전역 lazy initialization은 운영 금지
spring.main.lazy-initialization=true를 설정하면
모든 빈이 lazy가 된다. 시동이 빨라지는 매력이 있어서
얼핏 좋아 보이지만, 운영 환경에서 켜면 문제가 생긴다.
- refresh 중에 잡혔어야 할 설정 오류(타입 불일치, 누락된 빈, 잘못된 프로퍼티)가 첫 요청이 들어올 때까지 숨는다
- 그 첫 요청이
ClassNotFoundException,NoSuchBeanDefinitionException을 맞고 500으로 돌아간다 - 모니터링 입장에서는 시동은 성공했는데 런타임에 터지는 골치 아픈 형태가 된다
이 플래그는 개발 환경의 시동 시간을 줄이는 용도로만 써라. 운영에서는 eager 기본값이 안전하다. 런타임 오류 대신 시동 실패로 문제를 조기에 드러내는 쪽이 훨씬 낫다.
4) @Order, Ordered, @Priority: 컬렉션 주입 순서 전용
4-1) @Order는 빈 생성 순서가 아니다
이 글 전체에서 가장 중요한 한 문단이다. Spring 공식 Javadoc의
@Order 설명에는 이런 문장이 있다.
"@Order values do not influence singleton startup order, which is an orthogonal concern."
@Order는 싱글톤 기동 순서에 영향을 주지
않는다. 두 개념은 서로 직교(orthogonal)한다. 그럼
@Order가 뭘 하냐면 — 컬렉션으로 주입될 때의
순서를 결정한다. List<EventHandler>를
주입받을 때 리스트 내부의 인덱스를 정하는 것이 전부다.
@Component
@Order(1)
public class AuditHandler implements EventHandler { }
@Component
@Order(2)
public class NotifyHandler implements EventHandler { }
@Component
@Order(3)
public class MetricsHandler implements EventHandler { }
@Service
public class EventBus {
private final List<EventHandler> handlers;
// handlers 리스트는 [Audit, Notify, Metrics] 순으로 들어온다.
// 빈 생성 순서는 이것과 무관하다.
public EventBus(List<EventHandler> handlers) {
this.handlers = handlers;
}
public void publish(Event e) {
handlers.forEach(h -> h.handle(e)); // 순서대로 처리
}
}AuditHandler가 NotifyHandler보다 먼저
인스턴스화된다는 보장은 없다. 그저
EventBus가 List로 주입받는 시점에 Audit이
인덱스 0에 오고, Notify가 1, Metrics가 2에 올 뿐이다.
4-2) @Order vs Ordered vs @Priority
세 가지는 비슷해 보이지만 조금씩 다르다.
| 항목 | @Order |
Ordered 인터페이스 |
@Priority (JSR-250) |
|---|---|---|---|
| 형태 | 어노테이션 | 인터페이스 | 어노테이션 |
| 설정 방식 | @Order(1) |
getOrder() 구현 |
@Priority(1) |
| 낮은 값이 먼저 | 예 | 예 | 예 |
| 특이점 | 가장 흔하게 씀 | 동적으로 값 반환 가능 | @Autowired 단일 주입 시 tie-breaker |
셋 다 기본 의미는 같다 — 낮은 값이 먼저. 차이는 선언
방식과 몇 가지 엣지 케이스다. 실무에서는 @Order로
충분하다.
4-3) @Order가 정말 유효한 자리
그럼 @Order가 언제 빛을 발하느냐 — 다음과 같은
자리들이다.
- AOP 어드바이저 체인 — 여러
@Aspect가 같은 포인트컷을 타겟할 때 실행 순서 HandlerInterceptor— 인터셉터가preHandle에서 호출되는 순서jakarta.servlet.Filter/OncePerRequestFilter— 필터 체인에서 호출되는 순서ApplicationRunner/CommandLineRunner— §6에서 다룸List<T>주입 전반 — 전략 패턴 구현체 정렬
전부 공통점이 하나 있다 — 컬렉션으로 수집돼서 순서대로 호출되는 구조다. 빈 "생성" 순서가 아니라 "호출" 순서라는 점이 핵심이다.
4-4) 오해 주의: "@Order(1)을 붙이면 먼저 생성된다" 전면 오해
결론 한 번 더. "@Order(1)을 붙이면 컨테이너가 그 빈을
먼저 만든다"는 말은 완전한 오해다. 공식 문서가
명시적으로 orthogonal concern이라고 못 박았다. 기동 순서를
원한다면 §2
@DependsOn이나 §7
SmartLifecycle을 써야 한다.
5) 순환 의존성: 언제 허용되고 언제 터지는가
5-1) 생성자 순환은 해결 불가
A의 생성자가 B를 받고, B의 생성자가 A를 받는다면 — 둘 중 어느
쪽도 먼저 만들 수 없다. A를 만들려면 이미 만들어진 B가
필요하고, 그 역도 마찬가지다. 스프링은 이 경우
BeanCurrentlyInCreationException을 던진다.
@Component
public class A {
public A(B b) { } // B 필요
}
@Component
public class B {
public B(A a) { } // A 필요 → 데드락
}이 구조는 어떤 어노테이션으로도 못 푼다. 설계를 바꾸는 것이 정답이다.
5-2) 세터/필드 순환은 Boot 2.5까지 조용히 돌아갔다
세터 주입이나 필드 주입은 조금 다르다. 스프링은 A를 부분 생성(partially created) 상태로 캐시에 넣어두고, B를 만들 때 그 부분 생성된 A를 주입해준다. B가 완성되면 다시 돌아와 A의 세터를 호출해서 완성한다. 이 메커니즘 덕에 세터 순환은 Boot 2.5 이전까지 조용히 동작했다.
@Component
public class A {
@Autowired private B b; // 필드 주입, 순환 가능했음
}
@Component
public class B {
@Autowired private A a;
}5-3) Boot 2.6+ 기본 금지와 대응
Boot 2.6부터 spring.main.allow-circular-references의
기본값이 false가 되면서, 이 패턴도 시동
실패로 돌아섰다. 대응 방법은 세 가지다.
# 1) 명시적으로 다시 허용 (임시방편)
spring.main.allow-circular-references=true
// 2) 주입 지점에 @Lazy → 프록시로 받아 순환 끊기
@Component
public class A {
private final B b;
public A(@Lazy B b) { this.b = b; }
}// 3) 근본 해법: 공통 의존을 제3의 빈 C로 빼거나
// ApplicationEventPublisher로 이벤트 디커플링
@Component
public class A {
private final ApplicationEventPublisher publisher;
public A(ApplicationEventPublisher p) { this.publisher = p; }
public void doX() { publisher.publishEvent(new XEvent()); }
}1번은 빚을 넘기는 것이다. 당장 시동은 되지만 설계 냄새는 그대로 남는다. 2번은 한 자리에 쓸 만하지만 남발하면 §3에서 얘기한 함정을 밟는다. 3번이 옳은 방향이다 — 순환은 대부분 책임이 두 빈 사이에 잘못 나뉘어 있다는 신호다.
6) ApplicationRunner / CommandLineRunner: refresh 후 1회 훅
6-1) 실행 시점의 정확한 위치
ApplicationRunner와 CommandLineRunner는
컨테이너 refresh가 완료된 직후,
SpringApplication.run()이 반환되기 직전에 한 번
호출되는 인터페이스다. 모든 빈이 만들어졌고
@PostConstruct도 끝난 상태이므로, 전체 컨텍스트가
준비된 뒤에 해야 할 1회성 작업에 적합하다.
시간 축으로 보면 이렇다.
BeanDefinition등록- 싱글톤 빈 eager 생성 +
@PostConstruct ApplicationRunner/CommandLineRunner실행 ← 여기SpringApplication.run()반환ApplicationReadyEvent발행
그래서 데이터 프리로딩, 캐시 워밍업, 외부 시스템 헬스체크, 샘플 데이터 시딩 같은 작업에 자주 쓰인다.
6-2) 둘의 차이
// CommandLineRunner — String[] 원본 인자
@Component
public class LegacyRunner implements CommandLineRunner {
@Override
public void run(String... args) {
// args에 "--foo=bar --debug" 같은 원문이 그대로 들어온다
}
}
// ApplicationRunner — 파싱된 ApplicationArguments
@Component
public class ModernRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
boolean debug = args.containsOption("debug");
List<String> foo = args.getOptionValues("foo");
}
}ApplicationArguments는 --key=value 형태의
옵션 인자와 non-option 인자를 분리해서 제공한다. 실무에서는
ApplicationRunner 쪽이 거의 항상 낫다. 두
인터페이스는 형제지 서로를 대체하는 건 아니고, 필요하면 동시에 여러 개를
둘 수도 있다.
6-3) 여기서 @Order가 "정말로" 먹는다
Runner는 여러 개를 둘 수 있고, 스프링이 그 리스트를
돌면서 run()을 순서대로 호출한다. 즉 이 구조는
컬렉션 호출 순서이고, 이런 자리가 바로 §4 @Order가
유효한 자리다.
@Component
@Order(1)
public class SchemaValidator implements ApplicationRunner {
@Override public void run(ApplicationArguments args) {
// 1. 스키마 검증이 먼저
}
}
@Component
@Order(2)
public class SampleDataSeeder implements ApplicationRunner {
@Override public void run(ApplicationArguments args) {
// 2. 그 다음 시딩
}
}
@Component
@Order(3)
public class CacheWarmer implements ApplicationRunner {
@Override public void run(ApplicationArguments args) {
// 3. 마지막으로 캐시 워밍
}
}이 세 빈의 생성 순서는 여전히 DI 그래프로 정해진다 —
@Order와 무관하다. 그러나 run() 호출
순서는 @Order가 정확히 통제한다.
7) SmartLifecycle: Phase로 기동·종료 순서 직접 통제
7-1) SmartLifecycle이 해결하는 문제
SmartLifecycle은 Lifecycle 인터페이스를
확장해 자동 시작과 단계(phase) 개념을
더한 인터페이스다. refresh 완료 직후에 start()가 호출되고,
컨텍스트 종료 시 stop()이 호출된다. 이 호출은 phase
값으로 정렬된다 — 낮은 phase가 먼저 start, 나중에
stop(파괴는 역순).
3편이 한 빈 안의 @PostConstruct·@PreDestroy
같은 생애 단계를 다뤘다면,
SmartLifecycle은 여러 빈의 기동·종료 순서를 phase로
정렬한다는 점에서 층위가 다르다.
7-2) Kafka Consumer 예시
@Component
public class KafkaConsumerLifecycle implements SmartLifecycle {
private final KafkaMessageListenerContainer<String, String> container;
private volatile boolean running = false;
public KafkaConsumerLifecycle(KafkaMessageListenerContainer<String, String> c) {
this.container = c;
}
@Override
public int getPhase() {
// 늦게 시작, 일찍 멈춘다.
// DB, 캐시, 메시지 프로듀서가 먼저 준비되어야 소비를 시작할 수 있으므로
// 높은 phase를 준다.
return 1000;
}
@Override
public boolean isAutoStartup() {
return true; // 기본 true지만 명시 권장
}
@Override
public void start() {
container.start();
running = true;
}
@Override
public void stop() {
container.stop();
running = false;
}
@Override
public void stop(Runnable callback) {
try {
// 실제 종료 작업
container.stop();
running = false;
} finally {
// 반드시 콜백을 호출해야 한다. 이걸 빼먹으면 30초 타임아웃.
callback.run();
}
}
@Override
public boolean running() {
return running;
}
}phase를 1000으로 잡았으므로, phase 0인 일반 빈들이 먼저 start되고 이 컨슈머가 나중에 start된다. 종료 시에는 역으로 이 컨슈머가 먼저 stop되어 들어오는 메시지를 끊고, 그 다음 phase 0 빈들이 정리된다. 이렇게 해야 처리 중이던 메시지가 안전하게 커밋된 뒤 DB 커넥션이 닫힌다.
7-3) 치명 함정: stop(Runnable callback)에서 callback.run() 누락
이 글을 통틀어 가장 실수하기 쉽고, 가장 아프게 돌아오는 함정이다.
SmartLifecycle에는 stop()과
stop(Runnable callback) 두 오버로드가 있는데, 스프링은
비동기 종료 지원을 위해 후자를 호출한다. 이 메서드는
종료 작업이 끝났음을 스프링에게 알리기 위해 반드시
callback.run()을 호출해야 한다.
// 피하기: callback 호출 누락
@Override
public void stop(Runnable callback) {
container.stop();
// callback.run() 누락 → 스프링은 이 빈이 아직 종료 중이라고 판단
}
// 선호: try-finally로 보장
@Override
public void stop(Runnable callback) {
try {
container.stop();
} finally {
callback.run();
}
}콜백이 호출되지 않으면 스프링은 기본 30초 타임아웃을 기다린 뒤 강제로 넘어간다. 로그에 이런 메시지가 찍힌다.
"Failed to shut down N beans with phase value of 0 within timeout of 30000ms"
문제는 여기서 끝이 아니다. K8s 환경이라면
terminationGracePeriodSeconds(기본 30초)가 이 타임아웃과
거의 같거나 짧다. 그래서 파드가 셧다운 시그널을 받고
30초가 지나면 K8s가 SIGKILL을 쏘고, 정리 중이던
빈들은 강제 종료된다 — 트랜잭션이 중간에 끊기고, 메시지가
커밋되지 않고, 파일 핸들이 안 닫힌 채 프로세스가 죽는다.
해결은 단순하다.
try { doCleanup(); } finally { callback.run(); }
패턴을 무조건 쓸 것. 로그에 위 메시지가 보이면
stop(Runnable) 구현체들을 뒤져서 누락된
callback.run()을 찾는 것이 정석 진단이다.
8) 실무에서 이렇게 읽고 쓴다
- 먼저 물어라 — DI 그래프로 풀리는가. 대부분의 "순서 문제"는 생성자 주입 한 줄로 끝난다. 제어 도구는 그 다음이다.
- side-effect 선행이면
@DependsOn. 단 생성자 주입이 가능하면 그쪽이 우선.@DependsOn은 문자열 이름이라 리팩터링에 약하다. - 생성 지연이면
@Lazy(빈 정의). 시동 시간이 시급한 빈에만. 다른 빈이 생성자로 받으면 무력화된다는 점 기억. - 순환 해결이면
@Lazy(주입 지점) +allow-circular-references명시. 단 임시방편으로 보고, 책임 분리나 이벤트 디커플링으로 돌아갈 것. - 컬렉션 정렬이면
@Order. 필터, 인터셉터, Aspect, Runner,List<Strategy>주입 — 이 자리들에서만 의미가 있다. - 기동 후 1회 훅이면
ApplicationRunner.CommandLineRunner도 살아 있지만ApplicationArguments가 더 쓸만하다.@Order로 여러 Runner 정렬. - 기동·종료 phase 통제면
SmartLifecycle. Kafka, 스케줄러, 외부 커넥션 풀처럼 들어오는 트래픽과 나가는 리소스 사이 순서가 중요한 컴포넌트는 여기로. spring.main.lazy-initialization=true는 개발 전용. 운영에서 켜면 첫 요청에서ClassNotFoundException이 줄줄 나온다.- Boot 2.5 → 2.6 이상 업그레이드 시 순환 의존 사전
점검.
allow-circular-references=true로 버티지 말고, 이번 기회에 분리해라. SmartLifecycle.stop(Runnable)구현체는try-finally로callback.run()보장. 로그에Failed to shut down N beans ... within timeout of 30000ms가 보이면 이걸 의심.@DependsOn은@PostConstruct내부 비동기까지 보장하지 않는다. 비동기 워밍업이 필요하면SmartLifecycle이나 Runner로 옮겨라.
9) 한 줄 정리
스프링 빈 초기화 순서의 기본은 의존성 그래프 자동
해석이다. @DependsOn은 side-effect 선행,
@Lazy는 생성 지연과 프록시, @Order는 컬렉션
정렬 전용, ApplicationRunner는 기동 후 1회 훅,
SmartLifecycle은 기동·종료 phase 통제 — 다섯 도구는
서로 직교한다. 도구를 꺼내기 전에 먼저 물어라: 이 순서
문제는 DI 그래프로 자연스럽게 풀리지 않는가.
태그: Spring Framework, 빈 초기화 순서, @DependsOn, @Lazy, @Order, 순환 의존성, SmartLifecycle, ApplicationRunner, Spring Boot 2.6, Java 17
'CS > Spring' 카테고리의 다른 글
| @Transactional 심화: propagation, isolation, rollback rules (0) | 2026.04.13 |
|---|---|
| Spring AOP 심화: AspectJ와의 경계, Pointcut 표현식, Advice 순서 (0) | 2026.04.12 |
| 조건부 빈 등록: @Conditional과 Auto-configuration의 뿌리 (1) | 2026.04.12 |
| 빈 스코프: singleton이 전부가 아니다 (0) | 2026.04.12 |
| 빈 라이프사이클: 태어나서 사라지기까지 (1) | 2026.04.12 |