배포 직전, 애플리케이션이 안 떴습니다. 콘솔에 빨간 글씨가 화면을 가득 채웠고, 맨 아래 한 줄이 눈에 들어왔습니다. BeanCurrentlyInCreationException. 스프링 순환참조였습니다.
원인은 허무할 만큼 단순했습니다. 주문 서비스가 쿠폰 서비스를 부르고, 쿠폰 서비스가 다시 주문 서비스를 참조하고 있었거든요. 둘이 서로를 만들어 달라고 기다리다 부팅 자체가 멈춘 겁니다.
그날은 일단 @Lazy 한 줄로 막고 퇴근했습니다. 솔직히 찜찜했습니다. 에러만 가렸지, 두 서비스가 서로 멱살을 잡고 있는 구조는 그대로였으니까요. 이 글은 그 멱살을 어떻게 떼어냈는지에 대한 기록입니다. 결론부터 말하면, 답은 이벤트였습니다.
그래서, 순환참조가 뭐길래
순환참조는 빈(Bean) 두 개가 서로를 의존해서 의존성 그래프에 고리가 생긴 상태입니다. A가 B를 필요로 하는데 B도 A를 필요로 하면, 스프링은 닭이 먼저냐 달걀이 먼저냐 하는 상황에 빠집니다. 누굴 먼저 만들어야 할지 정하질 못하는 거죠.
생성자 주입에서 특히 잘 터집니다. 스프링은 빈을 만들 때 생성자가 요구하는 빈부터 완성하려 하는데, 그 빈이 또 지금 만들고 있는 빈을 달라고 하면 그대로 교착에 빠집니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final CouponService couponService; // 쿠폰 서비스가 필요
}
@Service
@RequiredArgsConstructor
public class CouponService {
private final OrderService orderService; // 다시 주문 서비스가 필요 → 순환
}
여기서 한 가지 짚고 넘어가야 할 게 있습니다. 이건 컴파일 에러가 아니라 설계가 보내는 경고입니다. 두 클래스가 서로를 직접 호출한다는 건 책임 경계가 흐릿하다는 뜻이거든요. “주문이 끝나면 쿠폰을 적립한다”는 요구사항을, 주문 서비스가 쿠폰 서비스를 직접 부르는 방식으로 풀었더니 양방향 의존이 생겨버린 겁니다.
검색하면 나오는 우회법 세 가지, 그리고 그 함정
순환참조를 만나면 보통 검색 한 번에 세 가지 해법을 찾습니다. @Lazy, 수정자(Setter) 주입, 그리고 @PostConstruct로 직접 연결하기.
문제는 셋 다 부팅 에러만 없애준다는 점입니다. 구조는 못 고칩니다.
| 우회법 | 하는 일 | 남는 문제 |
|---|---|---|
@Lazy | 실제 호출 때까지 빈 생성을 미룸 | 고리는 그대로, 런타임에 숨을 뿐 |
| 수정자 주입 | 객체 만든 뒤 의존성 주입 | 불변성 깨지고 결합은 여전 |
@PostConstruct | 초기화 때 서로 수동 연결 | 코드만 복잡, 결합도는 최고치 |
세 방법 모두 “서로를 직접 안다”는 사실 자체엔 손을 못 댑니다. 그래서 시간이 지나면 이자까지 붙어 돌아옵니다. 한쪽을 고칠 때마다 다른 쪽을 확인해야 하고, 테스트할 때도 두 서비스를 항상 세트로 끌고 와야 합니다. 단위 테스트가 통합 테스트처럼 무거워지는 거죠.
참고로 스프링 부트는 2.6부터 순환참조를 기본 금지로 바꿨습니다. spring.main.allow-circular-references=true로 풀 수는 있지만, 프레임워크가 일부러 막아둔 문을 다시 여는 셈입니다. 굳이 그럴 이유를 저는 못 찾았습니다.
발상의 전환: 부르지 말고, 알리기
핵심은 의존성의 방향을 한쪽으로만 흐르게 만드는 것입니다. 그걸 가장 깔끔하게 해주는 게 이벤트 기반 설계고요.
생각보다 단순합니다. 주문 서비스가 쿠폰 서비스를 “직접 부르는” 대신, 그냥 “주문이 완료됐다”는 사실만 외칩니다. 그 사실에 관심 있는 쿠폰 서비스가 알아서 듣고 반응하는 거죠. 주문 서비스는 누가 자기 말을 듣는지 알 필요가 없습니다. 알아서도 안 되고요.
이렇게 되면 의존 관계가 주문 → 이벤트 → 쿠폰 한 방향으로만 흐릅니다. 주문 서비스 코드에서 쿠폰 서비스를 import하는 줄이 사라지니까, 고리 자체가 성립하지 않습니다. 호출하는 쪽이 받는 쪽의 구체 구현을 모르게 만드는 것 — “인터페이스와 상속 설계 이야기 — 결합을 끊는 설계”에서 다룬 원칙과 정확히 같은 이야기입니다.
직접 해보기: ApplicationEventPublisher와 @EventListener
좋은 소식은 따로 라이브러리가 필요 없다는 겁니다. 스프링이 이벤트 발행과 구독 도구를 기본으로 들고 있거든요.
먼저 “무슨 일이 일어났는지”를 담을 객체를 하나 만듭니다. 불변이면 좋으니 record가 제격입니다.
public record OrderCompletedEvent(Long orderId, Long memberId, int amount) { }
발행하는 쪽. 주문 서비스는 이제 쿠폰 서비스를 주입받지 않습니다. 사실만 던집니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
private final OrderRepository orderRepository;
public void completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.complete();
// 쿠폰 서비스를 부르지 않는다. '완료됐다'고 알릴 뿐.
eventPublisher.publishEvent(
new OrderCompletedEvent(order.getId(), order.getMemberId(), order.getAmount())
);
}
}
받는 쪽. 쿠폰 서비스가 귀를 엽니다.
@Service
@RequiredArgsConstructor
public class CouponService {
@EventListener
public void handleOrderCompleted(OrderCompletedEvent event) {
issueRewardCoupon(event.memberId(), event.amount()); // 금액 따라 쿠폰 적립
}
}
이걸로 BeanCurrentlyInCreationException은 깨끗이 사라졌습니다. 그런데 정작 더 크게 와닿은 건 따로 있었습니다. 테스트였어요. 주문 서비스를 검증할 때 더는 쿠폰 서비스를 끌어올 필요가 없어졌고, “이벤트가 제대로 발행됐는가”만 보면 끝이었습니다. 그제야 단위 테스트가 단위 테스트다워졌습니다.
여기서 한 번 데입니다: 트랜잭션과 이벤트
이벤트로 바꿨다고 끝이 아니었습니다. 트랜잭션 경계에서 한 번 호되게 당했거든요.
기본 @EventListener는 발행하는 그 순간, 같은 트랜잭션 안에서 동기로 실행됩니다. 무슨 뜻이냐면, 쿠폰 적립 도중에 예외가 터지면 멀쩡히 끝난 주문까지 같이 롤백된다는 겁니다. 쿠폰 로직 버그 하나에 주문이 통째로 날아가는 거죠.
“주문은 무조건 성공시키고, 쿠폰은 부가 작업으로 처리하고 싶다”면 도구를 바꿔야 합니다. @TransactionalEventListener로 주문 커밋이 끝난 뒤에 처리하도록 분리하면 됩니다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCompleted(OrderCompletedEvent event) {
issueRewardCoupon(event.memberId(), event.amount());
}
AFTER_COMMIT은 주문이 확정된 다음에만 쿠폰을 적립합니다. 롤백된 주문에 쿠폰이 잘못 나가는 사고를 원천 차단하는 거죠. 단, 커밋 이후 단계에서 다시 DB를 건드리려면 별도 트랜잭션이 필요하다는 점은 꼭 기억해 두세요. 트랜잭션 전파 이야기는 길어지니 “@Transactional 자주 틀리는 5가지 함정 — 트랜잭션 경계 정리“로 넘기겠습니다.
동기로 둘까, 비동기로 뺄까
이벤트 리스너는 기본적으로 발행한 스레드에서 동기로 돕니다. 쿠폰 적립이 느리면 주문 응답도 그만큼 느려진다는 뜻이죠. 후속 작업이 무겁다면 @Async로 빼낼 수 있습니다.
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCompleted(OrderCompletedEvent event) {
sendPushNotification(event.memberId());
}
다만 비동기는 공짜가 아닙니다. 둘의 성격이 꽤 다르거든요.
| 동기 이벤트 | 비동기(@Async) | |
|---|---|---|
| 실행 스레드 | 발행 스레드 그대로 | 별도 스레드 풀 |
| 응답 속도 | 후속 작업만큼 느려짐 | 거의 영향 없음 |
| 예외 | 호출 측까지 전파 | 호출 측과 단절 |
| 어울리는 일 | 정합성 중요한 핵심 후속 | 알림·로그 같은 부가 |
저는 이렇게 가릅니다. 실패하면 큰일 나는 작업은 동기로 두고 즉시 감지합니다. 푸시 알림이나 로그처럼 실패해도 본류에 지장 없는 일만 비동기로 뺍니다. 그리고 비동기로 뺄 땐 반드시 리스너 안에서 예외를 잡거나 로그를 남깁니다. 안 그러면 예외가 소리 없이 증발해서, 나중에 “쿠폰이 왜 안 들어왔지?” 하며 한참을 헤매게 되니까요. (경험담입니다.)
이벤트 설계, 막상 해보면 걸리는 것들
마지막으로, 전환하면서 제가 직접 걸려 넘어졌던 지점 몇 가지를 적어둡니다.
이벤트 이름은 과거형으로 짓는 게 좋습니다. OrderCompletedEvent처럼 “이미 끝난 일”을 담아야 합니다. CreateCouponCommand처럼 명령을 담으면, 그건 이름만 이벤트지 사실상 직접 호출과 다를 게 없습니다.
이벤트에 엔티티를 통째로 넣는 것도 피하세요. 지연 로딩이 따라오고 영속성 컨텍스트에 묶입니다. 식별자랑 꼭 필요한 값만 넘기는 게 속 편합니다.
그리고 — 이게 의외로 중요한데 — 모든 호출을 이벤트로 바꾸려 들지 마세요. 반환값이 바로 필요한 동기 호출까지 이벤트로 만들면 흐름 추적이 오히려 미궁에 빠집니다. 이벤트는 “부가 반응”이 자연스러운 곳, 특히 순환참조가 생기는 그 지점에만 골라서 씁니다.
호출이 코드에 안 보이는 만큼 로그는 필수입니다. 발행과 수신 지점에 로그를 박아두면, 어떤 이벤트가 누구한테 갔는지 추적할 수 있어 디버깅이 한결 수월해집니다.
자주 묻는 질문 (FAQ)
Q1. @Lazy로 막으면 진짜 안 되나요?
당장 부팅은 됩니다. 다만 순환 구조는 고스란히 남아서, 결합도·테스트 난이도·변경 영향 범위 문제가 그대로 따라옵니다. 급할 때 임시로는 쓰되, 결국 구조를 고치는 게 맞습니다.
Q2. 이벤트로 바꾸면 느려지지 않나요?
기본 이벤트는 동기라 직접 호출과 성능 차이가 사실상 없습니다. 후속 작업이 무거울 때만 @Async로 빼면 오히려 응답이 빨라지기도 합니다. 정합성이 중요한 작업은 동기로 두세요.
Q3. 스프링 이벤트랑 카프카는 뭐가 다른가요?
스프링 ApplicationEvent는 같은 프로세스 안에서만 도는 인메모리 이벤트입니다. 서버를 내리면 처리 중이던 건 사라집니다. 서비스 간 통신이나 유실 방지가 필요하면 카프카·RabbitMQ 같은 외부 메시지 큐로 가야 합니다. 둘은 용도가 다른 도구입니다.
Q4. 리스너에서 예외가 나면요?
동기 리스너의 예외는 발행한 쪽까지 전파돼 트랜잭션이 같이 롤백될 수 있습니다. 반대로 @Async 비동기 리스너의 예외는 호출 측과 끊겨 조용히 사라지므로, 리스너 안에서 직접 처리하거나 반드시 로깅해야 합니다.
Q5. 순환참조가 생긴 것 자체가 잘못된 설계인가요?
대개는 책임 분리가 덜 됐다는 신호입니다. 그렇다고 무조건 이벤트가 정답은 아닙니다. 두 서비스의 공통 로직을 제3의 서비스로 뽑아내는 게 더 깔끔할 때도 있어요. 상황에 맞는 도구를 고르는 안목이 결국 핵심입니다.
마무리
순환참조를 푸는 핵심은 에러 메시지를 지우는 게 아니라, 의존성의 방향을 한쪽으로 정리하는 데 있습니다. @Lazy나 수정자 주입은 부팅만 통과시킬 뿐, “서로를 직접 안다”는 사실은 끝까지 남습니다. 그리고 그 사실은 언젠가 결합도와 테스트 비용이라는 청구서로 돌아옵니다.
이벤트 기반 설계는 “직접 호출”을 “사실 발행”으로 바꿔 그 고리를 끊습니다. ApplicationEventPublisher로 알리고 @EventListener로 관심 있는 쪽이 반응하게 하면, 발행 측은 수신 측을 몰라도 됩니다. 거기에 @TransactionalEventListener로 트랜잭션 경계를 맞추고, 부가 작업만 @Async로 빼면 운영에서도 충분히 단단합니다.
다음에 순환참조 에러를 만나거든, 우회책으로 덮기 전에 30초만 멈춰서 물어보세요. “이 둘이 정말 서로를 알아야 하나, 아니면 한쪽이 알리기만 하면 되나?” 그 30초가 며칠 뒤의 야근을 막아줍니다.
더 읽어볼 거리로는 “인터페이스와 상속 설계 이야기 — 결합을 끊는 설계”를 추천합니다.
핵심 요약
- 순환참조는 두 빈이 서로를 직접 의존해 생기며, 스프링 부트 2.6+는 기본 금지합니다.
@Lazy·수정자 주입·@PostConstruct는 부팅만 통과시킬 뿐 구조 문제를 남깁니다.- 이벤트 기반 설계는 “직접 호출”을 “사실 발행”으로 바꿔 의존 방향을 한쪽으로 정리합니다.
ApplicationEventPublisher+@EventListener로 발행·구독하고, 트랜잭션은@TransactionalEventListener(AFTER_COMMIT)로 맞춥니다.- 부가 작업만
@Async로 빼되, 비동기 예외는 리스너 안에서 반드시 처리합니다.
“스프링 순환참조, 이벤트 리스너로 끊어낸 극복기”에 대한 2개의 생각