사이드 프로젝트/순수 자바로 스프링 만들기

순수 자바로 스프링 빈 라이프 사이클 구현하기

마늘냄새폴폴 2025. 6. 8. 19:07

이번 포스팅에선 Spring Bean LifeCycle을 깊이있게 파헤쳐보고 정리해보도록 하겠습니다. 코드는 깃허브를 참고해주시면 감사하겠습니다. 코드는 웬만하면 적지 않을 예정입니다. 제 블로그 포스팅은 기본적으로 코드를 되도록 적지 않는데 그 이유는 코드가 많아지면 설명해야할게 많아지고 그럼 가독성이 떨어진다고 생각하기 때문입니다.

 

또한, 저도 공부하면서 포스팅을 수백개 봤지만 사실 코드를 잘 안봅니다... 그리고 깃허브를 들어가서 실제 코드를 보면 아시겠지만 변수명과 함수명이 실제 스프링 프로젝트와 다른 부분이 있습니다. 그 이유는 

 

  1. 실제 스프링 프로젝트는 훨씬 더 복잡한 계층 구조와 다양한 팩토리 클래스를 가지고 있습니다. 그래서 제 프로젝트는 최대한 가볍게 만들기 위해서 변수명을 조정했습니다. 
  2. 사실 코드는 별로 중요하지 않다고 생각하고 흐름만 공부하면 좋겠다고 생각했습니다. 
  3. 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 이렇게 두 종류입니다. 

 

각각 어디서 호출되는지 알아보고 더 깊게 정리해보겠습니다. 

 

  1. 빈 생성
  2. 의존성 주입
  3. @PostConstruct 메서드 호출
  4. 빈을 런타임 때 사용 가능
  5. 애플리케이션 종료 신호
  6. @PreDestroy 메서드 호출
  7. 빈 소멸

여기서 중요한건 @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에서 작업해주는 것이 객체지향스럽긴 합니다. 

 

이런걸 보면 스프링이 객체지향에 얼마나 목숨을 걸었고 추상화, 다형성에 얼마나 진심인지 알 수 있는 대목이었습니다. 

 

이번 포스팅은 여기서 마치도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~