[DevBid - JPA] 낙관적 락 적용과 데드락
[DevBid - JPA] 낙관적 락 적용과 데드락
개인 프로젝트에서 경매 입찰 기능을 개발하던 중, JMeter로 동시 입찰을 테스트했더니 10명이 입찰했는데 3개의 입찰이 성공하고 입찰 카운트는 1만 증가하는 데이터 정합성 문제가 발생했습니다.
실제 저장된 입찰 수와 경매 객체의 상태가 일치하지 않았고, 이는 여러 트랜잭션이 동시에 같은 데이터를 수정하면서 발생한 동시성 문제였습니다.
초기 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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);
}
1
2
3
4
5
6
7
8
9
//입찰
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로 확인
- 동시 사용자 수: 10
- Ramp-up: 0초 (동시 실행)
- 입찰 금액: 고정값
1
2
3
4
5
SELECT COUNT(*) as actual_bid_count
FROM bids
WHERE auction_id = 6;
-> 3
- 3개의 입찰이 성공 했는데, 경매 객체의 카운트는 1만 증가한 데이터 정합성 문제 발생
동시성 제어 방법 비교
synchronized
- JVM 레벨의 락 (단일 서버에서만 동작)
- 멀티서버 환경에서 동시성 제어 불가능 (서버 A, B에서 동시 입찰 시 각자의 synchronized는 있으나 마나)
- 실시간성 중요한 경매 도메인에서 블로킹 방식은 비효율
비관적 락
- DB 레벨의 락
- 락 대기로 인한 응답시간 증가
- 인기 경매의 경우 병목 발생 가능성
- 데드락 가능성
낙관적 락
- 버전관리 방식
- 읽기는 락 없이 빠르게 처리
- 충돌 발생시에만 실패 처리 및 재시도 필요
- DB 커넥션 효율적 사용
경매 도메인에 낙관적 락이 적합한 이유
- 실패가 비지니스의 일부 → 충돌 시 실패해도 자연스러움
- 빠른 응답 > 순차 처리 → 락 대기 없이 즉시 성공/실패 판단
- 높은 처리량 → 마감 임박시 동시 입찰 처리
낙관적 락 적용
엔티티에 @Version 추가
1
2
3
4
5
6
7
8
9
10
11
@Entity
public class Auction {
@Version // 낙관적 락 적용
private Long version;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
...
}
서비스 입찰 로직에 @Transactional 추가 및 예외처리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@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("다른 입찰이 먼저 처리되었습니다.");
}
}
테스트 결과
1
2
SQL Error: 1213, SQLState: 40001
Deadlock found when trying to get lock; try restarting transaction
성공! 했지만 데드락 발생
트랜잭션 내의 INSERT가 외래키 제약으로 상위 테이블에 락을 걸면서 데드락이 발생했다
즉 교착상태(무한대기)
데드락문제를 과장님에게 물어보니 ‘애초의 작업의 단위를 잘 분리하면 데드락이 생기지 않는다. 데드락이 발생하는 케이스도 몇 개 없다. 지금 읽기-쓰기-읽기 구조니까 3가지를 확인하며 찾아봐’ 라고 조언해 주셨다
@Asnyc로 테스트- DB에 외래키가 있는지
- 트랜잭션 작업 단위를 생각하며 로직을 작성했는지..
문제점 찾기
Bid 테이블 확인
1
2
3
4
5
6
7
8
9
10
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;
1
2
ALTER TABLE bids
DROP FOREIGN KEY FKbm89m2gow82dotpnlcp7t3p5f;
- bid 테이블의 외래키 제약조건을 확인 후 제거
테스트
bis테이블의 외래키 제약조건 (auction_id) 제거- INSERT시 참조 무결성 검증을 위해 부모 테이블(auction)에 Lock 시도
- 이미 다른 트랜잭션이 X Lock을 보유중이므로 데드락 발생
즉 교착상태(무한대기)
이벤트 핸들러 @Async 적용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@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 작업)과 웹소켓(입찰가격, 입찰자) 전송이 같은 트랜잭션에 묶여 있으면:
- 웹소켓 호출 시간만큼 DB 락을 불필요하게 점유
- 웹소켓 전송 실패 시 이미 성공한 입찰까지 롤백
→ 트랜잭션 경계를 분리하여 입찰은 입찰대로 커밋하고, 알림은 별도로 처리하도록 개선
웹소켓 알림은 입찰 도메인 로직이 아닌 사용자 알림이라는 별도 책임이므로, 서비스 레이어의 트랜잭션과 분리하여 컨트롤러에서 처리하는 것이 적절하다고 판단했습니다.
입찰로직과 웹소켓이벤트 트랜잭션 경계 분리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@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";
}
}
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
@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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@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 전송 이벤트 종료..");
}
}
- 이벤트에 모든 데이터가 있으니 DB 조회를 제거
- 단일 책임 원칙을 생각해 입찰의 트랜잭션 처리로직을 publishBidEvent로 분리
성공!
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
[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건
최종 결과
- 10개 동시 입찰 요청 → 1개만 성공
- 경매 데이터 정합성 유지 (bid_count = 실제 입찰 수)
- 입찰 성공 여부와 웹소켓 알림 전송은 별개의 작업으로 보고, 트랜잭션 경계를 명확히 분리
- DB 락을 불필요하게 오래 점유하지 않고, 웹소켓 전송 실패로 입찰까지 롤백되는 문제 방지
- 코드도 깔끔해진거같다.
느낀점
- 책임분리원칙을 기억하자..
- 트랜잭션 하나로 가능한 작업인지 생각하자.
- “읽었으면 쓰고 다시 읽지 말자” — 동일한 데이터를 반복 조회 하기보다 명확한 상태 전이를 중심으로 로직을 구성하자.
- Spring AOP와 @Transactional 좀 더 깊이있게 알아봐야겠다.
This post is licensed under
CC BY 4.0
by the author.






