스프링 핵심 원리 (14) - 빈 생명주기 콜백
1. 개요
DB 커넥션 풀이나 네트워크 소켓처럼 외부 자원을 다루는 빈은 “애플리케이션이 뜰 때 연결하고, 종료될 때 끊는” 작업이 필요하다. 그런데 이 연결 작업을 생성자에 그냥 넣으면 문제가 생긴다. 스프링 빈은 객체가 먼저 만들어지고 그 다음에 의존관계(필드 값)가 채워지기 때문에, 생성자 시점에는 아직 필요한 값이 준비되어 있지 않다. 그래서 스프링은 “의존관계 주입이 다 끝난 후”와 “빈이 소멸되기 직전”이라는 두 시점을 콜백으로 알려주는데, 이번 글에서는 그 콜백을 받는 세 가지 방법을 예제로 정리한다.
2. 왜 콜백이 필요한가
외부 네트워크에 연결하는 역할을 하는 NetworkClient를 예로 들어본다. 이 클래스는 url 값을 받아 connect()로 연결하고, 애플리케이션이 끝나면 disconnect()로 연결을 끊어야 한다. 생성자에서 바로 연결을 시도하도록 짜면 어떻게 될까?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class NetworkClient {
private String url;
public NetworkClient() {
System.out.println("생성자 호출, url = " + url);
connect();
call("초기화 연결 메시지");
}
public void setUrl(String url) {
this.url = url;
}
public void connect() {
System.out.println("connect: " + url);
}
public void call(String message) {
System.out.println("call: " + url + " message = " + message);
}
public void disconnect() {
System.out.println("close: " + url);
}
}
이 빈을 등록해서 실행해보면 다음과 같이 의도와 다른 결과가 나온다.
1
2
3
생성자 호출, url = null
connect: null
call: null message = 초기화 연결 메시지
원인은 스프링 빈이 만들어지는 순서에 있다. 빈은 먼저 객체가 생성되고, 그 다음에야 setUrl() 같은 수정자를 통해 의존관계 주입이 이루어진다. 즉 생성자가 호출되는 시점에는 아직 url 값이 세팅되기 전이라서, 생성자 안에서 곧바로 connect()를 호출하면 필요한 데이터가 준비되지 않은 채로 실행되어 버린다.
그래서 스프링 빈은 다음과 같은 순서로 생명주기가 진행된다.
스프링 컨테이너 생성 → 빈 생성 → 의존관계 주입 → 초기화 콜백 → 빈 사용 → 소멸 전 콜백 → 스프링 종료
초기화 콜백은 의존관계 주입이 모두 끝난 뒤 호출되고, 소멸 전 콜백은 컨테이너가 종료되기 직전에 호출된다. 개발자 입장에서는 “의존관계 주입이 끝난 시점”을 직접 알아낼 방법이 없으므로, 스프링이 이 시점을 콜백으로 알려주는 장치가 필요하다.
참고: 생성자는 필수 값을 받아 객체를 만드는 역할에 집중하고, 무거운 초기화 작업(외부 커넥션 연결 등)은 별도의 초기화 메서드로 분리하는 편이 유지보수 관점에서 낫다. 초기화 작업이 단순한 값 세팅 정도라면 생성자에서 한 번에 처리해도 무방하다.
참고: 싱글톤 빈은 컨테이너가 종료될 때 함께 소멸되므로 소멸 전 콜백도 컨테이너 종료 직전에 발생한다. 반면 컨테이너보다 생명주기가 짧은 스코프의 빈은 해당 빈이 종료되는 시점에 맞춰 콜백이 발생한다.
스프링이 지원하는 생명주기 콜백 방법은 세 가지다.
- 인터페이스
InitializingBean,DisposableBean - 빈 등록 시 초기화·소멸 메서드 이름 지정
@PostConstruct,@PreDestroy애노테이션
3. 인터페이스 InitializingBean, DisposableBean
가장 먼저 나온 방식은 스프링이 제공하는 두 인터페이스를 구현하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class NetworkClient implements InitializingBean, DisposableBean {
private String url;
public NetworkClient() {
System.out.println("생성자 호출, url = " + url);
}
public void setUrl(String url) {
this.url = url;
}
public void connect() {
System.out.println("connect: " + url);
}
public void call(String message) {
System.out.println("call: " + url + " message = " + message);
}
public void disConnect() {
System.out.println("close + " + url);
}
@Override
public void afterPropertiesSet() throws Exception {
connect();
call("초기화 연결 메시지");
}
@Override
public void destroy() throws Exception {
disConnect();
}
}
InitializingBean은 afterPropertiesSet()으로 초기화 시점을, DisposableBean은 destroy()로 소멸 시점을 알려준다. 컨테이너를 실행하고 close()로 종료해보면, 의존관계 주입 이후 afterPropertiesSet()이 호출되고 컨테이너 종료 시 destroy()가 호출되는 것을 확인할 수 있다.
다만 이 방식은 실무에서 잘 쓰이지 않는다. 가장 큰 이유는 NetworkClient 클래스가 InitializingBean, DisposableBean이라는 스프링 API를 직접 구현하게 되어, 순수했던 클래스가 스프링 기술과 강하게 얽혀버린다는 점이다. 게다가 메서드 이름이 afterPropertiesSet, destroy로 고정되어 있어 원하는 이름을 쓸 수 없고, 애초에 인터페이스를 구현하는 방식이다 보니 소스 코드를 손댈 수 없는 외부 라이브러리 클래스에는 아예 적용 자체가 불가능하다.
4. 빈 등록 시 초기화·소멸 메서드 지정
두 번째 방법은 @Bean 애노테이션에 initMethod, destroyMethod 속성으로 메서드 이름을 지정하는 것이다. 이 경우 NetworkClient는 특정 인터페이스를 구현할 필요 없이, 원하는 이름의 메서드를 자유롭게 만들면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class NetworkClient {
private String url;
public void setUrl(String url) {
this.url = url;
}
public void connect() {
System.out.println("connect: " + url);
}
public void call(String message) {
System.out.println("call: " + url + " message = " + message);
}
public void disConnect() {
System.out.println("close + " + url);
}
public void init() {
System.out.println("NetworkClient.init");
connect();
call("초기화 연결 메시지");
}
public void close() {
System.out.println("NetworkClient.close");
disConnect();
}
}
1
2
3
4
5
6
@Bean(initMethod = "init", destroyMethod = "close")
public NetworkClient networkClient() {
NetworkClient networkClient = new NetworkClient();
networkClient.setUrl("http://hello-spring.dev");
return networkClient;
}
InitializingBean, DisposableBean 방식의 단점이 이 방식에서는 그대로 해소된다. NetworkClient 클래스 자체는 어떤 인터페이스도 구현하지 않는 순수한 자바 클래스이고, 초기화·소멸을 담당할 메서드 이름도 init, close처럼 원하는 대로 지을 수 있다. 무엇보다 이 정보가 클래스 코드가 아니라 설정 클래스 쪽에 적혀 있기 때문에, 소스를 손댈 수 없는 외부 라이브러리를 스프링 빈으로 등록할 때도 @Bean 설정만으로 초기화·종료 시점을 지정할 수 있다.
여기서 눈여겨볼 부분은 destroyMethod 속성의 기본값이 (inferred), 즉 “추론”으로 되어 있다는 점이다. 대부분의 라이브러리가 종료 메서드로 close나 shutdown이라는 이름을 관례적으로 사용하기 때문에, @Bean으로 등록한 빈에서 이 이름의 메서드가 있으면 destroyMethod를 따로 적지 않아도 스프링이 자동으로 호출해준다. 이 추론 동작을 원하지 않으면 destroyMethod=""처럼 빈 문자열을 지정하면 된다.
5. 애노테이션 @PostConstruct, @PreDestroy
마지막은 @PostConstruct와 @PreDestroy 애노테이션을 메서드에 붙이는 방식이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class NetworkClient {
private String url;
public void setUrl(String url) {
this.url = url;
}
public void connect() {
System.out.println("connect: " + url);
}
public void call(String message) {
System.out.println("call: " + url + " message = " + message);
}
public void disConnect() {
System.out.println("close + " + url);
}
@PostConstruct
public void init() {
System.out.println("NetworkClient.init");
connect();
call("초기화 연결 메시지");
}
@PreDestroy
public void close() {
System.out.println("NetworkClient.close");
disConnect();
}
}
빈을 등록하는 설정 클래스는 애노테이션 없이 등록할 때와 동일하게 작성하면 된다. 이 두 애노테이션은 스프링 전용 기술이 아니라 JSR-250이라는 자바 표준 애노테이션이라서, 스프링이 아닌 다른 컨테이너에서도 동일하게 동작하고 컴포넌트 스캔과도 잘 어울린다. 붙이는 것만으로 초기화·소멸 시점을 처리할 수 있어 코드도 가장 간결하다.
단점은 앞선 @Bean의 initMethod/destroyMethod 방식과 반대로, 이미 만들어진 클래스라 애노테이션을 붙일 수 없는 외부 라이브러리에는 사용할 수 없다는 점이다.
6. 세 가지 방법 비교
| 방법 | 장점 | 단점 |
|---|---|---|
InitializingBean, DisposableBean | 별도 설정 없이 인터페이스만 구현하면 됨 | 스프링 전용 인터페이스에 종속, 메서드 이름 고정, 외부 라이브러리 적용 불가 |
@Bean(initMethod, destroyMethod) | 메서드 이름 자유, 스프링 비의존, 외부 라이브러리에도 적용 가능 | @Bean으로 등록하는 빈마다 속성을 챙겨야 함 |
@PostConstruct, @PreDestroy | 자바 표준 애노테이션, 간결, 컴포넌트 스캔과 잘 맞음 | 외부 라이브러리 코드에는 적용 불가 |
기본적으로는 @PostConstruct, @PreDestroy가 가장 권장되는 방식이다. 다만 직접 코드를 고칠 수 없는 외부 라이브러리를 초기화·종료해야 한다면, 그 라이브러리를 등록하는 @Bean에 initMethod, destroyMethod를 지정하는 방식을 사용하면 된다.
7. 정리
스프링 빈은 생성과 의존관계 주입이 끝난 뒤에야 필요한 데이터를 온전히 사용할 수 있는 상태가 되므로, 생성자 안에서 무거운 초기화 작업을 함께 처리하면 값이 채워지기 전에 실행되는 문제가 생긴다. 스프링은 이 문제를 해결하기 위해 의존관계 주입이 끝난 시점에 초기화 콜백을, 컨테이너가 종료되기 직전에 소멸 콜백을 제공한다.
콜백을 받는 방법은 InitializingBean/DisposableBean 인터페이스, @Bean의 initMethod/destroyMethod 지정, @PostConstruct/@PreDestroy 애노테이션 세 가지가 있다. 직접 코드를 수정할 수 있는 상황이라면 표준 애노테이션인 @PostConstruct, @PreDestroy를 기본으로 사용하고, 코드를 고칠 수 없는 외부 라이브러리를 다뤄야 할 때만 @Bean의 속성 지정 방식으로 보완하면 된다.