내일배움캠프

[트러블 슈팅] Transaction Commit 전 서버에서 경매방이 생성되는 문제

cork-7 2025. 4. 30. 20:16

문제 상황

실시간 경매 시스템에서 메인 서버가 경매를 생성하고, WebSocket 서버에 경매방을 만들도록 요청하는 구조였다.

그런데 다음과 같은 문제가 발생했다:

❗ 메인 서버에서 경매 생성이 실패했는데도, WebSocket 서버에는 방이 생성돼버린다.

  • 경매를 생성할 때 유효성 검증 오류 등으로 DB에 저장이 되지 않았음
  • 하지만 그 시점에서 이미 WebSocket 서버에 경매방 생성 요청이 전달되어 실행됨
  • 이후 다시 요청을 보내면 auctionId가 달라져 두 서버 간 auctionId 불일치 문제 발생

원인 분석

문제의 핵심은 단순했다.

Auction saveAuction = auctionRepository.save(auction);

// ❌ 이 시점엔 아직 트랜잭션 커밋되지 않았음
webSocketClient.createAuctionRoom(...);

즉, @Transactional 메서드 내부에서 DB 커밋이 완료되기도 전에

WebSocket 서버에 방 생성 요청을 보내버리는 구조였다.

만약 DB 저장이 실패하거나 롤백되면? → WebSocket 서버에는 유령 경매방이 생겨버리는 상황

해결 방법 : @TransactionalEventListener 활용

Spring에서는 트랜잭션 커밋이 완료된 이후에만 실행되는 후처리 이벤트 리스너를 제공한다.

이를 활용해 다음과 같은 구조로 리팩토링했다.

이벤트 클래스 정의

@Getter
public class AuctionCreatedEvent {
    private final Auction auction;

    public AuctionCreatedEvent(Auction auction) {
        this.auction = auction;
    }
}

경매 생성 완료 시 Auction 객체를 담아 이벤트로 전달할 수 있도록 만든 단순 DTO

경매 생성 서비스에서 이벤트 발행 (AuctionService)

@Transactional
public AuctionSaveResponse createAuction(...) {
    Auction savedAuction = auctionRepository.save(auction);

    // ✅ 트랜잭션 커밋 후 처리될 이벤트 발행
    eventPublisher.publishEvent(new AuctionCreatedEvent(savedAuction));

    return new AuctionSaveResponse(...);
}

기존에 들어있던 webSocketClient.createAuctionRoom()호출은 제거하고,

대신 이벤트를 발행함.

이 시점에는 아직 트랜잭션 커밋 전이므로, 실제 요청은 바로 나가지 않음.

트랜잭션 커밋 후 실행되는 리스너 작성

@Component
@RequiredArgsConstructor
public class AuctionEventListener {

    private final WebSocketClient webSocketClient;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleAuctionCreated(AuctionCreatedEvent event) {
        Auction auction = event.getAuction();

        log.info("✅ 트랜잭션 커밋 완료 → 웹소켓 서버에 경매방 생성 요청");

        webSocketClient.createAuctionRoom(
            new WebSocketAuctionCreateRequest(
                auction.getId(),
                auction.getProduct().getProductName(),
                auction.getMinPrice(),
                auction.getStartTime(),
                auction.getEndTime()
            )
        );
    }
}

이 리스너는DB에 경매가 진짜 저장된 후에만 동작하므로,

유령 경매방이 생성되는 일을 완전히 방지할 수 있다.

WebSocketClient 클래스는 기존 그대로 사용

public void createAuctionRoom(WebSocketAuctionCreateRequest request) {
    webClient.post()
        .uri(websocketUrl + "/internal/auction/join")
        .bodyValue(request)
        .retrieve()
        .bodyToMono(Void.class)
        .block(); // 동기로 요청
}

 

WebSocket 서버에 HTTP 요청을 보내 경매방을 생성하는 로직

전체 흐름 요약

[메인 서버에서 경매 생성 요청]
       ↓
AuctionService.createAuction()
       ↓
DB에 저장 (아직 커밋 전)
       ↓
이벤트 발행 (publishEvent)
       ↓
트랜잭션 커밋 성공
       ↓
@TransactionEventListener 실행
       ↓
WebSocket 서버에 방 생성 요청

회고

  • 분산 구조에서 한 서버의 결과를 다른 서버에 반영해야 할 때는
  • 트랜잭션 커밋 전후를 구분하는 것이 매우 중요하다.
  • Spring의 @TransactionalEventListener는 이런 케이스에서 매우 유용하며,
  • 시스템 안정성을 높이고 코드의 관심사 분리도 가능하게 한다.