1편에서 이벤트로 코드 구조를 분리했고, 2편에서 @TransactionalEventListener로 트랜잭션 경계를 정리했다. 그런데 아직 해결하지 않은 질문이 있다. "커밋이 끝난 뒤 리스너에서 실패하면 어떻게 되는가?" 이벤트 기반 설계의 핵심은 분리 자체가 아니라 실패를 어떻게 다루느냐에 있다. 이 질문에 답하려면 비동기 이벤트, 실패 허용 설계, 보상 전략까지 이해해야 한다.
@Async와 비동기 이벤트
2편에서 @TransactionalEventListener(AFTER_COMMIT)을 사용하면 커밋이 확정된 뒤에 리스너가 실행된다는 것을 확인했다. 하지만 이 리스너는 여전히 발행자와 같은 스레드에서 동기적으로 실행된다. 알림 발송에 3초가 걸리면, API 응답도 3초 늦어진다.
@Async를 추가하면 리스너가 별도 스레드에서 실행된다. 발행자는 리스너의 완료를 기다리지 않고 즉시 응답할 수 있다.
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
public void sendNotification(ClothesCreatedEvent event) {
notificationService.notifyFollowers(event.getClothesId());
}
이 조합이 어떤 실행 흐름을 만드는지 보자.
(아직 실행 안 함) rect rgb(232, 248, 232) Note over Service, DB: 트랜잭션 커밋 Service-->>Client: 200 OK 응답 end rect rgb(240, 248, 255) Note over Spring, Listener: 별도 스레드 Spring--)Listener: 비동기 실행 Listener->>Listener: 알림 발송 end
초록 영역에서 클라이언트는 이미 응답을 받았다. 파란 영역의 알림 발송은 별도 스레드에서 독립적으로 진행된다. 알림이 3초 걸려도 API 응답 시간에는 영향이 없다.
@Async + AFTER_COMMIT이 표준 패턴인 이유
외부 부수효과(알림, 이메일, 외부 API 호출)에는 이 조합이 사실상 표준이다. 세 가지 조건을 동시에 충족하기 때문이다.
- 트랜잭션 안전성 : 커밋이 확정된 뒤에 실행되므로, 롤백된 데이터에 대한 부수효과가 발생하지 않는다
- 장애 격리 : 별도 스레드이므로, 리스너 예외가 발행자에게 전파되지 않는다
- 응답 속도 : 발행자는 리스너의 완료를 기다리지 않으므로, API 응답이 빠르다
@Async만 사용하고 @TransactionalEventListener를 쓰지 않으면(@EventListener + @Async), 커밋 전에 비동기 스레드가 실행될 수 있다. 아직 커밋되지 않은 데이터를 읽으려다 실패하는 경우가 생긴다. 반드시 AFTER_COMMIT과 함께 써야 한다.
@Async의 활성화
@Async는 선언만으로 동작하지 않는다. 스프링 설정에서 @EnableAsync를 추가해야 한다.
@EnableAsync
@Configuration
public class AsyncConfig {
}
이 설정이 없으면 @Async가 무시되고 동기로 실행된다. 에러가 나지 않아서 놓치기 쉽다.
스레드 풀 설정
@Async를 활성화하면 Spring은 기본적으로 SimpleAsyncTaskExecutor를 사용한다. 이 실행기는 요청마다 새 스레드를 생성한다. 스레드 풀이 아니라 스레드 팩토리에 가깝기 때문에, 요청이 몰리면 스레드가 무한히 늘어난다.
운영 환경에서는 ThreadPoolTaskExecutor로 교체해야 한다.
@EnableAsync
@Configuration
public class AsyncConfig {
@Bean
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-event-");
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
executor.initialize();
return executor;
}
}
각 설정의 의미를 정리하면 이렇다.
- corePoolSize : 기본으로 유지하는 스레드 수. 요청이 없어도 이만큼은 살아 있다
- maxPoolSize : 큐가 가득 찼을 때 늘어날 수 있는 최대 스레드 수
- queueCapacity : core 스레드가 모두 바쁠 때 대기하는 작업 큐 크기
- rejectedExecutionHandler : 큐도 꽉 차고 max 스레드도 꽉 찼을 때의 정책.
CallerRunsPolicy는 호출한 스레드가 직접 실행한다 — 작업을 버리지 않는다
요청이 들어올 때 스레드 풀이 어떤 순서로 반응하는지 보자.
여유 있는가?"} C["core 스레드에서 실행"] D{"큐에
자리 있는가?"} E["큐에서 대기"] F{"max 스레드
여유 있는가?"} G["추가 스레드 생성 후 실행"] H["CallerRunsPolicy
호출 스레드가 직접 실행"] A --> B B -->|"예"| C B -->|"아니오"| D D -->|"예"| E D -->|"아니오"| F F -->|"예"| G F -->|"아니오"| H style A fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style B fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style C fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style D fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style E fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style F fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style G fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style H fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000
대부분의 작업은 core 스레드 → 큐 대기 → 추가 스레드 생성 순서로 처리된다. 마지막 단계(CallerRunsPolicy)에 도달하면 호출 스레드가 직접 처리하므로, 일시적으로 동기 실행이 된다. 작업을 버리는 것보다는 낫다.
threadNamePrefix를 지정하지 않으면 로그에서 비동기 작업을 구분할 수 없다. 장애 추적 시 어떤 이벤트 리스너가 어떤 스레드에서 실행됐는지 파악하려면 접두사를 반드시 설정한다.
동기 이벤트와 비동기 이벤트의 판단 기준
모든 이벤트 리스너를 비동기로 만들면 안 된다. 두 가지 질문으로 판단한다.
호출자가 결과를 알아야 하는가
첫 번째 질문은 "호출자가 이 작업의 성공/실패를 알아야 하는가?"이다.
- 옷 등록 후 이미지 썸네일 생성 → 실패해도 등록은 유효하다. 호출자가 몰라도 된다 → 비동기
- 결제 후 주문 상태 변경 → 실패하면 주문이 잘못된 상태에 머문다. 호출자가 알아야 한다 → 동기
나중에 재시도할 수 있는가
두 번째 질문은 "이 작업이 실패했을 때 나중에 다시 시도할 수 있는가?"이다.
- 알림 발송 실패 → 1분 뒤에 다시 보내면 된다 → 비동기 + 재시도
- DB 정합성을 맞추는 작업 → 나중에 재시도하면 그 사이에 데이터가 꼬인다 → 동기
이 두 질문을 의사결정 흐름으로 정리하면 이렇다.
알아야 하는가?"} B["동기 이벤트
@TransactionalEventListener"] C{"나중에
재시도 가능한가?"} D["비동기 이벤트
@Async + AFTER_COMMIT"] E["동기 이벤트
+ 예외 처리"] A -->|"예"| B A -->|"아니오"| C C -->|"예"| D C -->|"아니오"| E style A fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style B fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style C fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style D fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style E fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000
"아니오 → 예"의 경로가 비동기 이벤트의 적용 영역이다. 대부분의 부수효과(알림, 로그 기록, 외부 연동)가 여기에 해당한다.
비동기 리스너의 실패 처리
비동기 이벤트의 가장 큰 특징은 발행자가 이미 응답을 반환한 뒤에 리스너가 실행된다는 것이다. 리스너에서 예외가 발생해도 발행자에게 전파할 방법이 없다. 클라이언트는 성공 응답을 받았지만, 부수효과는 실패한 상태가 된다.
이 상황에서 아무 처리도 하지 않으면 실패가 조용히 묻힌다. 이것을 방지하려면 단계적으로 대응해야 한다.
로깅
가장 기본적인 대응이다. 비동기 스레드에서 발생한 예외는 기본적으로 콘솔에만 찍히고 사라진다. 리스너 안에서 명시적으로 로그를 남겨야 한다.
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
public void sendNotification(ClothesCreatedEvent event) {
try {
notificationService.notifyFollowers(event.getClothesId());
} catch (Exception e) {
log.error("알림 발송 실패. clothesId={}", event.getClothesId(), e);
}
}
로깅만으로 문제를 해결할 수는 없지만, 장애를 인지할 수 있게 된다. 모니터링 시스템이 이 로그를 감지하면 알림을 보낼 수 있다.
자동 재시도
일시적인 네트워크 장애라면, 잠시 후 다시 시도하면 성공할 수 있다. Spring Retry의 @Retryable을 사용하면 재시도 로직을 선언적으로 적용할 수 있다.
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
@Retryable(
retryFor = NotificationException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public void sendNotification(ClothesCreatedEvent event) {
notificationService.notifyFollowers(event.getClothesId());
}
@Recover
public void recoverNotification(NotificationException e, ClothesCreatedEvent event) {
log.error("알림 발송 3회 실패. clothesId={}", event.getClothesId(), e);
// 실패 기록을 DB에 저장하거나, Dead Letter 큐에 넣기
}
@Retryable은 지정한 예외가 발생하면 자동으로 재시도한다. backoff으로 재시도 간격을 점진적으로 늘린다. 3회 모두 실패하면 @Recover 메서드가 호출된다.
재시도 흐름을 시각적으로 보면 이렇다.
분홍 영역에서 첫 시도가 실패하고, 주황 영역에서 두 번째도 실패했지만, 초록 영역에서 세 번째 시도에 성공했다. 재시도 간격이 1초 → 2초로 늘어나는 것이 multiplier = 2의 효과다.
재시도가 적합하지 않은 경우도 있다. 잘못된 데이터로 인한 실패(400 Bad Request)는 몇 번을 재시도해도 같은 결과다. 일시적 장애에만 재시도를 적용해야 한다.
Dead Letter 패턴
재시도까지 전부 실패하면, 그 작업을 어딘가에 기록해두고 나중에 수동이나 배치로 처리하는 방식이다. 메시지 큐 시스템에서 유래한 이름이지만, 개념 자체는 단순하다 — 처리 못 한 작업을 버리지 않고 보관하는 것이다.
@Recover
public void recoverNotification(NotificationException e, ClothesCreatedEvent event) {
failedEventRepository.save(FailedEvent.of(
"NOTIFICATION",
event.getClothesId(),
e.getMessage()
));
}
보관된 실패 이벤트는 배치 잡이나 관리자 화면에서 재처리할 수 있다. 이 패턴의 흐름을 정리하면 이렇다.
성공?"} G["처리 완료"] H["Dead Letter 저장"] I["배치 재처리"] A -.-> B B --> C C -->|"예"| D C -->|"아니오"| E E --> F F -->|"예"| G F -->|"모두 실패"| H H -.-> I style A fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style B fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style C fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style D fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style E fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style F fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style G fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style H fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style I fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000
점선 화살표는 비동기 연결을 나타낸다. 이벤트 발행에서 리스너까지, 그리고 Dead Letter에서 배치 재처리까지가 비동기다.
보상 트랜잭션
비동기 리스너의 실패는 "아직 안 한 것"이다. 알림을 못 보냈으면 다시 보내면 된다. 그런데 "이미 한 것"을 되돌려야 하는 경우도 있다. 이것을 보상 트랜잭션이라 한다.
외부 시스템과의 불일치
옷 등록 시 이미지를 S3에 업로드하는 시나리오를 보자. @TransactionalEventListener(AFTER_COMMIT)으로 커밋 후에 S3 업로드를 실행한다. 그런데 업로드는 성공했는데, 후속 처리(이미지 메타데이터 저장 등)에서 실패하면 어떻게 되는가?
남아 있다 Note over Meta: 메타데이터는
없다
초록 영역에서 S3 업로드는 성공했지만, 분홍 영역에서 메타데이터 저장이 실패했다. S3에는 파일이 있고 DB에는 메타데이터가 없는 불일치 상태다. DB 트랜잭션이 롤백돼도 S3 업로드는 되돌려지지 않는다 — 외부 시스템은 우리의 트랜잭션에 참여하지 않기 때문이다.
보상 로직의 구현
이런 불일치를 해결하려면, 실패 시 이전에 성공한 외부 작업을 명시적으로 되돌리는 로직이 필요하다.
@Async
@TransactionalEventListener(phase = AFTER_COMMIT)
public void processImage(ClothesCreatedEvent event) {
String s3Key = null;
try {
s3Key = s3Service.upload(event.getClothesId(), event.getImageData());
imageMetadataService.save(event.getClothesId(), s3Key);
} catch (Exception e) {
// 보상: S3에 올린 파일 삭제
if (s3Key != null) {
s3Service.delete(s3Key);
}
log.error("이미지 처리 실패, 보상 완료. clothesId={}", event.getClothesId(), e);
}
}
보상 트랜잭션의 핵심 원칙은 실패 시 이전 단계의 부수효과를 역순으로 되돌리는 것이다. DB에서 ROLLBACK이 자동으로 해주는 일을 직접 코드로 구현한다.
보상 로직 자체도 실패할 수 있다. S3 삭제가 실패하면 고아 파일이 남는다. 이런 경우를 대비해 주기적으로 DB와 S3의 정합성을 검사하는 배치 작업을 두는 것이 안전하다.
최종 일관성과 강한 일관성
지금까지 다룬 비동기 이벤트 패턴은 근본적으로 최종 일관성을 선택한 것이다. 이 개념을 정리하자.
강한 일관성
모든 작업이 하나의 트랜잭션 안에서 완료된다. 성공하면 모든 데이터가 즉시 일관되고, 실패하면 전부 롤백된다.
- 옷 저장 + 이미지 업로드 + 알림 발송이 모두 같은 트랜잭션
- 하나라도 실패하면 전부 취소
- 사용자는 항상 완벽하게 일관된 데이터를 본다
대신 1편에서 봤듯이, 부가 로직의 실패가 핵심 로직을 롤백시키고, 외부 호출이 트랜잭션 시간을 늘린다.
최종 일관성
핵심 작업만 즉시 완료하고, 부수효과는 "언젠가 반드시 완료된다"고 보장한다. 잠시 동안 데이터 불일치가 있을 수 있지만, 결국 일관된 상태에 도달한다.
- 옷 저장은 즉시 커밋
- 알림 발송은 비동기로 나중에 처리
- 알림이 실패하면 재시도하고, 재시도도 실패하면 Dead Letter에 보관
두 방식의 차이를 비교하면 이렇다.
왼쪽의 강한 일관성은 실선 화살표로 순차 실행되고, 오른쪽의 최종 일관성은 점선 화살표로 비동기 실행된다. 실패 시 재시도까지 포함하면 최종 일관성이 더 복잡하지만, 핵심 로직의 안정성과 응답 속도에서 유리하다.
선택 기준
강한 일관성이 필요한 경우는 생각보다 적다. 판단 기준은 이렇다.
- 강한 일관성 : 금융 거래, 재고 차감처럼 즉시 정확해야 하고 불일치가 치명적인 경우
- 최종 일관성 : 알림, 로그, 검색 인덱스 갱신처럼 잠시 지연돼도 비즈니스에 영향이 없는 경우
대부분의 부수효과는 최종 일관성으로 충분하다. "옷 등록 후 알림이 3초 늦게 도착했다"는 것은 사용자 경험에 거의 영향이 없지만, "알림 서버 장애로 옷 등록이 실패했다"는 것은 심각한 문제다.
면접 Q&A
@EventListener는 publishEvent() 호출 시점에 같은 스레드, 같은 트랜잭션에서 즉시 실행된다. @TransactionalEventListener는 트랜잭션 특정 시점(BEFORE_COMMIT, AFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION)에 실행된다. 가장 큰 실무적 차이는 @EventListener의 리스너 예외가 발행자의 트랜잭션을 롤백시키지만, @TransactionalEventListener(AFTER_COMMIT)은 이미 커밋 후이므로 발행자에게 영향을 주지 않는다는 점이다.
[!QUESTION] @TransactionalEventListener(AFTER_COMMIT)에서 DB를 수정하면?
AFTER_COMMIT 시점에는 발행자의 트랜잭션이 이미 커밋되어 닫혀 있다. 여기서 DB를 수정하려고 하면 트랜잭션이 없어서 실패한다. 해결 방법은 @Transactional(propagation = REQUIRES_NEW)로 새 트랜잭션을 열거나, @Async를 사용해 별도 스레드에서 실행하는 것이다. @Async가 붙으면 새 스레드에서 새 트랜잭션 컨텍스트가 시작되므로, 별도로 REQUIRES_NEW를 지정하지 않아도 @Transactional이 정상 동작한다.
[!QUESTION] 비동기 이벤트에서 예외가 발생하면 발행자에게 영향이 있는가?
없다. @Async 리스너는 별도 스레드에서 실행되므로, 예외가 발행자의 스레드로 전파되지 않는다. 발행자는 이미 응답을 반환한 상태다. 다만 이것이 "안전하다"는 뜻은 아니다. 부수효과가 조용히 실패하면 데이터 불일치가 생기므로, 로깅 + 재시도 + Dead Letter 패턴으로 실패를 관리해야 한다.
[!QUESTION] 이벤트 기반 아키텍처의 단점은?
네 가지 단점이 있다.
1. 흐름 추적 어려움 : publishEvent()에서 코드 흐름이 끊긴다. 어떤 리스너가 반응하는지 IDE에서 바로 따라갈 수 없다. 이벤트 클래스를 검색해야 한다.
2. 디버깅 복잡성 : 비동기 이벤트는 별도 스레드에서 실행되므로, 하나의 요청에 대한 로그가 여러 스레드에 흩어진다. MDC 전파 등 별도 설정이 필요하다.
3. 순서 보장 어려움 : 비동기 리스너 간의 실행 순서가 보장되지 않는다. 리스너 A가 끝나야 리스너 B가 의미 있다면 이벤트 분리가 부적절한 것이다.
4. 최종 일관성 수용 : 비동기 처리는 본질적으로 잠시 동안의 데이터 불일치를 허용한다. 이 불일치가 허용되지 않는 도메인에서는 사용할 수 없다.
[!QUESTION] Spring ApplicationEvent는 분산 환경에서 동작하는가?
아니다. Spring의 ApplicationEventPublisher는 같은 JVM 안에서만 동작한다. 서버가 여러 대인 분산 환경에서는 A 서버에서 발행한 이벤트를 B 서버의 리스너가 수신하지 못한다. 분산 환경에서 이벤트 기반 통신이 필요하면 Kafka, RabbitMQ 같은 메시지 브로커를 사용해야 한다.
자주 하는 실수
@Async를 리스너 메서드에 선언했지만, 설정 클래스에 @EnableAsync를 추가하지 않으면 어노테이션이 무시되고 동기로 실행된다. 에러도 발생하지 않기 때문에 비동기로 동작한다고 착각한 채로 운영에 나가기 쉽다. 반드시 @EnableAsync 설정을 확인한다.
[!DANGER] 비동기 리스너에서 예외를 무시
@Async 리스너에서 예외가 발생하면 해당 스레드에서 로그가 찍히고 끝난다. 별도로 catch해서 로깅하거나, AsyncUncaughtExceptionHandler를 설정하지 않으면 실패를 인지조차 못 한다. 모든 비동기 리스너에는 예외 처리 전략(로깅, 재시도, Dead Letter)을 반드시 구현한다.
[!DANGER] 보상 로직 없이 외부 시스템 호출
S3 업로드 후 DB 저장이 실패했을 때, S3에 올린 파일을 삭제하는 보상 로직이 없으면 고아 파일이 쌓인다. 외부 시스템과 DB를 함께 다루는 리스너에서는 "앞 단계 성공 → 뒷 단계 실패"를 항상 고려하고, 실패 시 이전 단계의 부수효과를 되돌리는 코드를 작성한다.
[!DANGER] 모든 이벤트를 비동기로 처리
"비동기가 빠르니까 전부 @Async로 하자"는 위험한 발상이다. 호출자가 결과를 알아야 하는 작업(주문 상태 변경, 포인트 차감 등)을 비동기로 만들면, 성공 응답을 반환했는데 실제로는 실패한 상태가 된다. 비동기 전환 전에 "호출자가 결과를 알아야 하는가?"를 반드시 따져본다.
[!DANGER] 재시도에 멱등성을 고려하지 않음
알림 발송을 재시도할 때, 이미 한 번 성공한 건이 재시도 대상에 포함되면 같은 알림이 두 번 간다. 재시도 대상이 되는 작업은 반드시 멱등하게(여러 번 실행해도 결과가 같게) 설계해야 한다. 고유 ID 기반 중복 체크나 상태 플래그를 활용한다.