쇼핑몰에서 안전하게 재고 차감하기

2026. 6. 12. 17:37·루퍼스 4기

이커머스 주문에서 재고 차감이나 쿠폰 사용을 JPA에 더티 체킹으로 구현한다면 Lost Update 가 일어날 수 있다. 

Lost Update 란 동시에 여러 트랜잭션이 같은 데이터를 읽고 같이 수정한 뒤 다시 저장하면서, 한쪽 수정 결과가 덮어써져서 사라지는 문제이다. 

 

평소에는 주문량이 많지 않아 문제가 없어 보일 수 있지만 쇼핑몰의 인기 상품이 동시에 여러 건이 팔린다면 이전 트랜잭션이 부정확한 재고를 수정하는 문제가 발생하면서 어디서 재고가 차감됐는지 알 수 없다. 

 

실제 문제를 눈으로 확인하기

 

현재 주문 시 먼저 상품 주문을 취합하고 상품에 맞는 재고를 차감한 뒤 쿠폰이 존재하면 쿠폰을 사용하고 마지막에 주문 요청을 마무리하는 흐름으로 되어 있다. 

@Transactional
public OrderInfo createOrder(String orderNumber, Long userId, List<OrderItemInput> items, Long userCouponId) {
    List<OrderLine> lines = new ArrayList<>();
    for (OrderItemInput input : OrderItemInput.merge(items)) {
        ProductStockModel stock = productStockService.decrease(input.stockId(), input.quantity());
        lines.add(new OrderLine(stock.getId(), stock.getProduct().getId(),
                stock.getProduct().getName(), stock.getPrice(), input.quantity()));
    }
    long originalTotal = lines.stream().mapToLong(OrderLine::amount).sum();
    UserCouponService.UseResult amounts = userCouponService.use(userCouponId, userId, originalTotal);
    try {
        return OrderInfo.from(orderService.placeOrder(new OrderModel(orderNumber, userId, userCouponId), lines,
                new Money(amounts.originalAmount()), new Money(amounts.discountAmount())));
    } catch (DataIntegrityViolationException e) {
        throw new CoreException(ErrorType.CONFLICT, "이미 처리된 주문입니다.");
    }
}

이 코드에서 실제 동시성 문제를 k6 툴로 테스트로 진행하고자 한다. 

 

테스트 시나리오는 10명의 사용자는 stock_id 가 1인 제품을 동시에 주문하는 시나리오로 구성했다. 

사용자 10명이 요청하는 상품의 전체 재고는 총 24개로 선정했고, 전체 상품의 수량은 20개로 한정되어 있다. 

import http from 'k6/http';
import { check } from 'k6';

const BASE_URL = 'http://localhost:8080';
const STOCK_ID = 1;

const USERS = [
    { loginId: 'testuser1',  loginPw: 'Test1234!', quantity: 1 },
    { loginId: 'testuser2',  loginPw: 'Test1234!', quantity: 2 },
    { loginId: 'testuser3',  loginPw: 'Test1234!', quantity: 3 },
    { loginId: 'testuser4',  loginPw: 'Test1234!', quantity: 1 },
    { loginId: 'testuser5',  loginPw: 'Test1234!', quantity: 2 },
    { loginId: 'testuser6',  loginPw: 'Test1234!', quantity: 4 },
    { loginId: 'testuser7',  loginPw: 'Test1234!', quantity: 1 },
    { loginId: 'testuser8',  loginPw: 'Test1234!', quantity: 3 },
    { loginId: 'testuser9',  loginPw: 'Test1234!', quantity: 2 },
    { loginId: 'testuser10', loginPw: 'Test1234!', quantity: 5 },
];

export const options = {
    scenarios: {
        concurrent_orders: {
            executor: 'per-vu-iterations',
            vus: 10,
            iterations: 1,
        },
    },
};

function headers(user) {
    return {
        'Content-Type':      'application/json',
        'X-Loopers-LoginId': user.loginId,
        'X-Loopers-LoginPw': user.loginPw,
    };
}

function generateOrderNumber() {
    const now = new Date();
    const date = now.getFullYear().toString()
        + String(now.getMonth() + 1).padStart(2, '0')
        + String(now.getDate()).padStart(2, '0')
        + String(now.getHours()).padStart(2, '0')
        + String(now.getMinutes()).padStart(2, '0')
        + String(now.getSeconds()).padStart(2, '0');
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let random = '';
    for (let i = 0; i < 6; i++) {
        random += chars.charAt(Math.floor(Math.random() * chars.length));
    }
    return date + random;
}

export default function () {
    const user = USERS[__VU - 1];

    const res = http.post(
        `${BASE_URL}/api/v1/orders`,
        JSON.stringify({
            orderNumber:  generateOrderNumber(),
            items:        [{ stockId: STOCK_ID, quantity: user.quantity }],
            userCouponId: null,
        }),
        { headers: headers(user) }
    );

    check(res, { 'status 201': (r) => r.status === 201 });
    console.log(`[VU ${__VU}] user=${user.loginId} status=${res.status} body=${res.body}`);
}

 k6 테스트 결과 

사용자 10명의 주문
상품 1은 17개 정도 재고가 남아 있음

사용자 10명은 주문을 요청한 수량대로 완료했지만, 전체 수량은 재고가 17개 남은 것을 확인할 수 있다. 

 

예상했던 결과는 몇 명의 사용자는 재고가 부족하여 주문에 실패 나는 상황을 기대했고 재고가 주문한 수량만큼 차감되어 있어야 한다. 

 

이런 문제는 어떻게 해결할 수 있을까? 

낙관적 락 VS 비관적 락 VS 원자적 연산

 

하나의 DB에서 발생할 수 있는 동시성 문제를 해결하기 위한 방법으로, 크게 3가지 접근을 고려할 수 있다.

1. 낙관적 락 (Optimistic Lock)

낙관적 락은 여러 트랜잭션이 동시에 동일한 데이터를 읽고 수정하는 상황을 허용한 뒤, 저장 시점에 데이터가 변경되었는지를 검사하여 충돌을 감지하는 방식이다.

 

동시에 재고 변경 요청이 들어오더라도, 최초 조회 시점의 버전과 현재 DB의 버전이 동일하다면 업데이트를 수행하고, 그렇지 않으면 충돌로 판단하여 실패를 반환한다.

 

이 방식은 일반적인 상황에서는 락을 사용하지 않기 때문에 성능적으로 유리하고 확장성이 좋다.

다만, 충돌이 발생하면 예외를 반환하게 되며, 이 경우 재시도 로직을 별도로 구현하지 않으면 사용자 경험 측면에서 아쉬움이 남을 수 있다.

2. 비관적 락 (Pessimistic Lock)

비관적 락은 “충돌은 반드시 발생할 수 있다”는 전제를 기반으로, 데이터를 조회하는 시점부터 해당 Row에 락을 걸어 다른 트랜잭션의 접근을 차단하는 방식이다.

 

즉, 먼저 락을 획득한 트랜잭션이 작업을 완료할 때까지 다른 트랜잭션은 해당 데이터를 대기하게 된다.

데이터 정합성을 강하게 보장할 수 있다는 장점이 있지만, 동시에 요청이 몰리는 경우 락 대기로 인해 병목이 발생할 수 있고, 전체 처리량(throughput)이 감소할 가능성이 있다.

 

비관적 락은 “충돌이 반드시 발생할 수 있다”라고 가정하고, 미리 데이터를 잠가서 다른 트랜잭션 접근을 막는 방식이다. 

즉, 락을 먼저 잡은 사람이 끝날 때까지 독점해서 다른 사람은 기다리게 되고 만약 동시에 많이 몰린다면 병목의 원인이 될 수 있다.

3. 원자적 연산 (Atomic Update) - 선택

원자적 연산은 조회와 수정을 분리하지 않고, 단일 UPDATE 쿼리로 상태 변경을 끝내는 방식이다.

예를 들어 재고 차감의 경우 다음과 같이 한 번의 쿼리로 처리할 수 있다:

  • 조건을 만족하는 경우에만 UPDATE 수행
  • DB 내부적으로 해당 Row에 대한 락이 짧게 유지됨
  • 성공/실패가 즉시 결정됨

이 방식은 DB 레벨에서 원자성이 보장되기 때문에 동시성 문제를 효과적으로 해결할 수 있고, 락 유지 시간이 매우 짧아 성능적으로도 유리 하지만 복잡한 도메인 로직을 SQL로 표현하기 어렵고, 결과 역시 “성공/실패”로만 반환되는 경우가 많아 실패 원인을 세부적으로 구분하기 어렵다.

 

재고 차감 문제는 본질적으로 “현재 재고가 조건을 만족하면 감소시키고, 아니면 실패”라는 매우 단순한 조건 기반 연산으로 도메인 로직이 복잡하게 얽혀 있다기보다는 상태를 안전하게 변경하는 문제에 가깝다.

 

이런 경우에는 조회와 수정이 분리된 구조(SELECT → 검증 → UPDATE)보다, 하나의 SQL로 끝내는 방식이 더 적합하다고 판단하여 원자적 연산 방식을 선택했다. 

@Transactional
public ProductStockModel decrease(Long stockId, int quantity) {
    boolean decreased = productStockRepository.decreaseIfSufficient(stockId, quantity);
    if (!decreased) {
        throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
    }
    return get(stockId);
}

@Override
public boolean decreaseIfSufficient(Long stockId, int quantity) {
    QProductStockModel stock = QProductStockModel.productStockModel;
    return queryFactory
            .update(stock)
            .set(stock.stockQuantity.value, stock.stockQuantity.value.subtract(quantity))
            .where(stock.id.eq(stockId).and(stock.stockQuantity.value.goe(quantity)))
            .execute() > 0;
}

 

현재 수량보다 차감해야 할 수량이 작다면, 차감한 수량을 업데이트하는 쿼리로 원자적 연산을 만들었다. 

 

원자적 연산을 적용한 다음 처음과 동일하게 테스트를 진행해 보면 

동시 주문 결과
1개 남아버린 재고

10명의 주문은 8명으로 감소하였고, 19건의 재고도 차감된 것을 확인할 수 있다. 

 

그렇다면 문제를 해결했다고 볼 수 있을까?

 

사용자 요청은 안전하지 않다.

 

재고 차감의 원자적 연산은 안전하게 차감되게 했지만, 만약 서비스에서 재고 차감을 순차적으로 처리한다면 안전하지 않다. 

 

사용자 2명이 동시에 요청을 진행한다고 가정하자 

 

A 사용자는 StockId 가 1인 상품을 1개 StockId 가 2인 상품을 1개 산다고 요청하고 

B 사용자는 StockId 가 2인 상품을 1개 StockId 가 1인 상품을 1개 산다고 요청하면 어떻게 될까? 

 

  • Thread A 요청: items: [{ "stockId": 1, "quantity": 1 }, { "stockId": 2, "quantity": 1 }] → 서버 처리 순서: 1 → 2
  • Thread B 요청: items: [{ "stockId": 2, "quantity": 1 }, { "stockId": 1, "quantity": 1 }] → 서버 처리 순서: 2 → 1

 

A사용자는 1번 상품의 락을 쥐고 있고 2번을 대기하는 상황이 발생하고 B사용자는 2번 상품의 락을 쥐고 있고 1번 락을 대기하는 상황이 발생하면서 데드락이 발생된다. 

 

Mysql 기반으로 얘기를 하면 교착 상태를 감지하고 어느 트랜잭션을 실패시킬지를 선정한 후 롤백을 진행하는데 결과적으로 하나의 주문 요청은 성공하고 하나의 다른 요청은 실패하게 된다. 

 

만약 컨트롤러에서 재고 요청을 사용자 요청 순서로 처리한다고 했을 때 Mysql이 데드락을 감지하여 실패시키는 상황이 나온다. 

 

10명의 사용자가 동시 요청을 한다고 가정하면

사용자 요청에 데드락 발생

사용자 5명은 정상 주문이 가능했고 사용자 5명은 500 에러를 안내하는 상황이 발생한다. 

 

이와 같은 상황을 해결하려면 락 획득 순서를 다른 요청과 맞물리게 하지 않아야 한다.

 

첫 번째로 서비스 단에서 요청을 정렬한 뒤 동일한 순서로 락을 획득하도록 강제하는 방식이다.

예를 들어

  • A 사용자 요청: [3, 2, 1]
  • B 사용자 요청: [1, 2, 3]

이 경우 두 요청 모두 서비스 내부적으로 상품 ID 기준으로 정렬하여  [1 → 2 → 3] 순서로 락을 획득하도록 만든다.

 

이렇게 하면 서로 다른 순서로 락을 잡는 상황이 제거되어 데드락 발생 가능성이 크게 줄어든다.

 

두 번째로 비관적 락을 요청 Row에 한 번에 거는 방법도 존재한다. 

SELECT stock_id FROM product_stock WHERE stock_id in (1, 2, 3) FOR UPDATE

이 방식은 해당 조건에 맞는 row들을 한 번에 락으로 묶어버리기 때문에
개별적으로 락을 획득하는 과정에서 발생할 수 있는 순서 문제를 줄일 수 있다.

결론 

현재는 동시성 충돌을 줄이기 위해 원자적 업데이트 방식을 채택했고 이는 조회와 수정의 분리를 제거하여 성능과 구조적 단순성을 확보할 수 있다는 장점이 있다.

 

다만 여러 자원을 동시에 갱신하는 트랜잭션에서는 접근 순서 차이로 인해 데드락 가능성이 존재한다.

따라서 단순히 락 방식 선택의 문제가 아니라, 트랜잭션 내 자원 접근 순서를 일관되게 유지하는 설계가 핵심이며, 현재 구조에서는 해당 규칙을 통해 데드락 가능성을 통제하는 방향으로 결정한다.

저작자표시 (새창열림)

'루퍼스 4기' 카테고리의 다른 글

인덱스 적용부터 반정규화까지, 조회 성능 개선 실험  (0) 2026.06.19
루퍼스 4주차 WIL (동시성 문제 해결)  (0) 2026.06.14
주문 생성 흐름에서 책임을 어디까지 나눌 것인가  (0) 2026.05.29
루퍼스 2주차 WIL (소프트웨어 설계)  (0) 2026.05.24
이커머스 도메인을 설계하면서 나는 어떤 고민을 했을까  (0) 2026.05.22
'루퍼스 4기' 카테고리의 다른 글
  • 인덱스 적용부터 반정규화까지, 조회 성능 개선 실험
  • 루퍼스 4주차 WIL (동시성 문제 해결)
  • 주문 생성 흐름에서 책임을 어디까지 나눌 것인가
  • 루퍼스 2주차 WIL (소프트웨어 설계)
LIMTAEYANG
LIMTAEYANG
오늘 할 코딩을 내일로 미루지 말자
  • LIMTAEYANG
    lty's Blog
    LIMTAEYANG
  • 전체
    오늘
    어제
    • 분류 전체보기 (80)
      • 웹개발 (23)
        • JAVA (1)
        • Spring (6)
        • JavaScript (7)
        • css (2)
        • Vue (1)
      • 클라우드 (4)
        • AWS (2)
        • MSA (0)
        • Docker (2)
      • 운영 및 배포 (4)
      • Backend (6)
      • Database (2)
      • 명령어 (2)
        • Linux (2)
      • 형상관리 (8)
        • github & git (8)
      • 알고리즘 (1)
      • 개인 프로젝트 (2)
      • CS (11)
        • 소프트웨어 공학 (10)
        • 운영체제 (1)
      • 정보처리기사 (2)
      • 취미활동 (2)
        • 등산 (1)
        • 여행 (0)
        • 글쓰기 (1)
      • 회고 (0)
      • 루퍼스 4기 (13)
  • 블로그 메뉴

    • 홈
    • 개인프로젝트
    • 프로그래밍 언어
    • 운영체제
    • 형상관리
    • 자기개발
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    PG 정합성
    LOOPPAK
    재고 동시성 차감
    Jenkins
    랭킹 레디스 장애 대응
    CI/CD
    설계
    비관적 락
    배포 자동화
    낙관적 락
    대기열 배치사이즈
    루퍼스 백엔드 코스
    설계ERD
    루퍼스 4기 수료 후기
    환경변수
    되돌리기
    4주차 WIL
    루프팩백엔드4기
    TDD
    DDD 주문설계
    원자적 연산
    reset
    CDC 방식
    소프트웨어
    Callback 장애 대응
    대기열 TPS 테스트
    아웃박스 테이블 polling 방식
    2주차 회고
    prefilight
    테스팅
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
LIMTAEYANG
쇼핑몰에서 안전하게 재고 차감하기
상단으로

티스토리툴바