티스토리 뷰
Spring 기반 애플리케이션을 개발하다 보면 DI(Dependency Injection)는 거의 모든 코드에서 자연스럽게 사용하게 되는 기본 요소가 됩니다. 그러나 프로젝트 규모가 커지고 설계 복잡도가 증가할수록, 단순히 DI를 사용하는 수준을 넘어 컨테이너 내부에서 어떤 방식으로 의존성이 해결되는지 이해할 필요가 생깁니다. 특히 순환참조 문제가 발생하는 시점에서는 이러한 내부 동작 이해가 실질적인 설계 판단으로 이어지게 됩니다.
이번 글에서는 Spring DI의 기본 동작 흐름을 다시 정리한 뒤, 생성자 주입과 필드 주입 환경에서 순환참조가 어떤 차이를 보이며, Spring 컨테이너가 이를 어떤 방식으로 처리하는지 공식 문서 기반으로 정리해 보고자 합니다. 특정 방식을 단정적으로 권장하기보다는, 내부 동작을 이해하고 상황에 맞게 판단할 수 있도록 정리하는 데 목적을 둡니다.
DI와 IoC를 함께 이해할 필요가 있는 이유
Spring DI를 제대로 이해하기 위해서는 먼저 IoC(Inversion of Control) 개념을 함께 떠올릴 필요가 있습니다. 전통적인 객체지향 구조에서는 객체가 필요한 의존 객체를 직접 생성하거나 외부에서 전달받는 구조를 사용했습니다. 하지만 IoC 컨테이너를 사용하는 환경에서는 객체 생성과 생명주기 관리의 책임이 컨테이너로 넘어가게 됩니다.
즉, 애플리케이션 코드가 객체 생성과 의존성 연결을 직접 관리하는 대신, 컨테이너가 이를 담당하게 됩니다. DI는 이러한 IoC 구조를 실제 코드에서 구현하는 방식으로, 객체가 필요한 의존성을 직접 생성하지 않고 외부에서 주입받도록 만드는 구조를 의미합니다.
Spring 컨테이너는 애플리케이션 시작 시점에 Bean 정의를 수집하고, 필요한 객체를 생성하고 연결하는 과정을 자동으로 수행합니다. 이 과정에서 순환참조가 발생할 수 있는 구조가 만들어지기도 합니다.
Spring 컨테이너의 Bean 생성 과정 이해하기
Spring DI가 어떻게 동작하는지를 이해하려면 Bean 생성 과정을 살펴보는 것이 도움이 됩니다.
Spring 컨테이너는 먼저 설정 정보나 컴포넌트 스캔을 통해 Bean 정의를 수집합니다. 이후 Bean 생성이 시작되면 다음과 같은 단계가 순차적으로 진행됩니다.
우선 Bean 인스턴스가 생성됩니다. 이후 의존성이 주입되고, 초기화 콜백과 BeanPostProcessor 단계가 수행됩니다. 필요하다면 AOP 프록시가 생성되기도 합니다.
여기서 중요한 부분은 의존성 주입 시점입니다. 생성자 주입과 필드 주입은 이 시점이 서로 다르며, 이 차이가 순환참조 발생 방식에도 영향을 줍니다.
순환참조는 왜 발생할까
순환참조는 두 개 이상의 Bean이 서로를 의존하는 구조에서 발생합니다. 예를 들어 A Bean이 B Bean을 필요로 하고, 동시에 B Bean 역시 A Bean을 필요로 하는 상황이 대표적인 사례입니다.
컨테이너가 A Bean을 생성하려 할 때 B Bean이 필요하고, 다시 B Bean을 생성하려 하면 A Bean이 필요해지는 상황이 만들어집니다. 이 과정에서 어느 Bean도 완전히 생성되지 못하고 서로를 기다리는 구조가 형성됩니다.
이러한 문제는 설계 단계에서 자연스럽게 발생하기도 하며, 서비스 간 책임 분리가 충분하지 않은 경우 자주 나타납니다.
생성자 주입에서 순환참조가 실패하는 이유
생성자 주입 방식에서는 Bean 객체를 생성할 때 필요한 의존성이 모두 준비되어야 합니다. 컨테이너는 생성자 파라미터를 분석하고 필요한 Bean을 먼저 생성하려고 시도합니다.
문제는 A Bean 생성 시 B Bean이 필요하고, 동시에 B Bean 생성 시 A Bean이 필요한 경우입니다. 이 상황에서는 어느 쪽도 먼저 생성될 수 없기 때문에 즉시 순환참조 오류가 발생합니다.
즉, 생성자 주입에서는 Bean이 완전히 생성되기 전에 의존성이 모두 해결되어야 하므로 순환 구조가 허용되지 않습니다.
이 특성은 한편으로 설계 문제가 즉시 드러나는 장점으로도 볼 수 있습니다. 구조적으로 잘못된 의존 관계가 컨테이너 초기화 단계에서 바로 발견되기 때문입니다.
필드 주입과 세터 주입에서 순환참조가 가능했던 이유
필드 주입이나 세터 주입 방식은 객체가 먼저 생성된 이후 의존성이 주입됩니다. 컨테이너는 우선 기본 생성자 또는 선택된 생성자를 이용해 객체 인스턴스를 만들고, 이후 Reflection 기반으로 필드에 의존성을 주입합니다.
이 과정에서 Spring은 완전히 초기화되지 않은 Bean을 임시로 노출시키는 메커니즘을 사용합니다. 이를 Early Singleton Exposure 또는 조기 노출이라고 부릅니다.
즉, A Bean이 생성되는 도중 아직 초기화가 끝나지 않았지만 임시 참조를 B Bean 생성 과정에 제공하고, 이후 초기화를 마무리하는 방식으로 순환참조를 우회 처리합니다.
이 방식 덕분에 일부 순환참조 구조가 동작할 수 있었지만, 완전히 초기화되지 않은 객체가 외부에 노출된다는 점에서 예측하기 어려운 동작을 만들 수 있습니다.
최근 Spring에서 순환참조를 제한하는 이유
Spring Boot 2.6 이후 기본 설정에서는 순환참조가 허용되지 않도록 변경되었습니다. 이는 단순한 설정 변경이 아니라 설계 안정성을 높이기 위한 방향으로 이해할 수 있습니다.
순환참조는 종종 구조적인 설계 문제를 감추고 있는 경우가 많으며, 유지보수 과정에서 코드 복잡도를 증가시키는 원인이 됩니다. 초기에는 문제가 없어 보이지만 기능이 확장되면서 예상하지 못한 사이드 이펙트가 발생하는 사례도 존재합니다.
따라서 최근 흐름은 순환참조를 자동으로 해결하기보다는 설계 단계에서 문제를 드러내고 개선하도록 유도하는 방향으로 변화하고 있다고 볼 수 있습니다.
실무에서 자주 발생하는 순환참조 사례
실무 프로젝트에서는 서비스 계층 간 의존성이 복잡해지면서 순환참조가 자주 발생합니다. 예를 들어 주문 서비스가 결제 서비스를 호출하고, 동시에 결제 완료 후 주문 상태를 갱신하기 위해 주문 서비스를 다시 호출하는 구조가 만들어지는 경우가 있습니다.
초기 구현 단계에서는 빠르게 기능을 구현하기 위해 이러한 구조가 자연스럽게 만들어지지만, 시간이 지나면서 의존성이 서로 얽히게 됩니다.
이런 상황에서는 단순히 DI 설정을 수정하는 것보다 책임 분리를 다시 고민하는 것이 장기적으로 도움이 되는 경우가 많습니다.
순환참조를 해결하는 현실적인 접근 방법
순환참조를 해결하는 방법에는 여러 접근 방식이 있습니다. @Lazy를 사용하여 실제 Bean 생성 시점을 늦추거나, ObjectProvider를 사용하여 필요 시점에 의존성을 조회하는 방식도 가능합니다.
그러나 장기적인 관점에서는 서비스 역할을 분리하거나 중간 계층을 도입하여 의존 관계를 단방향으로 재구성하는 방식이 더 안정적인 해결책이 되는 경우가 많습니다.
의존성이 서로를 직접 참조하는 대신 이벤트 기반 구조나 도메인 계층 분리를 통해 흐름을 재구성하면 순환 구조를 자연스럽게 제거할 수 있습니다.
테스트 코드와 유지보수 관점에서의 영향
순환참조가 존재하는 구조는 테스트 코드 작성에도 영향을 줍니다. 특정 서비스를 테스트하려 할 때 예상보다 많은 의존성을 함께 구성해야 하는 상황이 발생할 수 있습니다.
또한 구조가 복잡해질수록 새로운 기능을 추가하거나 기존 로직을 수정할 때 영향을 받는 범위를 파악하기 어려워집니다.
결국 순환참조 문제는 단순히 컨테이너 설정 문제가 아니라 유지보수성과 구조 안정성에 영향을 주는 요소라고 이해하는 것이 현실적인 접근이라고 생각합니다.
마무리하며
Spring DI를 사용하면서 순환참조 문제를 처음 접했을 때는 단순히 설정 문제로만 생각했던 기억이 있습니다. 그러나 여러 프로젝트를 경험하면서 순환참조는 설계 구조를 다시 돌아보게 만드는 신호에 가깝다는 생각을 하게 되었습니다.
생성자 주입과 필드 주입의 차이를 이해하고, 컨테이너 내부 동작을 조금 더 깊이 들여다보게 되면서, 결국 중요한 것은 특정 방식의 사용 여부가 아니라 의존 관계를 어떻게 설계할 것인지에 대한 고민이라는 점을 느끼게 됩니다.
앞으로도 다양한 구조를 경험하게 되겠지만, 내부 동작 원리를 이해하고 설계 선택의 이유를 설명할 수 있는 개발자가 되는 것이 더 중요하지 않을까 개인적으로 정리해 보게 됩니다.
'STUDY' 카테고리의 다른 글
| Java 멀티 스레딩 성능 최적화 전략: ExecutorService 설계와 Holder Idiom 싱글톤 활용 (0) | 2026.02.03 |
|---|---|
| Java Thread는 어떻게 관리되는가: 플랫폼 스레드와 Executor 프레임워크의 역할 (0) | 2026.02.03 |
| 동시성과 병렬성의 차이와 가상 스레드 시대의 JVM 동시성 제어 전략: Spring Batch Partitioning 동시성 제어 전략까지 (1) | 2026.01.23 |
| Holder Idiom vs Enum: 실무에서 선택하는 Java 싱글톤 구현 (0) | 2026.01.02 |
| Java 동시성 문제 해결: 스레드 관리부터 InterruptedException 처리까지 (0) | 2026.01.02 |
- Total
- Today
- Yesterday
- 캐시와 인덱스
- 스레드 생명주기
- TTL 설계
- Redis vs DB
- Cache Aside
- 백엔드 성능 설계
- Redis 성능 개선
- InterruptedException
- 트랜잭션 관리
- DB 인덱스 성능
- 백엔드 아키텍처
- Java Performance
- Cache Avalanche
- Redis 캐시 전략
- 트래픽 처리
- Spring Batch
- Hot Key 문제
- 동시성처리
- Initialization-on-Demand Holder Idiom
- Double-Checked Locking
- 캐시 장애
- mybatis
- 백엔드 성능 튜닝
- Eager Initialization
- DB 트랜잭션
- spring batch 5
- Cache Penetration
- 백엔드 성능
- 캐시 성능 비교
- Enum 기반 싱글톤
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
| 29 | 30 | 31 |

