티스토리 뷰
개요
동시성 문제를 해결할 때 흔히 착각하는게 있다. "경합이 많으면 비관적 락, 적으면 낙관적 락." 깔끔해 보이지만 현실은 이렇게 단순하지 않다. 데이터가 어떤 성격인지, 비즈니스 규칙이 얼마나 복잡한지, 실패했을 때 어떤 일이 벌어지는지에 따라 답이 달라진다.
이커머스에서 상품 재고를 차감할 때 발생하는 동시성 문제를 예로 각 전략의 장단점을 살펴보겠다.
상황
고객이 물건을 주문하면 재고를 차감해야 한다. 흐름은 단순하다.
flowchart LR
A[상품 주문] --> B[재고 확인] --> C[재고 차감] --> D[주문 완료]
재고는 공유자원이다. 같은 상품을 여러 사용자가 동시에 주문하면 경합이 발생한다.
sequenceDiagram
participant A as 사용자 A
participant DB as 재고 (stock = 1)
participant B as 사용자 B
A->>DB: 재고 확인 → 1개 남음
B->>DB: 재고 확인 → 1개 남음
A->>DB: 재고 차감 (1 → 0)
Note over A: 주문 성공
B->>DB: 재고 차감 (1 → 0)
Note over B: 주문 성공?!
Note over DB: 실제로는 재고 0인데<br/>2건이 주문 완료됨
재고가 남아 있는지 확인하고 차감하려는 사이에 다른 요청이 같은 재고를 읽고 차감하면 의도하지 않은 결과가 발생한다. 이 문제를 어떻게 막을 수 있을까?
해결 전략
1. 원자적 업데이트
원자적(atomic)이란 "더 이상 쪼갤 수 없는"이라는 뜻이다. 조회, 계산, 수정을 하나의 쿼리에 담아서 중간에 다른 요청이 끼어들 수 없게 만드는 방법이다.
UPDATE product SET stock = stock - 1
WHERE id = 1 AND stock > 0
내부적으로는 이런 순서로 동작한다.
1. SELECT stock → 10 ← 읽기
2. stock - 1 = 9 ← 계산
3. UPDATE stock = 9 ← 쓰기간단하고, 자원 점유 시간이 짧다. UPDATE 실행 시점에만 공유자원을 점유한다.
원자적 쿼리도 내부적으로 배타락을 건다. InnoDB에서 UPDATE 문은 대상 행에 X Lock을 획득하고, WHERE 조건 평가와 값 갱신이 락 내에서 수행된다. 별도로 락을 걸 필요가 없다.
단점도 있다.
1.1 비즈니스 규칙의 분리
원래 재고 차감 규칙은 엔티티 안에 있다. 주문 수량보다 재고가 많을 때만 차감하는 식이다.
public class Product {
@Column
private int stock;
public void decreaseStock(int quantity) {
if(stock < quantity) {
throw new StockNotEnough();
}
this.stock -= quantity;
}
}
원자적 쿼리를 쓰면 이 규칙이 엔티티에서 빠져나와 DB 쿼리로 옮겨간다.
@Modifying
@Query("UPDATE Product p SET p.stock = p.stock - :quantity WHERE p.id = :id AND p.stock >= :quantity")
public int decreaseStock(@Param("id") Long id, @Param("quantity") int quantity);
지금은 재고 수량만 비교하면 되니까 괜찮다. 하지만 규칙이 복잡해지면? 최소 구매 수량, 최대 구매 수량, VIP 우선 차감 같은 조건이 붙기 시작하면 쿼리가 감당하기 어려워진다.
1.2 JPA 영속성 컨텍스트
JPA는 영속성 컨텍스트에 엔티티를 캐싱한다. 원자적 업데이트처럼 직접 쿼리를 날리면 영속성 컨텍스트를 거치지 않고 DB에 바로 쿼리가 간다. 캐싱된 엔티티와 실제 DB 데이터가 달라질 수 있다.
public class OrderFacade {
...
@Transactional
public OrderResult placeOrder(OrderPlaceCommand command) {
Product product = productService.getProduct(command.productId());
// 내부적으로 @Modifying @Query로 원자적 UPDATE 실행
productService.decreaseStock(command.productId(), command.quantity());
OrderResult order = orderService.placeOrder(command);
// 재고 정보로 무언가를 한다면??
product.getStock();
}
}
getProduct()로 조회한 시점에 영속성 컨텍스트에 엔티티가 캐싱된다. 이후 원자적 쿼리가 DB를 직접 수정하지만, 영속성 컨텍스트는 이 변경을 모른다. 이후 실행되는 product.getStock()은 차감 전 값을 그대로 반환한다.
이걸 해결하려면 @Modifying(clearAutomatically = true)로 영속성 컨텍스트를 비운 뒤 다시 조회해야 한다.
@Modifying(clearAutomatically = true)
@Query("UPDATE Product p SET p.stock = p.stock - :quantity WHERE p.id = :id AND p.stock >= :quantity")
public int decreaseStock(@Param("id") Long id, @Param("quantity") int quantity);
// 원자적 UPDATE 실행 → 영속성 컨텍스트 자동 초기화
productService.decreaseStock(command.productId(), command.quantity());
// 초기화됐으므로 다시 조회해야 최신 값을 얻을 수 있다
Product product = productService.getProduct(command.productId());
product.getStock(); // 차감 후 값
해결은 되지만 불필요한 조회가 추가된다. JPA를 쓰는 환경에서 원자적 업데이트는 이런 영속성 컨텍스트 불일치를 항상 신경 써야 한다. 트랜잭션 흐름이 단순하고 한눈에 보이는 경우에만 원자적 업데이트를 쓰는 게 안전하다.
1.3 변경 이력 추적 불가
세 가지 단점 중 이게 제일 중요하다. 비즈니스 규칙 분리나 영속성 컨텍스트 문제는 코드로 어떻게든 해결할 수 있지만, 이건 구조적인 한계다.
원자적 쿼리는 변경 이력을 남기지 않는다. 재고가 10건에서 9건으로 줄었는지, 누구의 주문으로 줄었는지 알 수 없다. 재고 관리 담당자가 "변경 이력을 보여주세요"라고 하면 코드를 뜯어고쳐야 한다.
실무에서 재고 같은 데이터는 변경 이력을 요구하는 경우가 많다. 재고처럼 틀어지면 돈과 직결되는 데이터는 추적 가능해야 한다. 원자적 업데이트만으로는 이걸 보장할 수 없다. 동시성 전략을 고를 때 데이터의 성격을 같이 봐야 하는 이유다.
2. 비관적 락
재고 조회 시점에 다른 요청들이 재고 정보를 수정하지 못하도록 잠궈버린다. 잠금은 트랜잭션이 commit되면 해제된다. 엔티티를 직접 조회하고 수정하기 때문에 비즈니스 규칙을 도메인 메서드에 유지할 수 있고, 변경 이력도 추적할 수 있다. 원자적 업데이트의 단점을 구조적으로 해결한다.
재고 차감 로직을 product.decreaseStock()같은 도메인 메서드에 담을 수 있다. 최소 구매 수량, 최대 구매 수량, 예약 재고와 가용 재고 분리, VIP 우선 차감 같은 규칙이 추가되더라도 한 곳에서 관리되고 테스트하기도 쉽다. 이런 규칙들이 쿼리로 흩어지면 관리가 급격히 어려워진다.
JPA에서는 이렇게 쓴다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
public Product findByIdWithLock(@Param("id") Long id);
실제 실행되는 쿼리:
SELECT * FROM product WHERE id = :id FOR UPDATE;
// Facade - 락 획득 없이 id만 넘김
productService.decreaseStock(command.productId(), command.quantity());
// ProductService - 내부에서 직접 락 조회
public void decreaseStock(Long id, int quantity) {
Product product = productRepository.findByIdWithLock(id); // FOR UPDATE
product.decreaseStock(quantity);
}
다만 조회 시점에 락을 걸기 때문에 조회에서 commit까지의 간격이 길수록 자원 점유 시간도 길어진다. 여러 상품을 주문하면 점유하는 자원이 늘어나고, 트랜잭션에 다른 로직이 많이 엮여 있을수록 더 심해진다. 할 수 있다면 락 획득 시점을 트랜잭션 안에서 최대한 뒤로 미루는 게 좋다.
public class OrderFacade {
...
@Transactional
public OrderResult placeOrder(OrderPlaceCommand command) {
OrderResult order = orderService.placeOrder(command);
// 락 획득을 가장 후순위로 배치 → 점유 시간 최소화
productService.decreaseStock(command.productId(), command.quantity());
}
}
점유 시간 외에 또 주의해야 할 문제가 데드락이다. 한 번에 여러 상품을 주문하면 발생할 수 있다. 비관적 락을 사용할 때 항상 주의 깊게 살펴봐야하는 문제이다. 사용자 A가 상품 A를 잠그고 B를 기다리고, 사용자 B가 상품 B를 잠그고 A를 기다리면 두 트랜잭션은 영원히 서로를 기다리며 대기한다.
sequenceDiagram
participant A as 사용자 A
participant PA as 상품 A (락)
participant PB as 상품 B (락)
participant B as 사용자 B
Note over A: 상품 A, B 주문
Note over B: 상품 B, A 주문
A->>PA: LOCK 획득
B->>PB: LOCK 획득
A--xPB: LOCK 대기 (B가 점유 중)
B--xPA: LOCK 대기 (A가 점유 중)
Note over A,B: 데드락 — 서로의 락 해제를 무한 대기
대부분의 RDBMS는 데드락을 감지하면 관련된 트랜잭션 중 하나를 골라 롤백시킨다. 롤백된 요청은 재시도가 필요할 수 있지만, 가능하면 애플리케이션에서 데드락 자체를 막는 게 낫다.
방법은 락 순서를 통일하는 것이다. 상품 id순으로 정렬해서 항상 같은 순서로 잠근다.
// 락 획득 전에 ID 기준으로 정렬
List<Long> productIds = command.productIds().stream().sorted().toList();
// 항상 같은 순서로 락 획득 → 데드락 방지
List<Product> products = productIds.stream().map(productService::getProductWithLock).toList();
3. 낙관적 락
비관적 락과 달리 실제로 자원을 점유하지 않기 때문에 다른 트랜잭션을 막지 않는다. commit 시점에 충돌 여부를 판단한다.
방식은 이렇다. version 컬럼을 두고, 데이터가 바뀔 때마다 version을 올린다. 내가 읽었던 version과 현재 version이 다르면 누군가 중간에 수정한 거다. 업데이트를 거부하고 충돌로 처리한다. 충돌이 발생하면 재시도 등의 비용을 치러야 한다.
JPA에서는 version 컬럼만 추가하면 된다. 나머진 JPA가 알아서 한다.
@Entity
public class Product {
@Version
private Long version; // 이것만 추가하면 끝
}
UPDATE product
SET stock = ?, version = version + 1
WHERE id = ? AND version = ? ← JPA가 자동으로 추가
충돌이 발생하면 재시도가 필요하다. 재고 차감에서 충돌이 발생했다고 주문 자체를 실패시키면 안 된다.
@Retryable(
retryFor = OptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100)
)
@Transactional
public void decreaseStock(Long id, int quantity) {
Product product = productRepository.findById(id).orElseThrow();
product.decreaseStock(quantity);
}
충돌이 잦으면 재시도도 잦아지고, 비관적 락보다 비용이 증가할 수 있다. 콘서트 좌석 예매처럼 충돌 시 재시도가 필요 없는 경우(다른 좌석을 고르면 된다)에는 낙관적 락이 맞다. 하지만 재고처럼 재시도가 꼭 필요한 경우에는 경합 빈도를 같이 따져봐야 한다.
그런데 재시도는 어떻게 이뤄질까? 충돌이 발생하면 OptimisticLockingFailureException 예외가 발생한다. 이 예외를 감지하고 재시도가 수행된다. 문제는 이 예외가 언제 발생하는지다.
public class OrderFacade {
...
@Transactional
public OrderResult placeOrder(OrderPlaceCommand command) {
ProductResult product = productService.getProduct(command.productId());
productService.decreaseStock(command.productId(), command.quantity());
OrderResult order = orderService.placeOrder(command);
}
//commit = 충돌 확인
}
여러 서비스에 걸쳐 트랜잭션이 실행되면, 충돌은 commit 시점에 확인된다. placeOrder 메서드가 끝난 후다. 그러면 decreaseStock에 붙인 @Retryable이 동작할까?
안 된다. @Retryable은 해당 메서드 안에서 예외가 터져야 재시도한다. 실제 예외는 decreaseStock이 끝난 후에 발생하니까 감지를 못 한다.
@Retryable(
...
)
@Transactional
public void decreaseStock(Long id, int quantity) {
Product product = productRepository.findById(id).orElseThrow();
product.decreaseStock(quantity);
}
그럼 트랜잭션 전파 레벨을 바꿔서 decreaseStock 안에서 commit하게 만들면 어떨까?
@Retryable(
...
)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decreaseStock(Long id, int quantity) {
Product product = productRepository.findById(id).orElseThrow();
product.decreaseStock(quantity);
}
주의: 동일한 클래스에 있는 내부 메서드 호출 시 REQUIRES_NEW 무시
@Transactional(propagation = REQUIRES_NEW)는 Spring AOP 프록시를 통해 호출될 때만 동작한다. 같은 Bean 안에서 this.decreaseStock()처럼 호출하면 프록시를 안 타서 REQUIRES_NEW가 무시된다. 지금 구조처럼 Facade에서 Service를 호출하는 건 별도 Bean이니까 해당되지 않는다.
REQUIRES_NEW로 바꾸면 decreaseStock이 별도 트랜잭션으로 실행되고, 메서드가 끝날 때 commit된다. 예외가 메서드 안에서 터지니까 @Retryable이 감지할 수 있다. 하지만 큰 문제가 있다. 재고 차감 트랜잭션이 먼저 완료된 후 이후 작업에서 오류가 발생한다면? 재고는 차감됐는데 주문은 생성되지 않은 상태가 된다. 주문 처리는 원자성을 보장해야 한다.
트랜잭션을 분리하면 원자성이 깨진다. 하나의 트랜잭션으로 유지하되, 재시도 범위를 주문 흐름 전체를 감싸는 상위 계층으로 올려야 한다.
public class OrderFacade {
...
@Retryable(
...
)
@Transactional
public OrderResult placeOrder(OrderPlaceCommand command) {
ProductResult product = productService.getProduct(command.productId());
productService.decreaseStock(command.productId(), command.quantity());
OrderResult order = orderService.placeOrder(command);
}
}
재고 차감 시 예외도 잘 탐지하고 전체 흐름을 재시도하니까 원자성은 지켜진다. 하지만 충돌이 한 건이라도 발생하면 주문 흐름 전체를 처음부터 다시 실행해야 한다. 이벤트 상품이나 인기 상품을 주문한 경우 재시도 비용이 비관적 락의 대기 비용보다 클 수 있다.
결론
재고는 음수가 되면 안 되고, 초과 차감도 안 된다. 정합성이 강하게 요구되는 데이터다.
원자적 UPDATE는 가장 단순하고 동시성도 보장된다. 하지만 비즈니스 규칙이 쿼리에 묶이기 때문에, 규칙이 복잡해지면 유지보수가 어려워진다. 낙관적 락은 자원을 점유하지 않아 가볍지만, 재고처럼 경합이 잦은 데이터에서는 재시도 비용이 크다. 비관적 락은 대기 비용이 있지만, 도메인 메서드에 규칙을 담을 수 있고 규칙이 추가되더라도 확장이 쉽다.
| 전략 | 재고 적합성 | 이유 |
|---|---|---|
| 원자적 UPDATE | 지금은 적합 | 단순하고 동시성 보장. 규칙이 복잡해지면 한계가 온다. 이력 관리가 불가능하다. |
| 낙관적 락 | 부적합 | 재고는 경합 가능성이 높다. 재시도 없이는 끝낼 수 없다. 경합 발생 시 재시도 범위가 넓다. |
| 비관적 락 | 적합 | 자원 점유 시 대기 비용이 발생한다. 비즈니스 규칙의 응집도가 높다. JPA를 활용할 수 있다. |
재고 차감에는 비관적 락이 적합하다고 본다. 다만 어떤 전략이든 만능은 없다. 데이터의 성격과 경합 빈도, 비즈니스 요구 사항 등 다양한 상황을 고려하고 트레이드 오프를 확인하여 종합적으로 판단해야 한다. 경합이 많으면 비관적 락, 경합이 적으면 낙관적 락과 같이 단순한 문제가 아니다.
'라이브러리&프레임워크 > Spring' 카테고리의 다른 글
| Application Event란?? (0) | 2026.03.28 |
|---|---|
| 이커머스 좋아요 집계 방식 비교 (0) | 2026.03.15 |
| 유비쿼터스 언어 (0) | 2026.03.01 |
| Bounded Context란 무엇인가? (0) | 2026.03.01 |
| API 개발 시 중복 요청을 고려해본 적이 있나요? (0) | 2025.11.23 |
