한줄요약 :
무거워진 트랜잭션 문제를 해결하려고 Spring ApplicationEvent를 처음 도입하며 겪은
개념 학습, 설정 고민, 기존 로직 변경 과정과 마주친 문제들의 실전 기록
문제의 시작: 기존 로직이 점점 무거워지고 있었다.
도메인 주도 이커머스 프로젝트를 진행함에 따라 로직이 점점 무거워졌다.
주문요청만 해도 5개의 도메인을 사용하고 있었다.
@Transactional
public Order placeOrder(OrderInfo orderInfo) {
// 1. 재고 차감 (Product 도메인)
productService.decreaseStock(items);
// 2. 포인트 차감 (Point 도메인)
pointService.usePoint(userId, amount);
// 3. 쿠폰 사용 (Coupon 도메인)
couponService.useCoupon(couponId);
// 4. 외부 PG 호출 (Payment)
paymentService.processPayment(paymentInfo);
// 5. 주문 저장 (Order 도메인)
return orderRepository.save(order);
}
간략하게 위 코드와 같이
5개의 도메인이 하나의 트랜잭션에 있어 강한 결합이 되어 있고 이로인해
트랜잭션 시간이 길어지는것이 성능 저하 문제를 일으킬 수 있다고 본다.
해결을 위한 고민 : Spring 이벤트를 통한 트랜잭션 분리
이를 해결하기 위한 방법 중 하나가 이벤트 방식인 것이다.
이벤트 방식(Spring Application Event)의 도입의 핵심 목적은
- [지금 당장 해야 하는 일]과 [조금 나중에 해도 되는 일]을 분리하는 것이다
Spring은 애플리케이션 내부에서는 이벤트 기반 흐름 제어를 위한 여러 도구를 제공한다
1. ApplicationEventPublisher
- 이벤트를 발행하는 역할
@Component
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
@Transactional
public Order createOrder(OrderInfo orderInfo) {
...
// 이벤트 발행 - 후속 처리는 다른 곳에서
eventPublisher.publishEvent(new OrderCreatedEvent(order));
return order;
}
}
2. @EventListener
- 이벤트를 받아서 처리하는 메서드에 붙이는 어노테이션
@Component
public class OrderEventHandler {
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
// 쿠폰 사용, 포인트 적립 등 후속 처리
couponService.useCoupon(event.getCouponId());
}
}
3. @TransactionalEventListener
- @EventListener와 달리, 트랜잭션의 특정 시점에서 이벤트를 처리하도록 제어할 수 있다.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
// 주문 생성 트랜잭션이 성공적으로 커밋된 후에만 실행
paymentService.processPayment(event.getPaymentInfo());
}
* TransactionPhase 옵션들
- BEFORE_COMMIT: 트랜잭션 커밋 직전
- AFTER_COMMIT: 트랜잭션 커밋 후 (가장 많이 사용)
- AFTER_ROLLBACK: 트랜잭션 롤백 후
- AFTER_COMPLETION: 트랜잭션 완료 후 (커밋/롤백 무관)
4. @Async
- 이벤트 리스너를 비동기로 실행하여 메인 스레드를 블로킹하지 않도록 할 수 있다.
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
// 별도 스레드에서 비동기 실행
// 외부 API 호출 등 시간이 오래 걸리는 작업에 유용
dataplatformClient.sendOrderData(event.getOrderData());
}
* 개선 예시
// Before: 모든 처리를 하나의 트랜잭션에서
@Transactional // 긴 트랜잭션 - 성능 문제
public Order placeOrder(OrderInfo orderInfo) {
productService.decreaseStock(items); // 실패 시 전체 롤백
pointService.usePoint(userId, amount); // 실패 시 전체 롤백
couponService.useCoupon(couponId); // 실패 시 전체 롤백
paymentService.processPayment(info); // PG 장애 시 전체 롤백
return orderRepository.save(order);
}
==================================================================================
// After: 핵심 로직과 후속 로직 분리
@Transactional // 짧고 빠른 트랜잭션
public Order placeOrder(OrderInfo orderInfo) {
// 1. 핵심 로직만 처리
Order order = orderRepository.save(new Order(orderInfo));
// 2. 이벤트 발행 (후속 처리는 별도 트랜잭션에서)
eventPublisher.publishEvent(new OrderCreatedEvent(order));
return order; // 빠른 응답
}
@TransactionalEventListener(phase = AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
// 주문 저장이 성공한 후에 별도 트랜잭션으로 처리
couponService.useCoupon(event.getCouponId());
paymentService.processPayment(event.getPaymentInfo());
// PG 실패해도 주문은 이미 저장됨
}
실제 적용하면서 겪은 시행착오들
1. @Async(비동기) 설정의 함정
처음에는 당연히 @Async를 써야 한다고 생각했다. 빠른 응답을 위해서는 비동기가 최고 아닌가?
* @Async를 쓰려고 했던 이유들:
- 빠른 API 응답 시간 확보
- 메인 비즈니스 로직과 부가 로직의 완전한 분리
- 외부 시스템 호출 시 블로킹 방지
- "이벤트 = 비동기"라는 고정관념
@TransactionalEventListener(phase = AFTER_COMMIT)
@Async
public void handleLikeAdded(LikeAddedEvent event) {
...
}
하지만 설정을 적용하자 테스트가 실패하기 시작했다.
실패 원인:
- 테스트 스레드와 @Async 처리 스레드 간의 타이밍 경쟁
- likeService.addLike() 호출은 완료되지만 이벤트 처리는 아직 진행 중
- 테스트는 비동기 작업 완료를 기다리지 않고 assertion 실행
위와 같은 사유로 실패가 나지 않았을까 추측을 해보는데
처음에는 해결하기 위하여 Thread.sleep() 추가하여 대기 시간을 주었지만
환경에 따라 비동기 작업 완료 시간이 다르기 때문에 테스트가 불안정할 것이고
실제로 여전히 테스트 통과 되지 않았다.
결국 테스트 안정성을 포기하면서까지 비동기를 유지할 가치가 있나? 라는 문제로
@Async 는 사용하지 않기로 했다.
* 언제 @Async를 사용해야 하나?
- 외부 API 호출 (수 초 이상 소요)
- 대용량 데이터 처리
- 이메일 발송, 파일 업로드 등 시간이 오래 걸리는 I/O 작업
- 사용자가 기다릴 필요가 없는 백그라운드 작업
2. 이벤트 vs 동시성
이벤트 기반으로 좋아요 집계를 분리한 후, 기존에 잘 통과하던 동시성 테스트가 간헐적으로 실패하기 시작했다.
ASIS_동기 방식(낙관적락)
@Transactional
public void addLike(Long userId, Long productId) {
// 1. 좋아요 저장
likeRepository.save(new Like(userId, productId));
// 2. 집계 업데이트 (같은 트랜잭션)
productService.increaseLikeCount(productId);
// 둘 다 성공하거나 둘 다 실패 → 강한 일관성 보장
}
==================================================================================
TOBE_이벤트 방식
@Transactional
public void addLike(Long userId, Long productId) {
// 1. 좋아요 저장 (즉시 커밋)
likeRepository.save(new Like(userId, productId));
// 2. 이벤트 발행
eventPublisher.publishEvent(new LikeAddedEvent(userId, productId));
}
@EventListener
@Transactional // 별도 트랜잭션!
@Retryable(value = ObjectOptimisticLockingFailureException.class)
public void handleLikeAdded(LikeAddedEvent event) {
// 다른 트랜잭션에서 집계 처리
productService.increaseLikeCount(event.getTargetId());
}
@Test
@DisplayName("동시에 100명이 좋아요를 누르면 집계가 정확해야 한다")
void 동시_좋아요_요청_시_집계_정확성_테스트() throws InterruptedException {
// given
Long productId = 1L;
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
// when: 100명이 동시에 좋아요
for (int i = 0; i < threadCount; i++) {
long userId = i + 1;
executorService.submit(() -> {
try {
likeApplicationService.addLike(userId, productId);
} finally {
latch.countDown();
}
});
}
latch.await();
// then: 좋아요 수가 정확히 100개여야 한다
ProductResponse product = productQuery.getProduct(productId);
assertThat(product.likeCount()).isEqualTo(100); // 실패! 97, 98, 99 등으로 나옴
}
테스트 결과:
- 예상: 100
- 실제: 97, 98, 99... (실행할 때마다 다름)
기존 방식의 동시성 테스트 시에는
100개 요청 → 정확히 100개 반영(원자성 충족) 이 되었으나
이벤트 방식으로 변경 시
좋아요 저장과 집계 업데이트 분리되면서 서로 다른 트랜잭션이 되어
집계가 업데이트 로직의 실패로 인해 정확히 반영안될 수 있는 문제가 생길 수 있다고 한다.
결국 동시성 과 이벤트를 동시에 챙길 수는 없다고 하는데.. 좀 더 찾아봐야 할 문제인 것 같다.
이번 결정은 이벤트를 사용해보자이기 때문에 동시성에 관한 테스트는 주석으로 막기로 했다.
1. 정확한 실시간 집계가 필요 → 동기식 처리(Strong Consistency)
* 적용 예시
- 재고 차감: 정확성이 절대적으로 중요
- 포인트 적립: 사용자 자산과 직결
- 주문 처리: 일관성이 필수
2. 높은 성능과 장애 격리가 필요 → 이벤트 기반 처리(Eventual Consistency)
* 적용 예시
- SNS 좋아요: 사용자는 즉시 피드백을 원함, 정확한 개수는 덜 중요
- 조회수 집계: 대략적인 수치로도 충분
- 알림 발송: 메인 기능과 분리되어야 함
배우고 느낀 점
이벤트 방식을 처음해보는거라 위에서 언급한 내용들이 완전히 정확하지 않을 수 있다.
특히 @Async 문제나 동시성 이슈의 경우, 더 나은 해결방법이 존재할 가능성이 높다.
하지만 처음 접하는 개념을 완벽하게 이해하고 적용하기보다는, 직접 부딪혀보고 문제를 경험하는 것 자체에
의미가 있다고 생각한다.
지금까지 모든 관련 작업을 하나의 트랜잭션에 묶어야 한다고 생각하고 그렇게 로직을 구현하고 있었는데
이벤트 방식을 배워 핵심 로직과 부가 로직을 구분할 수 있다는 점을 알게 되었다.
언제 어떤 도구를 사용할 것인가에 대한 판단 기준을 세우는 것이 중요하나 아직은 공부가 더 필요한 것 같다.
'Loopers > 테크니컬 라이팅' 카테고리의 다른 글
| Kafka 찍먹해보기! : Kafka통한 서비스 경계를 넘는 이벤트 파이프라인을 구축기 (0) | 2025.09.05 |
|---|---|
| DB 조회 및 정렬 성능 개선하기(비정규화, 인덱스, 캐시(Redis)) (1) | 2025.08.15 |
| 동시성 테스트(Flaky Test 삽질기) (4) | 2025.08.09 |
| 다들 이해하지?! 설계의 청사진 시퀀스 다이어그램 (0) | 2025.07.25 |
| TDD, 실패하는 테스트가 알려준 것들: 아래에서 내려다보는 TDD (0) | 2025.07.18 |