재고 차감 시 발생하는 동시성 이슈 해결 - 비관적 락

현재 상황

현재 재고 차감은 결제 요청 시 미리 차감 후, 결제 결과에 따라 복원하는 프로세스로 구현되어 있다.

결제 도중 재고가 선점되지 않아 발생하는 사용자 이탈 및 불편을 최소화하기 위해 구현이 조금 복잡해져도 선점 방식을 도입했다.

이러한 선점 로직이 동시 요청 환경에서 정합성을 보장하는지 검증하고자 동시성 테스트를 진행했다.

 

테스트 시나리오

테스트 환경

  • 도구: K6
    • 터미널 기반으로 리소스 효율이 높고, Grafana 연동을 통한 실시간 지표 시각화 용이
  • 테스트 환경: 로컬 환경
    • 인프라 지연 요인을 배제하고 로직 자체의 동시성 결함 및 정합성 검증
  • 데이터베이스: PostgreSQL
  • 테스트 대상 API: POST /internal/rewards/reserve-stock

테스트 데이터 설정

  • 리워드 초기 재고: 100개
  • 동시 요청 수: 500명
  • 각 요청당 차감 수량: 1개
더보기
import http from 'k6/http';
import { check, sleep } from 'k6';
import { concurrencyThresholds } from '../../config/thresholds.js';
import { generateUUID } from '../../utils/uuid-generator.js';

export let options = {
    vus: 500,
    duration: '1m',
    thresholds: concurrencyThresholds,
};

const BASE_URL = __ENV.BASE_URL || 'http://host.docker.internal:18083';
const REWARD_ID = __ENV.TEST_DATA_ID;

export default function() {
    const userId = generateUUID();
    const fundingId = generateUUID();

    const payload = JSON.stringify({
        fundingId: fundingId,
        items: [
            {
                rewardId: REWARD_ID,
                optionId: null,
                quantity: 1,
            }
        ],
    });

    const params = {
        headers: {
            'Content-Type': 'application/json',
            'X-User-Id': userId,
        },
    };

    const response = http.post(
        `${BASE_URL}/internal/rewards/reserve-stock`,
        payload,
        params
    );

    check(response, {
        'status is 200 or 409': (r) => r.status === 200 || r.status === 409,
        'response has body': (r) => r.body.length > 0,
    });
}

 

테스트 결과

테스트 결과
데이터 정합성 확인 쿼리 결과

 

1차 테스트 결과, 총 12,056건의 요청 중 916건만 성공했고 11,140건은 실패했다.

평균 응답 시간은 2.15초였지만, p95 지표가 5.15초까지 올라 일부 요청에서 큰 지연이 발생했다.

이유는 500명의 사용자가 같은 재고를 동시에 차감하려고 몰리면서, 재고를 줄이는 과정에서 요청들이 서로 충돌했기 때문이다. 이 과정에서 재고가 부족한 요청들은 끝까지 처리되지 못하고 중간 단계에서 실패했다.

 

즉, 락이 없는 상태에서는 요청을 빠르게 처리하려다 동시성 제어를 하지 못했고, 그 결과 처리량은 높았지만 성공률은 극단적으로 낮아졌다.

 

개선방향

  • 데이터 정합성 확보: 비관적 락과, 낙관적 락 중에 현재 내 상황에 맞게 선택하여 적용
  • 성능 최적화: 로컬 환경에서는 데이터 정합성을 확보하고, 운영 환경에서는 ECS Task 개수를 늘려 트래픽을 분산하여 개별 Task의 부하를 낮추고 대기 시간 감소

 

비관적 락 VS 낙관적 락

우리 서비스는 밴드의 공연 티켓과 한정판 굿즈를 펀딩 방식으로 판매한다.

이런 서비스에서 인기 밴드의 경우 순간적인 트래픽이 집중될 수 있고, 공연장 좌석 수 제한같은 재고의 희소성이 중요하다.

또한 선착순 판매인 만큼 사용자는 재고가 있다고 표시됐지만 결제 실패 시 최악의 경험이 된다.

 

테스트 결과에 따른 선택

테스트 결과 중 충돌률은 80% 였고, 이 결과는 500명이 동시 요청했을 때 400명이 충돌이 발생했다는 것이다.

낙관적 락은 충돌이 드물다는 가정하에 설계된 방식이고, 만약 낙관적 락을 적용한다면 다음과 같은 시나리오대로 흘러갈 것이다.

500명이 동시에 재고 100개 확인 → 구매 가능 표시
500명이 동시에 결제 진행100명만 성공, 400명은 충돌 감지
400명이 재시도 (1차) → 이미 재고 0, 다시 충돌
400명이 재시도 (2차, 3차...) → 의미 없는 재시도 반복 최종적으로 400명 실패

 

여기서 문제점은 400명이 여러 번 재시도하며 DB 부하가 증가하고, 재고가 있다고 보고 구매를 했지만 결제가 안 되어 사용자 불만 발생.

또한 재시도 과정에서 응답이 지연되고, 서버 리소스가 낭비된다.

 

비관적 락 선택

비관적 락은 충돌이 빈번할 것이라는 가정 하에 설계된 방식으로 현재 우리 서비스의 재고 차감 상황에 딱 맞다.

비관적 락을 적용하면 다음과 같은 시나리오대로 흘러갈 것이다.

첫 번째 사용자가 재고 조회 → 락 획득
나머지 499명은 대기
첫 번째 사용자 재고 차감 완료 → 락 해제
두 번째 사용자 처리 시작...
100번째 사용자까지 성공
101번째 사용자부터 즉시 품절 응답 (409 Conflict)

 

장점은 요청 순서대로 락을 획득해 공정성이 보장되고, 사용자에게 명확한 피드백(품절 알림) 즉시 안내할 수 있다.

또한 한 번에 성공하거나 실패하기 때문에 재시도가 불필요하고, 이로인해 서버 리소스를 낭비하는 일이 줄어든다.

 

하지만 시나리오에서 알 수 있듯이 대기하는 사용자가 존재할 수 있다.

낙관적 락과 달리 비관적 락은 충돌 시 즉시 실패하지 않고 락이 해제될 때까지 대기하게 되며, 이로 인해 응답 지연이 발생할 수 있다.
하지만 이 대기는 재시도를 반복하는 낙관적 락과 달리 한 번의 요청으로 성공 또는 품절이 명확히 결정되며, 재고가 소진된 이후에는 즉시 품절 응답을 반환할 수 있다는 점에서 사용자 경험과 정합성 측면에서 더 예측 가능한 방식이다.

 

비관적 락 적용

비관적 락은 JPA의 @Lock 어노테이션과 PESSIMISTIC_WRITE 모드를 사용해 구현했다.

 

Repository 계층

 

Application 계층

기존에 사용했던 findById에서 락이 적용된 findByIdWithLock으로 변경

 

조회 시점에 락을 거는 이유

재고 차감은 재고 조회, 재고 계산, 새로운 재고 저장 순서로 동작한다.

동시성 문제는 여러 트랜잭션이 동시에 조회하면서 발생한다.

모두 같은 재고(100)를 읽고 각자 계산한 뒤 저장하면 정합성 문제가 발생한다.

따라서 Read 시점부터 락을 획득해 한 트랜잭션이 조회, 수정, 저장을 완료할 때까지 다른 트랜잭션의 접근을 차단해야 한다.

 

비관적 락 적용 후 테스트 결과

테스트 결과
데이터 정합성 확인 쿼리 결과

 

비관적 락을 적용한 이후에는 요청 처리 방식이 달라졌다.

동시에 500명의 요청이 들어와도 하나의 요청만 재고에 접근하고, 나머지는 순서대로 대기하면서 재고 차감은 항상 순차적으로 처리되었다.

그 결과 재고는 정확히 100개만 차감되었고, 기존에 발생했던 정합성 문제는 해결됐다.

전체 실패율은 증가했지만, 이는 재고가 소진된 이후의 요청들이 재고 차감 과정에서 충돌하지 않고 즉시 품절로 처리되었기 때문이다.

 

즉, 비관적 락을 적용한 이후에는 처리량은 줄어들었지만, 성공과 실패가 명확히 구분되며 데이터 정합성과 사용자 경험 모두 예측 가능한 구조로 개선되었다.

 

Grafana를 통한 지표 비교

k6 결과를 통해 숫자상 변화는 확인했지만, Grafana 지표를 통해 요청 흐름이 실제로 어떻게 달라졌는지도 확인해봤다.

비관적 락 적용 전

락 적용 전

비관적 락을 적용하기 전에는 요청 수와 실패 요청이 동시에 급격하게 증가했다
500명의 사용자가 동시에 재고 차감 요청을 보내면서, 짧은 시간에 요청이 몰려 일부는 바로 처리됐지만 많은 양이 충돌로 실패했다.

그래프가 위아래로 크게 흔들리는 모습에서 알 수 있듯이,
동시에 들어온 요청들이 재고 차감 로직에 한꺼번에 진입하면서 충돌이 빈번하게 발생했고, 요청 처리 흐름 역시 매우 불안정한 상태였다.

 

즉, 락이 없는 상태에서는 많은 요청을 빠르게 처리하는 것처럼 보였지만, 실제로는 성공과 실패가 뒤섞이며 예측하기 어려운 흐름을 보였다.

비관적 락 적용 후

락 적용 후

비관적 락을 적용한 이후에는 요청 처리 패턴이 눈에 띄게 달라졌다.
요청 처리량 자체는 이전보다 낮아졌지만, 요청과 실패 요청의 흐름이 크게 흔들리지 않고 비교적 일정한 패턴으로 유지된다.

이는 한 번에 하나의 요청만 재고를 처리하도록 제한했기 때문이다.

먼저 들어온 요청이 처리를 마치면 그 다음 요청이 이어서 처리되는 구조가 되었고, 그 결과 재고 차감 과정에서의 충돌은 사라졌다.

요청 흐름 역시 이전처럼 크게 흔들리지 않고 차분하게 이어졌다.

 

마무리

낙관적 락을 선택해야 하는 이유는  충돌이 드문 상황에서는 성능상 이점이 크고, 시스템 전체 처리량을 높이기에도 적합한 방식이기 때문이라고 생각했다. 그래서 재고 차감 시나리오에서는 충돌이 빈번하기 때문에 낙관적 락의 재시도 전략이 오히려 부담이 될 거 같아 비관적 락을 선택했다.

 

이번 테스트를 통해 동시성 이슈는 비즈니스 로직과 DB 레벨에서 정합성을 먼저 보장해야 하고, 운영 환경에서는 인프라 확장으로 처리량이나 응답 지연 같은 성능 문제를 완화할 수 있다는 점을 배웠다.

 

또한 이번에는 JMeter 대신 k6를 선택했는데, Grafana와 연동해 지표를 함께 볼 수 있다는 점이 큰 이유였다.

요청 결과를 숫자로만 확인하는 것보다, Grafana를 통해 요청이 몰리고 흘러가는 모습을 직접 보면서 변화가 훨씬 직관적으로 와닿았다.

 

이번 테스트에서는 현재 상황에 맞는 락을 선택하는 것이 중요하다는 걸 깨닳았고, 그 결과를 시각화함으로써 직접 확인해보는 과정이 큰 의미가 있었다.