재고 관리 문제 해결(동시성 및 DB 동기화 전략에 대해)

2026. 1. 7. 01:57·Back-End

쇼핑몰, 이커머스 서비스 등에서 상품에 대한 재고 관리는 아주 중요한 기능이다. 또한, 이 기능은 백엔드 개발자로써 동시성 문제와 성능 개선 등에 대해서 많이 다뤄볼 수 있는 적합한 주제라고도 생각할 수 있다. 프로젝트를 진행하며, 단순히 DB에서 재고 개수를 차감시키던 기능이 어떤 문제들을 가져올 수 있었으며, 이를 해결하기 위해 어떤 방법들을 적용했는지를 작성해보려 한다.

동시성 문제(Lost update)

먼저 코드를 보자

@Transactional
public void deductUnsafe(UUID productId, int quantity) {
    // 재고 조회
    Inventory inventory = inventoryJpaRepository.findByProductId(productId)
        .orElseThrow(() -> new RuntimeException("재고 없음"));

    // 재고 차감 - dirty checking 이용
    inventory.deduct(quantity);
}

아주 전형적으로 상품 재고를 조회하고, 이를 JPA 더티체킹을 이용해 원하는 개수만큼 차감시키는 방법이다.

트랜잭션 또한 잘 걸려 있으며, 단순하지만 문제가 발생하지 않을 것처럼 보이는 코드이다.

한 번의 요청에 대해 quantity 만큼 차감을 잘 시켜주며, 상품 재고가 존재하지 않을 경우 예외를 발생시킨다. 또한 엔티티 내부에는 현재 재고의 개수보다 quantity가 클 경우 차감이 진행되지 않도록 설정해두었다.

2번의 차감이 진행될 때 우리가 바라는 이상적인 모습은 위와 같다.

한 번에 수많은 요청이 몰린다면?

이 상황에서 부하 테스트 혹은 테스트 코드를 이용해 여러 개의 쓰레드가 동시에 접근하도록 설정을 해봤다.

테스트 조건으로 상품이 1000개의 재고를 가지고 있으며, 30개의 쓰레드에서 동시에 10개씩 차감하도록 테스트 코드를 작성하고 실행했다.

=== Lost Update 테스트 결과 ===
초기 재고: 1000
예상 재고: 700 (정상 차감 시)
실제 재고: 960 (Lost Update 발생)
손실된 차감: 260개

실제로 상황이 모두 끝난 후 DB를 조회했을 때 실제 재고는 우리가 원하는 개수인 300개가 아니라 단지 40개만 차감되어 있는 것을 확인할 수 있었다. 이 문제는 한 번에 여러 개의 트랜잭션이 동일한 데이터를 수정하려 할 때 한 트랜잭션의 변경사항이 다른 트랜잭션에 의해 덮어씌이는 문제 때문에 발생하게 된다. 그림으로 보면 다음과 같이 표현할 수 있다.

 

예시에서는 트랜잭션1과 2의 숫자를 다르게 작성해두었다. 결국 문제가 되는 요점은, 트랜잭션 1의 변경사항이 커밋되기 이전에 다른 트랜잭션에서 데이터를 읽고 자신의 변경사항을 트랜잭션 1보다 늦게 커밋한다. 결국 트랜잭션 1에서는 100개가 차감되고 트랜잭션 2에서는 10개가 차감되어 110이 차감되어야 하지만, 10개만 차감된 결과를 가져올 수 있게 된다.

이러한 문제를 Lost Update 문제라고 하며 커밋되지 않은 데이터를 조회할 때 발생하는 문제이다. 이러한 문제를 해결하기 위해서 우리는 여러가지 옵션을 적용해볼 수 있다.

재고 Lost Update를 방지하기 위한 방법

원자성 쿼리

DB레벨에서 조회와 수정을 하나의 원자적인 연산으로 처리하는 것이다.

@Modifying
@Query("""
    UPDATE Inventory i
    SET i.quantity = i.quantity - :quantity
    WHERE i.productId = :productId
    AND i.quantity >= :quantity
""")
int deductByProductId(@Param("productId") UUID productId,
                      @Param("quantity") int quantity);

기존 더티체킹 방식을 이용할 때 우리는 조회 -> 수정의 2단계로 나눈 구현을 적용했는데, 바로 Update 쿼리를 사용해서 데이터베이스에서 조회와 수정을 하나의 쿼리로 사용한 것이다.

UPDATE 쿼리는 데이터베이스에서 행 레벨 락을 자동으로 획득하기 때문에 Lost Update가 발생하지 않는다.

또한, UPDATE 뒤에 WHERE 조건을 이용해 비즈니스 로직이 적절한 경우에만 동작하도록 작성되었으며, 재고 차감과 같이 단순한 경우에는 이 방식을 적용하기 적합하다.

=== 원자적 쿼리 테스트 결과 ===
초기 재고: 1000
예상 재고: 700
실제 재고: 700

10회 평균: 255.3ms

비관적 락(Pessimistic Lock)

데이터를 조회할 때 미리 DB Lock을 획득하여, 해당 트랜잭션이 커밋되기 전까지 다른 트랜잭션이 데이터에 접근하지 못하도록 차단하는 방식이다. "충돌이 발생할 것"이라고 비관적으로 가정하기 때문에 비관적 락으로 불린다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT i FROM Inventory i WHERE i.productId = :productId")
Optional<Inventory> findByProductIdWithPessimisticLock(UUID productId);

`@Lock` 어노테이션을 이용해서, 조회 시점에서 비관적 락을 획득한다. 따라서 더티 체킹이 진행되고 커밋되기 전까지 다른 트랜잭션이 접근할 수 없기 때문에 동시성을 보장할 수 있다.

비관적 락 방식의 쿼리는 SELECT ... FOR UPDATE로 변환되어 쓰기에 대한 락을 획득하게 된다. 조회 이후에 트랜잭션이 커밋되기 전까지 많은 작업이 이뤄질 경우, 다른 트랜잭션에서 접근할 수 없어 성능 저하 문제가 발생할 수도 있으며, 데드락 발생 가능성 또한 존재한다.

=== 비관적 락 테스트 결과 ===
초기 재고: 1000
예상 재고: 700
실제 재고: 700

10회 평균: 310.1ms

낙관적 락(Optimistic Lock)

비관적 락과 반대로 "충돌이 발생하지 않을 것"이라고 낙관적으로 가정하고, 커밋 시점에 버전을 확인하여 충돌을 감지하는 방식이다.

엔티티를 작성할 때 낙관적 락용 버전 필드가 별도로 필요하다.

@Entity
@Table(name = "p_inventory_versioned")
public class InventoryWithVersion {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    private UUID productId;
    private Integer quantity;

    @Version
    private Long version;  // 낙관적 락용 버전 필드
}
@Transactional
public void deductWithOptimisticLock(UUID productId, int quantity) {
    InventoryWithVersion inventory = versionedRepository.findByProductId(productId)
        .orElseThrow(() -> new RuntimeException("재고 없음"));

    inventory.deduct(quantity);
}

낙관적 락의 결과는 앞선 결과들과는 조금 다르게 봐야 한다. JPA에서 낙관적 락을 이용하게 되면 `WHERE version = ?`  조건이 추가 되게 되는데, 조회 시점과 버전이 달라 쓰기 작업에 실패했을 경우, `ObjectOptimisticLockingFailureException`이 발생하게 된다. 따라서 실제 성공한 작업의 수로 결과를 비교해야 한다.

=== 낙관적 락 테스트 결과 ===
초기 재고: 1000
성공: 4건, 실패(충돌): 26건
실제 재고: 960
예상 재고: 960

10회 평균: 457ms

동시성 문제는 예방되었다고 볼 수 있지만, 우리가 원하는 700이라는 결과가 아니다. 따라서, 낙관적 락은 재시도 로직을 도입하여 작업을 성공시키도록 해야 한다.

SERIALIZABLE 격리 수준 적용

트랜잭션 격리 레벨을 가장 높은 수준인 SERIALIZABLE로 설정한다. 그 아래 단계인 READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ는 다른 동시성 문제들은 해결할 수 있지만 Lost Update문제를 해결하지는 못한다.

현재 프로젝트는 PostgreSQL을 이용하고 있었는데, MySQL의 InnoDB에서는 REPEATABLE READ에서 Lost Update를 방지할 수 있다는 말이 나와서 조사를 해봤다. 정말 DB 쿼리적인 레벨에서만 이루어진다면 REPEATABLE READ에서도 방지를 할 수 있겠지만, 현재 상황은 조회 -> 애플리케이션 레벨에서 차감 이기 때문에 적합하지 않다. 또한 격리 수준을 조절하기보다는 원자성 쿼리를 사용하는 것이 더 효율적이라고 생각했다.
@Transactional(isolation = Isolation.SERIALIZABLE)
public void deductWithSerializable(UUID productId, int quantity) {
    Inventory inventory = inventoryJpaRepository.findByProductId(productId)
        .orElseThrow(() -> new RuntimeException("재고 없음"));

    inventory.deduct(quantity);
}
=== SERIALIZABLE 테스트 결과 ===
초기 재고: 1000
성공: 5건, 실패(충돌): 25건
실제 재고: 950

10회 평균: 517ms

SERIALIZABLE 격리 수준 또한 동시 접근 시에 직렬화 오류를 발생시키기 때문에, 낙관적 락과 마찬가지로 재시도 전략을 사용해야 한다고 한다. 하지만, 굳이 성능 저하가 심한 격리 수준과 데드락의 위험성이 존재하는 방식을 선택하지는 않을 것 같았다.

 

DB 레벨에서의 동시성 문제를 테스트 해가면서, DB병목을 피하거나 캐싱 등 성능적인 이점을 가져가기 위해 Redis를 사용할 수도 있다고 생각했다. Redis에서도 분산 환경에서도 동작하는 락을 구현할 수 있기에 이에 대한 조사도 같이 진행했다.

Redis 스핀락

먼저 스핀락 형태를 통해서 가장 기본적인 Redis 락을적용했다. `setnx`를 이용해 구현했으며 이 Lock을 획득했을 경우에만 재고 차감 로직에 접근할 수 있도록 작성했다.

@Transactional
public void deductWithDistributedLock(UUID productId, int quantity) {
    String lockKey = "inventory:" + productId;

    lockManager.lock(lockKey);
    try {
        Inventory inventory = inventoryJpaRepository.findByProductId(productId)
            .orElseThrow(() -> new RuntimeException("재고 없음"));

        inventory.deduct(quantity);
    } finally {
        lockManager.unlock(lockKey);
    }
}

lockManager.lock() 내부에는 while 루프를 통해서 락을 계속해서 획득하게 하도록 작성하고 테스트를 진행했다.

테스트 결과는 다음과 같았다.

=== Redis 분산락 테스트 결과 ===
초기 재고: 1000
예상 재고: 700
실제 재고: 760

스핀락을 적용했지만 불일치 현상이 일어나서 원인을 파악해봤으며 원인은 다음과 같았다. DB의 트랜잭션이 커밋되기 전에 Redis 락이 해제되기 때문에 Lost Update가 발생했던 것이다.

  • Thread 1: 락 획득 → 재고 조회 → 재고 차감 → 락 해제 → 트랜잭션 커밋
  • Thread 2: 락 획득 → 재고 조회 (아직 커밋 안 된 데이터) → Lost Update 발생

Redis는 DB 트랜잭션의 영향을 받지 않기 때문에 트랜잭션 메소드를 분리하는 등의 방식으로 unlock 시점 이전에 수동으로 DB commit을 진행해야 했다. 해당 리팩토링을 진행한 이후에는 문제 없는 테스트 결과를 얻을 수 있었다.

=== Redis 분산락 테스트 결과 ===
초기 재고: 1000
예상 재고: 700
실제 재고: 700

10회 평균: 588.7ms

Redis Lua Script

스크립트 형식을 통해 여러 Redis 명령을 원자적으로 실행할 수 있다. DB의 트랜잭션과 같이 해당 스크립트에 작성된 작업의 원자성을 보장할 수 있기 때문에 동시성을 방지하는 데 있어 효율적이다.

// 락 획득 스크립트
private static final String LOCK_ACQUIRE_SCRIPT = """
    if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then
        redis.call('expire', KEYS[1], ARGV[2])
        return 1
    end
    return 0
    """;

// 락 해제 스크립트 (본인 락만 해제)
private static final String LOCK_RELEASE_SCRIPT = """
    if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('del', KEYS[1])
    end
    return 0
    """;

현재는 단순하게 락을 획득하고 해제하는 작업만 작성하기 때문에 별도의 파일로 생성하지 않고 String으로 작성했다.

=== Lua Script 테스트 결과 ===
초기 재고: 1000
예상 재고: 700
실제 재고: 700

10회 평균: 628.4ms

Redisson 분산 락

인터넷에서 Redis 락에 관해서 검색하면 가장 많이보게 되는 Redisson이다. Redisson은 Redis 기반의 분산 락을 제공하는 Java 클라이언트이다. Lua Script를 내부적으로 사용하며, Pub/Sub 기반으로 스핀락보다 효율적이라고 한다. 또한, Watch Dog 등의 기능으로 락 연장을 자동으로 관리한다고 한다. 사용하기 위해서는 별도의 의존성을 추가해주어야 한다.

@Component
@RequiredArgsConstructor
public class RedissonLockManager {

    private final RedissonClient redissonClient;
    private static final String KEY_PREFIX = "lock:inventory:";

    public void lock(String key) {
        RLock lock = redissonClient.getLock(KEY_PREFIX + key);

        try {
            // 최대 10초 대기, 락 획득 후 30초 후 자동 해제
            boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);
            if (!acquired) {
                throw new RuntimeException("락 획득 실패");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("락 대기 중 인터럽트", e);
        }
    }

    public void unlock(String key) {
        RLock lock = redissonClient.getLock(KEY_PREFIX + key);
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}
=== Redisson Lock 테스트 결과 ===
초기 재고: 1000
예상 재고: 700
실제 재고: 700

10회 평균: 466ms

내 프로젝트에는 어떤 방식을 선택하는 것이 좋을까?

물론 부하 테스트로 진행하지는 않고, 단순 테스트 코드를 통해 쓰레드 개수를 바탕으로 동시성 테스트만 진행하였기 때문에 실제 부하 테스트 환경에서는 평균 시간이 다를 수 있다. 하지만, 현재 주어진 데이터를 바탕으로 최선의 선택을 해보려고 했다.

 

우선 기본 전략으로 응답 시간이 가장 낮은 DB 원자성 쿼리를 가져가는 방향으로 생각했다. 현재 프로젝트의 인프라는 1 서비스 1 DB 형태로, 분산 DB 환경이 아니다. 따라서, 별도의 인프라 구축 없이 가장 간단하고 빠르게 Lost Update를 방지하는 원자성 쿼리를 생각했다.

 

그 다음으로 생각한 점은, 이벤트 혹은 인기 상품 등 갑자기 트래픽이 몰릴 수 있는 경우였다. 이 경우에는 원자성 쿼리를 적용하더라도 모든 요청이 하나의 DB로 몰리기 때문에 필연적으로 병목 현상이 발생할 것이라고 생각했다. DB 스케일 업 등의 방식으로 DB 성능을 높일 수도 있지만, 비용 상의 문제로 이 부분은 고려하지 않기로 했다.

따라서, DB 병목 문제를 해결하기 위해서는 Redis와 같은 캐싱 계층의 도입이 필수적이라고 판단했다.

 

프로젝트 특성 상 DB는 스케일 아웃 되지 않지만, 서비스에는 스케일 아웃이 적용될 수 있다. 따라서, Redis에서 또한 동시성 제어가 들어가야 한다고 판단했으며 앞서 정리한 3가지를 비교했다.

  • 스핀락: 테스트 환경에서는 단순 락 획득만 사용했지만, 실제 비즈니스 로직에서는 락 획득과 재고 차감이 원자적으로 이루어져야 하기 때문에 제외
  • Lua Script vs Redisson
    • 현재 프로젝트에 구현하려는 로직은 단순한 '조회 - 차감'의 형태이다.
    • Redisson은 다른 락들에 비해 더 많은 장점이 존재하지만, 현재 프로젝트 성격 상 복잡한 분산 락 관리 기능이 그렇게 필요하지 않으며, 새로운 의존성을 관리하는 것이 불필요하다고 생각했다.
    • 반면 Lua Script는 외부 라이브러리 의존성 없이 여러 명령을 원자적으로 실행할 수 있다고 판단했다.

위의 내용들을 바탕으로, 기본적으로 병목이 예상되는 인기 및 이벤트 상품은 Redis Lua Script + DB 원자성 쿼리를 적용하고, 일반 상품의 경우 Redis에 적재하지 않고 DB에서 원자성 쿼리만으로 동시성을 제어하기로 결정했다.

 

실제로, DB만을 이용했을 때와 Redis를 적용했을 때의 처리량은 35135 vs 97020(100 vu / 1m duration)으로 약 2.76배의 성능 향상을 보였다.

 

Redis 캐싱 전략은 어떻게?

Redis를 도입하게 된다면 필연적으로 따라오는 것이 바로 캐싱 전략이다. Redis는 매우 빠르고 강력한 성능을 보장하지만, 인메모리DB로서 서버에 오류가 발생할 경우 담겨 있는 모든 데이터가 사라질 수 있다. 또한, Redis가 단일 장애 지점이 될 수 있기 때문에, Redis에 오류가 발생했을 때는 DB에서 작업을 수행할 수 있도록 구성해야 한다.(느린것이 아예 사용할 수 없는 것 보다는 낫다고 생각한다..)

따라서 DB에 동기화를 하는 것이 매우 중요하다고 생각했다.

 

우선, 인기 및 이벤트 상품만 Redis에서 관리하고 일반 상품은 DB에서 조회하기 때문에 읽기 전략은 Look Aside 전략을 선택했다. 다음은, 쓰기 전략에 대해서 고민해봤다.

Redis 데이터 동기화 전략

Write-Through

요청 -> Redis 차감 -> DB 차감 -> 응답 으로 DB와 Redis에서 동시에 데이터를 저장하는 전략이다.

Write-Through를 적용했을 때, 위에서 Redis를 이용한 처리량에 비해 매우 낮아진 모습을 볼 수 있다. 다시 생각해보면 당연할 수도 있는 건 매 요청 마다 쓰기 작업이 이뤄진다는 것은 결국에는 DB 성능에 영향을 그대로 받는 것이고 DB만 사용했을 때와 비슷한 성능이 나올 수 있다는 것이다.

Write-Behind

캐시에 적용된 내용을 DB에 동기화할 때, 동기적인 방식이 아니라 비동기적인 방식을 이용한다.

하나의 요청마다 DB에 계속 UPDATE 쿼리를 보내는 것이 아니라, 스케쥴러 등을 이용해서 DB에 한번에 작업을 진행하게 된다.

초기에 내가 구현했던 방식은, 일정 시간(ex. 3초)마다 주기적으로 캐시에 있는 모든 재고 값을 가져와서 DB에 한번에 업데이트 시키는 방식으로 구현했다.

이 방식의 장점은, 비동기로 DB 동기화가 이루어지기 때문에 사용자에게 응답은 Redis만 사용했을 때와 비슷하게 빠르게 동작하게 된다는 것이다. 하지만, 서버에서 지속적으로 스케쥴러가 동작하기 때문에 불필요한 메모리 낭비가 이뤄질 수 있다고 판단했다.

 

따라서, 비동기 메시징 방식으로 관심을 가지게 되었다. 현재 프로젝트에서 MSA로 진행하며 Kafka를 이용해 비동기 메시징 통신을 사용하고 있었기 때문에 Redis에서 작업이 이뤄질 때마다 Kafka로 메시지를 날린다면 불필요한 메모리 낭비가 줄어들 것이라고 판단했다.

 

데이터 동기화 전략으로 Kafka를 사용하며 발생한 문제들

초반에는 Kafka에 대한 지식이 부족해서, Redis -> Kafka -> DB Update로 구성을 하며 다음과 같은 문제들이 발생했다.

단일 파티션 사용 문제

시나리오 상에서 단일 product에 대해서 재고를 차감시키는 로직을 적용했는데, productId를 key로 사용하다 보니 단일 파티션에만 메시지가 집중되는 문제가 발생

 

3개의 파티션으로 나뉘어서 동작하도록 했지만, 원하던 만큼의 처리량 향상이 이루어지지 않음(초당 85개 -> 초당 104개)

소비속도보다는 DB에서 병목이 발생했을 것으로 판단해서 초당 트랜잭션 커밋 수를 모니터링 하여 DB 성능 문제라는 것을 파악

DB 병목 문제

분명히 Kafka로 비동기 방식으로 변경했지만 DB 병목 현상이 이뤄지고 있었다.  Kafka Lag이 대략 파티션 당 2000이상 보이는 현상이 있었으며, 테스트가 끝나고 나서도 1분 이상 계속해서 UPDATE 쿼리가 발생했다. 문제는 비동기 처리임에도 과정이 동기 처리와 동일하게 구성했기 때문이었다. 한 번에 하나의 메시지만 소비하며 DB에서도 단일 UPDATE를 계속 진행했기 때문이다.

 

이 문제를 해결하기 위해 Kafka를 배치 처리 방식으로 변경하였으며, DB 쿼리 또한 한 번에 배치 처리를 진행하도록 설정하여 문제를 해결할 수 있었다.

 

메시지 유실 문제

Redis와 동일하게 Kafka를 사용하며 메시지가 유실되는 문제가 발생할 수 있다. 이 문제를 해결하기 위해 Outbox 패턴을 적용하였다. 이 Outbox가 새로운 병목 지점이 될 것이라고는 이 때는 생각하지 못했다...

평소처럼 당연하게도 메시지 유실문제를 방지해야 하기 때문에 Redis -> Outbox 생성 -> Kafka로 발행의 과정에서 당연하게도 하나의 요청 당 1 Outbox 생성 및 DB Insert가 이뤄지며, 단건 Insert로 인한 DB 병목 문제가 다시 발생했다.

 

이 문제의 본질은 결국에는 Redis와 DB를 같이 사용하며 분산 트랜잭션이 불가능하다는 점 때문이었다. Redis에서의 재고 차감과 Outbox DB 저장을 하나의 트랜잭션으로 묶을 수 없기 때문에, 둘 사이에서 장애가 발생하면 어떤 방법을 적용하더라도 100%의 정합성을 보장할 수 없다. 결국에는 성능과 정합성이라는 두 가지 문제 중 하나를 선택해야 했다.

 

현재 상황은, 재고의 정합성보다는 많은 트래픽을 받아내는 성능이 중요하다고 판단 해서 2-Phase 방식을 도입했다. 하나의 요청이 들어왔을 때 Redis에서는 재고를 차감 후 바로 응답을 주고, Outbox와 DB 저장은 백그라운드에서 배치로 처리하도록 했다. 또한, Outbox의 경우 바로 DB에 저장하는 것이 아니기 때문에 메모리 상에서 버퍼를 구성하여 배치 Insert로 병목지점이 되지 않도록 구성했다.

 

성능을 우선시 했기 때문에 Phase 1과 2 사이에서 장애로 인한 불일치의 경우에는 주기적인 Reconciliation 스케쥴러를 통해 감지하고 동기화하도록 선택했다. 일정 수량의 Threshold를 설정하고, 주기적으로 (DB 재고 - DB 선점 재고)와 Redis에 적용된 재고를 비교하여 일정 수치를 벗어날 경우 (DB 재고 - DB 선점 재고)의 값으로 동기화하도록 설정했다.

 

만약, 돈과 관련되거나 정합성이 매우 중요한 경우라면 Redis를 사용하지 않고, DB 샤딩 혹은 어쩔 수 없이 스케일 업을 선택하여 DB 성능 자체를 끌어올리는 방향으로 진행하는 것이 더 맞다는 생각을 하게 되었다.

(추가) 재고 차감 방식의 변화

Kafka 메시지 유실 문제 부분에서 뜬금 없이 DB 선점 재고라는 부분이 존재한다. 글의 흐름을 작성하며, 중간에 작성하기 애매하다고 판단해서 적지 않았지만 헷갈릴 수 있는 포인트가 될 수 있어 추가로 작성한다.

결제 처리에 관한 흐름을 생각하며 3가지 방법을 고려하게 되었다.

주문 생성 -> 결제 처리 -> 재고 차감

가장 위험한 경우라고 판단했다. 우선 가장 크리티컬하게는 재고 부족 이슈가 발생할 수 있다. 사용자 입장에서는 결제가 완료되었고 내 돈이 실제로 빠져나갔는데 재고가 부족해서 물건을 구매하지 못한다는 사용자 경험에서 가장 좋지 않은 방법이라고 생각했다.

또한, MSA 환경에서 보상 트랜잭션이 제대로 구현되지 못하거나 장애가 발생한다면 사용자는 환불을 받기 위해 추가적인 조치를 취해야 한다. 과연 사용자 입장에서 이런 서비스를 믿고 사용할 수 있을까? 전혀 아니라고 판단했으며, 따라서 이 방법은 바로 제외하게 되었다.

주문 생성 -> 재고 차감 -> 결제 처리

우선 재고를 차감하고 결제로 넘어가기 때문에 앞선 경우보다 안정적이다. 애초에 재고 개수를 파악하고 결제가 가능한 지 아닌지 여부를 판단하기 때문에 사용자 입장에서 안전하다고 판단할 수 있다.

하지만, 재고 데이터 자체를 건드리기 때문에 반드시 재고를 복구하는 보상 트랜잭션 로직을 필수적으로 작성해야 하고 문제 발생 시 재고 데이터 자체를 잃어버릴 위험성도 존재한다.

주문 생성 -> 재고 선점 -> 결제 처리 -> 재고 차감

선점(Reservation)이라는 예약 시스템을 도입하여, 실제 DB 재고 데이터를 건드리지 않고도 안전하게 처리할 수 있는 방식이다.

  • 주문/선점: 먼저 Redis나 DB등을 통해 재고 선점(예약)을 진행 -> 인기 상품의 경우 Redis에서 먼저 처리 이후 Kafka를 통해 동기화
  • 결제: 결제 처리를 진행
  • 확정: 결제 성공 시 실제 DB 재고를 차감(이벤트 방식으로 이루어짐)

이 방식의 가장 큰 장점은 결제 실패나 시간 초과 시 처리가 단순해진다는 것이다. 실제 재고 데이터를 건드리지 않기 때문에 비교적 안전하며, 단순히 주문 ID에 해당하는 선점 정보를 삭제하기만 하면 된다. 인기 상품의 경우 앞서 설명한 Redis Lua Script의 원자적 연산이 바로 이 단계의 역할을 수행하게 된다.(락 획득 -> 재고 차감 이라는 원자적 연산)

'Back-End' 카테고리의 다른 글

MSA 학습 - API Gateway  (0) 2025.09.25
MSA 학습 - 서킷 브레이커  (0) 2025.09.24
MSA 학습 - 로드밸런싱(FeignClient, Ribbon)  (0) 2025.09.23
MSA 학습 - Spring Cloud와 Eureka  (0) 2025.09.22
비동기 메시징 방식(RabbitMQ vs Kafka)  (0) 2025.09.19
'Back-End' 카테고리의 다른 글
  • MSA 학습 - API Gateway
  • MSA 학습 - 서킷 브레이커
  • MSA 학습 - 로드밸런싱(FeignClient, Ribbon)
  • MSA 학습 - Spring Cloud와 Eureka
dev_Mins
dev_Mins
  • dev_Mins
    천천히 빠르게!
    dev_Mins
  • 전체
    오늘
    어제
    • 분류 전체보기 (49)
      • 42Seoul (2)
      • Back-End (20)
        • Spring (8)
      • Project (14)
        • PickLab (3)
      • 끄적끄적 (3)
      • Algorithm (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Spring Data JPA
    Kafka
    MSA Gateway
    Servcie Discovery
    스프링 게이트웨이
    로드밸런싱
    Spring Boot
    42서울
    Spring Cloud
    Spring Security
    스프링 jwt
    MSA
    로드밸런서
    Spring
    재고 서비스
    AWS
    JWT
    주문 서비스
    스프링
    42seoul
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
dev_Mins
재고 관리 문제 해결(동시성 및 DB 동기화 전략에 대해)
상단으로

티스토리툴바