트랜잭션과 동시성 이슈를 정리해 본 글
같은 데이터를 여러 요청이 동시에 만질 때 어떤 문제가 생겼는지, 트랜잭션 경계와 락 선택을 어떻게 판단했는지 정리한 기록입니다.
트랜잭션은 처음 배울 때 꽤 단순해 보입니다.
메서드에 @Transactional 하나 붙이면 끝나는 것처럼 느껴지기 때문입니다.
그런데 실제 서비스에서는 같은 데이터에 여러 요청이 동시에 들어오기 시작하면서 이야기가 달라집니다.
재고 차감, 쿠폰 발급, 중복 결제 방지 같은 문제는 "로직이 맞는가"보다 "동시에 들어오면 어떻게 되는가"를 먼저 보게 됩니다.
저도 처음에는 트랜잭션이 있으니 괜찮다고 생각했습니다. 하지만 같은 요청을 거의 동시에 두 번 보내보니, 코드가 생각보다 쉽게 흔들렸습니다.
가장 먼저 겪었던 문제
대표적인 건 재고 차감이었습니다.
@Transactional
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new BusinessException(ErrorCode.PRODUCT_NOT_FOUND));
if (product.getStock() < quantity) {
throw new BusinessException(ErrorCode.STOCK_NOT_ENOUGH);
}
product.decrease(quantity);
}코드만 보면 문제가 없어 보입니다.
그런데 거의 동시에 두 요청이 들어오면, 둘 다 같은 재고 수량을 읽은 뒤 통과해버릴 수 있습니다.
예를 들어 재고가 1개 남은 상황에서 두 요청이 함께 들어오면:
- 요청 A가 재고 1개를 읽음
- 요청 B도 재고 1개를 읽음
- 둘 다 "재고 충분"이라고 판단
- 둘 다 차감 수행
이 순간부터는 재고가 음수가 되거나, 적어도 기대와 다른 상태가 만들어집니다.
트랜잭션만으로는 부족했던 이유
여기서 많이 헷갈렸던 게, @Transactional이 붙어 있는데 왜 문제가 생기느냐는 점이었습니다.
트랜잭션은 하나의 작업 단위를 보장해주지만, 동시에 들어온 여러 트랜잭션 사이의 경쟁까지 자동으로 다 해결해주지는 않습니다.
결국 봐야 하는 건 아래였습니다.
- 언제 읽고
- 언제 검증하고
- 언제 쓰는지
- 그 사이에 다른 요청이 끼어들 수 있는지
문제는 코드 한 줄이 아니라, 읽기와 쓰기 사이의 시간 차에서 자주 생겼습니다.
처음에는 synchronized도 떠올랐다
로컬에서만 볼 때는 synchronized 같은 키워드가 가장 먼저 떠오르기도 합니다.
하지만 서버가 여러 대가 되는 순간 이 방식은 바로 한계가 있습니다.
애플리케이션 인스턴스가 둘 이상이면, JVM 메모리 안에서만 잡는 락은 전체 요청을 막아주지 못합니다.
그래서 실제 서비스 기준으로는 DB 락이나 분산 락처럼 더 바깥쪽에서 보는 편이 낫다고 느꼈습니다.
주로 봤던 선택지는 세 가지였다
실제로는 아래 세 가지를 상황에 따라 나눠서 봤습니다.
- 낙관적 락
- 비관적 락
- 아예 업데이트 쿼리를 원자적으로 바꾸는 방식
모든 문제를 한 가지 방식으로 풀기보다, 충돌 가능성과 트래픽 패턴을 보고 골랐습니다.
낙관적 락은 충돌이 드문 경우에 좋았다
JPA에서는 @Version으로 비교적 쉽게 붙일 수 있습니다.
@Version
private Long version;이 방식이 좋았던 건 평소에는 락으로 막지 않고 진행하다가, 실제 충돌이 났을 때만 예외로 감지할 수 있다는 점이었습니다.
하지만 충돌이 자주 나는 상황에서는 재시도가 많아지고, 결국 사용자 경험이 나빠질 수 있었습니다.
그래서 "가끔 동시에 수정될 수 있는 데이터"에는 괜찮았지만, 재고처럼 충돌이 잦은 경우에는 아쉬움이 있었습니다.
비관적 락은 명확하지만 무거웠다
정말 충돌을 막아야 하는 구간에서는 비관적 락이 훨씬 직관적이었습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(Long id);이렇게 가져오면 수정이 끝날 때까지 다른 트랜잭션이 쉽게 끼어들지 못합니다.
재고 차감처럼 "실패하더라도 값이 틀리면 안 되는" 경우에는 이 방식이 안정적이었습니다.
대신 락이 길어지면 대기 시간이 늘어나고, 전체 처리량에 영향을 줄 수 있다는 점은 항상 같이 봐야 했습니다.
업데이트 쿼리를 아예 한 번에 바꾸는 방식도 유용했다
생각보다 좋았던 건 조회 후 변경이 아니라, 조건부 업데이트 쿼리로 바로 처리하는 방식이었습니다.
@Modifying
@Query("""
update Product p
set p.stock = p.stock - :quantity
where p.id = :productId
and p.stock >= :quantity
""")
int decreaseStock(Long productId, int quantity);이렇게 하면 읽기와 검증과 쓰기가 한 SQL 안에서 끝납니다.
성공하면 1, 실패하면 0을 반환하니 결과 해석도 단순했습니다.
모든 경우에 이 방식이 정답은 아니지만, 재고 차감처럼 규칙이 단순한 경우에는 꽤 안정적이었습니다.
결국 중요한 건 문제 성격이었다
동시성 문제를 정리하면서 느낀 건, 먼저 락 종류를 고르는 게 아니라 무엇이 절대 틀리면 안 되는가를 정하는 게 더 중요하다는 점이었습니다.
예를 들면:
- 재고 차감: 값이 틀리면 안 됨
- 게시글 조회수: 약간의 오차를 감수할 수 있음
- 쿠폰 발급 수량: 초과 발급이 되면 안 됨
이 기준이 선명해야 어떤 방식이 맞는지도 빨리 정해졌습니다.
운영에서는 재시도 전략도 같이 봤다
낙관적 락이나 일시적인 충돌은 재시도로 풀 수 있을 때도 많았습니다.
문제는 재시도를 넣을 때도 무작정 반복하면 안 된다는 점이었습니다.
재시도는 결국 중복 실행 가능성, 멱등성, 사용자 경험까지 같이 봐야 합니다.
그래서 중요한 쓰기 요청에는 요청 id를 두고, 중복 호출을 막는 방식도 함께 고려했습니다.
지금 기준에서는 이렇게 본다
트랜잭션과 동시성 문제를 볼 때 지금은 아래 질문을 먼저 던집니다.
- 이 데이터는 동시에 수정될 수 있는가
- 충돌이 자주 나는가, 드문가
- 값이 조금 어긋나도 되는가, 절대 안 되는가
- 재시도로 회복 가능한가
예전에는 @Transactional을 붙이면 끝났다고 생각했는데, 지금은 그게 시작점일 뿐이라는 걸 더 분명히 느끼고 있습니다.
마무리
트랜잭션은 데이터 일관성을 지키기 위한 기본 장치였고, 동시성 제어는 그 위에서 실제 요청 경쟁을 다루는 문제에 더 가까웠습니다.
결국 중요한 건 기술 이름보다 문제의 성격을 정확히 보는 일이었습니다.
충돌을 막아야 하는 데이터인지, 재시도로 풀 수 있는 상황인지 먼저 분명해질수록 선택도 훨씬 쉬워졌습니다.