전자상거래 장바구니 및 주문 설계

1. 개요

장바구니와 주문은 전자상거래 플랫폼에서 가장 많이 사용되는 두 가지 기능입니다. 또한 장애가 가장 자주 발생하는 부분이기도 합니다: 과매출, 이중 결제, 플래시 세일 중 “걸려있는” 주문.

이 글은 사례 연구 형태로 완전한 설계를 다룹니다. 설계는 요구사항, 데이터 모델, API, 처리 흐름 및 난제들로 구성되어 있습니다. 각 난제마다 Spring Boot 3, PostgreSQL, Redis 및 Kafka를 사용한 해결책을 제시합니다. 이 글을 읽은 후 실무에 적용하거나 시스템 설계 면접을 위한 설계 프레임워크를 가지게 될 것입니다.

2. 요구사항

2.1. 기능 요구사항

  • 고객은 장바구니에서 상품(SKU)을 추가, 수정, 삭제할 수 있습니다. 장바구니는 여러 로그인 세션에 걸쳐 유지됩니다.
  • 체크아웃 시 시스템은 가격을 다시 계산하고 재고를 확인한 후 상태가 PENDING_PAYMENT인 주문을 생성합니다.
  • 15분 내에 결제되지 않은 주문은 취소되고 상품은 창고로 반환됩니다.
  • 결제가 성공하면 시스템은 창고, 이메일 및 포인트 서비스에 이벤트를 발행합니다.

2.2. 가정된 규모

  • 월간 활성 사용자: 1,000만 명
  • 장바구니 작업: 평균 2,000 req/s, 최고 20,000 req/s
  • 주문: 평균 200건/초. 플래시 세일 중 최대 5,000건/초로 증가하며 수십 개의 SKU에 집중됩니다.

2.3. 제약 조건

  • 과매출 금지. 이것은 하드 제약입니다.
  • 중복 주문 생성 금지 - 클라이언트가 타임아웃으로 인한 재시도 또는 사용자가 두 번 클릭하는 경우
  • 생성된 주문이면 Kafka가 일시적으로 중단되더라도 이벤트는 반드시 발행되어야 합니다.
  • 장바구니는 “대략 일관성” 수준이 허용됩니다. 주문과 재고는 강한 일관성이 필요합니다.

마지막 제약이 아키텍처를 결정합니다. 장바구니는 읽기/쓰기가 많고 덜 중요하므로 Redis에 배치합니다. 주문과 재고는 트랜잭션이 필요하므로 PostgreSQL에 배치합니다.

3. 데이터 모델

고객당 하나의 Hash를 사용하여 Redis에 장바구니를 저장합니다:

key:   cart:{customerId}
field: sku
value: quantity
TTL:   30일

장바구니는 SKU와 수량만 저장하며 가격은 저장하지 않습니다. 가격은 언제든지 변할 수 있으므로 시스템은 항상 체크아웃 시 가격을 다시 계산합니다.

트랜잭션이 필요한 데이터는 PostgreSQL에 있습니다:

CREATE TABLE inventory (
    sku        VARCHAR(64) PRIMARY KEY,
    available  INT NOT NULL CHECK (available >= 0),
    reserved   INT NOT NULL DEFAULT 0
);

CREATE TABLE orders (
    id               BIGSERIAL PRIMARY KEY,
    customer_id      BIGINT       NOT NULL,
    idempotency_key  VARCHAR(64)  NOT NULL,
    status           VARCHAR(32)  NOT NULL,
    total_amount     BIGINT       NOT NULL,
    expires_at       TIMESTAMPTZ  NOT NULL,
    created_at       TIMESTAMPTZ  NOT NULL DEFAULT now(),
    UNIQUE (customer_id, idempotency_key)
);

CREATE TABLE order_items (
    order_id    BIGINT REFERENCES orders(id),
    sku         VARCHAR(64) NOT NULL,
    quantity    INT         NOT NULL,
    unit_price  BIGINT      NOT NULL,
    PRIMARY KEY (order_id, sku)
);

CREATE TABLE outbox_events (
    id           BIGSERIAL PRIMARY KEY,
    aggregate_id BIGINT      NOT NULL,
    event_type   VARCHAR(64) NOT NULL,
    payload      JSONB       NOT NULL,
    published    BOOLEAN     NOT NULL DEFAULT false
);

이 스키마에서 주목할 점들:

  • CHECK (available >= 0)는 마지막 방어선입니다. 코드에 버그가 있으면 데이터베이스가 여전히 음수 재고를 차단합니다.
  • UNIQUE (customer_id, idempotency_key)는 데이터베이스 수준에서 중복 주문 생성을 차단합니다.
  • order_items.unit_price는 주문 생성 시점의 가격 스냅샷입니다. 오래된 주문은 상품 가격 변경의 영향을 받지 않습니다.
  • 금액은 최소 단위(원)로 BIGINT를 사용합니다. DOUBLE은 사용하지 않습니다.

4. API 설계

GET    /api/cart                       -> 장바구니 보기 (현재 가격 포함)
PUT    /api/cart/items/{sku}           -> 본문 {"quantity": 2}, 수량 설정
DELETE /api/cart/items/{sku}           -> 장바구니에서 SKU 제거
POST   /api/orders                     -> 헤더 Idempotency-Key, 장바구니에서 주문 생성
GET    /api/orders/{id}                -> 주문 상태 조회

절대 수량을 가진 PUT은 멱등성을 가집니다. 여러 번 호출해도 같은 결과를 얻습니다. 만약 POST /add 형태로 +1을 했다면, 클라이언트가 재시도할 때마다 상품이 추가될 것입니다.

POST /api/orders는 본질적으로 멱등성을 가지지 않으므로 클라이언트는 “주문하기” 버튼을 누를 때마다 Idempotency-Key(UUID)를 생성해야 합니다. 재시도할 때 클라이언트는 동일한 키를 다시 보냅니다.

5. Redis의 장바구니

의존성 추가:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

장바구니 서비스:

@Service
public class CartService {

    private static final Duration CART_TTL = Duration.ofDays(30);
    private static final int MAX_ITEMS = 100;

    private final StringRedisTemplate redis;

    public CartService(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public void setQuantity(long customerId, String sku, int quantity) {
        String key = "cart:" + customerId;
        HashOperations<String, String, String> ops = redis.opsForHash();
        if (quantity <= 0) {
            ops.delete(key, sku);
            return;
        }
        if (!ops.hasKey(key, sku) && ops.size(key) >= MAX_ITEMS) {
            throw new IllegalStateException("장바구니에 이미 " + MAX_ITEMS + "개 상품이 있습니다");
        }
        ops.put(key, sku, String.valueOf(quantity));
        redis.expire(key, CART_TTL);
    }

    public Map<String, Integer> getItems(long customerId) {
        Map<String, String> raw = redis.<String, String>opsForHash().entries("cart:" + customerId);
        return raw.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, e -> Integer.parseInt(e.getValue())));
    }
}

설명:

  • ops.put(key, sku, ...)은 Hash 내의 필드에 O(1) 복잡도로 쓰기 작업을 합니다. 두 기기가 다른 SKU를 동시에 수정해도 서로 덮어쓰지 않습니다. 전체 장바구니를 JSON 문자열로 저장했다면 덮어쓰기가 발생합니다.
  • expire는 매번 쓰기할 때마다 갱신됩니다. 장바구니는 30일 동안 작업이 없을 때만 만료됩니다.
  • MAX_ITEMS은 봇으로 인한 과도한 키 크기 증가를 방지합니다.

hasKey와 size 호출은 원자적이지 않습니다. 두 개의 병렬 요청이 제한을 1-2개 초과할 수 있습니다. 장바구니의 경우 이 정도의 작은 불일치는 허용됩니다. 하지만 이 수준의 “대략 일관성”을 재고에는 적용해서는 안 됩니다.

주의: Redis는 절대 지속적인 데이터 소스가 아닙니다. AOF를 활성화하세요 (appendfsync everysec). 약 1초의 데이터 손실을 제한합니다. 1초의 장바구니 데이터 손실은 허용되지만 주문 손실은 그렇지 않습니다.

6. 주문 흐름

POST /api/orders 흐름은 다음 단계를 거칩니다:

1. Redis에서 장바구니 읽기
2. Catalog에서 현재 가격 조회
3. BEGIN 트랜잭션 (PostgreSQL)
   3a. 주문 삽입 (idempotency_key 포함) -> 중복이면 기존 주문 반환
   3b. 각 SKU의 재고 차감 (조건부 업데이트) -> 재고 부족하면 ROLLBACK
   3c. order_items 삽입 (가격 스냅샷)
   3d. outbox_events "OrderCreated" 삽입
4. COMMIT
5. 장바구니 삭제 (최선의 노력)
6. Relay가 아웃박스를 읽고 -> Kafka에 게시

6.1. 조건부 업데이트를 통한 과매출 방지

흔한 실수는 재고를 읽고, Java에서 비교한 후 쓰기입니다. 읽기와 쓰기 사이에 다른 스레드가 상품을 구매할 수 있습니다. 올바른 방법은 검사와 차감을 하나의 SQL 문으로 병합하는 것입니다:

@Repository
public class InventoryRepository {

    private final JdbcClient jdbc;

    public InventoryRepository(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    public boolean reserve(String sku, int quantity) {
        int updated = jdbc.sql("""
                UPDATE inventory
                   SET available = available - :qty,
                       reserved  = reserved  + :qty
                 WHERE sku = :sku AND available >= :qty
                """)
                .param("qty", quantity)
                .param("sku", sku)
                .update();
        return updated == 1;
    }
}

데이터베이스는 UPDATE가 실행되는 동안 행을 잠급니다. 두 번째 요청은 첫 번째 요청이 완료될 때까지 기다린 후 새로운 값에 대해 available >= :qty 조건을 다시 평가합니다. 재고가 부족하면 updated == 0이고 메서드는 false를 반환합니다. SELECT ... FOR UPDATE 또는 버전 컬럼이 필요하지 않습니다.

6.2. 멱등성과 트랜잭션

@Service
public class OrderService {

    private final JdbcClient jdbc;
    private final InventoryRepository inventory;

    public OrderService(JdbcClient jdbc, InventoryRepository inventory) {
        this.jdbc = jdbc;
        this.inventory = inventory;
    }

    @Transactional
    public long placeOrder(long customerId, String idempotencyKey, List<OrderLine> lines) {
        Optional<Long> existing = jdbc.sql("""
                INSERT INTO orders (customer_id, idempotency_key, status, total_amount, expires_at)
                VALUES (:c, :k, 'PENDING_PAYMENT', :total, now() + interval '15 minutes')
                ON CONFLICT (customer_id, idempotency_key) DO NOTHING
                RETURNING id
                """)
                .param("c", customerId).param("k", idempotencyKey)
                .param("total", lines.stream().mapToLong(OrderLine::subtotal).sum())
                .query(Long.class).optional();

        if (existing.isEmpty()) {
            return jdbc.sql("SELECT id FROM orders WHERE customer_id = :c AND idempotency_key = :k")
                    .param("c", customerId).param("k", idempotencyKey)
                    .query(Long.class).single();
        }
        long orderId = existing.get();

        for (OrderLine line : lines.stream().sorted(Comparator.comparing(OrderLine::sku)).toList()) {
            if (!inventory.reserve(line.sku(), line.quantity())) {
                throw new OutOfStockException(line.sku());
            }
            jdbc.sql("INSERT INTO order_items VALUES (:o, :s, :q, :p)")
                    .param("o", orderId).param("s", line.sku())
                    .param("q", line.quantity()).param("p", line.unitPrice())
                    .update();
        }

        jdbc.sql("""
                INSERT INTO outbox_events (aggregate_id, event_type, payload)
                VALUES (:id, 'OrderCreated', CAST(:payload AS jsonb))
                """)
                .param("id", orderId)
                .param("payload", "{\"orderId\":" + orderId + ",\"customerId\":" + customerId + "}")
                .update();
        return orderId;
    }
}

public record OrderLine(String sku, int quantity, long unitPrice) {
    long subtotal() { return quantity * unitPrice; }
}

public class OutOfStockException extends RuntimeException {
    public OutOfStockException(String sku) { super("재고 부족: " + sku); }
}

핵심 포인트:

  • ON CONFLICT ... DO NOTHING RETURNING id는 삽입 성공 시에만 id를 반환합니다. 키가 이미 존재하면 결과가 비어있습니다. 이 경우 서비스가 기존 주문을 다시 읽고 반환하므로 클라이언트 재시도도 정확히 하나의 주문을 받습니다.
  • OutOfStockException은 RuntimeException이므로 @Transactional은 전체를 롤백합니다: 주문, 이전에 차감한 모든 SKU 및 아웃박스. “부분 상태”가 없습니다.
  • SKU 목록은 재고 차감 전에 정렬됩니다. 같은 SKU A와 B를 포함하는 두 주문은 항상 A를 먼저 잠금 후 B를 잠금하므로 교착 상태를 피할 수 있습니다.
  • 첫 번째 요청이 롤백되면 키도 롤백됩니다. 동일한 키로 클라이언트 재시도는 처음부터 다시 실행됩니다. 이것은 정확히 원하는 동작입니다.

6.3. 트랜잭션 아웃박스

트랜잭션 내에서 kafkaTemplate.send()를 호출하면 두 가지 오류 중 하나가 발생합니다. Kafka 전송이 성공하지만 DB 롤백되면 “유령” 이벤트가 발생합니다. DB 커밋되지만 Kafka 오류이면 이벤트가 손실됩니다. 아웃박스는 동일한 트랜잭션으로 이벤트를 데이터베이스에 쓴 후 relay가 이벤트를 Kafka로 푸시하여 이를 해결합니다.

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Component
public class OutboxRelay {

    private final JdbcClient jdbc;
    private final KafkaTemplate<String, String> kafka;

    public OutboxRelay(JdbcClient jdbc, KafkaTemplate<String, String> kafka) {
        this.jdbc = jdbc;
        this.kafka = kafka;
    }

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publish() throws Exception {
        List<OutboxEvent> events = jdbc.sql("""
                SELECT id, aggregate_id, event_type, payload::text AS payload
                  FROM outbox_events WHERE published = false
                 ORDER BY id LIMIT 100
                 FOR UPDATE SKIP LOCKED
                """).query(OutboxEvent.class).list();

        for (OutboxEvent e : events) {
            kafka.send("orders", String.valueOf(e.aggregateId()), e.payload()).get();
            jdbc.sql("UPDATE outbox_events SET published = true WHERE id = :id")
                    .param("id", e.id()).update();
        }
    }
}

record OutboxEvent(long id, long aggregateId, String eventType, String payload) {}

@Scheduled는 애플리케이션이 @EnableScheduling을 활성화할 때만 실행됩니다.

  • FOR UPDATE SKIP LOCKED를 사용하면 여러 relay 인스턴스가 동시에 실행되면서도 같은 행을 처리하지 않습니다.
  • 메시지의 키는 aggregateId입니다. 동일한 주문의 모든 이벤트는 같은 파티션으로 들어가고 순서가 유지됩니다.
  • .get()은 Kafka 확인을 기다린 후에만 published를 표시합니다. Relay가 중간에 중단되면 이벤트는 다시 전송될 수 있습니다. 아웃박스는 at-least-once를 보장하므로 consumer는 멱등성을 가져야 하며 예를 들어 처리된 orderId를 저장합니다.

6.4. 결제 만료

정기적으로 만료된 주문을 취소하고 재고를 반환하는 작업:

WITH expired AS (
    UPDATE orders SET status = 'CANCELLED'
     WHERE status = 'PENDING_PAYMENT' AND expires_at < now()
    RETURNING id
)
UPDATE inventory i
   SET available = i.available + oi.quantity,
       reserved  = i.reserved  - oi.quantity
  FROM order_items oi
 WHERE oi.order_id IN (SELECT id FROM expired) AND oi.sku = i.sku;

status = 'PENDING_PAYMENT' 조건은 결제 콜백과의 경쟁을 해결합니다. 콜백도 UPDATE ... WHERE status = 'PENDING_PAYMENT'로 상태를 변경해야 합니다. 먼저 업데이트하는 쪽이 승리하고 다른 쪽은 0개 행을 업데이트합니다. 주문이 이미 취소된 후 결제가 도착하면 시스템은 주문을 “부활”시키는 대신 환불합니다.

위의 명령은 각 주문에서 각 SKU가 한 번만 나타나는 것으로 가정합니다. 주 키 (order_id, sku)가 이를 보장합니다. 한 번의 취소로 여러 주문이 같은 SKU를 포함하면 PostgreSQL은 각 inventory 행에 대해 하나의 행 조인만 적용합니다. 이 경우 먼저 GROUP BY sku 및 SUM(quantity)로 통합해야 합니다.

7. 트래픽 급증: 플래시 세일

5,000 req/s가 모두 같은 SKU를 놓고 싸우면 모두 inventory의 한 행을 잠금하기 위해 대기열에 줄을 섭니다. 데이터베이스가 막혀 지연 시간이 급증하고 다른 SKU도 함께 영향을 받습니다. 해결책은 데이터베이스에 도달하기 전에 Redis의 Lua 스크립트를 실행하여 일부 트래픽을 차단하는 것입니다:

@Component
public class FlashSaleGate {

    private static final RedisScript<Long> TRY_ACQUIRE = RedisScript.of("""
            local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
            local qty = tonumber(ARGV[1])
            if stock >= qty then
                redis.call('DECRBY', KEYS[1], qty)
                return 1
            end
            return 0
            """, Long.class);

    private final StringRedisTemplate redis;

    public FlashSaleGate(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public boolean tryAcquire(String sku, int quantity) {
        Long ok = redis.execute(TRY_ACQUIRE, List.of("flash:stock:" + sku), String.valueOf(quantity));
        return ok != null && ok == 1L;
    }

    public void release(String sku, int quantity) {
        redis.opsForValue().increment("flash:stock:" + sku, quantity);
    }
}

Redis는 Lua 스크립트를 원자적으로 실행합니다. GET과 DECRBY 사이에 명령이 끼어들 수 없습니다. 1,000개의 할당량과 50,000개의 요청으로 약 1,000개의 요청만 게이트를 통과하여 OrderService에 들어갑니다. 나머지 49,000개의 요청은 즉시 HTTP 409 “Sold out”을 받으며 데이터베이스 쿼리를 낭비하지 않습니다.

결합된 흐름:

tryAcquire (Redis)  --false-->  409 Sold out
       |true
placeOrder (PostgreSQL, 조건부 업데이트 유지)
       |exception
release (Redis)

Redis는 필터일 뿐입니다. 실제 진실은 PostgreSQL의 조건부 업데이트입니다. Redis가 숫자가 맞지 않으면(재시작으로 인한 또는 release가 실행되지 않음) 최악의 경우는 수량을 적게 파는 것이지 과도하게 파는 것이 아닙니다.

기타 보조 조치:

  • 고객당 속도 제한 - 봇 스팸 차단
  • 대기열: 게이트를 통과한 요청을 Kafka로 푸시하고, consumer가 데이터베이스가 처리할 수 있는 속도로 주문을 생성합니다. 클라이언트는 GET /api/orders/{id}를 폴합니다. 이 방법은 지연 시간을 안정성으로 교환합니다.
  • 핫 SKU 분리 - 별도의 테이블 또는 인스턴스로 이동하여 일반 주문에 영향을 주지 않도록 합니다.

8. 재고 경쟁 테스트

가장 중요한 테스트는 많은 스레드가 동시에 구매할 때 시스템이 과매출하지 않는다는 증명입니다.

@JdbcTest
@Import(InventoryRepository.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class InventoryRepositoryTest {

    @Autowired JdbcClient jdbc;
    @Autowired InventoryRepository inventory;

    @BeforeEach
    void setUp() {
        jdbc.sql("""
                CREATE TABLE IF NOT EXISTS inventory (
                  sku VARCHAR(64) PRIMARY KEY,
                  available INT NOT NULL CHECK (available >= 0),
                  reserved INT NOT NULL DEFAULT 0)
                """).update();
        jdbc.sql("DELETE FROM inventory").update();
        jdbc.sql("INSERT INTO inventory (sku, available) VALUES ('IPHONE-16', 5)").update();
    }

    @Test
    void givenStockOf5_when20ConcurrentBuyers_thenExactly5Succeed() throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Boolean>> results = IntStream.range(0, 20)
                    .mapToObj(i -> executor.submit(() -> inventory.reserve("IPHONE-16", 1)))
                    .toList();

            long success = 0;
            for (Future<Boolean> f : results) {
                if (f.get()) success++;
            }
            assertThat(success).isEqualTo(5);
        }

        Integer left = jdbc.sql("SELECT available FROM inventory WHERE sku = 'IPHONE-16'")
                .query(Integer.class).single();
        assertThat(left).isZero();
    }
}
  • @Transactional(propagation = NOT_SUPPORTED)는 @JdbcTest의 기본 트랜잭션을 비활성화합니다. 이를 하지 않으면 setUp에서 삽입된 데이터가 아직 커밋되지 않았고 다른 스레드에서 볼 수 없습니다.
  • 테스트는 Java 21의 virtual thread를 사용하여 20개의 동시 요청을 생성합니다.
  • 기본 포함된 데이터베이스 @JdbcTest (H2)는 이 검사에 충분합니다. ON CONFLICT 및 SKIP LOCKED를 테스트하려면 Testcontainers를 통해 실제 PostgreSQL을 사용합니다.

예상 결과:

success = 5, available = 0

UPDATE를 “SELECT 후 Java에서 비교” 방식으로 변경하면 이 테스트는 무작위로 실패하며 success가 7 또는 8에 도달할 수 있습니다.

9. 일반적인 실수 및 사용하면 안 되는 경우

  • 장바구니에 가격을 저장한 후 체크아웃 시 그 가격을 사용합니다. 나쁜 행위자는 오래된 가격을 유지하거나 페이로드를 수정할 수 있습니다. 항상 서버에서 가격을 가져옵니다.
  • 장바구니에 추가할 때 즉시 재고를 차감합니다. 사용자가 장바구니를 버리는 경우가 매우 많으므로 재고는 불필요하게 “고정”됩니다. 체크아웃할 때만 보류하고 항상 시간 제한을 가집니다.
  • @Transactional 내에서 Kafka 또는 결제 서비스를 호출합니다. 긴 트랜잭션은 잠금을 오래 유지하고 일관성을 보장하지도 않습니다. 아웃박스를 사용합니다.
  • Redis를 재고의 진실 공급원으로 취급합니다. Redis는 필터 계층일 뿐입니다.
  • 서비스 간 분산 트랜잭션(2PC/XA) 사용합니다. 이것은 복잡하고 느립니다. 이벤트 기반 saga를 보상 단계(주문 취소, 재고 반환) 함께 사용합니다.

주의: 플랫폼이 1분에 수십 개의 주문만 처리하면 조건부 업데이트 및 idempotency 키가 있는 단일 데이터베이스로 충분합니다. Redis 게이트, Kafka 대기열 및 핫 SKU 분리는 측정된 데이터에서 데이터베이스가 실제로 막혀 있음을 보여줄 때만 추가됩니다.

10. 결론

이 글은 장바구니 및 주문 흐름을 계층별로 설계했습니다. 장바구니는 빠르고 대략 일관성을 허용하기 때문에 Redis Hash에 있습니다. 주문과 재고는 강한 일관성이 필요하기 때문에 PostgreSQL에 있습니다.

어려운 3가지 핵심 기술이 있습니다:

  • 조건부 업데이트 - 과매출 방지
  • Idempotency 키와 UNIQUE 제약 - 중복 주문 방지
  • 트랜잭션 아웃박스 - 이벤트 손실 방지

플래시 세일이 있을 때 Redis에서 Lua 스크립트를 실행하는 게이트가 데이터베이스에 도달하기 전에 트래픽을 필터링하는 반면 데이터베이스는 여전히 진실의 원천으로 역할합니다. 가장 단순한 버전으로 시작하여 실제 로드가 요구할 때 각 계층을 추가할 수 있습니다.

system-designspring-bootrediskafkacase-study