Post

무거운 트랜잭션, 이벤트로 가볍게 만들기

무거운 트랜잭션, 이벤트로 가볍게 만들기

2.8초짜리 트랜잭션

주문 생성 API의 응답 시간을 측정했더니 2.8초가 나왔습니다. 트랜잭션 안에서 일어나는 일들을 나열해봤습니다.

1
2
3
4
5
6
주문 생성 트랜잭션 (2.8초)
├── 재고 차감 (150ms) - 핵심 비즈니스
├── 주문 생성 (100ms) - 핵심 비즈니스
├── 쿠폰 사용 처리 (200ms) - 부가 작업
├── 결제 데이터 전송 (2000ms) - 외부 시스템 호출
└── 데이터 웨어하우스 전송 (350ms) - 분석용 부가 작업

대부분의 시간을 외부 시스템 호출과 부가 작업이 차지하고 있었습니다. 핵심 작업(재고 + 주문)은 250ms입니다.

핵심과 부가를 분리하는 기준

모든 작업이 같은 트랜잭션에 있을 필요가 없습니다. 분리 기준을 정했습니다.

동기로 처리해야 하는 것 (핵심):

  • 재고 차감 - 즉시 반영이 필요합니다
  • 주문 생성 - 주문 결과를 응답해야 합니다

비동기로 처리해도 되는 것 (부가):

  • 쿠폰 사용 처리 - 주문 성공 후 처리해도 됩니다
  • 결제 데이터 전송 - 외부 시스템이 잠깐 늦어도 됩니다
  • 데이터 웨어하우스 전송 - 분석 데이터는 약간의 지연이 허용됩니다

Spring ApplicationEvent로 비동기 분리

1
2
3
4
5
6
7
8
9
// 주문 생성 완료 이벤트
public class OrderCreatedEvent {
    private final Long orderId;
    private final Long userId;
    private final Long couponId;
    private final PaymentData paymentData;

    // constructor, getters
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Service
@RequiredArgsConstructor
public class OrderFacade {

    private final ProductService productService;
    private final OrderService orderService;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public OrderResult order(OrderCommand command) {
        // 핵심: 재고 차감 + 주문 생성 (동기)
        productService.deductStock(command.getProductId(), command.getQuantity());
        Order order = orderService.create(command);

        // 이벤트 발행 (트랜잭션 커밋 후 처리)
        eventPublisher.publishEvent(new OrderCreatedEvent(
            order.getId(),
            command.getUserId(),
            command.getCouponId(),
            command.getPaymentData()
        ));

        return OrderResult.from(order);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Component
@RequiredArgsConstructor
public class OrderEventHandler {

    private final CouponService couponService;
    private final DataWarehouseClient dataWarehouseClient;
    private final PaymentDataClient paymentDataClient;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Async
    public void handleOrderCreated(OrderCreatedEvent event) {
        // 쿠폰 처리 (별도 트랜잭션) - 실패해도 다음 작업은 계속 진행합니다
        if (event.getCouponId() != null) {
            try {
                couponService.markAsUsed(event.getCouponId());
            } catch (Exception e) {
                log.error("쿠폰 처리 실패: couponId={}", event.getCouponId(), e);
            }
        }

        // 결제 데이터 전송
        try {
            paymentDataClient.send(event.getPaymentData());
        } catch (Exception e) {
            log.error("결제 데이터 전송 실패: orderId={}", event.getOrderId(), e);
        }

        // 데이터 웨어하우스 전송
        try {
            dataWarehouseClient.send(event.getOrderId());
        } catch (Exception e) {
            log.error("데이터 웨어하우스 전송 실패: orderId={}", event.getOrderId(), e);
        }
    }
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 는 트랜잭션이 커밋된 후에만 이벤트를 처리합니다. 주문이 롤백됐는데 쿠폰이 처리되는 불상사를 막습니다.

@Async는 별도 스레드에서 비동기로 처리합니다. @EnableAsync를 설정 클래스에 추가해야 합니다.

별도 트랜잭션 처리

이벤트 핸들러는 새로운 트랜잭션에서 동작해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
@RequiredArgsConstructor
public class CouponService {

    private final CouponRepository couponRepository;

    @Transactional
    public void markAsUsed(Long couponId) {
        Coupon coupon = couponRepository.findById(couponId)
            .orElseThrow();
        coupon.use();
    }
}

@Async 핸들러는 별도 스레드에서 실행되므로 기존 트랜잭션 컨텍스트가 전파되지 않습니다. 따라서 REQUIRES_NEW가 아닌 기본 @Transactional만으로도 새 트랜잭션이 생성됩니다. 동기 이벤트 핸들러(@Async 없이)에서 기존 트랜잭션과 완전히 분리된 새 트랜잭션이 필요한 경우에는 REQUIRES_NEW가 필요합니다. 쿠폰 처리가 실패해도 주문은 이미 커밋됐으므로 영향받지 않습니다.

좋아요 기능에도 적용

상품 좋아요 기능에서도 락 경합 문제가 있었습니다. 좋아요를 누를 때마다 product 테이블의 like_count를 업데이트하는데, 동시 요청이 많으면 락 경합이 발생했습니다.

1
2
3
4
5
6
7
8
9
// Before: 매 요청마다 직접 업데이트
@Transactional
public void like(Long userId, Long productId) {
    productLikeRepository.save(new ProductLike(userId, productId));

    // 이 업데이트가 락 경합 유발
    Product product = productRepository.findByIdWithLock(productId);
    product.increaseLikeCount();
}

이벤트로 분리했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
@Transactional
public void like(Long userId, Long productId) {
    productLikeRepository.save(new ProductLike(userId, productId));
    eventPublisher.publishEvent(new ProductLikedEvent(productId));
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void handleProductLiked(ProductLikedEvent event) {
    // 비동기로 처리하거나, 배치로 집계
    productService.incrementLikeCount(event.getProductId());
}

좋아요 저장과 카운트 업데이트를 분리해서 락 경합을 줄였습니다.

개선 결과

1
2
Before: 주문 생성 응답 시간 = 2.8초
After:  주문 생성 응답 시간 = 280ms (재고 + 주문만 동기 처리)

약 10배 개선입니다.

한계: 이 방식의 문제점

그런데 이 구조에는 한계가 있습니다.

이벤트 유실: 애플리케이션이 이벤트를 발행하고 처리하기 전에 재시작되면 이벤트가 사라집니다. ApplicationEvent는 메모리 내 이벤트이기 때문입니다.

순서 보장 불가: 여러 이벤트 핸들러가 병렬로 실행되면 순서가 보장되지 않습니다.

재시도 없음: 핸들러가 실패하면 이벤트가 손실됩니다.

이 한계들을 해결하려면 메시지 브로커가 필요합니다. 다음 단계는 Kafka 도입입니다.

This post is licensed under CC BY 4.0 by the author.