당니의 개발자 스토리

빈 생명주기 콜백 시작 본문

스프링/스프링 핵심 원리 - 기본편

빈 생명주기 콜백 시작

clainy 2024. 1. 26. 18:25

8. 빈 생명주기 콜백

이번 시간부터는 이제 빈 생명 주기 콜백에 대해서 알아보겠습니다. 이거는 스프링 빈생성되거나 죽기 일보 직전에 스프링이 빈 안에 있는 메서드를 호출해줄 수 있는 기능이거든요. 되게 간단해요.

그래서 생성되고 나서 초기화할 때 호출하고, 또 빈이 사라지기 일보 직전에 안전하게 종료할 수 있는 메서드를 호출해주고 이런 간단한 내용인데, 하면 금방 끝낼 수 있는데,

세 가지 방식이 있거든요. 각 방식별로 특징이 있는데 거기에서 배울 게 있어요. 그래서 좀 내용을 풀어서 설명을 드리겠습니다.


현업에 있는 분들은 너무 당연하게 알텐데, 데이터베이스 커넥션 풀이라는 게 있어요. 우리가 보통 애플리케이션은 관계형 데이터베이스를 쓰거든요. 아니면 다른 데이터베이스를 쓰기도 하는데, 미리 애플리케이션 서버가 올라올 때 데이터베이스랑 연결을 미리 맺어놔요. 왜냐하면 TCP/IP 핸드쉐이킹하고 연결하는데 오래 걸리거든요. 그래서 미리 애플리케이션 서버랑 DB랑 커넥션을 서버 뜰 때, 애플리케이션에서 DB 쪽에 미리 연결을 해놔요. 그래서 한 10개, 많으면 100개 이렇게 미리 딱 잡아놔요. 그렇게 하면 고객 요청이 올 때 미리 연결 해놓은 걸 가져다가 그대로 재활용할 수 있거든요.

또 네트워크를 가지고 서버가 뜰 때 다른 쪽이랑 소켓을 미리 열어놔야 돼요. 그러면 아무래도 서버가 뜰 때 미리 소켓을 다 열어놓으면, 다음에 고객이 요청이 왔을 때 이미 열려있는 소켓을 가지고 빨리 응답을 줄 수 있겠죠. 그래서 네트워크 소켓처럼 애플리케이션 시작 시점에 필요한 연결을 미리 해두고, 또 애플리케이션이 종료될 때 안전하게 연결을 끊어줘야 되거든요. 데이터베이스랑 연결도 다 미리 끊어주고, 서버 꺼질 때 확 꺼질 수도 있는데 그러면 불안하잖아요. 그래서 안전하게 정상적으로 잘 종료 처리되는지, 이렇게 할 수 있는 작업들을 스프링이 제공합니다.

그래서 객체의 초기화와 종료 작업이 필요해요. 객체가 초기화 됐을 때 할 수 있는 것과 객체가 죽기 일보 직전에 안전하게 메서드를 호출할 수 있는 이러한 기능들이 필요합니다.

이번 시간에는 스프링을 통해 이런 초기화 작업종료 작업이 어떻게 진행되는지 간단한 예제로 알아볼게요.

어떤 예제를 만들거냐? 정말 간단하게 서버가 뜰 때 미리 외부 네트워크에 연결을 해놓고, 서버가 종료할 때는 미리 외부 네트워크랑 연결을 끊어야 되는 객체가 있다고 칩시다.

실제로 네트워크 연결을 하는 건 아니고요. 단순히 문자만 출력하도록 했어요.

그래서 NetworkClient 라는 걸 만들 건데, 이 클라이언트가 애플리케이션 시작할 때, connect()를 호출해서 미리 연결을 맺어놓아야 되고, 애플리케이션이 종료되면 disConnect()를 호출해서 연결을 끊어야 됩니다.


이거를 한번 예제코드로 만들어 볼게요. test의 hello.core에다가 lifecycle 이라는 패키지를 만들겠습니다.

그리고 여기에다가

NetworkClient 라는 클래스를 만들겠습니다. 가짜 네트워크 클라이언트죠.

여기에다가는 접속해야 될 서버의 URL을 하나 넣어 놓고요.

Constructor를 만들 건데, Select None을 해서 디폴트 생성자를 만들겠습니다.

이제 출력을 할 건데,

url을 출력해놓고, url은 외부에서 setter로 값을 넣을 수 있도록 만들겠습니다.

이렇게 만들어놓고, 서비스를 시작할 때 호출하는 메서드를 만들게요. 위에서 말씀드린 connect()를 만들 건데, 얘는 실제로 네트워크에 붙지 않을거고요.

connect 해서 이 url에 붙어! 라는 의미로 이렇게 출력하겠습니다.

그리고 나서, 연결이 된 상태에서 call을 부를 수 있다고 가정합시다. 그래서 연결한 서버에다가 전달할 메시지를 던질 수 있다고 할게요.

그래서 call을 호출했고, 메시지를 던질 서버는 어떤 url인지, 메시지는 뭔지 출력을 하겠습니다.

그리고 서비스가 종료될 때 disconnect()를 호출해야 돼요. 서비스 종료 시, 이걸 호출해야 안전하게 서비스 연결이 끊어지는 겁니다.

disconnect()를 호출하면 연결한 서버의 url이 잘 close 됐다는 의미로, 정상적으로 잘 출력되는지 해보겠습니다.

이제 먼저, NetworkClient 생성자에서 connect()를 먼저 하구요. 그리고 call을 호출해서 메시지를 보낸다고 합시다.

이렇게 하면, NetworkClient 객체가 생성될 때,

이 코드들이 불러지면서, 연결하고 초기화 메시지를 저쪽 서버의 url에다가 보내겠죠. 물론 여기서는 그냥 로그에 출력만 하는 겁니다.

그리고 나서 테스트를 하나 만들게요. 테스트 이름은 BeanLifeCycleTest 입니다.

그리고 나서 얘도 Configuration을 하나 만들어야겠죠.

그리고 여기에다가 방금 만든 걸 빈으로 직접 등록할게요.

그리고 networkClient에다가 setUrl을 호출해서 어떤 서버인지 넘겨줍니다.

url은 그냥 가짜로 적은 거예요. 그리고 나서,

반환해주면, networkClient()를 호출한 결과물스프링 빈으로 등록되겠죠. 빈 이름은 메서드 명이니까 networkClient가 되고요.

그 다음에 이제 스프링 컨테이너를 만들고, LifeCycleConfig.class를 빈으로 등록합니다.

그리고 우리가 등록한 빈을 조회합니다.

여기서 중요한 게 있어요. 이전 예제에서는 안했었는데 스프링 컨테이너의 close()를 호출해줘야 합니다.

ApplicationContext를 닫아야 되거든요. 보통 ApplicationContext를 이용하지, 닫는 메서드는 잘 안쓰기 때문에 기본 ApplicationContext에서 제공해주지 않아요.

그래서 ApplicationContext를 AnnotationConfigApplicationContext로 바꾸거나,

ConfigurableApplicationContext로 바꾸면, 얘도 인터페이스인데,

ConfigurableApplicationContext이 ApplicationContext 밑에 있고, 동시에 AnnotationConfigApplicationContext 보다는 위에 있습니다.

아무튼 얘가 인터페이스로 필요해요. 왜냐면 우리가 직접 ApplicationContext 할 때는 보통 close 할 일이 별로 없기 때문에 close는 인터페이스인 ApplicationContext가 제공해주지 않아요. 하위까지 내려와야 제공해줘요.

이렇게 해서 테스트를 실행해 보겠습니다.

테스트는 성공했는데, 처음 출력했는데 null이고 connect가 null로 연결하죠. 왜 이렇게 될까요?

지금 생성만 하고 막상 메서드를 호출하지 않았기 때문에 그렇습니다. 현재 LifeCycleConfig를 스프링 빈으로 올리면서,

networkClient를 빈으로 올리려고 메서드를 호출하면서,

NetworkClient()를 호출하면서, url에 아무것도 안 담긴 채로 출력이 되는겁니다. 밑에 setUrl 한 건 그 다음 이야기라 영향이 없어요.

그래서 지금은 생성만 하고 실제 로직을 호출한 건 없습니다.

지금처럼 url이 null이면 연결이 실패된 거죠.

그래서 생성자 부분을 보면, url 정보 없이 connect가 호출되는 것을 확인할 수 있죠. 그래서 너무 당연한 이야기이지만 객체를 생성하는 단계에는 url이 없고, 객체를 생성한 다음에 외부에서 수정자 주입을 통해서 setUrl() 이 호출되어야 url이 들어오게 됩니다.

왜 굳이 예를 들었냐면,

먼저, 스프링 빈은 간단하게 이러한 라이프사이클을 가져요.

객체를 생성한 다음에 의존 관계 주입을 합니다. 당연히 객체를 다 생성해놔야 그 다음에 의존관계 주입을 할 수 있겠죠.

물론 예외가 있다고 했어요. 생성자 주입 같은 경우엔 예외예요. 생성자는 객체를 만들 때 이미 파라미터에 스프링 빈이 같이 들어와야 되기 때문에 예외입니다.

나머지 setter 라던가, 필드 인젝션 같은 경우에는 객체 생성이 다 끝나고 그 다음에 의존관계 주입이 일어납니다.

그래서 스프링 빈은 객체를 생성하고, 의존관계 주입이 다 끝난 다음에야 필요한 데이터를 사용할 수 있는 준비가 완료되겠죠. 왜냐면, setter자동 의존관계 주입을 했다고 칩시다.

그러면 setter는 생성자 다음에 일어나기 때문에, 객체를 생성하는 단계에는 값이 없단 말이에요. 그럼 생성자에서 함부로 초기화를 하거나 연결을 하면, setter를 만나기 전이니까 아까 우리가 만들었던 예제에서 null이 들어간 것처럼 URL이 값이 없겠죠.

그래서 초기화라는 작업객체를 생성하는 작업을 말하는 게 아니에요.

객체 안에 필요한 값이 다 연결이 되고 진짜 외부랑 연결해야(의존관계 주입), 객체가 처음 제대로 일을 시작하는 거예요. 그걸 초기화라고 그러거든요.

따라서 의존관계 주입까지 모든 데이터가 다 세팅이 되고 난 다음에, 초기화 작업을 호출해야 돼요.

그런데 개발자가 의존관계 주입이 완료된 시점을 어떻게 알 수 있을까요?

우리가 자동 의존관계 주입을 했다고 칩시다. 그래서 스프링 컨테이너가 '나는 이제 의심 관계 주입을 다했어' 라는 걸 나한테 알려줘야 되겠죠.

스프링의존관계 주입이 완료되면, 스프링 빈에게 콜백 메서드를 통해서 "이제 의존관계 주입이 끝났으니까 초기화 할 차례야!" 라고 알려주는 기능을 굉장히 다양한 방식으로 제공합니다.

또한 스프링은 스프링 컨테이너가 종료되기 직전에 또는 스프링 빈이 종료되기 직전소멸 콜백을 줘요.

따라서 빈이 종료 되기 직전에 disconnect() 같은 소멸 콜백을 받을 수 있어요. 그럼 안전하게 종료 작업을 할 수 있어요.


그래서 간단하게 정리를 해보면,

스프링 빈은 이벤트 라이프 사이클이 있어요.

먼저, 스프링 컨테이너가 생성이 됐어요. 그리고 스프링 빈을 생성을 합니다. 그 다음에 의존관계 주입을 하는 단계가 일어나요. 특히 setter, 수정자 인젝션이나 필드 인젝션 같은 경우가 이 단계에서 일어납니다.

그런데 생성자 인젝션 같은 경우에는 객체를 생성해야 되기 때문에

이 단계에서 의존관계 주입이 함께 일어납니다. 그 다음에 의존관계 주입이 끝나고 나면, 초기화 콜백이라는 것을 줍니다. "너 의존관계 주입 끝났으니까 이제 너가 하고 싶은 대로 마음껏 해!" 하고 그 다음에 실제 애플리케이션이 동작하겠죠. 사용할 겁니다.

그리고 난 다음 소멸 전 콜백이 옵니다. 그리고 스프링이 종료가 됩니다.

사실 이 라이프사이클싱글톤에 대한 예시구요. 다른 라이프사이클은 뒤에서 알려드릴게요.

그래서 초기화 콜백은 빈이 생성되고 빈의 의존관계 주입이 완료된 후에 호출이 되고요. 소멸 전 콜백이라는 것은 빈이 소멸되기 직전에 호출됩니다.

그래서 스프링은 다양한 방식으로 생명주기 콜백을 지원합니다.

'그런데 그냥 초기화도 최대한 생성자에서 다 해버리는 게 낫지 않나요?'

이걸 다 생성자의 파라미터로 넘겨버리고, 의존관계 주입도 생성자에서 주입을 하고 하면 더 낫지 않나요? 이렇게 고민 할 수 있는데, 사실은 문제가 있어요.

뭐냐면,

객체의 생성, 즉 new 해서 객체 인스턴스를 생성하는 것과 초기화는 분리하는 게 좋아요.

왜냐면 단일 책임 원칙처럼, 객체를 생성하는 생성자객체를 생성하는 데에만 초집중을 해야돼요. 필요한 필수 값들을 넣어 가지고 객체 인스턴스를 new해서 java에서 생성되는 것까지만 딱 집중을 해야 됩니다.

그리고 실제 초기화 작업을 한다는 건 객체가 동작하는 거거든요. 예를 들어서, 외부랑 커넥션을 맺고 이런 거는 진짜 동작하는거란 말이에요. 그래서 객체 생성은 딱 메모리에 할당하는 것까지, 최대한 필요한 데이터를 세팅하는 것까지만 하고, 그 객체가 실제로 동작하는 행위는 별도의 초기화 메서드로 분리하도록 설계하는 게 좋습니다.

생성자는 필수 정보를 파라미터로 받고 메모리를 할당해서 객체를 생성하는 책임을 가진단 말이에요.

반면에 초기화는 이렇게 생성된 값들을 활용해서, 외부 커넥션을 연결하거나 커넥션 풀을 만들거나 하는 굉장히 무거운 동작을 수행합니다. 그래서 생성자 안에서 무거운 초기화 작업을 함께 하는 것보다는 객체를 생성하는 부분과 초기화하는 부분을 명확하게 나누는 게 유지 보수 관점에서 훨씬 좋아요.

생성자 안에서는 생성자 객체 내부에 어떤 값을 세팅한다거나 이 정도만 하시고요. 무거운 작업을 하거나 외부 연결을 맺거나 하는 작업들은 그냥 별도의 초기화 메서드로 제공하는 게 훨씬 유지보수하기에 좋습니다.

물론 초기화 작업 중에 내부 값을 약간 변경하는 정도로 단순한 경우에는 생성자에서 한 번에 처리하는 게 더 나을 수도 있습니다.

객체를 생성을 하는 단계와 초기화하는 단계를 분리하면 또 어떤 장점이 있냐면, 보통 지연이라고 하는데 객체를 딱 생성을 해놓고, 실제 외부 커넥션을 맺거나 최초의 어떤 행위가 올 때까지는 초기화를 미룰 수 있는 거예요. 즉 생성만 해놓고 기다리다가 최초의 어떤 액션이 주어지면 그때 초기화를 호출하는 거예요. 그럼 애플리케이션이 훨씬 가벼워지겠죠.

그래서 좀 동작을 지연시킬 수도 있는 장점도 있습니다. 근데 자주 쓰지는 않습니다.

참고로 싱글톤 빈들은 스프링 컨테이너가 종료될 때 함께 종료되기 때문에, 스프링 컨테이너가 종료되기 직전소멸전 콜백이 일어납니다. 어차피 스프링 컨테이너와 빈 소멸 시기가 같기 때문이죠.

그런데 싱글톤처럼 컨테이너의 시작과 종료까지 생존하는 빈도 있지만, 생명주기가 짧은 빈들도 있는데 이 빈들은 컨테이너와 무관하게, 해당 빈이 종료되기 직전에 소멸전 콜백이 일어납니다.

자세한 내용은 빈 스코프에서 알아보겠습니다.


그러면 스프링은 "나 의존관계 주입 다 끝났으니까 이제 초기화 해도 돼" 또는 스프링 빈이 사라지기 직전에 "나 이제 소멸되니까 커넥션 미리 닫아" 이런 콜백을 어떤 방식으로 우리한테 알려줄까요?

3가지 방법이 있습니다. 인터페이스를 통해서도 지원을 하고요.

그냥 빈 등록할 때 "얘가 초기화야, 얘가 종료야" 이렇게 지정할 수도 있고요.

그 다음에 @PostConstruct, @PreDestroy 애노테이션을 특정 메서드 위에 달아놔도 됩니다.

다음 시간부터 하나씩 알아볼게요.