Repeat is the best medicine for memory
Bean 생명주기와 활용
문제 상황
보통 어플리케이션의 동작에는 외부 라이브러리나 api, 네트워크 연결 등등과 같이 다양한 외부 자원들과의 연동을 필요로한다.
예를 들어, 데이터베이스의 connection pool, network socket 처럼 어플리케이션의 시작과 함께 필요한 연결을 미리 동작하는 과정이 필요할 것이다.
그리고 이러한 동작 수행에 있어 스프링 컨테이너 생성 및 Bean 등록과 관련한 라이프 사이클과 관련하여 어떻게 필요한 작업을 수행하는 지에 대해서 알아보자.
아래는 간단하게 외부 네트워크에 미리 연결하는 객체의 예시로 어플리케이션의 시작 시점에 connect()으로 연결하고, 종료 시점에는 disConnect()으로 연결을 끊어야하는 상황으로 간주하자.
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);
}
}
public class BeanLifeCycleTest {
@Test
public void lifeCycleTest() {
ConfigurableApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);
NetworkClient client = ac.getBean(NetworkClient.class);
ac.close(); //스프링 컨테이너를 종료, ConfigurableApplicationContext 필요
}
@Configuration
static class LifeCycleConfig {
@Bean
public NetworkClient networkClient() {
NetworkClient networkClient = new NetworkClient();
networkClient.setUrl("http://hello-spring.dev");
return networkClient;
}
}
}
그리고 이를 실행하면, 아래와 같은 출력문이 나온다.
생성자 호출, url = null
connect: null
call: null message = 초기화 연결 메시지
스프링 컨테이너 생성 시점에 Bean 등록을 위해서 NetworkClient 생성자를 호출하면
NetworkClient 객체 내부에 connect(); call("초기화 연결 메시지"); 호출 이후,
url을 지정하는 별도의 방법이 없기에 당연하게도 null 값이 출력된다.
초기화 콜백 사용
컨테이너 및 Bean 생성 이후 필요한 동작을 수행하도록 만들기 위해 초기화 콜백을 쓸 수 있다.
먼저, 스프링 Bean의 라이프사이클은 아래와 같은 진행된다.
여기서 초기화 콜백을 사용하여, Bean의 생성 및 의존관계 주입이 완료된 이후 필요한 작업을 수행하는 초기화 작업을 진행하는 것이다.
참고:
객체의 생성과 초기화를 분리하자. 생성자는 필수 정보(파라미터)를 받고, 메모리를 할당해서 객체를 생성하는 책임을 가진다. 반면에 초기화는 이렇게 생성된 값들을 활용해서 외부 커넥션을 연결하는등 무거운 동작을 수행한다. 따라서 생성자 안에서 무거운 초기화 작업을 함께 하는 것 보다는 객체를 생성하는 부분과 초기화 하는 부분을 명확하게 나누는 것이 유지보수 관점에서 좋다. 물론 초기화 작업이 내부 값들만 약간 변경하는 정도로 단순한 경우에는 생성자에서 한번에 다 처리하는게 더 나을 수 있다.
이와 같은 라이프 사이클에 맞춰 초기화 콜백을 사용할 수 있는 방법에는 크게 3가지가 존재한다.
인터페이스 InitializingBean, DisposableBean
이를 사용한 예시는 아래와 같다.
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() 메서드로 컨테이너 및 Bean 등록 이후 초기화에 필요한 작업을 수행하도록 기능한다.
그리고 DisposableBean 은 destroy() 메서드로 소멸을 지원한다.
이를 실행하면 아래와 같은 출력문이 나온다.
생성자 호출, url = null
NetworkClient.afterPropertiesSet
connect: http://hello-spring.dev
call: http://hello-spring.dev message = 초기화 연결 메시지
13:24:49.043 [main] DEBUG
org.springframework.context.annotation.AnnotationConfigApplicationContext -
Closing NetworkClient.destroy
close + http://hello-spring.dev
이와 같은 동작에는 @Configration과 @Bean으로 처음 컨테이너 생성 및 Bean 등록에 따라 NetworkClient 생성자가 호출되는 데,
처음에는 아무런 값이 없기에 null이 발생한다.
그리고 Bean 등록으로 인한 생성자 호출 코드 안에 networkClient.setUrl("http://hello-spring.dev");이 있어 url값이 세팅되며, Bean이 생성된다.
이어서 컨테이너 및 Bean 등록이 끝났으니, afterPropertiesSet()이 호출된다. 그리고 컨테이너가 close();로 종료되는 시점에 destroy()가 호출되는 작동을 따르기에 위와 같은 출력을 볼 수 있었다.
현재는 해당 인터페이스를 사용하여 초기화 콜백 기능을 사용하지 않는다. 더 좋은 방법들이 있기 때문인데, 이 초기화 및 소멸 인터페이스를 사용하는 경우 단점은 아래와 같다.
✅ 스프링 전용 인터페이스로 스프링에 의존한다.
✅ 초기화, 소멸 메서드의 이름을 변경할 수 없다.
✅ 내가 코드를 수정할 수 없는 외부 라이브러리에 적용할 수 없다.
설정 정보에 초기화 메서드 및 종료 메서드 지정
@Bean의 옵션으로 설정 정보를 지정하는 것이다.
예를 들어, @Bean(initMethod = "init", destroyMethod = "close") 처럼 초기화, 소멸 메서드를 지 정할 수 있다.
아래는 예시 코드이다.
public class NetworkClient {
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);
}
public void init() {
System.out.println("NetworkClient.init");
connect();
call("초기화 연결 메시지");
}
public void close() {
System.out.println("NetworkClient.close");
disConnect();
}
}
@Configuration
static class LifeCycleConfig {
@Bean(initMethod = "init", destroyMethod = "close")
public NetworkClient networkClient() {
NetworkClient networkClient = new NetworkClient();
networkClient.setUrl("http://hello-spring.dev");
return networkClient;
}
}
그 결과 출력은 아래와 같다.
생성자 호출, url = null
NetworkClient.init
connect: http://hello-spring.dev
call: http://hello-spring.dev message = 초기화 연결 메시지
13:33:10.029 [main] DEBUG
org.springframework.context.annotation.AnnotationConfigApplicationContext -
Closing NetworkClient.close
close + http://hello-spring.dev
앞서, 인터페이스를 활용한 방법에 비해 다음과 같은 이점을 지닌다.
✅ 메서드 이름을 자유롭게 줄 수 있다.
✅ 스프링 빈이 스프링 코드에 의존하지 않는다.
✅ 코드가 아니라 설정 정보를 사용하기 때문에 코드를 고칠 수 없는 외부 라이브러리에도 초기화, 종료 메서드를 적용할 수 있다.
그런데, ' 코드를 고칠 수 없는 외부 라이브러리에도 초기화, 종료 메서드를 적용할 수 있다.' 는 점이 이해가 되지 않았다.
“NetworkClient를 외부 라이브러리라고 가정한다면, 외부 라이브러리 내부에 init 메서드와 close 메서드를 작성하고 설정 정보를 활용하는 것이 아닌가?”
그리고 해당 궁금증을 가진 다른 사람의 질문에서 대답을 확인할 수 있었다.
즉, 외부라이브러리 자체에 초기화와 종료 시점에 필요한 메서드 자체가 정의된 경우,
@Bean의 옵션을 통해 사용할 수 있다는 것이다.
또한 초기화 및 종료 메서드 자체가 정의되지 않았다면, 아래와 같은 방법을 통해서 정의할 수도 있다.
만약 NetworkClient라는 외부 라이브러리에 init, close와 같은 메서드가 없다면,
우리는 래퍼 클래스(wrapper class)를 만들어서 외부 라이브러리를 감싸고(데코레이트) 그 안에서 적절한 초기화와 소멸 메서드를 제공할 수 있습니다. 이렇게 하면 실제 라이브러리의 코드를 수정하지 않고도 원하는 초기화와 소멸 로직을 적용할 수 있게 됩니다.
아래는 래퍼 클래스를 사용한 예시로, NetworkClient를 이용함에 초기화 메서드 및 종료 메서드를 정의하기 위해서
NetworkClientWrapper 클래스에 정의하여 사용한다.
public class NetworkClientWrapper {
private NetworkClient networkClient;
public NetworkClientWrapper(String url) {
this.networkClient = new NetworkClient();
this.networkClient.setUrl(url);
}
public void init() {
// NetworkClient 초기화 로직
networkClient.connect();
networkClient.call("초기화 연결 메시지");
}
public void close() {
// NetworkClient 소멸 로직
networkClient.disconnect();
}
public NetworkClient getNetworkClient() {
return networkClient;
}
}
@Configuration
public class AppConfig {
@Bean(initMethod = "init", destroyMethod = "close")
public NetworkClientWrapper networkClientWrapper() {
NetworkClientWrapper wrapper = new NetworkClientWrapper("http://hello-spring.dev");
return wrapper;
}
}
어노테이션 @PostConstruct, @PreDestroy 사용
앞서 사용했던 방법들과 크게 다르지 않다.
바로 코드를 먼저 보자.
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
public class NetworkClient {
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);
}
@PostConstruct
public void init() {
System.out.println("NetworkClient.init");
connect();
call("초기화 연결 메시지");
}
@PreDestroy
public void close() {
System.out.println("NetworkClient.close");
disConnect();
}
}
@Configuration
static class LifeCycleConfig {
@Bean
public NetworkClient networkClient() {
NetworkClient networkClient = new NetworkClient();
networkClient.setUrl("http://hello-spring.dev");
return networkClient;
}
}
실행 결과 아래와 같은 출력문이 나온다.
생성자 호출, url = null
NetworkClient.init
connect: http://hello-spring.dev
call: http://hello-spring.dev message = 초기화 연결 메시지
19:40:50.269 [main] DEBUG
org.springframework.context.annotation.AnnotationConfigApplicationContext -
Closing NetworkClient.close
close + http://hello-spring.dev
@PostConstruct , @PreDestroy 이 두 애노테이션을 사용하면 가장 편리하게 초기화와 종료를 실행할 수 있다.
그리고 아래와 같은 특징을 지닌다.
@Bean의 옵션 설정 방법과 @PostConstruct, @PreDestroy을 적절하게 잘 사용하면 될 것 같다.
댓글