이커머스에서 재고는 단순한 숫자가 아닙니다. 여러 사용자가 동시에 같은 상품을 주문하면 재고가 음수가 될 수 있고 결제 실패나 타임아웃이 발생하면 차감한 재고를 다시 돌려놓아야 합니다. 상품 정보(이름, 가격, 설명)는 관리자가 가끔 바꾸지만 재고는 주문이 들어올 때마다 바뀝니다. 변경 빈도와 동시성 요구사항이 근본적으로 다릅니다.

이 차이를 무시하고 하나의 엔티티에 모으면 어떤 문제가 생기는지, 그리고 분리하면 어떤 갈림길이 새로 열리는지를 이번 글에서 학습하려고 합니다.

이 글에서 사용하는 용어는 다음 뜻으로 읽으면 됩니다.

용어이 글에서의 의미
낙관적 락 (Optimistic Lock)충돌이 드물다고 가정하고 커밋 시점에 버전을 비교하여 충돌을 감지하는 방식
비관적 락 (Pessimistic Lock)충돌이 잦다고 가정하고 데이터를 읽을 때 미리 잠그는 방식
분산 락 (Distributed Lock)여러 프로세스/서버가 동일 자원에 접근할 때 외부 저장소(Redis 등)로 동기화하는 방식
오버셀링 (Overselling)실제 재고보다 많은 수량이 판매되어 재고가 음수가 되는 상황
lost update두 트랜잭션이 같은 데이터를 읽고 각각 수정해서 한쪽 변경이 사라지는 현상

이 연재는 PeekCart를 네 단계로 진행했습니다. Phase 1은 단일 Spring Boot 모놀리스로 도메인을 구현한 단계, Phase 2는 캐시, 분산 락, Kafka와 Outbox로 성능과 정합성을 보강한 단계, Phase 3은 CI, Kubernetes, 관측성, 부하 테스트로 운영 환경을 검증한 단계, Phase 4는 서비스별로 나눈 MSA 단계입니다.

이번 학습에서는 다음 질문을 확인하려고 합니다.

  1. 상품과 재고를 왜 별도 엔티티로 분리했을까요?
  2. 재고 동시성 제어에는 어떤 선택지가 있고 각각의 트레이드오프는 무엇일까?
  3. 낙관적 락은 어떤 상황에서 충분하고 어떤 상황에서 부족할까?
  4. 분산 락과 낙관적 락을 함께 쓰는 이유는 무엇일까?
  5. 락 범위와 트랜잭션 범위의 순서가 왜 중요할까?
  6. 재고 차감 시점은 언제가 적절할까: 주문 생성 시점일까, 결제 완료 시점일까?
  7. 캐싱에서 재고를 제외한 이유는 무엇일까?
  8. Phase 4 MSA에서 Product Service는 어떤 책임을 갖게 될까?

상품 정보와 재고 정보는 왜 변경 특성이 다른가

이커머스에서 상품 테이블의 데이터를 두 종류로 나눠보면 변경 패턴이 확연히 다릅니다.

구분예시 컬럼변경 주체변경 빈도동시성 경합
상품 정보name, price, description, image_url, status관리자낮음 (하루 수회)거의 없음
재고 정보stock, version사용자 주문높음 (초당 수회~수백회)인기 상품에서 매우 높음

하나의 row에 둘 다 있으면 다음 문제가 생깁니다.

1. 불필요한 락 경합이 생긴다. 관리자가 상품 설명을 수정하는 것과 사용자가 재고를 차감하는 것은 완전히 다른 비즈니스 사건입니다. 그런데 같은 row를 갱신하면 낙관적 락에서 version 충돌이 발생합니다. 관리자가 설명을 수정한 것이 주문 트랜잭션을 실패시킬 수 있습니다.

2. 캐싱 전략이 복잡해진다. 상품 이름이나 가격은 변경이 드물어 캐싱에 적합합니다. 하지만 재고는 실시간으로 변해서 캐시에 넣으면 stale 데이터 위험이 큽니다. 하나의 row에 섞여 있으면 “상품 정보만 캐시하고 재고는 빼라”는 정책을 구현하기 어렵습니다.

PeekCart는 이 차이를 반영해 Product와 Inventory를 별도 엔티티로 분리했습니다.

erDiagram  
    categories {
        bigint id PK
        string name
        bigint parent_id FK
    }
    products {
        bigint id PK
        bigint category_id FK
        string name
        string description
        bigint price
        string image_url
        string status
        timestamp created_at
    }
    inventories {
        bigint id PK
        bigint product_id FK
        int stock
        int version
        timestamp updated_at
    }
    categories ||--o{ products : contains
    categories ||--o{ categories : parent
    products ||--|| inventories : tracks

products 테이블에는 version 컬럼이 없습니다. 관리자 수정은 동시 경합이 거의 없기 때문입니다. inventories 테이블에만 version이 있고 재고 변경 시에만 낙관적 락이 작동합니다.


도메인 모델로 보기

Product: 상품 정보의 원천

@Entity  
@Table(name = "products")  
public class Product extends BaseTimeEntity {  
  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
  
    @ManyToOne(fetch = FetchType.LAZY)  
    @JoinColumn(name = "category_id", nullable = false)  
    private Category category;  
  
    @Column(nullable = false)  
    private String name;  
  
    private String description;  
  
    @Column(nullable = false)  
    private long price;  
  
    @Column(name = "image_url")  
    private String imageUrl;  
  
    @Enumerated(EnumType.STRING)  
    @Column(nullable = false)  
    private ProductStatus status;  
  
    public static Product create(Category category, String name,  
            String description, long price, String imageUrl) {  
        return new Product(category, name, description, price, imageUrl);  
    }  
  
    public void update(Category category, String name,  
            String description, long price, String imageUrl) {  
        this.category = category;  
        this.name = name;  
        this.description = description;  
        this.price = price;  
        this.imageUrl = imageUrl;  
    }  
  
    public void discontinue() {  
        this.status = ProductStatus.DISCONTINUED;  
    }  
  
    public boolean isOnSale() {  
        return this.status == ProductStatus.ON_SALE;  
    }  
}  

Product에는 비즈니스 메서드가 있지만 동시성 관련 장치는 없습니다. 생성 시 기본 상태는 ON_SALE이고 가격은 0 이상이어야 한다는 규칙이 생성자에 들어 있습니다. discontinue()는 soft delete로, 물리적 삭제 대신 상태를 DISCONTINUED로 바꿉니다.

상태는 ProductStatus enum으로 정의합니다.

public enum ProductStatus {  
    ON_SALE, SOLD_OUT, DISCONTINUED}  

상태 전이 규칙이 enum에 있으면 좋을 수 있지만 PeekCart에서 ProductStatus의 전이는 단순합니다. ON_SALE에서 DISCONTINUED로 가는 것과, 재고가 0이 되면 SOLD_OUT으로 바꾸는 것 정도입니다. 현재 복잡도에서는 discontinue() 하나면 충분합니다.

Inventory: 재고의 변경 단위

@Entity  
@Table(name = "inventories")  
public class Inventory {  
  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
  
    @OneToOne(fetch = FetchType.LAZY)  
    @JoinColumn(name = "product_id", nullable = false, unique = true)  
    private Product product;  
  
    @Column(nullable = false)  
    private int stock;  
  
    @Version  
    private int version;  
  
    @UpdateTimestamp  
    @Column(name = "updated_at")  
    private LocalDateTime updatedAt;  
  
    public void decrease(int quantity) {  
        if (this.stock < quantity) {  
            throw new ProductException(ErrorCode.PRD_002);  
        }  
        this.stock -= quantity;  
    }  
  
    public void restore(int quantity) {  
        if (quantity <= 0)  
            throw new IllegalArgumentException("복구 수량은 1 이상이어야 합니다.");  
        this.stock += quantity;  
    }  
}  

여기서 살펴볼 필드는 @Version입니다. JPA의 낙관적 락은 이 필드 하나로 동작합니다. Hibernate가 UPDATE 쿼리를 날릴 때 WHERE version = ?을 자동으로 붙이고 영향 받은 row가 0이면 OptimisticLockException을 던집니다.

-- Hibernate가 생성하는 실제 SQL
UPDATE inventories  
SET stock = ?, version = version + 1, updated_at = ?  
WHERE id = ? AND version = ?  

이 SQL 자체가 낙관적 락의 전부입니다. 중간에 다른 트랜잭션이 version을 올렸다면 이 UPDATE는 0 row를 갱신하고 Hibernate가 그것을 충돌로 감지합니다.

비즈니스 규칙도 Inventory에 응집되어 있습니다. decrease()는 재고가 부족하면 PRD-002를 던지고 restore()는 복구 수량이 0 이하이면 거절합니다. 이 규칙들은 HTTP나 DB를 몰라도 테스트할 수 있습니다.

// InventoryTest.java  
@Test  
void decrease_insufficientStock_throwsPRD002() {  
    Inventory inventory = Inventory.create(product(), 10);  
    assertThatThrownBy(() -> inventory.decrease(11))  
            .isInstanceOf(ProductException.class)  
            .extracting(e -> ((ProductException) e).getErrorCode())  
            .isEqualTo(ErrorCode.PRD_002);  
}  

도메인 규칙이 Entity에 있으면 단위 테스트가 가벼워집니다. Mock 없이 Inventory.create(product(), 10)만으로 테스트 대상을 만들 수 있습니다.


갈림길 1. 재고 동시성 제어는 어떤 선택지가 있는가

이커머스에서 재고 동시성은 핵심 문제입니다. 여러 사용자가 동시에 같은 상품을 주문하면 재고가 음수가 되거나(오버셀링) 한쪽의 변경이 사라질(lost update) 수 있습니다.

이 문제를 해결하는 방법을 정리하면 다음과 같습니다.

전략핵심 아이디어장점단점
DB 낙관적 락 (@Version)읽을 때 잠그지 않고 커밋 시 version 비교락 없이 높은 처리량, 경합 적을 때 효율적충돌 시 재시도 필요, 경합 높으면 실패율 증가
DB 비관적 락 (SELECT FOR UPDATE)읽을 때 row를 잠그고 트랜잭션 끝날 때까지 유지충돌 자체를 예방, 구현 단순데드락 위험, 동시 처리량 저하, 커넥션 점유
Redis 분산 락 (Redisson)외부 Redis로 애플리케이션 수준에서 직렬화DB 부하 감소, 다중 Pod에서도 동작Redis SPOF, 네트워크 지연, 락 만료 이슈
DB 원자적 UPDATEUPDATE SET stock = stock - ? WHERE stock >= ?가장 단순, 별도 락 불필요비즈니스 로직이 SQL에 묶임, 도메인 모델 약화
Kafka + 이벤트 직렬화파티션 키로 순차 처리 보장높은 확장성, 비동기응답 지연, 복잡한 보상 로직
재고를 Redis에 유지DECRBY로 원자적 차감, 주기적으로 DB 동기화극히 빠른 차감Redis/DB 정합성 관리, 장애 시 유실 위험

여기서 트레이드오프를 두 축으로 볼 수 있습니다.

quadrantChart
    title 재고 동시성 제어 전략 비교
    x-axis "복잡한 구현" --> "단순한 구현"
    y-axis "높은 정합성" --> "높은 처리량"
    quadrant-1 "단순 + 빠름"
    quadrant-2 "복잡 + 빠름"
    quadrant-3 "복잡 + 안전"
    quadrant-4 "단순 + 안전"
    "Redis 재고 (DECRBY)": [0.75, 0.85]
    "Redis 분산 락 + 낙관적 락": [0.30, 0.75]
    "원자적 UPDATE": [0.80, 0.55]
    "DB 낙관적 락": [0.60, 0.55]
    "DB 비관적 락": [0.50, 0.30]
    "Kafka 직렬화": [0.25, 0.25]

이 중에서 흔하게 비교되는 두 가지를 좀 더 깊이 보려고 합니다. 낙관적 락과 비관적 락입니다.


갈림길 1-1. 낙관적 락 vs 비관적 락

두 전략은 “충돌을 언제 해결하는가”가 다릅니다.

낙관적 락: 충돌은 드물다고 가정한다

sequenceDiagram
    participant A as Tx A
    participant DB as Database
    participant B as Tx B

    A->>DB: SELECT (stock=100, ver=1)
    B->>DB: SELECT (stock=100, ver=1)

    rect rgb(220, 240, 220)
        Note over A,DB: A 커밋 성공
        A->>DB: UPDATE stock=99<br/>WHERE ver=1
        DB-->>A: 1 row affected
        Note over DB: ver → 2
    end

    rect rgb(255, 220, 220)
        Note over DB,B: B 커밋 실패
        B->>DB: UPDATE stock=99<br/>WHERE ver=1
        DB-->>B: 0 row affected
        Note over B: OptimisticLockException
    end

트랜잭션 B는 커밋 시점에야 충돌을 알게 됩니다. 충돌이 드물면 대부분의 요청이 락 없이 통과하므로 처리량이 높습니다. 그러나 충돌이 잦아지면 실패 후 재시도 비용이 커집니다.

비관적 락: 충돌이 잦다고 가정한다

sequenceDiagram
    participant A as Tx A
    participant DB as Database
    participant B as Tx B

    rect rgb(220, 240, 220)
        Note over A,DB: A가 row 잠금
        A->>DB: SELECT FOR UPDATE<br/>(stock=100)
        DB-->>A: row locked
    end

    rect rgb(255, 243, 220)
        Note over DB,B: B 대기
        B->>DB: SELECT FOR UPDATE
        Note over B: 대기 중...
    end

    A->>DB: UPDATE stock=99
    A->>DB: COMMIT
    Note over DB: row 잠금 해제

    rect rgb(220, 240, 220)
        Note over DB,B: B 진행
        DB-->>B: row locked (stock=99)
        B->>DB: UPDATE stock=98
        B->>DB: COMMIT
    end

트랜잭션 B는 A가 끝날 때까지 기다립니다. 충돌 자체가 발생하지 않으므로 재시도가 필요 없습니다. 그러나 동시 요청이 많으면 대기 시간이 길어지고 데드락 위험이 생깁니다.

어떤 상황에서 어느 쪽이 유리한가

기준낙관적 락이 유리비관적 락이 유리
동시 경합 빈도낮음 (대부분 충돌 없이 성공)높음 (대부분 충돌이 발생)
트랜잭션 길이짧음길어도 괜찮음
실패 시 비용재시도가 저렴재시도가 비쌈 (외부 호출 등)
데드락 위험없음 (잠그지 않으므로)있음 (여러 row를 순서 없이 잠그면)
DB 커넥션 점유짧음대기 중에도 점유

이커머스 재고는 평소와 타임세일의 상황이 다릅니다.

  • 평소: 같은 상품을 동시에 주문하는 경우는 드뭅니다. 낙관적 락만으로 충분합니다.
  • 인기 상품 타임세일: 같은 상품에 수백 요청이 몰립니다. 낙관적 락만 쓰면 대부분 충돌로 실패하고 재시도가 급증합니다.

이 두 상황을 모두 커버하려면 낙관적 락 하나로는 부족합니다. 그래서 PeekCart는 Redis 분산 락을 먼저 적용해 경합을 직렬화하고 낙관적 락은 최종 정합성을 검사하도록 남겨둡니다. 이 직렬화가 호출 경로에 따라 어떻게 달라지는지는 뒤의 “락 범위와 트랜잭션 범위” 절에서 확인합니다.


갈림길 1-2. 분산 락은 왜 필요한가

단일 인스턴스에서는 DB 락만으로도 충분할 수 있습니다. 그러나 다중 Pod 환경에서는 다른 문제가 생깁니다.

Pod A: 트랜잭션 시작 → 재고 조회 (stock=10)
Pod B: 트랜잭션 시작 → 재고 조회 (stock=10)
Pod A: stock = 10 - 1 = 9 → 커밋  
Pod B: stock = 10 - 1 = 9 → 커밋 (version이 같으면 lost update, 다르면 충돌)  

낙관적 락이 있으면 lost update는 막을 수 있습니다. 하지만 충돌로 실패한 요청을 재시도할지, 사용자에게 실패를 알릴지가 문제입니다. 인기 상품에서 10개 요청 중 9개가 충돌로 실패하면 사용자 경험이 나빠집니다. 이 시점의 PeekCart에는 재고 차감 재시도가 없어서, 충돌은 예외 핸들러가 409로 응답하고 재시도 정책은 정하지 않은 상태입니다.

분산 락은 이 충돌 자체를 줄여줍니다. Redis에서 상품 단위로 락을 잡으면 같은 상품에 대한 재고 차감이 순차적으로 실행됩니다.

전략경합 해결실패 비율DB 부하추가 인프라
낙관적 락만커밋 시점 충돌 감지경합 시 높음낮음없음
비관적 락만DB row 잠금없음 (대기)DB 커넥션 점유없음
분산 락만애플리케이션 수준 직렬화락 획득 실패만낮음Redis
분산 락 + 낙관적 락직렬화 + 최후 방어매우 낮음낮음Redis

PeekCart는 네 번째, 분산 락 + 낙관적 락의 이중 방어를 선택했습니다. 표의 “직렬화”는 분산 락이 설계대로 동작할 때의 이야기이고, 주문 경로에서 락이 먼저 풀리는 경우는 다음 절의 경우 2에서 다룹니다.


락 범위와 트랜잭션 범위: PeekCart의 재고 동시성 구조

InventoryLockFacade: 락 범위를 트랜잭션 바깥에 둔다

@Component  
@RequiredArgsConstructor  
public class InventoryLockFacade {  
  
    private static final String LOCK_KEY_PREFIX = "inventory-lock:";    
    private static final long LOCK_WAIT_TIME = 3;    
    private static final long LOCK_LEASE_TIME = 5;  
    private final InventoryService inventoryService;  
    private final DistributedLockManager lockManager;  
  
    public void decreaseStock(Long productId, int quantity) {  
        String lockKey = LOCK_KEY_PREFIX + productId;        
        boolean locked = lockManager.tryLock(  
                lockKey, LOCK_WAIT_TIME, LOCK_LEASE_TIME, TimeUnit.SECONDS);  
        if (!locked) {  
            throw new ProductException(ErrorCode.PRD_004);  
        }  
        try {  
            inventoryService.decreaseStock(productId, quantity);  
        } finally {  
            lockManager.unlock(lockKey);  
        }  
    }  
}  

이 코드에서 살펴볼 부분은 InventoryLockFacade와 InventoryService의 분리입니다.

InventoryService는 @Transactional이 붙어 있습니다(기본 Propagation.REQUIRED). InventoryLockFacade는 @Transactional이 없습니다. 이렇게 나눠서 “락 획득, 트랜잭션 시작, 재고 변경, 트랜잭션 커밋, 락 해제” 순서를 보장하려 했습니다.

하지만 이 순서가 항상 보장되는 것은 아닙니다. 호출자의 트랜잭션 컨텍스트에 따라 실제 동작이 달라진다.

경우 1: 단독 호출, 의도대로 동작

InventoryLockFacade를 외부 트랜잭션 없이 직접 호출하면 설계 의도대로 동작합니다.

1. Redis 분산 락 획득
2. InventoryService 진입 → 새 트랜잭션 시작 (REQUIRED, 기존 없으므로)
3. 재고 조회 → 차감
4. InventoryService 반환 → 트랜잭션 커밋 (DB 반영 완료)
5. Redis 분산 락 해제

다른 스레드가 락을 획득하는 시점에는 이미 커밋이 끝나 있습니다. 분산 락만으로 직렬화가 완성됩니다.

경우 2: 외부 트랜잭션 안에서 호출, 락이 먼저 풀린다

OrderCommandService.createOrder()처럼 이미 @Transactional이 열린 컨텍스트에서 호출하면 이야기가 달라집니다.

1. OrderCommandService 트랜잭션 시작 (외부)
2. Redis 분산 락 획득
3. InventoryService 진입 → 외부 트랜잭션에 참여 (REQUIRED, 기존 있으므로)
4. 재고 조회 → 차감
5. InventoryService 반환: 트랜잭션은 아직 커밋되지 않음
6. Redis 분산 락 해제                ← DB 반영 전에 락이 풀림!
7. ... 주문 저장, 장바구니 비우기, Outbox 이벤트 저장 ...
8. OrderCommandService 반환 → 외부 트랜잭션 커밋 (여기서야 DB 반영)

6번에서 락이 풀린 뒤 8번에서 커밋이 완료되기 전, 다른 스레드가 같은 상품의 락을 획득하고 아직 커밋되지 않은 재고를 읽을 수 있습니다.

sequenceDiagram
    participant A as 스레드 A
    participant Redis as Redis Lock
    participant DB as Database
    participant B as 스레드 B

    rect rgb(255, 243, 220)
        Note over A,DB: 외부 트랜잭션 시작
        A->>Redis: 락 획득 ✓
        A->>DB: stock 100 → 99 (미커밋)
        A->>Redis: 락 해제
        Note over A: 주문 저장, Outbox 등 계속 진행
    end

    rect rgb(255, 220, 220)
        Note over Redis,B: 위험 구간
        B->>Redis: 락 획득 ✓
        B->>DB: stock 100 읽음 (A 미커밋)
        B->>DB: stock 99로 변경 시도
    end

    A->>DB: 외부 트랜잭션 커밋 ✓
    Note over DB: A의 version 반영

    rect rgb(220, 240, 220)
        Note over DB,B: 낙관적 락이 방어
        B->>DB: 커밋 시도 → version 불일치
        Note over B: OptimisticLockException
    end

그래서 낙관적 락(@Version)은 Redis 장애 때만 쓰는 fallback에 그치지 않고, 정상 경로에서도 필요하다. 외부 트랜잭션 안에서 호출될 때 분산 락은 경합을 줄여주지만 완전한 직렬화를 보장하지 못합니다. 낙관적 락이 커밋 시점에 version을 비교해서 실제 오버셀링을 막습니다.

DistributedLockManager: Redis 장애 시 fallback

@Component  
@RequiredArgsConstructor  
public class DistributedLockManager {  
  
    private final RedissonClient redissonClient;  
  
    public boolean tryLock(String key, long waitTime, long leaseTime, TimeUnit unit) {  
        try {  
            RLock lock = redissonClient.getLock(key);  
            return lock.tryLock(waitTime, leaseTime, unit);  
        } catch (InterruptedException e) {  
            Thread.currentThread().interrupt();  
            return false;  
        } catch (Exception e) {  
            log.warn("Redis 분산 락 획득 실패 (Redis 장애), DB 낙관적 락으로 fallback: {}", key, e);  
            return true;  // ← 핵심  
        }  
    }  
}  

Redis에 연결할 수 없을 때 false를 반환하면 모든 주문이 실패합니다. 분산 락이 가용성을 떨어뜨리는 SPOF가 됩니다. 그래서 PeekCart는 Redis 장애 시 true를 반환해서 락 없이 진행하게 합니다.

이때 오버셀링은 어떻게 막을까? Inventory.decrease()가 실행되면 JPA의 @Version이 동작합니다. 여러 트랜잭션이 동시에 같은 재고를 차감하면 한쪽은 OptimisticLockException으로 실패합니다. 즉, 낙관적 락이 최후의 방어선입니다.

세 경우의 비교

PeekCart에서 재고 차감의 주요 경로는 OrderCommandService.createOrder() 안입니다. 즉 대부분의 실제 호출은 경우 2에 해당합니다. 분산 락은 동시 경합의 대부분을 걸러주지만 낙관적 락이 커밋 시점의 최종 정합성을 보장합니다. 아래 표는 Redis 장애 시 fallback까지 포함해 세 경우를 나란히 놓은 것입니다.

호출 컨텍스트트랜잭션 커밋 시점분산 락 효과낙관적 락 역할
단독 호출InventoryService 반환 시 (락 해제 전)완전한 직렬화사실상 불필요 (충돌 0)
외부 트랜잭션 안외부 트랜잭션 반환 시 (락 해제 후)경합 감소 (완전하지 않음)실제 오버셀링 방어
Redis 장애해당 없음없음 (락 없이 진행)동시 접근에서 충돌 감지, 최후의 방어선

세 경우 모두에서 오버셀링은 발생하지 않습니다. 분산 락의 역할은 상황에 따라 “완전한 직렬화”에서 “경합 감소”까지 달라지지만 낙관적 락이 항상 최종 정합성을 보장합니다. 이것이 이중 방어의 실질적 의미입니다.

InventoryService: 트랜잭션 안에서 도메인 로직만

@Service  
@Transactional  
@RequiredArgsConstructor  
public class InventoryService {  
  
    private final InventoryRepository inventoryRepository;  
  
    public void decreaseStock(Long productId, int quantity) {  
        Inventory inventory = inventoryRepository.findByProductId(productId)  
                .orElseThrow(() -> new ProductException(ErrorCode.PRD_001));  
        inventory.decrease(quantity);  
    }  
  
    public void restoreStock(Long productId, int quantity) {  
        Inventory inventory = inventoryRepository.findByProductId(productId)  
                .orElseThrow(() -> new ProductException(ErrorCode.PRD_001));  
        inventory.restore(quantity);  
    }  
}  

InventoryService는 분산 락을 모릅니다. 트랜잭션 안에서 Inventory를 꺼내고 도메인 메서드를 호출합니다. dirty checking에 의해 트랜잭션 커밋 시 UPDATE가 나갑니다.

restoreStock에는 분산 락이 없습니다. 재고 복구는 주문 취소나 결제 실패 시에만 일어나고 같은 주문에 대해 두 번 복구가 동시에 호출되는 일은 멱등성 처리(Phase 2의 processed_events)에서 막기 때문입니다.


테스트로 검증하기

PeekCart는 동시성을 두 가지 수준에서 테스트합니다.

테스트 1: 낙관적 락만으로 lost update 방지 확인

@Test  
@DisplayName("동시 재고 차감 시 낙관적 락으로 충돌이 발생하고, 최종 재고가 성공 횟수만큼만 차감된다")  
void concurrentDecrease_optimisticLock_preventsLostUpdate() throws InterruptedException {  
    int threadCount = 10;  
    // ...10개 스레드가 동시에 재고를 1씩 차감  
  
    // 성공 + 충돌 = 전체 스레드 수  
    assertThat(successCount.get() + conflictCount.get()).isEqualTo(threadCount);  
    // 충돌이 최소 1건 이상 발생해야 낙관적 락이 작동하는 것  
    assertThat(conflictCount.get()).isGreaterThanOrEqualTo(1);  
  
    // 최종 재고 = 초기 재고 - (성공 횟수 × 차감량) — lost update 없음 검증  
    Inventory result = em.find(Inventory.class, inventoryId);  
    assertThat(result.getStock()).isEqualTo(100 - (successCount.get() * decreasePerThread));  
}  

이 테스트는 마지막 줄에서 핵심 조건을 검증합니다. 10개 스레드 중 일부만 성공했더라도 최종 재고가 초기 - (성공 수 × 차감량)과 정확히 일치해야 합니다. 만약 @Version이 없었다면 lost update가 발생해 실제 차감보다 재고가 많이 남았을 것입니다.

테스트 2: 분산 락으로 오버셀링 없이 전부 성공

@Test  
@DisplayName("50스레드 동시 차감 시 분산 락으로 오버셀링 없이 전부 성공한다")  
void concurrentDecrease_distributedLock_preventsOverselling() throws InterruptedException {  
    int threadCount = 50;  
    // ...50개 스레드가 동시에 InventoryLockFacade를 통해 차감  
  
    // 50스레드 전부 성공 (분산 락이 순차 실행을 보장)  
    assertThat(successCount.get()).isEqualTo(threadCount);  
    assertThat(failCount.get()).isZero();  
  
    // 최종 재고 = 초기(100) - 50 = 50 — 오버셀링 0건  
    Inventory result = em.find(Inventory.class, inventoryId);  
    assertThat(result.getStock()).isEqualTo(100 - threadCount);  
}  

첫 번째 테스트와의 차이는 InventoryLockFacade를 통해 호출한다는 것입니다. 분산 락이 순차 실행을 보장하므로 50개 스레드가 전부 성공하고 재고가 정확히 50이 됩니다. 낙관적 락 충돌은 0건입니다(분산 락이 이미 직렬화했으므로).

두 테스트를 비교하면 이중 방어의 의미가 보입니다.

테스트락 전략성공률충돌최종 재고 정확성
테스트 1낙관적 락만일부만 성공발생정확 (lost update 없음)
테스트 2분산 락 + 낙관적 락전부 성공0건정확 (오버셀링 없음)

갈림길 2. 재고 차감 시점, 주문 생성 시인가, 결제 완료 후인가

재고를 언제 차감하느냐도 큰 갈림길입니다. 이것은 동시성 제어와는 별개의 비즈니스 결정입니다.

전략재고 차감 시점장점단점
전략 A: 주문 생성 시 즉시 차감주문 트랜잭션 안에서 차감이중 주문 방지, 구현 단순결제 실패 시 재고가 묶임
전략 B: 결제 완료 후 확정 차감주문 시 예약(soft lock), 결제 성공 시 확정재고 묶임 최소화예약 상태 관리, 예약 만료 처리 복잡
전략 C: 결제 시도 시 차감결제 요청 직전에 차감묶임 시간 중간결제 실패 시 복구 필요 + 차감 시점 애매

각 전략의 시간 흐름을 비교하면 이렇습니다.

gantt  
    title 재고 차감 시점 비교  
    dateFormat X
    axisFormat %s
    section 전략 A (채택)  
    재고 차감        :active, a1, 0, 1
    주문 생성        :a2, 0, 1
    결제 대기        :a3, 1, 15
    결제 완료/실패   :a4, 15, 16
    실패 시 복구     :crit, a5, 16, 17
    section 전략 B
    재고 예약(soft)  :b1, 0, 1
    주문 생성        :b2, 0, 1
    결제 대기        :b3, 1, 15
    결제 완료 시 확정 :b4, 15, 16
    예약 만료 시 해제 :crit, b5, 15, 16

PeekCart는 전략 A를 선택했습니다. 주문 생성 트랜잭션 안에서 재고 차감과 주문 저장을 함께 처리합니다.

주문 생성 트랜잭션:  
    재고 차감 (Inventory.decrease) + 주문 저장 (Order.create)
    + Outbox 이벤트 저장: 모두 단일 트랜잭션  

이 방식의 가장 큰 장점은 재고 차감과 주문 생성이 원자적이라는 것입니다. 둘 중 하나만 성공하는 상황이 없습니다. 전략 B는 “예약”과 “확정”이라는 두 단계가 생기면서 중간 상태 관리가 복잡해집니다.

단점은 결제 실패 시 재고가 묶인다는 것입니다. 사용자가 주문을 생성하고 15분 동안 결제를 하지 않으면 그 기간 동안 다른 사용자가 해당 재고를 구매할 수 없습니다. PeekCart는 이 문제를 타임아웃 스케줄러로 해결합니다. PAYMENT_REQUESTED 상태가 15분을 초과한 주문을 자동으로 취소하고 재고를 복구합니다.

Order 도메인이 재고 차감을 요청할 때는 ProductPort를 통합니다.

// ProductPort.java (Order 도메인의 application/port에 정의)  
public interface ProductPort {  
    void verifyProductExists(Long productId);  
    long decreaseStockAndGetUnitPrice(Long productId, int quantity);  
    void restoreStock(Long productId, int quantity);  
}  
// ProductPortAdapter.java (Product 도메인의 infrastructure에 구현)  
@Override  
public long decreaseStockAndGetUnitPrice(Long productId, int quantity) {  
    Product product = productRepository.findById(productId)  
            .orElseThrow(() -> new ProductException(ErrorCode.PRD_001));  
    inventoryLockFacade.decreaseStock(productId, quantity);  
    return product.getPrice();  
}  

decreaseStockAndGetUnitPrice는 이름 그대로 재고를 차감하면서 동시에 주문 시점의 상품 가격을 반환합니다. 이 가격은 order_items.unit_price에 스냅샷으로 저장되어, 나중에 상품 가격이 바뀌더라도 주문 당시 가격이 보존됩니다.

Order 도메인은 ProductPort 인터페이스만 알고 Inventory, InventoryLockFacade, DistributedLockManager의 존재를 모릅니다. 재고 차감의 구체적인 동시성 전략이 바뀌더라도 Order 코드는 영향을 받지 않습니다.


갈림길 3. 캐싱에서 재고를 제외한 이유

Product 도메인에서 캐싱 전략도 상품과 재고의 분리에 영향을 받습니다.

PeekCart의 ProductCacheService는 상품 정보만 캐시하고 재고는 항상 DB에서 조회합니다.

// ProductCacheService.java  
@Cacheable(cacheNames = "product", key = "#productId")  
public ProductInfoDto getProductInfo(Long productId) {  
    Product product = productRepository.findById(productId)  
            .orElseThrow(() -> new ProductException(ErrorCode.PRD_001));  
    return ProductInfoDto.of(product);  // 재고 미포함  
}  
// ProductQueryService.java  
public ProductDetailDto getProduct(Long productId) {  
    ProductInfoDto info = productCacheService.getProductInfo(productId);  // 캐시 히트  
  
    int stock = inventoryRepository.findByProductId(productId)  // 항상 DB  
            .map(Inventory::getStock)  
            .orElse(0);  
    return new ProductDetailDto(  
            info.id(), info.categoryId(), info.categoryName(),  
            info.name(), info.description(), info.price(),  
            info.imageUrl(), info.status(), stock);  
}  

캐시에 재고를 포함하면 어떤 문제가 생길까?

  1. stale 재고 표시: 캐시된 재고가 “10개 남음”인데 실제로는 0개일 수 있습니다. 사용자가 주문을 시도하면 재고 부족으로 실패합니다.
  2. 캐시 무효화 폭풍: 재고가 변경될 때마다 캐시를 무효화하면 캐시의 의미가 사라집니다. 인기 상품은 초당 수십 번 재고가 바뀔 수 있습니다.
  3. 캐시 TTL과 정합성 트레이드오프: 짧은 TTL(예: 5초)로 완화할 수 있지만 그 5초 동안은 여전히 부정확하고 TTL이 짧을수록 캐시 히트율이 떨어집니다.

반면 상품 이름, 가격, 설명, 이미지 같은 정보는 관리자가 가끔 바꿀 뿐입니다. TTL이 30분이어도 대부분의 경우 정확합니다. 관리자가 수정하면 @CacheEvict로 즉시 무효화하면 됩니다.

// ProductCommandService.java  
@Caching(evict = {  
        @CacheEvict(cacheNames = "product", key = "#productId"),  
        @CacheEvict(cacheNames = "products", allEntries = true)})  
public ProductDetailDto update(Long productId, UpdateProductCommand command) {  
    // ...  
}  

결국 상품과 재고를 별도 엔티티로 분리한 것이 캐싱 전략에서도 자연스러운 분리를 만들어줍니다. 상품 정보는 캐시, 재고는 실시간 DB 조회. 이 패턴은 Phase 2의 캐시 부하 테스트(뒤의 상품 캐시 글에서 다룰 예정)에서 캐시 ON/OFF TPS 차이를 측정하는 기반이 됩니다.


한계와 트레이드오프

의도적으로 안 한 것

  • 재고를 Redis에 두는 방식: DECRBY로 원자적 차감을 하면 DB보다 훨씬 빠릅니다. 하지만 Redis/DB 간 정합성 동기화 비용이 크고 Redis 장애 시 재고 데이터 유실 위험이 있습니다. 학습 범위에서는 DB를 SSOT로 유지하는 쪽을 택했습니다.
  • 비관적 락: Phase 1 단일 인스턴스에서는 SELECT FOR UPDATE가 더 단순할 수 있습니다. 그러나 Phase 2에서 분산 락으로 가는 흐름을 보여주기 위해 처음부터 낙관적 락을 기반으로 설계했습니다.
  • 재고 예약(soft lock) 패턴: 전략 B를 채택하면 재고 묶임을 줄일 수 있지만 예약 상태 관리와 예약 만료 처리의 복잡도가 큽니다. 타임아웃 스케줄러(15분)로 묶임 시간을 제한하는 것이 현재 범위에서는 더 단순합니다.
  • SOLD_OUT 자동 전이: 현재 decrease() 후 재고가 0이 되어도 ProductStatus를 SOLD_OUT으로 자동 변경하지 않습니다. Inventory가 Product의 상태를 직접 바꾸면 도메인 간 결합이 생기기 때문입니다. 이벤트 기반으로 Product 상태를 갱신하는 것이 Phase 2 이후의 후보입니다.

남아 있는 리스크

  • 분산 락 lease time(5초) 초과: 트랜잭션이 5초 안에 끝나지 않으면 락이 풀리고 다른 요청이 들어옵니다. 그때 낙관적 락이 방어하지만 정상 시나리오에서 5초를 넘기면 설계를 재점검해야 합니다.
  • Redis 장애 fallback의 UX 영향: Redis 장애 시 모든 요청이 낙관적 락 경쟁으로 전환되면 충돌 실패율이 급등합니다. 사용자에게 “일시적으로 주문이 어렵습니다”를 보여줄지, 재시도를 할지는 아직 결정되지 않았습니다.

Phase 4 MSA에서는 어떻게 바뀌는가

Phase 4에서 Product Service는 독립 서비스가 됩니다. 현재 모놀리스 안에서 ProductPort로 분리해둔 경계가 서비스 간 경계로 바뀝니다.

flowchart LR  
    GW["API Gateway"]
    OrderSvc["Order Service"]
    ProductSvc["Product Service<br/>상품 CRUD · 재고 · 캐시"]
    Redis["Redis<br/>캐시 · 분산 락"]  
    ProductDB["Product DB<br/>products · inventories · categories"]
    Kafka["Kafka"]
    GW --> OrderSvc
    GW --> ProductSvc
    ProductSvc --> ProductDB
    ProductSvc --> Redis
    OrderSvc -->|"order.created"| Kafka
    OrderSvc -->|"order.cancelled"| Kafka
    Kafka -->|"order.cancelled"| ProductSvc

여기서 새로 생기는 질문들이 있습니다.

  • Order Service가 재고를 어떻게 차감할 것인가: 현재는 ProductPort.decreaseStockAndGetUnitPrice()를 같은 트랜잭션 안에서 호출합니다. MSA에서는 동기 HTTP 호출(API) 또는 비동기 이벤트(Saga) 중 하나를 선택해야 합니다. 동기 호출이면 분산 트랜잭션 문제가 생기고 비동기면 즉시 재고 차감을 보장하기 어렵습니다.
  • 재고 복구는 Saga 보상 트랜잭션이 된다: payment.failed → order.cancelled → inventory.restore 흐름이 이벤트 기반으로 바뀝니다. 이미 Phase 2에서 Outbox + Kafka + 멱등성 처리를 갖추고 있으므로 인프라는 준비돼 있습니다.
  • 상품 가격 스냅샷은 Order Service에 남는다: order_items.unit_price에 주문 시점 가격을 저장하는 패턴은 MSA에서도 그대로 유효합니다. FK 없이도 주문 시점의 상품 정보가 보존됩니다.
  • 캐시 소유권은 Product Service에 간다: 상품 목록/상세 캐시와 분산 락 Redis는 Product Service의 책임으로 이동합니다. Order Service는 Product API를 호출하거나 CQRS 로컬 캐시를 유지합니다.
  • Product DB가 독립되면 FK가 사라진다: 현재 cart_items.product_id나 order_items.product_id가 Product DB를 직접 FK로 참조합니다. MSA에서는 이 FK가 사라지고 논리적 ID로만 연결됩니다. 존재하지 않는 상품을 참조하는 경우를 애플리케이션 레벨에서 처리해야 합니다.

핵심은, Product/Inventory 분리와 ProductPort 추상화가 이미 서비스 경계의 후보를 보여준다는 점입니다. 지금은 같은 JVM 안에서 메서드 호출로 연결되지만 그 호출 계약(인터페이스)이 나중에 HTTP API나 이벤트 계약으로 변환될 수 있습니다.


참고 자료

질문같이 읽을 자료이 글에서 연결되는 지점
낙관적 락과 비관적 락은 어떻게 다른가?Martin Fowler, Optimistic Offline Lock, Pessimistic Offline Lock@Version과 SELECT FOR UPDATE의 트레이드오프를 비교
JPA의 낙관적 락은 내부에서 어떻게 동작하는가?Vlad Mihalcea, A beginner’s guide to database locking and the lost update phenomenaHibernate가 version을 어떻게 붙이는지, lost update를 어떻게 막는지
분산 락은 어떤 보장을 하고 어떤 한계가 있는가?Martin Kleppmann, How to do distributed locking, Redis, Distributed Locks with RedisRedlock의 위험성과 단일 Redis 락의 트레이드오프
재고 차감 시점은 언제가 좋은가?쿠팡 기술 블로그 등 이커머스 사례, AWS, Saga Pattern전략 A/B 선택과 보상 트랜잭션의 관계
캐시 전략은 어떻게 설계하는가?Martin Fowler, Two Hard Things, AWS, Caching Best Practices상품 정보 캐시와 재고 실시간 조회의 분리 설계
엔티티 분리와 애그리거트 설계의 기준은 무엇인가?Vaughn Vernon, Effective Aggregate DesignProduct와 Inventory를 왜 별도 애그리거트로 볼 수 있는가를 따질 때