프로그래밍 PROGRAMMING/자바 JAVA AND FRAMEWORKS

[JAVA/SPRING] Spring AOP 순환 참조: 원인 분석과 해결책

매운할라피뇨 2026. 2. 12. 08:53
반응형
Spring AOP 순환 참조

개요

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는 아직 완성되지 않았음 (순환 참조!) ❌

순환 참조의 정확한 원인:

  1. 생성자 주입 + AOP: Spring이 Bean을 생성하는 도중에 프록시를 만들어야 하는데, 프록시 생성 중 의존성이 필요하면 아직 생성 중인 원본 Bean을 참조하게 됩니다.
  2. Aspect가 자신을 포함한 Pointcut 적용: @Pointcut("execution(* *.*(..))")) 같은 광범위한 Pointcut을 사용하면, Aspect 자신의 메서드도 프록시 대상이 됩니다.
  3. 직접 순환 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를 필요로 함

진단 단계:

  1. Aspect 여부 확인: @Aspect 클래스가 orderService를 의존하는가?
  2. Pointcut 확인: 너무 광범위한 Pointcut이 있는가?
  3. 의존성 체인 확인: A → B → C → A 같은 간접 순환이 있는가?
// 디버깅 팁: 스택 트레이스 전체를 읽으면 정확한 의존성 체인을 파악할 수 있습니다
// "Creating shared instance of singleton bean 'xxx'" 라인들을 추적하면
// 어디서 순환이 시작되는지 알 수 있습니다.

맺음말

순환 참조 문제의 완전한 이해

Spring AOP의 순환 참조는 다음과 같은 원인에서 발생합니다:

  1. Bean 초기화 순서의 복잡성 - Spring은 Bean을 만드는 도중 프록시를 생성해야 하는데, 이 과정이 다른 Bean의 의존성을 필요로 할 때 충돌합니다.
  2. Aspect의 광범위한 Pointcut - execution(* *.*(..)) 같은 패턴은 Aspect 자신도 포함할 수 있어 무한 재귀나 순환 참조를 유발합니다.
  3. 생성자 주입과 AOP의 결합 - AOP 프록시가 생성되기 전에 생성자 주입이 완료되어야 하는데, 이 순서가 뒤바뀌면 문제가 발생합니다.

상황별 최적의 해결책

  • Aspect 자신이 다른 Service를 의존: 필드 주입 또는 ObjectProvider 사용
  • 생성자 주입으로 인한 순환: 필드 주입으로 변경
  • 광범위한 Pointcut: !within() 또는 @annotation() 조건으로 제한
  • 복잡한 의존성 구조: ApplicationContext 또는 Bean Factory 패턴 사용
  • 명확한 구조 필요: Aspect를 별도 Bean으로 분리

예방 원칙

순환 참조를 사전에 방지하는 3가지 원칙:

  1. Aspect는 최소한의 의존성만 - 로깅, 모니터링처럼 독립적으로 동작 가능한 기능만 구현
  2. Pointcut은 정확하게 - 필요한 범위만 명시, 자신을 포함하지 않도록 !within() 사용
  3. 생성자 주입 신중히 - AOP 대상이 되는 Service는 생성자 주입을 최소화

이 3가지 원칙을 따르면 대부분의 순환 참조 문제를 사전에 방지할 수 있습니다.

반응형