순수 자바로 스프링 빈 라이프 사이클 구현하기
이번 포스팅에선 Spring Bean LifeCycle을 깊이있게 파헤쳐보고 정리해보도록 하겠습니다. 코드는 깃허브를 참고해주시면 감사하겠습니다. 코드는 웬만하면 적지 않을 예정입니다. 제 블로그 포스팅은 기본적으로 코드를 되도록 적지 않는데 그 이유는 코드가 많아지면 설명해야할게 많아지고 그럼 가독성이 떨어진다고 생각하기 때문입니다.
또한, 저도 공부하면서 포스팅을 수백개 봤지만 사실 코드를 잘 안봅니다... 그리고 깃허브를 들어가서 실제 코드를 보면 아시겠지만 변수명과 함수명이 실제 스프링 프로젝트와 다른 부분이 있습니다. 그 이유는
- 실제 스프링 프로젝트는 훨씬 더 복잡한 계층 구조와 다양한 팩토리 클래스를 가지고 있습니다. 그래서 제 프로젝트는 최대한 가볍게 만들기 위해서 변수명을 조정했습니다.
- 사실 코드는 별로 중요하지 않다고 생각하고 흐름만 공부하면 좋겠다고 생각했습니다.
- AI의 도움을 받긴 했지만 커서와 같은 AI는 도움만 주었고 실제 코드 작성은 제가 하는 편입니다. 그 중에서 AI가 적어준 변수명보다 제가 이해하기 편한 변수명을 사용한 것도 있습니다.
https://github.com/garlicpollpoll/springlite
GitHub - garlicpollpoll/springlite: 순수 자바로 스프링 만들기 프로젝트입니다.
순수 자바로 스프링 만들기 프로젝트입니다. Contribute to garlicpollpoll/springlite development by creating an account on GitHub.
github.com
이제 본격적으로 시작해보겠습니다.
우선 스프링에선 빈 라이프 사이클이 크게 두가지 있습니다. PostConstruct와 PreDestroy 이렇게 두 종류입니다.
각각 어디서 호출되는지 알아보고 더 깊게 정리해보겠습니다.
- 빈 생성
- 의존성 주입
- @PostConstruct 메서드 호출
- 빈을 런타임 때 사용 가능
- 애플리케이션 종료 신호
- @PreDestroy 메서드 호출
- 빈 소멸
여기서 중요한건 @PostConstruct 메서드가 호출되는 시점이 모든 의존성이 주입되고 난 뒤라는 점입니다. @PostConstruct가 호출되는 시점을 모든 빈이 주입되고 나서 작업을 해야합니다.
이제 핵심 코드를 확인해보겠습니다.
private Object createBean(BeanDefinition beanDefinition) {
String beanName = beanDefinition.getBeanName();
// 순환 의존성 체크
if (creatingBeans.contains(beanName)) {
throw new RuntimeException("Circular dependency detected for bean: " + beanName);
}
creatingBeans.add(beanName);
try {
Object instance;
// @Bean 메서드로 생성된 빈인지 확인
if (beanDefinition.isBeanMethod()) {
instance = createBeanFromMethod(beanDefinition);
} else {
instance = instantiateBean(beanDefinition);
populateBean(instance, beanDefinition);
}
// 빈 초기화 (라이프사이클 메서드 호출)
initializeBean(instance, beanDefinition);
// @Transactional이 있으면 프록시 생성
if (needsProxy(beanDefinition.getBeanClass())) {
instance = proxyFactory.createProxy(instance);
}
return instance;
} catch (Exception e) {
throw new RuntimeException("Failed to create bean: " + beanName, e);
} finally {
creatingBeans.remove(beanName);
}
}
다음 포스팅 내용이긴 하지만 @Configuration으로 인한 @Bean 메서드들을 빈으로 등록하고 나서 모든 것이 끝나고 나면 initializeBean을 호출해서 PostConstruct 메서드를 호출하는 작업을 수행해줍니다.
getBean에서 instantiateBean -> populateBean으로 이어지는 재귀를 이용한 빈 등록 과정은 아래의 포스팅에 자세히 기록되어있습니다.
https://coding-review.tistory.com/595
순수 자바로 ApplicationContext 구현하기
이번 포스팅에선 AnnotationApplicationContext를 깊이있게 파헤쳐보고 정리해보도록 하겠습니다. 코드는 깃허브를 참고해주시면 감사하겠습니다. 코드는 웬만하면 적지 않을 예정입니다. 제 블로그 포
coding-review.tistory.com
private void initializeBean(Object bean, BeanDefinition beanDefinition) throws Exception {
// 1. @PostConstruct 메서드들 호출
for (Method postConstructMethod : beanDefinition.getPostConstructMethods()) {
postConstructMethod.setAccessible(true);
postConstructMethod.invoke(bean);
System.out.println("@PostConstruct 호출: " + beanDefinition.getBeanName() + "." + postConstructMethod.getName());
}
// 2. @Bean의 initMethod 호출 (있는 경우)
String initMethodName = beanDefinition.getInitMethodName();
if (initMethodName != null && !initMethodName.isEmpty()) {
try {
Method initMethod = bean.getClass().getDeclaredMethod(initMethodName);
initMethod.setAccessible(true);
initMethod.invoke(bean);
System.out.println("initMethod 호출: " + beanDefinition.getBeanName() + "." + initMethodName);
} catch (NoSuchMethodException e) {
System.err.println("initMethod 를 찾을 수 없습니다: " + initMethodName);
}
}
}
initializeBean 내부는 이렇게 생겼고 BeanDefinition에 등록된 PostConstruct 메서드들을 하나씩 for문으로 돌리면서 실행시키는 모습입니다.
BeanDefinition에 등록하는 과정은
private void scanLifecycleMethods(Class<?> clazz, BeanDefinition beanDefinition) {
Method[] methods = clazz.getDeclaredMethods();
for (Method method : methods) {
if (method.isAnnotationPresent(PostConstruct.class)) {
beanDefinition.getPostConstructMethods().add(method);
System.out.println(" 📋 @PostConstruct 메서드 발견: " + method.getName());
}
if (method.isAnnotationPresent(PreDestroy.class)) {
beanDefinition.getPreDestroyMethods().add(method);
System.out.println(" 📋 @PreDestroy 메서드 발견: " + method.getName());
}
}
}
바로 registerBean에서 @Autowired 어노테이션이 붙은 생성자, 메서드, 필드를 찾고 나서 BeanDefinition에 등록하는 findAutowiredMembers 메서드 이후입니다.
더 자세한 코드가 있지만 해당 포스팅에서는 생략하도록 하겠습니다.
그리고 gradle을 빌드하고 테스트를 해보겠습니다.
LifecycleTestService 두 번째 초기화 메서드 호출!
@PostConstruct 호출: lifecycleTestService.secondInit
LifecycleTestService 초기화 완료! (시작 시간: 1749370310456)
@PostConstruct 호출: lifecycleTestService.init
@PostConstruct
public void init() {
startTime = System.currentTimeMillis();
status = "INITIALIZED";
System.out.println("LifecycleTestService 초기화 완료! (시작 시간: " + startTime + ")");
}
@PostConstruct
public void secondInit() {
System.out.println("LifecycleTestService 두 번째 초기화 메서드 호출!");
}
이렇게 등록한 @PostConstruct가 위의 로그처럼 찍히는 것을 볼 수 있습니다.
마치며
빈 라이프 사이클을 순수 자바로 구현해보니 왜 @PostConstruct와 @PreDestroy로 라이프 사이클을 나누는지 대강 알 것 같습니다. 아마 의도는 빈이 모두 주입된 뒤 초기화해야하는 작업들을 위해 만들어 두었을 것이고 생성자를 만들면서 곧장 초기화하지 않고 @PostConstruct로 나눈 이유는 다른 생성자에서 주입한 빈을 초기화하려면 객체지향이랑 멀어지긴 하는 것 같습니다.
그 이유는 A클래스에서 생성자 주입으로 A' 를 주입했고 B클래스에서 생성자 주입으로 B' 를 주입했다고 가정해보면 @PostConstruct에서 A' B' 를 이용해서 초기화 해야하는데 만약 생성자 주입을 하는 동시에 초기화를 해버리면 A클래스에서, B클래스에서 각각 초기화를 해줘야합니다.
생성자 주입으로 A' B' 를 주입하고 초기화 작업은 @PostConstruct라는 메서드 한 군데에서만 작업하면 A' B' 에 대한 정보가 변해서 초기화 작업을 변경해야하더라도 각각의 클래스에서 변경하는 것이 아니라 @PostConstruct에서 작업해주는 것이 객체지향스럽긴 합니다.
이런걸 보면 스프링이 객체지향에 얼마나 목숨을 걸었고 추상화, 다형성에 얼마나 진심인지 알 수 있는 대목이었습니다.
이번 포스팅은 여기서 마치도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~