순환참조란?
두 개 이상의 객체가 서로를 참조하여 끊을 수 없는 고리를 만드는 상황이다. 가비지 컬렉터 (Garbage Collector) 가 더 이상 사용되지 않는 객체라고 판단하지 못해서 메모리에서 해제하지 못하는 문제가 발생할 수 있다. 결국, 메모리 누수로 이어질 수 있다.
주로 객체들이 서로를 필요로 하는 복잡한 관계를 가질 때 발생한다.
Spring Bean 생성 메커니즘
Spring IoC 컨테이너는 Bean 을 생성하고 관리하기 위해 체계적인 단계가 있다. 순환 참조는 이 과정에서 두 개 이상의 Bean 이 서로의 생성을 기다리며 무한 대기 상태에 빠질 때 발생한다.
Bean 의 생성 과정
1. 정의(Definition)
가장 먼저, Spring 컨테이너는 Bean을 어떻게 만들어야 하는지에 대한 '설명서' 또는 '레시피'를 가져온다.
- 소스: 이 정보는 XML 설정 파일 (<bean>), Java 어노테이션 (@Component, @Bean), 또는 Java 기반 설정 (@Configuration)에서 가져온다.
- 생성: 컨테이너는 이 정보를 바탕으로 BeanDefinition 객체를 생성한다. BeanDefinition에는 해당 Bean의 클래스 이름, 스코프(singleton, prototype 등), 의존성 정보, 초기화 메서드 이름 등 Bean을 만드는 데 필요한 모든 메타데이터가 담겨 있다.
- 저장: 생성된 BeanDefinition들은 BeanDefinitionRegistry라는 내부 저장소에 맵(Map) 형태로 저장된다. 아직 실제 Bean 인스턴스가 만들어진 것은 아니다.
2. 생성 (Creation)
이제 실제 Bean 객체, 즉 인스턴스를 만드는 단계이다. 이 과정은 보통 컨테이너가 시작되거나 해당 Bean이 처음으로 요청될 때 시작된다.
- 생성자 선택: Spring 컨테이너는 BeanDefinition을 보고 어떤 생성자를 사용해야 할지 결정한다. @Autowired가 붙은 생성자나, 매개변수가 가장 많은 생성자 등을 우선적으로 고려한다.
- 객체 생성: 선택된 생성자를 통해 인스턴스를 생성한다. new UserServiceImpl()과 같이 객체의 '뼈대'가 만들어진다.
- 순환 참조 해결 지점 (Setter/Field 주입): 싱글톤 Bean의 경우, 이 단계에서 생성된 '미완성' 인스턴스가 순환 참조 문제를 해결하기 위해 내부 캐시(3단계 캐시)에 저장된다.
'뼈대'만 있던 인스턴스에 이제 '살'을 붙이는 과정이다. 즉, Bean이 필요로 하는 의존성(다른 Bean)이나 설정 값들을 주입한다.
- 의존성 주입(DI): @Autowired, @Resource 등이 붙은 필드나 수정자(setter) 메서드를 찾아 해당 의존성을 주입한다. 컨테이너는 getBean()을 호출하여 필요한 다른 Bean들을 가져와서 설정해준다.
- 설정 값 주입: @Value 어노테이션을 통해 .properties 파일이나 환경 변수의 값을 주입한다.
3. 초기화(Initialization)
의존성 주입까지 마친 Bean은 이제 사용할 준비를 하는 초기화 단계를 거친다. 이 단계는 개발자가 Bean의 동작을 커스터마이징할 수 있는 여러 확장 지점을 제공하기 때문에 매우 중요하다.
- Aware 인터페이스 처리: Bean이 BeanNameAware, BeanFactoryAware, ApplicationContextAware 등의 Aware 인터페이스를 구현했다면, Spring 컨테이너는 해당 Bean에게 자신의 이름, 자기를 만든 BeanFactory, 그리고 자신을 관리하는 ApplicationContext 등의 내부 컴포넌트에 대한 참조를 주입해준다. 이를 통해 Bean은 컨테이너의 기능에 직접 접근할 수 있게 된다.
- BeanPostProcessor (전-초기화): postProcessBeforeInitialization 메서드가 호출된다. BeanPostProcessor는 모든 Bean의 초기화 전후에 개입하여 추가적인 로직을 적용할 수 있는 강력한 확장 포인트이다. 예를 들어, 여기서 특정 어노테이션이 붙은 Bean을 찾아 프록시(Proxy) 객체로 바꿔치기하는 작업(AOP의 핵심)이 일어난다.
- 초기화 메서드 호출:
- @PostConstruct 어노테이션이 붙은 메서드가 호출된다.
- Bean이 InitializingBean 인터페이스를 구현했다면 afterPropertiesSet() 메서드가 호출된다.
- XML이나 @Bean(initMethod = "...")에 지정된 init-method가 호출된다.
- BeanPostProcessor (후-초기화): postProcessAfterInitialization 메서드가 호출된다. 모든 초기화가 끝난 Bean(또는 프록시로 감싸진 Bean)이 최종적으로 반환되기 전에 마지막으로 처리할 기회를 준다.
모든 초기화 과정이 끝나면, Bean은 드디어 완전히 준비된 상태가 된다.
- 싱글톤 캐시 등록: 완성된 싱글톤 Bean은 컨테이너의 싱글톤 캐시(1단계 캐시)에 저장된다. 이후부터는 이 Bean에 대한 모든 요청은 캐시에서 즉시 반환된다.
- 애플리케이션에서 사용: 이제 애플리케이션의 다른 컴포넌트에서 이 Bean을 주입받아 사용할 수 있다.
4. 소멸 (Destruction)
컨테이너가 종료될 때(예: 애플리케이션 종료) 싱글톤 Bean들은 소멸 단계를 거친다.
- @PreDestroy 어노테이션이 붙은 메서드가 호출된다.
- DisposableBean 인터페이스의 destroy() 메서드가 호출된다.
- 커스텀 destroy-method가 호출된다.
순환참조 해결하기

이미지를 보면 알다시피, 나의 경우 userService 와 kakaoService 가 서로를 참조하여 발생하였다. userService 에서도 kakaoService 를 사용한다고 참조하였고, kakaoService 에서도 userService 를 참조한다고 호출하여 발생하였다.
해결 방법
한 곳의 순환 참조를 해제하여 준다. 나의 경우, controller로 통하는 서비스를 userService로 결정하였고, kakaoService 에서 userService 를 제거하였다.
userService 의 코드를 kakaoService 와 naverService 로 분리 (리팩토링) 하면서 발생하였다.
어떤 서비스를 Service 엔드포인트로 결정할지 정하지 않고, 되는대로 코드를 이관하다보니 발생하였다. 앞으로는 마음이 급해도 침착하게 구조 설계를 먼저 진행해야겠다고 느꼈다.