9osari.log
← 목록으로

[DevBid - JPA] 낙관적 락 적용과 데드락

개인 프로젝트에서 경매 입찰 기능을 개발하던 중, JMeter로 동시 입찰을 테스트했더니 10명이 입찰했는데 3개의 입찰이 성공하고 입찰 카운트는 1만 증가하는 데이터 정합성 문제가 발생했습니다.

실제 저장된 입찰 수와 경매 객체의 상태가 일치하지 않았고, 이는 여러 트랜잭션이 동시에 같은 데이터를 수정하면서 발생한 동시성 문제였습니다.

초기 구현

@Override
public void placeBid(Long auctionId, Long bidderId, BigDecimal bidAmount) {
    Auction auction = findById(auctionId);  //경매찾기
    User bidder = userRepository.findById(bidderId) //사용자 찾기
            .orElseThrow(() -> new IllegalArgumentException("User not found"));

    //엔티티에 비즈니스 로직 위임
    Bid bid = auction.placeBid(bidder, new BidAmount(bidAmount));
    bidRepository.save(bid);
    
    //웹소켓 이벤트 발행
    BidPlacedEvent event = BidPlacedEvent.of(auctionId, bidderId, bidAmount);
    eventPublisher.publishEvent(event);
}
//입찰
public Bid placeBid(User bidder, BidAmount bidAmount) {
    //검증로직
    ...
    this.currentPrice = new CurrentPrice(bidAmount.getValue());
    this.currentBidderId = bidder.getId();
    this.bidCount++;
    return Bid.of(this, bidder, bidAmount); //새 입찰 생성
}

Jmeter로 확인

image.png

SELECT COUNT(*) as actual_bid_count
FROM bids
WHERE auction_id = 6;

-> 3

image.png


동시성 제어 방법 비교

synchronized

비관적 락

낙관적 락

경매 도메인에 낙관적 락이 적합한 이유


낙관적 락 적용

엔티티에 @Version 추가

@Entity
public class Auction {
    @Version  // 낙관적 락 적용
    private Long version;
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    ...
}

서비스 입찰 로직에 @Transactional 추가 및 예외처리

@Override
@Transactional
public void placeBid(Long auctionId, Long bidderId, BigDecimal bidAmount) {
    Auction auction = findById(auctionId);  //경매찾기
    User bidder = userRepository.findById(bidderId) //사용자 찾기
            .orElseThrow(() -> new IllegalArgumentException("User not found"));

    try {
        //도메인 객체에 비즈니스 로직 위임
        Bid bid = auction.placeBid(bidder, new BidAmount(bidAmount));
        bidRepository.save(bid);

        //웹소켓 이벤트 발행
        BidPlacedEvent event = BidPlacedEvent.of(auctionId, bidderId, bidAmount);
        eventPublisher.publishEvent(event);
        log.info("입찰 이벤트 - event: {}", event);
    } catch (OptimisticLockingFailureException e) {
        log.error("낙관적 락 충돌 auctionId: {}", auctionId, e);
        throw new IllegalArgumentException("다른 입찰이 먼저 처리되었습니다.");
    }
}

테스트 결과

image.png

SQL Error: 1213, SQLState: 40001
Deadlock found when trying to get lock; try restarting transaction

성공! 했지만 데드락 발생

트랜잭션 내의 INSERT가 외래키 제약으로 상위 테이블에 락을 걸면서 데드락이 발생했다

즉 교착상태(무한대기)

데드락문제를 과장님에게 물어보니 ‘애초의 작업의 단위를 잘 분리하면 데드락이 생기지 않는다. 데드락이 발생하는 케이스도 몇 개 없다. 지금 읽기-쓰기-읽기 구조니까 3가지를 확인하며 찾아봐’ 라고 조언해 주셨다


문제점 찾기

Bid 테이블 확인

CREATE TABLE `bids` (
  `bid_amount` decimal(38,2) NOT NULL,
...
  `updated_at` datetime(6) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `FKbm89m2gow82dotpnlcp7t3p5f` (`auction_id`),
  KEY `FKmtrc6tnwawlpk1u2km6qnxbha` (`bidder_id`),
  CONSTRAINT `FKbm89m2gow82dotpnlcp7t3p5f` FOREIGN KEY (`auction_id`) REFERENCES `auctions` (`id`),
  CONSTRAINT `FKmtrc6tnwawlpk1u2km6qnxbha` FOREIGN KEY (`bidder_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=30 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
ALTER TABLE bids
  DROP FOREIGN KEY FKbm89m2gow82dotpnlcp7t3p5f;

테스트

JMeter 확인

유효성 검사

즉 교착상태(무한대기)

이벤트 핸들러 @Async 적용

@Async
@EventListener
public void handleBidPlacedEvent(BidPlacedEvent event ) {
    log.info("입찰 이벤트 수신: {}", event);

    //DTO 생성
    Auction auction = auctionService.findById(event.getAuctionId());
    User bidder = userRepository.findById(event.getBidderId()).orElseThrow();

    BidEventDto dto = BidEventDto.from(
            event.getAuctionId(),
            auction.getCurrentPrice().getValue(),
            bidder.getNickname().getValue(),
            event.getBidderId(),
            auction.getBidCount()
    );

    //WebSocket 전송
    messagingTemplate.convertAndSend("/topic/auction/" + event.getAuctionId(), dto);
}

테스트 결과

Async

@Async를 적용하면 별도 스레드에서 실행되지만, 이벤트 발행 시점이 트랜잭션 커밋 전이라면 아직 DB에 반영되지 않은 데이터를 조회하려다 문제가 생길 수 있다


입찰 저장(DB 작업)과 웹소켓(입찰가격, 입찰자) 전송이 같은 트랜잭션에 묶여 있으면:

트랜잭션 경계를 분리하여 입찰은 입찰대로 커밋하고, 알림은 별도로 처리하도록 개선

웹소켓 알림은 입찰 도메인 로직이 아닌 사용자 알림이라는 별도 책임이므로, 서비스 레이어의 트랜잭션과 분리하여 컨트롤러에서 처리하는 것이 적절하다고 판단했습니다.

입찰로직과 웹소켓이벤트 트랜잭션 경계 분리

@PostMapping("/bid/{auctionId}")
public String bid(@PathVariable Long auctionId,
                  @RequestParam("bidAmount") BigDecimal bidAmount,
                  @AuthenticationPrincipal AuthUser authUser,
                  RedirectAttributes ra) {
    try {
        //입찰
        BidPlacedEvent event = auctionApplicationService.placeBid(
                        auctionId,
                        authUser.getId(),
                        bidAmount
                    );
        
        //웹소켓이벤트
        auctionApplicationService.publishBidEvent(event);
        
        ra.addFlashAttribute("message", "Bid Successfully.");
        return "redirect:/auction/" + auctionId + "/success";
    } catch (IllegalArgumentException e) {
        log.error("입찰 실패 - auctionId: {}, error: {}", auctionId, e.getMessage(), e);
        ra.addFlashAttribute("error", e.getMessage());
        return "redirect:/auction/" + auctionId + "/detail";
    }
}
@Override
@Transactional
public BidPlacedEvent placeBid(Long auctionId, Long bidderId, BigDecimal bidAmount) {
    Auction auction = findById(auctionId);  //경매찾기
    User bidder = userRepository.findById(bidderId) //사용자 찾기
            .orElseThrow(() -> new IllegalArgumentException("User not found"));

    try {
        //엔티티에 비즈니스 로직 위임
        Bid bid = auction.placeBid(bidder, new BidAmount(bidAmount));
        bidRepository.save(bid);

        return BidPlacedEvent.of(auctionId,
                bidderId,
                bid.getBidder().getNickname().getValue(),
                bidAmount,
                auction.getBidCount()
        );
    } catch (OptimisticLockingFailureException e) {
        log.error("낙관적 락 충돌 auctionId: {}", auctionId, e);
        throw new IllegalArgumentException("다른 입찰이 먼저 처리되었습니다.");
    }
}

public void publishBidEvent(BidPlacedEvent event) {
    //웹소켓 이벤트 발행
    eventPublisher.publishEvent(event);
    log.info("입찰 이벤트 발행 - event: {}", event);
}
@Component
@RequiredArgsConstructor
@Slf4j
public class AuctionWebSocketEventListener {
    private final SimpMessagingTemplate messagingTemplate;

    @EventListener
    public void handleBidPlacedEvent(BidPlacedEvent event ) {
        log.info("입찰 이벤트 수신: {}", event);
        //DTO 생성
        BidEventDto dto = BidEventDto.from(
                event.getAuctionId(),
                event.getCurrentPrice(),
                event.getBidderNickname(),
                event.getBidderId(),
                event.getBidCount()
        );

        //WebSocket 전송
        messagingTemplate.convertAndSend("/topic/auction/" + event.getAuctionId(), dto);
        log.info("WebSocket 전송 이벤트 종료..");
    }
}

성공!

[INFO] ApplicationService 시작
[INFO] 트랜잭션 시작

[SQL] 경매 조회: SELECT * FROM auctions WHERE id=6;
[SQL] 사용자 조회: SELECT * FROM users WHERE id=10000011;
[SQL] 상품 조회: SELECT * FROM products WHERE id=12303;

[SQL] 입찰 등록:
INSERT INTO bids (auction_id, bid_amount, bidder_id, created_at)
VALUES (6, 1325000, 10000011, ...);

[INFO] 트랜잭션 커밋 직전
[SQL] 경매 정보 갱신:
UPDATE auctions
SET current_price=1325000, current_bidder_id=10000011, version=10
WHERE id=6 AND version=9;

[INFO] 트랜잭션 커밋 완료 - 이벤트 발행 시작
[INFO] 입찰 이벤트 수신: BidPlacedEvent@771b7b3b
[INFO] 이벤트 발행 완료

[INFO] WebSocket 상태:
- 현재 세션 1개
- 총 연결 2회
- 비정상 종료 0건

최종 결과

Jmeter 최종 확인


느낀점

···
← PREV [Network] 네트워크 장애 대응기 NEXT → [DevBid - 실시간통신] WebSocket으로 실시간 경매 구현하기