DB 락으로 선착순 시스템 구현하기: FOR UPDATE부터 동시성 테스트까지

DB 락으로 선착순 시스템 구현하기: FOR UPDATE부터 동시성 테스트까지


서론

이전 글에서 선착순 시스템의 6가지 구현 방식을 비교했다. 이번 글에서는 그중 가장 단순한 방식 — DB 비관적 락(SELECT FOR UPDATE) 으로 직접 구현해본다.

코드로 구현하고, 100명이 동시에 구매하는 테스트로 정합성을 확인하고, 어디서 한계가 오는지까지 직접 본다.


1. 왜 DB 락부터 시작하나?

DB 락은 선착순 시스템의 가장 기본적인 구현이다.

  • 추가 인프라 없이 DB만으로 동작한다
  • 동시성 문제의 본질을 코드로 직접 확인할 수 있다
  • Redis나 큐 방식의 필요성을 체감하기 위한 기준점이 된다

어떤 기술이 왜 필요한지를 알려면, 그 기술 없이 먼저 해봐야 한다.


2. 문제 상황: 락 없이 재고를 차감하면?

재고 1개짜리 상품에 2명이 동시에 구매하는 상황을 보자.

단계TX1 (주문 A)TX2 (주문 B)실제 재고
1SELECT stock1 (앱 메모리에 저장)1
2SELECT stock1 (앱 메모리에 저장)1
3앱에서 1 > 0 확인 → UPDATE stock = stock - 10
4COMMIT0
5앱에서 1 > 0 확인 (아까 읽은 값) → UPDATE stock = stock - 1-1 💀
6COMMIT-1

재고가 음수가 됐다. TX2는 앱 메모리에 저장된 옛날 값(1)으로 조건을 통과했지만, UPDATEstock - 1은 DB의 현재 값(0)에서 차감한다. 결과적으로 0 - 1 = -1. 이것이 Lost Update 문제다.


3. 해결: SELECT FOR UPDATE

FOR UPDATE를 붙이면 해당 행에 배타 락(exclusive lock) 이 걸린다. 다른 트랜잭션은 이 행을 읽지도 수정하지도 못하고 대기한다.

단계TX1 (주문 A)TX2 (주문 B)재고
1SELECT stock FOR UPDATE1 (행 락 획득 🔒)1
2SELECT stock FOR UPDATE → 락 대기 ⏳1
3stock > 0 → UPDATE stock = 00
4COMMIT (락 해제 🔓)0
50 (최신 값!) → 품절 처리0
6ROLLBACK0

TX2는 TX1이 끝날 때까지 기다렸다가, 최신 재고(0)를 읽고 품절로 처리한다. 초과 판매 없음.


4. Spring Boot + JPA로 구현

4.1 엔티티

@Entity
@Table(name = "products")
public class Product {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private int stockQuantity;

    @Enumerated(EnumType.STRING)
    private ProductStatus status; // ON_SALE, SOLD_OUT

    public void decreaseStock(int quantity) {
        if (this.stockQuantity < quantity) {
            throw new RuntimeException("재고 부족");
        }
        this.stockQuantity -= quantity;
        if (this.stockQuantity == 0) {
            this.status = ProductStatus.SOLD_OUT;
        }
    }
}

재고 차감 로직은 엔티티 내부에 둔다. stockQuantity < quantity이면 예외를 던져서 음수를 방지한다.

4.2 리포지토리: FOR UPDATE 쿼리

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdForUpdate(@Param("id") Long id);
}

@Lock(LockModeType.PESSIMISTIC_WRITE) — JPA가 실제로 실행하는 SQL은 이렇다:

SELECT * FROM products WHERE id = ? FOR UPDATE

QueryDSL을 쓴다면:

Product product = queryFactory
    .selectFrom(QProduct.product)
    .where(QProduct.product.id.eq(id))
    .setLockMode(LockModeType.PESSIMISTIC_WRITE)
    .fetchOne();

어떤 방식이든 결과는 동일하다 — 해당 행에 배타 락을 건다.

4.3 서비스: 락 + 재고 차감

@Service
public class PessimisticLockStockService {
    private final ProductRepository productRepository;

    @Transactional
    public void decreaseStock(Long productId, int quantity) {
        // 1. FOR UPDATE로 행 잠금 + 조회
        Product product = productRepository.findByIdForUpdate(productId)
            .orElseThrow(() -> new RuntimeException("상품 없음"));

        // 2. 재고 차감 (부족하면 예외)
        product.decreaseStock(quantity);

        // 3. 트랜잭션 커밋 시 UPDATE 실행 + 락 해제
    }
}

핵심은 3줄이다:

  1. findByIdForUpdate — 행을 잠그고 조회
  2. decreaseStock — 재고 차감 (엔티티 메서드)
  3. @Transactional 종료 시 — JPA dirty checking으로 UPDATE 실행, 커밋과 함께 락 해제

5. 동시성 테스트

“정말 동시에 100명이 요청해도 재고가 정확하게 맞을까?” — 직접 확인한다.

5.1 테스트 구조

@SpringBootTest
class PessimisticLockStockConcurrencyTest {

    @Autowired
    PessimisticLockStockService stockService;

    @Autowired
    ProductRepository productRepository;

    @Test
    @DisplayName("100명이 동시에 1개씩 구매하면 재고가 정확히 0이 된다")
    void concurrentPurchase_100users() throws InterruptedException {
        // 재고 100개인 상품 생성
        Product product = productRepository.save(
            new Product("한정판 스니커즈", 100, ProductStatus.ON_SALE)
        );

        int threadCount = 100;
        ExecutorService executor = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);
        AtomicInteger successCount = new AtomicInteger(0);
        AtomicInteger failCount = new AtomicInteger(0);

        long startTime = System.currentTimeMillis();

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    stockService.decreaseStock(product.getId(), 1);
                    successCount.incrementAndGet();
                } catch (Exception e) {
                    failCount.incrementAndGet();
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        executor.shutdown();
        long elapsed = System.currentTimeMillis() - startTime;

        Product updated = productRepository.findById(product.getId()).get();

        System.out.println("성공: " + successCount.get());
        System.out.println("실패: " + failCount.get());
        System.out.println("최종 재고: " + updated.getStockQuantity());
        System.out.println("소요 시간: " + elapsed + "ms");

        assertEquals(100, successCount.get());
        assertEquals(0, updated.getStockQuantity());
    }
}

CountDownLatch 는 모든 스레드가 작업을 마칠 때까지 테스트를 대기시킨다. 32개의 스레드 풀에 100개의 작업을 넣어서 동시 요청을 시뮬레이션한다.

5.2 테스트 결과

=== 비관적 락 (FOR UPDATE) 동시성 테스트 결과 ===
동시 요청 수: 100
성공: 100
실패: 0
최종 재고: 0
소요 시간: 851ms
==========================================

100명이 동시에 요청해도 재고가 정확히 0이 된다. 초과 판매 없음, 음수 재고 없음.

5.3 초과 요청 테스트

재고 100개에 150명이 동시 구매하면?

=== 비관적 락 (FOR UPDATE) 초과 요청 테스트 결과 ===
동시 요청 수: 150
성공: 100
실패 (품절): 50
최종 재고: 0
소요 시간: 816ms
==========================================

정확히 100명만 성공하고 50명은 품절 처리. 데이터 정합성 완벽.


6. 한계: 왜 이것만으로는 부족한가

테스트 결과만 보면 완벽하다. 하지만 실제 서비스에서는 3가지 병목이 발생한다.

6.1 직렬화 병목

FOR UPDATE는 한 번에 하나의 트랜잭션만 해당 행을 처리할 수 있다.

동시 1,000명 → FOR UPDATE → 1명 처리, 999명 대기

트랜잭션 50ms × 1,000명 = 최대 50초 대기
트랜잭션 200ms × 10,000명 = 최대 2,000초(33분) 대기 💀

테스트에서는 100명이 851ms에 끝났지만, 실제 서비스에서는 트랜잭션 안에 결제 API 호출, 주문 생성, 이벤트 발행 등이 포함된다. 트랜잭션이 길어질수록 대기 시간은 급격히 늘어난다.

6.2 DB 커넥션 풀 고갈

락을 기다리는 트랜잭션은 DB 커넥션을 물고 있다. HikariCP 기본 풀 크기는 10개인데:

동시 100명 → FOR UPDATE → 10개 커넥션 전부 락 대기 중
→ 11번째 요청 → 커넥션 없음 → HikariCP timeout → 에러!

일반 조회 요청(상품 목록, 마이페이지)도 커넥션을 얻지 못해서 전체 서비스가 느려진다.

6.3 데드락

하나의 주문에서 여러 상품의 재고를 차감한다면:

단계TX1TX2상태
1상품 A 락 획득
2상품 B 락 획득
3상품 B 락 대기 ⏳
4상품 A 락 대기 ⏳💀 Deadlock!

데드락 방지법(락 순서 통일, 타임아웃)은 2편에서 다뤘다.

6.4 현실적인 한계선

상황DB 락으로 괜찮은가?
사내 이벤트 (동시 50명)✅ 충분
소규모 쇼핑몰 (동시 수백 명)⚠️ 커넥션 풀 조정 필요
한정판 판매 (동시 수천 명)❌ Redis 필요
콘서트 티켓팅 (동시 수만 명)❌ 대기열 + Redis 필요

7. 보완: Atomic UPDATE 방식

FOR UPDATE는 SELECT → 비즈니스 로직 → UPDATE → COMMIT까지 행 락을 잡고 있어야 한다. 그 시간 동안 다른 트랜잭션은 전부 대기한다.

단순 재고 차감이라면 이 과정을 UPDATE 한 문장으로 줄일 수 있다.

UPDATE products
SET stock_quantity = stock_quantity - 1,
    sales_count = sales_count + 1
WHERE id = 1
AND stock_quantity >= 1
AND status = 'ON_SALE'

왜 이게 안전한가?

DB는 UPDATE 문을 실행할 때 내부적으로 해당 행에 락을 건다. 이 락은 UPDATE 시점에 걸리고 COMMIT 시점에 해제된다. FOR UPDATE와 다른 점은 락이 시작되는 시점이다.

방식락 시작락 해제
FOR UPDATESELECT 시점COMMIT 시점
Atomic UPDATEUPDATE 시점COMMIT 시점

FOR UPDATE는 SELECT부터 COMMIT까지 전 구간을 잡지만, Atomic UPDATE는 UPDATE부터 COMMIT까지만 잡는다. 락이 시작되는 시점이 늦으니 점유 시간이 짧다.

다른 쿼리와 함께 써도 되나?

UPDATE 하나만 단독으로 실행할 필요는 없다. 같은 트랜잭션 안에 다른 쿼리가 있어도 된다.

@Transactional
public void purchase(Long productId, Long userId) {
    // 1. 재고 차감 (이 시점부터 products 행 락 시작)
    int updated = productRepository.decreaseStockAtomically(productId, 1);
    if (updated == 0) throw new RuntimeException("품절");

    // 2. 주문 생성 (orders 테이블 INSERT → products 행 락과 무관)
    orderRepository.save(new Order(productId, userId));

    // 3. COMMIT (이 시점에 products 행 락 해제)
}

INSERT INTO orders는 products 테이블의 행 락과 무관하다. 다만 UPDATE 이후 COMMIT까지 락이 유지되므로, UPDATE 뒤에 외부 API 호출처럼 오래 걸리는 작업이 있으면 락 점유 시간이 길어진다.

: Atomic UPDATE는 트랜잭션 안에서 가능한 늦게 실행하자. UPDATE 전에 필요한 조회나 검증을 먼저 하고, UPDATE는 COMMIT 직전에 배치하면 락 점유 시간을 최소화할 수 있다.

FOR UPDATE vs Atomic UPDATE

항목FOR UPDATEAtomic UPDATE
락 시작 시점SELECT 시점 (빠름)UPDATE 시점 (늦음)
락 해제 시점COMMITCOMMIT
동시성전 구간 직렬화락 점유 시간이 짧아 대기가 적음
재고 읽기최신 값을 조회 후 비즈니스 로직 가능현재 재고를 읽을 필요 없음
복잡한 검증재고 외에 추가 조건 검증 가능WHERE 절에 넣을 수 있는 조건만
성능트래픽 증가 시 대기 시간이 길어짐더 빠름 (락 점유 구간이 짧으니까)

실무에서 Atomic UPDATE를 쓸 수 있는가?

충분히 쓸 수 있다. 다만 상황에 따라 적합도가 다르다.

적합한 경우:

  • 재고 차감, 좋아요 수 증가, 쿠폰 발급 횟수 차감처럼 “조건 확인 + 숫자 변경”이 전부인 경우
  • 차감 전에 별도 비즈니스 로직(등급 확인, 외부 API 호출 등)이 필요 없는 경우
  • 트래픽이 높아서 FOR UPDATE의 락 점유 시간이 부담되는 경우

적합하지 않은 경우:

  • 차감 전에 현재 재고 값을 읽어서 분기해야 할 때 (예: 재고 5개 이하면 알림 발송)
  • 여러 테이블을 함께 검증해야 할 때 (예: 사용자 등급 확인 → 할인율 적용 → 재고 차감)
  • 실패 원인을 세분화해야 할 때 — Atomic UPDATE는 updated == 0만 반환하므로, “재고 부족”인지 “상품이 OFF_SALE”인지 구분할 수 없다

실무 판단 기준:

복잡도방식
숫자 하나 차감이 전부Atomic UPDATE
차감 전후로 비즈니스 로직이 있음FOR UPDATE
트래픽이 수천 이상Redis DECR 또는 Redis + Lua

Atomic UPDATE로 되는 건 Atomic UPDATE로 하고, 안 되는 건 FOR UPDATE로 — 이것이 실무에서의 자연스러운 기준이다.


정리

핵심 포인트내용
FOR UPDATE의 역할행을 잠그고 다른 트랜잭션의 접근을 차단
구현 핵심@Lock(PESSIMISTIC_WRITE) + @Transactional
동시성 테스트 결과100명 동시 요청에도 재고 정합성 완벽
한계직렬화 병목, 커넥션 풀 고갈, 데드락 위험
현실적 한계선동시 수십 명 수준까지 적합
보완Atomic UPDATE로 단순 차감 성능 개선 가능

DB 락은 동시성 문제의 본질을 이해하기 위한 출발점이다. 다음 글에서는 DB의 한계를 넘어서 Redis로 초당 수만 건을 처리하는 방법을 다룬다.

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.