본문 바로가기
🚀 데브 노트 (IT 지식 & 팁)

회원가입 API가 5초나 걸린다고요? @TransactionalEventListener로 스파게티 코드 분리하기

by 데브프리 2026. 8. 6.

안녕하세요, 여러분의 든든한 IT 길잡이 데브프리입니다.

 

백엔드 서버를 개발하다 보면 서비스 규모가 커질수록 하나의 API에 수많은 요구사항이 덕지덕지 붙기 시작합니다. 처음에는 단순히 DB에 회원 정보를 저장하기만 했던 '회원가입 API'가 있다고 가정해 봅시다.

시간이 지나면서 기획팀의 요구에 따라 "가입 시 환영 이메일 발송", "가입 축하 1,000 포인트 지급", "슬랙(Slack) 알림 전송" 기능이 끝없이 추가됩니다.

 

이 모든 로직을 UserService.register()라는 하나의 메서드에 욱여넣으면 어떤 일이 발생할까요? 이메일 발송 서버가 느려지면 사용자는 회원가입 버튼을 누르고 5초 동안 멍하니 로딩 화면만 봐야 합니다. 더 최악인 것은, 이메일 발송에 실패해서 예외가 터지면 회원가입 자체도 함께 롤백(Rollback)되어 버린다는 것입니다.

 

이러한 스파게티 코드와 트랜잭션 동기화 문제를 완벽하게 해결하기 위해 실무에서 적극 도입하는 기술이 바로 스프링의 이벤트 발행(ApplicationEvent)과 @TransactionalEventListener입니다. 오늘은 이 기술의 핵심 원리와, 실무 도입 시 초보자들이 100% 겪게 되는 'Silent Failure(조용한 실패)' 트러블슈팅 과정을 명쾌하게 분석해 드리겠습니다.

 

1. 결합도를 낮추는 마법, 스프링 이벤트(Event)

핵심 비즈니스 로직(회원 정보 저장)과 부가적인 로직(이메일 발송, 포인트 지급)을 분리하려면 코드를 어떻게 짜야 할까요? 스프링이 제공하는 ApplicationEventPublisher를 사용하면 아주 깔끔하게 해결됩니다.

 

메인 서비스에서는 데이터베이스에 저장을 마치고 "회원가입이 완료되었습니다!"라는 이벤트(알림)만 허공에 던집니다(Publish). 그러면 이메일을 보내는 리스너(Listener)와 포인트를 지급하는 리스너가 각각 알림을 듣고 각자의 할 일을 수행하는 것이죠.

Java

// 핵심 로직: 회원 저장 후 이벤트만 발행하고 쿨하게 끝냄
@Transactional
public void registerUser(UserDto dto) {
    User savedUser = userRepository.save(dto.toEntity());
    // 💡 이벤트 발행! 이메일 발송이나 포인트 지급 로직은 여기서 모름
    eventPublisher.publishEvent(new UserRegisteredEvent(savedUser.getId()));
}

강하게 결합된 스파게티 코드와 이벤트 기반 분리 아키텍처 비교 비유
코드가 강하게 결합되어 있으면 하나의 오류가 전체 시스템을 무너뜨리지만, 이벤트를 통해 결합도를 낮추면 각 모듈이 독립적이고 안전하게 동작합니다.

 

2. 왜 @EventListener 대신 @TransactionalEventListener인가?

그렇다면 이벤트를 수신할 때 일반적인 @EventListener를 쓰면 될까요? 실무에서는 절대 권장하지 않습니다.

 

일반 @EventListener는 이벤트를 발행한 즉시 동작합니다. 만약 이메일 발송 로직이 실행되었는데, 그 직후에 메인 로직인 회원가입 트랜잭션이 DB 제약 조건 위반으로 롤백되어 버렸다면 어떻게 될까요? DB에는 회원이 없는데, 고객은 "회원가입을 축하합니다"라는 이메일을 받는 끔찍한 정합성 오류가 발생합니다.

 

이 문제를 막기 위해 사용하는 것이 @TransactionalEventListener입니다. 이 어노테이션은 기본적으로 AFTER_COMMIT (메인 트랜잭션이 성공적으로 DB에 커밋된 직후) 단계에서만 이벤트를 실행하도록 보장합니다. 즉, 회원가입이 DB에 완벽하게 저장된 것이 확정되었을 때만 부가 작업을 시작하는 매우 안전한 방식입니다.

 

3. 실전 트러블슈팅: 리스너 안에서 DB 저장이 안 돼요! (조용한 롤백)

안전하게 분리까지 완료했고, 개발 서버에서 테스트를 진행합니다. 회원가입 커밋 직후에 환영 쿠폰을 발급하기 위해 리스너 내부에서 couponRepository.save()를 호출했습니다.

Java

// 🚨 실무에서 가장 많이 하는 실수
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void issueWelcomeCoupon(UserRegisteredEvent event) {
    Coupon coupon = new Coupon(event.getUserId(), "WELCOME_1000");
    couponRepository.save(coupon); // -> 실행은 되는데 DB에 안 들어감!
}

에러 로그도 찍히지 않습니다. 코드는 정상적으로 실행되었는데, 데이터베이스를 확인해 보면 쿠폰 데이터가 텅 비어 있습니다. 도대체 왜 이런 조용한 실패(Silent Failure)가 발생할까요?

원인 분석: 이미 닫혀버린 트랜잭션의 문

스프링의 JPA 환경에서 INSERT나 UPDATE 쿼리가 실제로 DB에 날아가려면(Flush), 트랜잭션이 커밋되어야 합니다.

그런데 AFTER_COMMIT 옵션은 말 그대로 '메인 트랜잭션의 커밋이 이미 끝난 직후'에 동작합니다.

커밋이 끝나서 문이 닫혀버렸기 때문에, JPA 영속성 컨텍스트에 쿠폰 엔티티를 추가해 봤자 DB로 변경 사항(Flush)을 내보낼 타이밍을 영원히 잃어버린 것입니다.

트랜잭션 커밋 완료 후 데이터 추가 시도 실패 비유
AFTER_COMMIT 단계는 메인 트랜잭션이라는 기차가 이미 커밋(출발)을 완료한 상태입니다. 이 시점에서 기존 기차에 새로운 데이터를 태울 수는 없습니다.

해결 베스트 프랙티스: REQUIRES_NEW

닫힌 문을 억지로 열 수는 없습니다. 쿠폰 저장을 위한 '새로운 독립적인 트랜잭션'을 열어주어야 합니다. 리스너 메서드 위에 @Transactional(propagation = Propagation.REQUIRES_NEW)를 명시해 주면 깔끔하게 해결됩니다.

Java

// ✅ 완벽한 실무 해결 코드
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW) // 독립적인 새 트랜잭션 오픈!
public void issueWelcomeCoupon(UserRegisteredEvent event) {
    Coupon coupon = new Coupon(event.getUserId(), "WELCOME_1000");
    couponRepository.save(coupon); // 이제 DB에 완벽하게 저장됩니다.
}

[추가 꿀팁] @Async로 응답 속도 최적화하기

REQUIRES_NEW로 저장은 성공했지만, 클라이언트 입장에서는 결국 메인 트랜잭션 시간 + 쿠폰 발급 트랜잭션 시간을 모두 기다려야 API 응답을 받게 됩니다.

이를 해결하려면 리스너를 비동기로 동작시키는 @Async 어노테이션을 함께 사용하세요. 메인 로직이 끝남과 동시에 클라이언트에게는 즉시 200 OK를 내려주고, 쿠폰 발급이나 이메일 발송은 백그라운드 스레드에서 조용히 처리되어 사용자 경험(UX)이 극적으로 상승합니다. (단, 스프링 부트 메인 클래스에 @EnableAsync 설정이 필수입니다.)

 

💡 [데브프리의 심층 면접 꿀팁: @Async와 REQUIRES_NEW의 양면성, 완벽하게 대답하기!]

면접관이 "비동기(@Async) 리스너에서도 REQUIRES_NEW가 반드시 필요한가요?"라고 묻는다면 단순한 답변을 넘어 시니어급 아키텍처 지식을 뽐낼 기회입니다!

먼저, 스프링의 트랜잭션은 '스레드 로컬(ThreadLocal)'을 기반으로 동작합니다. 따라서 @Async를 붙여 새로운 백그라운드 스레드에서 리스너가 실행되면, 메인 스레드의 닫혀버린 트랜잭션 정보는 아예 전달되지 않습니다. 즉, 참여할 기존 트랜잭션 자체가 없기 때문에 REQUIRES_NEW를 적지 않고 기본 @Transactional만 붙여도 알아서 새로운 독립적인 트랜잭션이 열리게 됩니다!

그럼에도 로직이 추후 동기(Sync)로 바뀔 가능성에 대비하여 REQUIRES_NEW를 명시적으로 적어두는 것은 롤백 범위 오염을 막아주는 훌륭한 방어적 프로그래밍(Defensive Programming)입니다.

하지만 여기서 끝이 아닙니다! 진짜 100점짜리 대답은 '인프라 관점의 경고'를 덧붙이는 것입니다.

"다만 방어적 목적으로 동기(Sync) 로직에 REQUIRES_NEW를 남발하면 치명적인 부작용이 있습니다. 하나의 사용자 요청(스레드)이 부모와 자식 트랜잭션을 위해 동시에 2개의 DB 커넥션을 쥐게 됩니다. 트래픽 폭주 시 이는 HikariCP 커넥션 풀을 순식간에 고갈시키고 스레드 기아 데드락(Thread Starvation Deadlock)을 유발할 수 있습니다. 따라서 논리적 안전함과 물리적 커넥션 비용을 반드시 저울질해야 합니다."

이 대답까지 덧붙인다면 면접관의 기립 박수를 받을 수 있을 것입니다!

 

4. 전문가의 확고한 결론 (Verdict)

@TransactionalEventListener와 @Async 조합, 실무에 언제 도입하고 언제 피해야 할까요?

  • 👍 확실한 도입 타이밍: 핵심 도메인 로직(주문 완료, 회원 가입)과 부가적인 정책(알림톡 발송, 통계 데이터 적재, 검색 엔진 동기화 등)이 강하게 결합되어 API 응답 속도 저하와 불필요한 롤백을 유발할 때 적극적으로 도입하세요. 코드의 응집도는 높아지고, 외부 시스템 장애가 우리 핵심 서비스에 미치는 영향을 완벽하게 차단할 수 있습니다.
  • 🛑 도입을 피해야 할 때: '두 작업이 무조건 하나의 원자적(Atomic) 트랜잭션으로 묶여야만 하는 경우'에는 절대 분리해서는 안 됩니다. 예를 들어 "계좌 이체" 로직에서 출금과 입금은 무조건 함께 성공하거나 함께 실패해야 합니다. 입금이 실패했다고 출금만 된 채로 끝나는 것은 치명적입니다. 이처럼 강한 데이터 정합성(Strong Consistency)이 요구되는 핵심 도메인 간의 로직은 이벤트를 통한 분리나 비동기 처리를 피하고 단일 트랜잭션으로 묶어두는 것이 안전합니다.

백엔드 서버의 성능과 안정성은 결국 '무엇을 함께 묶고, 무엇을 잘라낼 것인가'에 대한 아키텍처 설계에서 판가름 납니다. 오늘 다룬 스프링 이벤트와 트랜잭션 전파 속성의 원리를 실무에 적용하셔서, 더 가볍고 장애에 강한 서버를 설계해 보시길 바랍니다.

 

항상 팩트에 기반한 객관적인 실무 가이드로 여러분의 삽질 시간을 줄여드리겠습니다. 다음 포스팅에서도 개발자들에게 유용한 기술 분석으로 찾아뵙겠습니다!

 

👉 대규모 트래픽 처리 중 내 서버가 뻗을 위험도는? 카프카(Kafka) 리밸런싱 타임아웃 계산기 [링크]