트랜잭션과 동시성 이슈를 정리해 본 글
같은 데이터를 여러 요청이 동시에 만질 때 어떤 문제가 생겼는지, 트랜잭션 경계와 락 선택을 어떻게 판단했는지 정리한 기록입니다.

트랜잭션은 처음 배울 때 꽤 단순해 보입니다.
메서드에 @Transactional 하나 붙이면 끝나는 것처럼 느껴지기 때문입니다.
그런데 실제 서비스에서는 같은 데이터에 여러 요청이 동시에 들어오기 시작하면서 이야기가 달라집니다.
재고 차감, 쿠폰 발급, 중복 결제 방지 같은 문제는 "로직이 맞는가"보다 "동시에 들어오면 어떻게 되는가"를 먼저 보게 됩니다.
저도 처음에는 트랜잭션이 있으니 괜찮다고 생각했습니다. 하지만 같은 요청을 거의 동시에 두 번 보내보니, 코드가 생각보다 쉽게 흔들렸습니다.
(a) 무방비 코드 — 조회 후 감소 후 저장의 race
대표적인 건 재고 차감이었습니다. 처음 작성한 코드는 이런 모양이었습니다.
@Transactional
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new BusinessException(ErrorCode.PRODUCT_NOT_FOUND));
if (product.getStock() < quantity) { // 1) 읽고 검증
throw new BusinessException(ErrorCode.STOCK_NOT_ENOUGH);
}
product.decrease(quantity); // 2) 메모리에서 감소
// 3) 트랜잭션 커밋 시점에 UPDATE 반영
}코드만 보면 문제가 없어 보입니다.
그런데 거의 동시에 두 요청이 들어오면, 둘 다 같은 재고 수량을 읽은 뒤 통과해버릴 수 있습니다.
예를 들어 재고가 1개 남은 상황에서 두 요청이 함께 들어오면, 실행 순서가 이렇게 엇갈립니다.
T1: SELECT stock -> 1
T2: SELECT stock -> 1 // 아직 T1이 커밋 전이라 똑같이 1을 읽음
T1: 1 >= 1 통과 -> stock = 0
T2: 1 >= 1 통과 -> stock = 0 // 또는 -1, 마지막 UPDATE가 덮어씀읽기와 쓰기 사이에 다른 트랜잭션이 끼어들 수 있다는 게 핵심이었습니다. 이 순간부터는 재고가 음수가 되거나, 적어도 기대와 다른 상태가 만들어집니다.
트랜잭션만으로는 부족했던 이유
여기서 많이 헷갈렸던 게, @Transactional이 붙어 있는데 왜 문제가 생기느냐는 점이었습니다.
트랜잭션은 하나의 작업 단위를 보장해주지만, 동시에 들어온 여러 트랜잭션 사이의 경쟁까지 자동으로 다 해결해주지는 않습니다. 기본 격리 수준(READ COMMITTED)에서는 위처럼 각자 커밋된 값을 읽고 진행하는 게 정상 동작입니다.
결국 봐야 하는 건 아래였습니다.
- 언제 읽고
- 언제 검증하고
- 언제 쓰는지
- 그 사이에 다른 요청이 끼어들 수 있는지
문제는 코드 한 줄이 아니라, 읽기와 쓰기 사이의 시간 차에서 자주 생겼습니다.
처음에는 synchronized도 떠올랐다
로컬에서만 볼 때는 synchronized 같은 키워드가 가장 먼저 떠오르기도 합니다.
하지만 서버가 여러 대가 되는 순간 이 방식은 바로 한계가 있습니다.
애플리케이션 인스턴스가 둘 이상이면, JVM 메모리 안에서만 잡는 락은 전체 요청을 막아주지 못합니다.
그래서 실제 서비스 기준으로는 DB 락과 분산 락을 비교해 고른 기준처럼 더 바깥쪽에서 보는 편이 낫다고 느꼈습니다.
이후로는 DB 한 대 안에서 끝낼 수 있는 선택지부터 정리했습니다. 크게 세 가지였습니다.
- 비관적 락
- 낙관적 락
- 아예 업데이트 쿼리를 원자적으로 바꾸는 방식
모든 문제를 한 가지 방식으로 풀기보다, 충돌 가능성과 트래픽 패턴을 보고 골랐습니다.
(b) 비관적 락 — 명확하지만 무거웠다
정말 충돌을 막아야 하는 구간에서는 비관적 락이 훨씬 직관적이었습니다. 조회 시점에 행을 잠가서 다른 트랜잭션을 줄 세우는 방식입니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
// 락을 무한정 기다리지 않도록 타임아웃을 건다 (ms)
@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")
})
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);@Transactional
public void decreaseStock(Long productId, int quantity) {
// SELECT ... FOR UPDATE 로 행을 잠그고 읽는다
Product product = productRepository.findByIdForUpdate(productId)
.orElseThrow(() -> new BusinessException(ErrorCode.PRODUCT_NOT_FOUND));
if (product.getStock() < quantity) {
throw new BusinessException(ErrorCode.STOCK_NOT_ENOUGH);
}
product.decrease(quantity);
// 트랜잭션이 끝나야 락이 풀려, 그 사이 다른 요청은 대기한다
}이렇게 가져오면 수정이 끝날 때까지 다른 트랜잭션이 쉽게 끼어들지 못합니다. 재고 차감처럼 "실패하더라도 값이 틀리면 안 되는" 경우에는 이 방식이 안정적이었습니다.
대신 무게가 있었습니다. 직접 부딪힌 단점은 두 가지였습니다.
- 커넥션 점유: 락은 트랜잭션이 끝나야 풀립니다. 트래픽이 몰리면 대기하는 요청들이 커넥션을 붙잡고 있어, HikariCP 풀이 먼저 마릅니다. 락 타임아웃을 짧게 걸어 무한 대기를 막아야 했습니다.
- 데드락: 한 트랜잭션에서 여러 행을 잠글 때 잠그는 순서가 엇갈리면 서로를 기다리며 교착됩니다.
T1: lock A ... 그다음 lock B 시도 (B는 T2가 쥐고 있음)
T2: lock B ... 그다음 lock A 시도 (A는 T1이 쥐고 있음)
-> DB가 교착을 감지하고 한쪽을 강제 롤백그래서 비관적 락을 쓸 때는 여러 행을 항상 같은 순서(예: id 오름차순)로 잠그기, 트랜잭션을 짧게 유지하기, 락 타임아웃 걸기를 같이 챙겼습니다. 데드락으로 롤백된 요청은 뒤에서 다룰 재시도로 회복시켰습니다.
(c) 낙관적 락 — 충돌이 드문 경우에 좋았다
충돌이 드문 데이터라면 굳이 행을 잠그지 않고, 커밋 시점에 충돌만 감지하는 낙관적 락이 더 가벼웠습니다. JPA에서는 @Version으로 비교적 쉽게 붙일 수 있습니다.
@Entity
public class Product {
@Id
private Long id;
private int stock;
@Version // UPDATE 시 version 일치 여부를 함께 검사
private Long version;
}엔티티를 수정하고 커밋하면 하이버네이트는 UPDATE ... WHERE id = ? AND version = ? 형태로 나갑니다. 그 사이 다른 트랜잭션이 먼저 커밋해 version이 올라갔다면, 갱신된 행이 0건이라 ObjectOptimisticLockingFailureException이 터집니다.
이 방식이 좋았던 건 평소에는 락으로 막지 않고 진행하다가, 실제 충돌이 났을 때만 예외로 감지할 수 있다는 점이었습니다. 다만 예외가 곧 실패는 아니어서, 충돌은 재시도로 흡수해야 의미가 있었습니다.
private static final int MAX_ATTEMPTS = 3;
// 주의: 재시도 루프는 트랜잭션 "바깥"에 있어야 한다.
// 같은 트랜잭션 안에서 다시 시도하면 이미 롤백 표시된 트랜잭션을 재사용하게 된다.
public void decreaseWithRetry(Long productId, int quantity) {
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
stockService.decreaseStock(productId, quantity); // @Transactional 메서드
return;
} catch (ObjectOptimisticLockingFailureException e) {
if (attempt == MAX_ATTEMPTS) {
throw new BusinessException(ErrorCode.STOCK_CONFLICT);
}
sleepBackoff(attempt); // 짧은 지터 backoff 후 재시도
}
}
}충돌이 드물면 재시도가 거의 일어나지 않아 비용이 낮았습니다. 하지만 충돌이 자주 나는 상황에서는 재시도가 많아지고, 결국 사용자 경험이 나빠질 수 있었습니다. 그래서 "가끔 동시에 수정될 수 있는 데이터"에는 괜찮았지만, 재고처럼 충돌이 잦은 경우에는 아쉬움이 있었습니다.
(d) 원자적 UPDATE — 조회 없이 한 번에 처리
생각보다 좋았던 건 조회 후 변경이 아니라, 조건부 업데이트 쿼리로 바로 처리하는 방식이었습니다. 읽기·검증·쓰기를 한 SQL 안에 담는 방식입니다.
UPDATE stock
SET qty = qty - 1
WHERE id = ?
AND qty > 0; -- 재고가 있을 때만 갱신, 영향받은 행 수로 성공 여부 판단JPA에서는 @Modifying 쿼리로 같은 일을 합니다.
@Modifying
@Query("""
update Product p
set p.stock = p.stock - :quantity
where p.id = :productId
and p.stock >= :quantity
""")
int decreaseStock(@Param("productId") Long productId,
@Param("quantity") int quantity);@Transactional
public void decreaseStock(Long productId, int quantity) {
int updated = productRepository.decreaseStock(productId, quantity);
if (updated == 0) { // 0이면 재고 부족 또는 상품 없음
throw new BusinessException(ErrorCode.STOCK_NOT_ENOUGH);
}
}이렇게 하면 읽기와 검증과 쓰기가 한 SQL 안에서 끝납니다. 행에 대한 쓰기 락은 DB가 짧게 잡았다 풀어주므로 별도 재시도도 거의 필요 없었습니다. 성공하면 1, 실패하면 0을 반환하니 결과 해석도 단순했습니다. 불필요한 조회를 줄인다는 점에서는 JPA에서 N+1로 쿼리가 늘어나는 문제를 줄였던 과정과도 닿아 있었습니다.
대신 한계도 분명했습니다. 검증 로직이 qty > 0처럼 SQL로 표현 가능한 단순한 규칙일 때만 깔끔했고, 등급별 한도나 여러 테이블을 함께 봐야 하는 복잡한 도메인 규칙에는 잘 맞지 않았습니다. 그래도 재고 차감처럼 규칙이 단순한 경우에는 꽤 안정적이었습니다.
네 가지를 한 표로 비교하면
| 전략 | 동시성 처리 | 단점 | 적합 상황 |
|---|---|---|---|
| (a) 무방비 조회 후 갱신 | 없음 (race 발생) | 초과 차감·중복 발급 | 사실상 동시성이 없는 데이터 |
| (b) 비관적 락 (PESSIMISTIC_WRITE) | 행을 잠가 직렬화 | 커넥션 점유·대기 증가, 데드락 위험 | 충돌이 잦고 절대 틀리면 안 되는 값 |
| (c) 낙관적 락 (@Version) | 커밋 시 충돌 감지 후 재시도 | 충돌 잦으면 재시도 폭증 | 동시 수정이 드문 데이터 |
| (d) 원자적 UPDATE | 단일 SQL에서 검증+갱신 | 복잡한 도메인 규칙엔 부적합 | 규칙이 단순한 카운터·재고 |
표로 정리해 보니, 재고처럼 충돌이 잦고 규칙이 단순한 값에는 (d) 원자적 UPDATE가, 도메인 규칙이 복잡해 코드에서 검증해야 하는 값에는 (b) 비관적 락이 잘 맞았습니다. (c) 낙관적 락은 동시 수정이 드문 일반 엔티티에 가벼웠습니다.
결국 중요한 건 문제 성격이었다
동시성 문제를 정리하면서 느낀 건, 먼저 락 종류를 고르는 게 아니라 무엇이 절대 틀리면 안 되는가를 정하는 게 더 중요하다는 점이었습니다.
예를 들면:
- 재고 차감: 값이 틀리면 안 됨
- 게시글 조회수: 약간의 오차를 감수할 수 있음
- 쿠폰 발급 수량: 초과 발급이 되면 안 됨
이 기준이 선명해야 어떤 방식이 맞는지도 빨리 정해졌습니다.
재시도와 멱등성을 같이 봐야 했다
낙관적 락 충돌이나 데드락 롤백, 일시적인 경합은 재시도로 풀 수 있을 때가 많았습니다.
문제는 재시도를 넣을 때도 무작정 반복하면 안 된다는 점이었습니다.
- 재시도는 트랜잭션 바깥에서: 롤백 표시된 트랜잭션을 다시 쓰면 또 실패합니다. 위
decreaseWithRetry처럼 트랜잭션 메서드를 통째로 다시 호출해야 했습니다. - 재시도 횟수와 backoff 제한: 무한 반복은 오히려 경합을 키웁니다. 최대 횟수를 두고, 매 시도 사이에 짧은 지터를 줬습니다.
- 멱등성: 결제·발급 같은 쓰기는 재시도로 두 번 실행될 수 있어, 요청 id를 두고 중복 호출을 막는 장치를 함께 뒀습니다.
재시도는 결국 중복 실행 가능성, 멱등성, 사용자 경험까지 같이 봐야 하는 문제였습니다.
지금 기준에서는 이렇게 본다
트랜잭션과 동시성 문제를 볼 때 지금은 아래 질문을 먼저 던집니다.
- 이 데이터는 동시에 수정될 수 있는가
- 충돌이 자주 나는가, 드문가
- 값이 조금 어긋나도 되는가, 절대 안 되는가
- 검증 규칙이 SQL 한 줄로 표현되는가, 코드가 필요한가
- 재시도로 회복 가능한가
예전에는 @Transactional을 붙이면 끝났다고 생각했는데, 지금은 그게 시작점일 뿐이라는 걸 더 분명히 느끼고 있습니다.
마무리
트랜잭션은 데이터 일관성을 지키기 위한 기본 장치였고, 동시성 제어는 그 위에서 실제 요청 경쟁을 다루는 문제에 더 가까웠습니다.
결국 중요한 건 기술 이름보다 문제의 성격을 정확히 보는 일이었습니다.
충돌을 막아야 하는 데이터인지, 재시도로 풀 수 있는 상황인지 먼저 분명해질수록 선택도 훨씬 쉬워졌습니다.