
개요
Spring AOP와 순환 참조는 개발자가 마주치는 가장 복잡한 문제 중 하나입니다. BeanCurrentlyInCreationException, No qualifying bean of type, 무한 루프 같은 오류는 원인을 알 수 없으면 디버깅하기 매우 어렵습니다. 많은 개발자는 순환 참조의 근본 원인을 모른 채, 어노테이션을 제거하거나 클래스를 분리하는 식의 임시 방편으로 해결합니다.
순환 참조가 발생하는 핵심 이유:
- Aspect Bean이 자신을 프록시화하여 자기 자신을 주입받음
- 생성자 의존성 주입으로 인한 원형 참조
- 여러 Aspect가 서로 다른 Bean을 의존하면서 발생하는 간접 순환 참조
- AOP 프록시 생성 시점과 Bean 초기화 시점의 불일치
이 글에서는 순환 참조의 근본 원인을 정확히 이해하고, 7가지 상황별 해결책을 실제 코드로 살펴봅니다. 각 해결책의 장단점을 파악하면, 프로젝트의 아키텍처에 맞는 최적의 방법을 선택할 수 있습니다.
1. Spring AOP와 순환 참조의 근본 원인
왜 Aspect 때문에 순환 참조가 발생하는가?
순환 참조(Circular Reference)는 Bean A가 Bean B를 의존하고, Bean B가 Bean A를 의존하는 상황입니다. Spring은 이 상황을 감지하면 BeanCurrentlyInCreationException을 던집니다.
AOP 없을 때:
OrderService 초기화
↓
OrderRepository 의존성 주입
↓
초기화 완료AOP 있을 때:
OrderService 초기화
↓
프록시 생성 필요 (Aspect 감지)
↓
LoggingAspect 초기화
↓
LoggingAspect가 OrderService를 @Autowired로 주입 요청
↓
OrderService는 아직 완성되지 않았음 (순환 참조!) ❌순환 참조의 정확한 원인:
- 생성자 주입 + AOP: Spring이 Bean을 생성하는 도중에 프록시를 만들어야 하는데, 프록시 생성 중 의존성이 필요하면 아직 생성 중인 원본 Bean을 참조하게 됩니다.
- Aspect가 자신을 포함한 Pointcut 적용:
@Pointcut("execution(* *.*(..))"))같은 광범위한 Pointcut을 사용하면, Aspect 자신의 메서드도 프록시 대상이 됩니다. - 직접 순환 vs 간접 순환: A → B → C → A 같은 간접 순환 참조는 더 발견하기 어렵습니다.
프록시 생성과 Bean 초기화의 타이밍 문제
Spring은 생성자를 통한 의존성 주입 후 프록시를 생성합니다. 이 순서가 역순이 되면 순환 참조가 발생합니다.
정상 경로: 생성자 호출 → 필드 주입 → @PostConstruct → 프록시 생성 → 다른 Bean에 주입
문제 상황: 생성자 호출 중 → 다른 Bean 의존성 필요 → 그 Bean도 생성 시작 → 원본 Bean이 아직 미완성2. 순환 참조 발생 패턴: 5가지 실제 사례
패턴 1: Aspect가 자신이 프록시화하는 Service를 의존
변경전: 순환 참조 발생
@Aspect
@Component
public class ProblematicLoggingAspect {
@Autowired
private OrderService orderService; // ❌ 자신이 프록시화할 Bean을 의존!
@Around("execution(* com.example.service..*(..))")
public Object logging(ProceedingJoinPoint joinPoint) throws Throwable {
orderService.someMethod(); // OrderService 호출 시도
return joinPoint.proceed();
}
}
@Service
public class OrderService {
public void createOrder(Order order) {
// 비즈니스 로직
}
}
// 실행 결과:
// BeanCurrentlyInCreationException: Error creating bean with name 'orderService'
// Requested bean is currently in creation: Is there an unresolved circular reference?변경후: Aspect에서 직접 로깅 처리
@Aspect
@Component
public class FixedLoggingAspect {
// ✅ OrderService를 주입받지 않음!
private static final Logger logger = LoggerFactory.getLogger(FixedLoggingAspect.class);
@Around("execution(* com.example.service..*(..))")
public Object logging(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().getName();
logger.info("메서드 호출: {}", methodName); // 직접 로깅
long start = System.nanoTime();
Object result = joinPoint.proceed();
long elapsed = (System.nanoTime() - start) / 1_000_000;
logger.info("메서드 완료: {} ({}ms)", methodName, elapsed);
return result;
}
}Output:
✅ Aspect 초기화 성공
✅ OrderService 초기화 성공
✅ 메서드 호출: createOrder
✅ 메서드 완료: createOrder (45ms)주의: Aspect 내에서 joinPoint.getTarget()으로 원본 Bean을 얻으려고 하지 마세요. 이것도 순환 참조를 유발할 수 있습니다.
패턴 2: 생성자 주입과 AOP의 조합
변경전: 생성자 주입 + Aspect 적용
@Service
public class UserService {
private final UserRepository userRepository;
private final NotificationService notificationService;
// ❌ 생성자 주입 + AOP = 프록시 생성 중 의존성 충돌
@Autowired
public UserService(UserRepository userRepository, NotificationService notificationService) {
this.userRepository = userRepository;
this.notificationService = notificationService;
}
@Transactional
public void updateUser(User user) {
userRepository.save(user);
notificationService.notifyUserUpdate(user);
}
}
@Aspect
@Component
public class TransactionAspect {
@Around("@annotation(org.springframework.transaction.annotation.Transactional)")
public Object manageTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
// AOP 프록시 생성 중 UserService 의존성 해결 필요
return joinPoint.proceed();
}
}변경후: 필드 주입 또는 지연 주입
@Service
public class UserService {
private final UserRepository userRepository;
@Autowired
private NotificationService notificationService; // ✅ 필드 주입 (생성자 주입 제거)
@Autowired
public UserService(UserRepository userRepository) { // ✅ 필수 의존성만 생성자에
this.userRepository = userRepository;
}
@Transactional
public void updateUser(User user) {
userRepository.save(user);
notificationService.notifyUserUpdate(user);
}
}또는 ObjectProvider를 사용한 지연 주입:
@Service
public class UserService {
private final UserRepository userRepository;
private final ObjectProvider<NotificationService> notificationProvider;
@Autowired
public UserService(UserRepository userRepository,
ObjectProvider<NotificationService> notificationProvider) {
this.userRepository = userRepository;
this.notificationProvider = notificationProvider;
}
public void updateUser(User user) {
userRepository.save(user);
notificationProvider.getIfAvailable().notifyUserUpdate(user); // 지연 주입
}
}Output:
✅ UserService 초기화 (생성자 주입 완료)
✅ NotificationService 초기화 (필드 주입으로 지연)
✅ Aspect 프록시 생성 (더 이상 충돌 없음)패턴 3: Aspect 자체가 광범위한 Pointcut으로 자신을 포함
변경전: Aspect가 자신을 프록시화
@Aspect
@Component
public class MonitoringAspect {
// ❌ 너무 광범위: 모든 메서드! (@Aspect 메서드도 포함)
@Around("execution(* *.*(..))")
public Object monitor(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();
Object result = joinPoint.proceed();
long elapsed = (System.nanoTime() - start) / 1_000_000;
if (elapsed > 100) {
System.out.println("SLOW: " + joinPoint.getSignature().getName());
}
return result;
}
}
// 문제: MonitoringAspect 자신의 monitor() 메서드도 대상이 되어 무한 재귀 발생!
// 또는 프록시가 자신을 프록시하려고 하면서 순환 참조 발생변경후: Pointcut 범위 제한
@Aspect
@Component
public class MonitoringAspect {
// ✅ 특정 패키지만: Service 패키지 제외 Aspect 자신
@Around("execution(* com.example.service..*(..)) && !within(com.example.aop.*)")
public Object monitor(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.nanoTime();
Object result = joinPoint.proceed();
long elapsed = (System.nanoTime() - start) / 1_000_000;
if (elapsed > 100) {
System.out.println("SLOW: " + joinPoint.getSignature().getName());
}
return result;
}
}Output:
✅ Aspect 초기화 완료
✅ Service 메서드만 모니터링
✅ Aspect 메서드는 AOP 대상 제외3. 순환 참조 해결책: 7가지 전략
1️⃣ 필드 주입으로 변경
권장 상황: 선택적 의존성이 있을 때
@Aspect
@Component
public class LoggingAspect {
private static final Logger logger = LoggerFactory.getLogger(LoggingAspect.class);
@Autowired(required = false) // ✅ 필드 주입 (선택적)
private OrderRepository orderRepository;
@Around("execution(* com.example.service..*(..))")
public Object logging(ProceedingJoinPoint joinPoint) throws Throwable {
if (orderRepository != null) {
// orderRepository 사용
}
return joinPoint.proceed();
}
}2️⃣ ObjectProvider를 사용한 지연 주입
권장 상황: Bean이 나중에 필요할 때, 순환 참조 해결 필요할 때
@Aspect
@Component
public class MonitoringAspect {
private final ObjectProvider<MetricsService> metricsProvider;
@Autowired
public MonitoringAspect(ObjectProvider<MetricsService> metricsProvider) {
this.metricsProvider = metricsProvider; // ✅ 지연 주입
}
@Around("execution(* com.example.service..*(..))")
public Object monitor(ProceedingJoinPoint joinPoint) throws Throwable {
MetricsService metrics = metricsProvider.getIfAvailable(); // 필요할 때만 주입
if (metrics != null) {
metrics.recordMethodCall(joinPoint.getSignature().getName());
}
return joinPoint.proceed();
}
}3️⃣ ApplicationContext를 주입받아 명시적으로 Bean 얻기
권장 상황: 여러 Bean을 동적으로 선택해야 할 때
@Aspect
@Component
public class DynamicAspect {
private final ApplicationContext applicationContext;
@Autowired
public DynamicAspect(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Around("execution(* com.example.service..*(..))")
public Object executeWithDynamicBean(ProceedingJoinPoint joinPoint) throws Throwable {
// ✅ 명시적으로 Bean 조회 (프록시 안 됨)
OrderService orderService = applicationContext.getBean(OrderService.class);
// 실행
Object result = joinPoint.proceed();
return result;
}
}4️⃣ 생성자 주입 대신 필드 주입 사용
권장 상황: AOP 적용 대상 Service에서 생성자 주입 제거 필요할 때
// ❌ 문제가 있는 버전
@Service
public class OrderService {
@Autowired
public OrderService(OrderRepository repo, NotificationService notification) {
// 순환 참조 위험
}
}
// ✅ 개선된 버전
@Service
public class OrderService {
@Autowired
private OrderRepository repo; // 필드 주입
@Autowired
private NotificationService notification; // 필드 주입
// 생성자는 비워둠 (Spring이 자동으로 기본 생성자 호출)
}5️⃣ Pointcut 범위를 정확하게 지정
권장 상황: Aspect가 너무 광범위하게 적용될 때
// ❌ 문제: 모든 메서드 프록시
@Around("execution(* *.*(..))")
// ✅ 개선: 특정 패키지, Aspect는 제외
@Around("execution(* com.example.service..*(..)) && " +
"!within(com.example.aop.*) && " +
"!@annotation(NoMonitor)")
public Object monitorService(ProceedingJoinPoint joinPoint) throws Throwable {
// ...
}6️⃣ @Lazy 어노테이션으로 지연 초기화
권장 상황: Bean의 초기화 시점을 늦춰야 할 때
@Aspect
@Component
@Lazy // ✅ Spring이 Aspect 초기화를 지연
public class LazyAspect {
@Autowired
private OrderService orderService;
@Around("execution(* com.example.service..*(..))")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
// 처음 사용될 때 초기화됨
return joinPoint.proceed();
}
}
// 또는 의존성 Bean에 @Lazy 적용
@Service
public class OrderService {
@Autowired
@Lazy
private NotificationService notification; // 필요할 때만 초기화
}7️⃣ Aspect를 별도의 Bean으로 분리
권장 상황: 복잡한 의존성이 있을 때, 명확한 구조 필요할 때
// ✅ Service 계층과 Aspect 계층 분리
@Component
public class LoggingAspectFactory {
private final OrderRepository orderRepository;
private final NotificationService notificationService;
@Autowired
public LoggingAspectFactory(OrderRepository orderRepository,
NotificationService notificationService) {
this.orderRepository = orderRepository;
this.notificationService = notificationService;
}
// Aspect를 별도 Bean으로 제공
@Bean
public LoggingAspect loggingAspect() {
return new LoggingAspect(orderRepository, notificationService);
}
}
@Aspect
public class LoggingAspect {
private final OrderRepository orderRepository;
private final NotificationService notificationService;
// Component 어노테이션 없음 (Factory에서 관리)
public LoggingAspect(OrderRepository orderRepository,
NotificationService notificationService) {
this.orderRepository = orderRepository;
this.notificationService = notificationService;
}
@Around("execution(* com.example.service..*(..))")
public Object logging(ProceedingJoinPoint joinPoint) throws Throwable {
// 안전하게 의존성 사용
return joinPoint.proceed();
}
}4. 순환 참조 디버깅 전략
순환 참조 에러 읽는 방법
Error creating bean with name 'orderService':
Requested bean is currently in creation:
Is there an unresolved circular reference?이 메시지가 의미하는 것:
orderService초기화 중- 다른 Bean이
orderService를 의존하고 있음 - 그 다른 Bean도
orderService를 필요로 함
진단 단계:
- Aspect 여부 확인:
@Aspect클래스가orderService를 의존하는가? - Pointcut 확인: 너무 광범위한 Pointcut이 있는가?
- 의존성 체인 확인: A → B → C → A 같은 간접 순환이 있는가?
// 디버깅 팁: 스택 트레이스 전체를 읽으면 정확한 의존성 체인을 파악할 수 있습니다
// "Creating shared instance of singleton bean 'xxx'" 라인들을 추적하면
// 어디서 순환이 시작되는지 알 수 있습니다.맺음말
순환 참조 문제의 완전한 이해
Spring AOP의 순환 참조는 다음과 같은 원인에서 발생합니다:
- Bean 초기화 순서의 복잡성 - Spring은 Bean을 만드는 도중 프록시를 생성해야 하는데, 이 과정이 다른 Bean의 의존성을 필요로 할 때 충돌합니다.
- Aspect의 광범위한 Pointcut -
execution(* *.*(..))같은 패턴은 Aspect 자신도 포함할 수 있어 무한 재귀나 순환 참조를 유발합니다. - 생성자 주입과 AOP의 결합 - AOP 프록시가 생성되기 전에 생성자 주입이 완료되어야 하는데, 이 순서가 뒤바뀌면 문제가 발생합니다.
상황별 최적의 해결책
- Aspect 자신이 다른 Service를 의존: 필드 주입 또는
ObjectProvider사용 - 생성자 주입으로 인한 순환: 필드 주입으로 변경
- 광범위한 Pointcut:
!within()또는@annotation()조건으로 제한 - 복잡한 의존성 구조: ApplicationContext 또는 Bean Factory 패턴 사용
- 명확한 구조 필요: Aspect를 별도 Bean으로 분리
예방 원칙
순환 참조를 사전에 방지하는 3가지 원칙:
- Aspect는 최소한의 의존성만 - 로깅, 모니터링처럼 독립적으로 동작 가능한 기능만 구현
- Pointcut은 정확하게 - 필요한 범위만 명시, 자신을 포함하지 않도록
!within()사용 - 생성자 주입 신중히 - AOP 대상이 되는 Service는 생성자 주입을 최소화
이 3가지 원칙을 따르면 대부분의 순환 참조 문제를 사전에 방지할 수 있습니다.
'프로그래밍 PROGRAMMING > 자바 JAVA AND FRAMEWORKS' 카테고리의 다른 글
| [JAVA] 텍스트 처리 파이프라인 설계: 전처리와 토큰화 기준 (0) | 2026.02.14 |
|---|---|
| [JAVA] Java 생성자 사용법: 올바른 설계와 실전 활용법 (0) | 2026.02.13 |
| [JAVA/SPRING] Java AOP Aspect 심화: 동적 프록시와 Pointcut 최적화 (0) | 2026.02.11 |
| [JAVA/SPRING] Spring AOP 이해하기: @Aspect로 횡단 관심사 처리하기 (0) | 2026.02.10 |
| [JAVA] Caffeine 캐시 성능 최적화: 히트율 90% 달성하기 (0) | 2026.02.09 |