스프링부트 실무 가이드 1편: 동시성 제어와 재고 관리

스프링부트 실무 가이드 1편: 동시성 제어와 재고 관리


시리즈 네비게이션

이전현재다음
-1편: 동시성 제어2편: 캐싱 전략

서론

이 시리즈는 Spring Boot 기반 실무 프로젝트에서 자주 마주치는 문제들과 해결 패턴을 정리한 가이드다.

1편에서 다루는 내용:

  • 동시성 문제가 발생하는 원인 (Check-Then-Act 패턴)
  • 원자적 UPDATE로 재고/쿠폰 문제 해결
  • 멱등성 키로 중복 주문 방지
  • 분산 락이 정말 필요한 경우

목차


1. 문제 정의: 왜 동시성 제어가 필요한가?

1.1 문제 1: 재고 과잉 판매 (Overselling)

재고가 1개 남은 상품에 2명이 동시에 주문하는 상황:

시간    사용자 A              사용자 B              재고(DB)
─────────────────────────────────────────────────────────────
T1      재고 조회 → 1개        -                    1
T2      -                    재고 조회 → 1개        1
T3      1 >= 1 → 주문 가능!   -                    1
T4      -                    1 >= 1 → 주문 가능!   1
T5      재고 감소 (1→0)       -                    0
T6      -                    재고 감소 (0→-1)      -1 ❌

결과: 재고 1개인데 2개 판매 → 과잉 판매(Overselling) 발생!

원인: Check-Then-Act 패턴의 취약점

// ❌ 위험한 코드
fun createOrder(productId: Long, quantity: Int) {
    val product = productRepository.findById(productId)
    if (product.stockQuantity >= quantity) {       // Check
        product.stockQuantity -= quantity          // Act (이 사이에 끼어듦!)
        productRepository.save(product)
    }
}

1.2 문제 2: 중복 주문 / 쿠폰 중복 사용

같은 사용자가 주문 버튼을 연타하거나, 쿠폰을 중복 사용하려는 상황:

시간    사용자 A (요청 1)         사용자 A (요청 2)         문제
─────────────────────────────────────────────────────────────────
T1      쿠폰 조회 → 있음           -
T2      -                        쿠폰 조회 → 있음
T3      쿠폰 사용 처리             -
T4      -                        쿠폰 사용 처리             ⚠️ 중복 사용?
T5      주문 생성 #1              -
T6      -                        주문 생성 #2              ⚠️ 중복 주문?

1.3 문제별 해결책 요약

문제원인권장 해결책비고
재고 과잉 판매Check-Then-Act원자적 UPDATE필수
쿠폰 중복 사용Check-Then-Act원자적 UPDATE필수
중복 주문 (따닥)버튼 연타멱등성 키권장
캐시 스탬피드캐시 만료분산 락선택
배치 중복 실행다중 인스턴스분산 락선택

2. 해결책 1: 원자적 재고 업데이트

2.1 원자적(Atomic) 연산이란?

중간에 끊기지 않고 한 번에 완료 되는 연산. 다른 트랜잭션이 끼어들 수 없음.

┌─────────────────────────────────────────────────────────────┐
│  일반 방식 (3단계)                 원자적 방식 (1단계)         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. SELECT stock     ← 끼어들 수 있음                        │
│  2. 애플리케이션 계산  ← 끼어들 수 있음      vs    1. UPDATE   │
│  3. UPDATE stock     ← 끼어들 수 있음            WHERE 조건  │
│                                                             │
│  ❌ Race Condition 발생              ✅ DB가 원자성 보장      │
└─────────────────────────────────────────────────────────────┘

2.2 비교: 기존 방식 vs 원자적 방식

// ❌ 기존 방식 (3번의 쿼리, 중간에 끼어들 수 있음)
val product = repository.findById(id)        // SELECT
if (product.stockQuantity >= quantity) {
    product.stockQuantity -= quantity
    repository.save(product)                  // UPDATE
}

// ✅ 원자적 방식 (1번의 쿼리로 조건 확인 + 업데이트)
val updated = repository.decreaseStockAtomically(id, quantity)
if (updated == 0) throw BusinessException(ErrorCode.INSUFFICIENT_STOCK)

2.3 원자적 재고 감소 쿼리

@Modifying
@Query("""
    UPDATE Product p
    SET p.stockQuantity = p.stockQuantity - :quantity,
        p.salesCount = p.salesCount + :quantity
    WHERE p.id = :productId
    AND p.stockQuantity >= :quantity   -- ⭐ 핵심: 조건부 업데이트
    AND p.status = 'ON_SALE'
""")
fun decreaseStockAtomically(productId: Long, quantity: Int): Int

2.4 동시 요청 시 동작

시간    사용자 A                           사용자 B
────────────────────────────────────────────────────────────
        재고: 1개

T1      UPDATE WHERE stock >= 1           UPDATE WHERE stock >= 1
        ↓                                 ↓
        DB Row Lock 획득                   DB Row Lock 대기...

T2      stock = 0으로 변경                 (대기중)
        COMMIT

T3      updateCount = 1 ✅                 DB Row Lock 획득
                                          stock(0) >= 1? → FALSE

T4                                        updateCount = 0 ❌
                                          → INSUFFICIENT_STOCK

결과: 정확히 1개만 판매됨!

2.5 왜 동작하는가? (DB Row Lock)

┌─────────────────────────────────────────────────────────────┐
│  InnoDB (MySQL) / PostgreSQL의 Row-Level Lock               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  UPDATE 문 실행 시:                                          │
│  1. 해당 Row에 Exclusive Lock (X-Lock) 획득                  │
│  2. 다른 트랜잭션은 같은 Row 수정 불가 (대기)                   │
│  3. COMMIT 후 Lock 해제 → 다음 트랜잭션 진행                  │
│                                                             │
│  ※ WHERE 조건은 Lock 획득 후 재평가됨                        │
│  → 이미 재고가 0이면 조건 불충족 → updateCount = 0           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

핵심: DB 자체의 Row Lock 메커니즘이 동시성을 처리합니다. 별도의 분산 락 없이도 재고 보호가 가능한 이유입니다.


3. 해결책 2: 멱등성 키

3.1 멱등성 키 (Idempotency Key)란?

같은 요청을 여러 번 보내도 한 번만 처리되도록 보장하는 고유 키입니다.

@PostMapping("/orders")
fun createOrder(
    @RequestHeader("Idempotency-Key") idempotencyKey: String,
    @RequestBody request: OrderCreateRequest
): OrderResponse {
    // 1. 이미 처리된 요청인지 확인
    val cached = redisTemplate.opsForValue().get("idempotency:$idempotencyKey")
    if (cached != null) return cached  // 이전 결과 반환

    // 2. 새로운 주문 처리
    val result = orderService.createOrder(request)

    // 3. 결과 캐시 (24시간)
    redisTemplate.opsForValue().set("idempotency:$idempotencyKey", result, 24, TimeUnit.HOURS)
    return result
}

클라이언트 측:

const response = await fetch('/api/v1/orders', {
    method: 'POST',
    headers: {
        'Idempotency-Key': crypto.randomUUID(),  // 요청마다 고유 키
        'Content-Type': 'application/json'
    },
    body: JSON.stringify(orderData)
});

3.2 개선된 구현 (처리 중 상태 관리)

fun createOrder(idempotencyKey: String, request: OrderCreateRequest): OrderResponse {
    val cacheKey = "idempotency:$idempotencyKey"

    // 1. 이미 완료된 요청 확인
    val cached = redisTemplate.opsForValue().get(cacheKey)
    if (cached is OrderResponse) return cached

    // 2. 처리 중인지 확인 (SETNX로 원자적 체크)
    val acquired = redisTemplate.opsForValue()
        .setIfAbsent("$cacheKey:processing", "1", Duration.ofSeconds(30))
    if (acquired != true) {
        throw BusinessException(ErrorCode.REQUEST_IN_PROGRESS)
    }

    try {
        // 3. 주문 처리
        val result = orderService.createOrder(request)

        // 4. 결과 캐시
        redisTemplate.opsForValue().set(cacheKey, result, Duration.ofHours(24))
        return result
    } finally {
        redisTemplate.delete("$cacheKey:processing")
    }
}

3.3 멱등성 키 vs 분산 락

구분멱등성 키분산 락
목적중복 요청 방지동시 실행 직렬화
방식결과 캐싱락 획득/해제
복잡도낮음중간
적합한 상황중복 주문 방지캐시 스탬피드, 배치

4. 분산 락이 필요한 경우

4.1 언제 분산 락이 필요한가?

대부분의 주문 시나리오에서 분산 락은 오버엔지니어링 입니다.

문제분산 락 필요?더 나은 대안
재고 과잉 판매원자적 UPDATE
쿠폰 중복 사용원자적 UPDATE
중복 주문 (따닥)멱등성 키
캐시 스탬피드-
배치 중복 실행-
외부 API 직렬화-

4.2 캐시 스탬피드 방지

fun getProduct(productId: Long): Product {
    val cached = redisTemplate.opsForValue().get("product:$productId")
    if (cached != null) return cached

    // 캐시 미스 시 1000개 요청이 동시에 DB 조회 → DB 죽음
    val lock = redissonClient.getLock("cache:product:$productId")

    return if (lock.tryLock(1, 5, TimeUnit.SECONDS)) {
        try {
            // Double-check
            val recheck = redisTemplate.opsForValue().get("product:$productId")
            if (recheck != null) return recheck

            // 1개만 DB 조회
            val product = productRepository.findById(productId)
            redisTemplate.opsForValue().set("product:$productId", product, 1, TimeUnit.HOURS)
            product
        } finally {
            lock.unlock()
        }
    } else {
        Thread.sleep(100)
        redisTemplate.opsForValue().get("product:$productId")!!
    }
}

4.3 배치 중복 실행 방지

@Scheduled(cron = "0 0 0 * * *")
fun dailySettlement() {
    val lock = redissonClient.getLock("batch:daily-settlement")

    if (lock.tryLock(0, 30, TimeUnit.MINUTES)) {
        try {
            settlementService.process()  // 30분 소요
        } finally {
            lock.unlock()
        }
    }
    // 락 못 잡으면 다른 인스턴스가 실행 중 → 무시
}

4.4 외부 API 직렬화 제약

// PG사가 동일 사용자 동시 결제 요청 시 오류 발생
@DistributedLock(key = "'payment:' + #userId")
fun processPayment(userId: Long, amount: Long) {
    paymentGateway.charge(userId, amount)  // 외부 API
}

5. 분산 락 심화

5.1 Redis에서의 분산 락 동작 원리

Redisson의 Lua 스크립트 (락 획득)

-- 락이 없으면 새로 생성
if redis.call('exists', KEYS[1]) == 0 then
    redis.call('hset', KEYS[1], ARGV[2], 1)      -- 소유자 ID 저장
    redis.call('pexpire', KEYS[1], ARGV[1])      -- TTL 설정
    return nil  -- 락 획득 성공
end

-- 같은 스레드가 이미 보유 중이면 (재진입)
if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then
    redis.call('hincrby', KEYS[1], ARGV[2], 1)   -- 카운트 증가
    redis.call('pexpire', KEYS[1], ARGV[1])
    return nil  -- 락 획득 성공
end

return redis.call('pttl', KEYS[1])  -- 남은 TTL 반환 (락 획득 실패)

Watch Dog 자동 연장

┌─────────────────────────────────────────────────────────┐
│  Redisson Watch Dog (백그라운드 스레드)                    │
│                                                         │
│  leaseTime이 명시되지 않으면 자동 활성화 (기본 30초)         │
│  락 보유 중 leaseTime/3 (10초) 마다 TTL 갱신              │
│                                                         │
│  [비즈니스 로직 10초 경과] → TTL 30초로 갱신                │
│  [비즈니스 로직 20초 경과] → TTL 30초로 갱신                │
│  [비즈니스 로직 완료] → 락 해제                            │
└─────────────────────────────────────────────────────────┘

5.2 락 전략 선택 가이드

구분낙관적 락비관적 락분산 락
방식버전 체크 후 충돌 시 실패SELECT FOR UPDATERedis/ZooKeeper
충돌 처리애플리케이션 재시도DB 대기열 관리외부 시스템 관리
구현@Version@Lock(PESSIMISTIC)Redisson 등

의사결정 플로우

                        시작


                  ┌───────────────┐
                  │ 충돌이 자주    │
                  │ 발생하는가?    │
                  └───────────────┘
                     │         │
                  아니오        예
                     │         │
                     ▼         ▼
              ┌──────────┐  ┌───────────────┐
              │ 낙관적 락 │  │ 락 보유 시간이  │
              │   사용    │  │ 긴가? (>100ms) │
              └──────────┘  └───────────────┘
                               │         │
                            아니오        예
                               │         │
                               ▼         ▼
                        ┌──────────┐  ┌──────────┐
                        │ 비관적 락 │  │ 분산 락  │
                        │   사용   │  │   사용   │
                        └──────────┘  └──────────┘

5.3 실무 권장

서비스권장 방식이유
게시글 수정낙관적 락동시 수정 거의 없음
좋아요 카운트없음/원자적 UPDATE정확도보다 성능
재고 차감원자적 UPDATEDB 레벨에서 해결
중복 주문 방지멱등성 키가볍고 효과적
캐시 갱신분산 락스탬피드 방지
배치 작업분산 락다중 인스턴스

6. 실습 및 테스트

6.1 k6 동시성 테스트

// k6/concurrency-test.js
import http from 'k6/http';
import { check } from 'k6';

export let options = {
    vus: 10,
    duration: '5s',
};

export function setup() {
    let loginRes = http.post('http://localhost:8080/api/v1/auth/login',
        JSON.stringify({
            email: 'buyer@example.com',
            password: 'buyer123!'
        }),
        { headers: { 'Content-Type': 'application/json' } }
    );
    return { token: JSON.parse(loginRes.body).data.accessToken };
}

export default function(data) {
    let orderRes = http.post('http://localhost:8080/api/v1/orders',
        JSON.stringify({
            orderItems: [{ productId: 2, quantity: 1 }],
            shippingAddress: {
                zipCode: '12345', address: 'Test', addressDetail: 'Apt',
                receiverName: 'Test', receiverPhone: '010-1234-5678'
            }
        }),
        { headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${data.token}`
        }}
    );

    check(orderRes, {
        'status is 200 or 409': (r) => r.status === 200 || r.status === 409
    });
}

실행: k6 run k6/concurrency-test.js

6.2 Redis 락 확인

docker exec -it marketplace-redis redis-cli
> KEYS order:*
> HGETALL "order:create:1"
> TTL "order:create:1"

7. FAQ (자주 묻는 질문)

Q1. 분산 락 없이 재고 보호가 정말 되나요?

A: 네, 원자적 UPDATE만으로 충분합니다.

UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock >= 1

DB의 Row Lock이 동시성을 처리합니다.

Q2. 원자적 UPDATE가 실패하면 어떻게 되나요?

A: affected rows = 0이 반환되고, 애플리케이션에서 예외를 던집니다.

val updated = productRepository.decreaseStockAtomically(productId, quantity)
if (updated == 0) throw BusinessException(ErrorCode.INSUFFICIENT_STOCK)

Q3. 여러 상품을 동시에 주문할 때 데드락이 발생하나요?

A: 상품 ID 순으로 정렬하여 UPDATE하면 데드락을 방지할 수 있습니다.

val items = orderItems.sortedBy { it.productId }
items.forEach { productRepository.decreaseStockAtomically(it.productId, it.quantity) }

Q4. 멱등성 키는 누가 생성하나요?

A: 일반적으로 클라이언트 가 생성합니다. 버튼 클릭 시 키를 생성하고, 재시도 시 동일한 키를 사용합니다.

Q5. 언제 분산 락을 써야 하나요?

A: 아래 경우에만 사용하세요:

사용 O사용 X
캐시 스탬피드 방지재고 차감
배치 작업 중복 방지쿠폰 사용
외부 API 직렬화중복 주문 방지

판단 기준: “원자적 UPDATE나 멱등성 키로 해결 가능한가?” → 가능하면 분산 락 불필요


정리

핵심 요약

문제해결책설명
재고 과잉 판매Atomic UpdateUPDATE WHERE stock >= qty 조건부 감소
쿠폰 중복 사용Atomic UpdateUPDATE WHERE used = false 조건부 갱신
중복 주문 (따닥)멱등성 키클라이언트 UUID + Redis 캐시
캐시 스탬피드분산 락캐시 만료 시 동시 DB 조회 방지
배치 중복 실행분산 락다중 인스턴스 환경

Quick Checklist

  • 재고 차감에 원자적 UPDATE (WHERE stock >= qty)를 사용하는가?
  • 쿠폰 사용에 원자적 UPDATE (WHERE used = false)를 사용하는가?
  • 중복 주문 방지를 위해 멱등성 키를 사용하는가?
  • 분산 락은 정말 필요한 경우에만 사용하는가?
  • 여러 상품 주문 시 ID 순 정렬로 데드락을 방지하는가?

판단 기준

"원자적 UPDATE나 멱등성 키로 해결 가능한가?"
    → 가능하면 분산 락 불필요!

다음 편에서는 캐싱 전략과 Redis 활용 에 대해 다룹니다.

👉 다음: 2편 - 캐싱 전략

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