동시성은 여러 작업을 동시에 실행하는 것이 아니다

vx_developer·2026년 10월 7일

개발하다가

목록 보기
47/49
post-thumbnail

프로그래밍을 처음 배울 때 동시성은 보통 여러 작업이 같은 시간에 실행되는 것이라고 배운다.

await Promise.all([
  createOrder(firstCustomer),
  createOrder(secondCustomer),
]);

두 주문 작업을 함께 시작하면 각각을 차례대로 실행할 때보다 빠르게 끝날 수 있다.

기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 개발하면 동시에 실행되는 작업의 개수보다 같은 데이터가 어떤 결과로 남는지가 더 중요하다.

  • 재고가 하나 남았을 때 두 고객이 동시에 주문하면 어떻게 되는가?
  • 두 요청이 같은 값을 읽은 뒤 각각 수정하면 어느 변경이 남는가?
  • 중복 클릭이나 네트워크 재시도로 주문이 두 번 생성되지 않는가?
  • 충돌을 막기 위해 어디까지 잠가야 하는가?
  • 충돌한 작업을 재시도해도 안전한가?
  • 애플리케이션 서버가 여러 대일 때도 같은 규칙이 유지되는가?

동시성은 여러 작업을 동시에 실행하는 것이 아니다.

동시성은 여러 작업이 같은 상태를 겹쳐 읽고 변경하더라도 서비스가 지켜야 할 규칙과 데이터의 일관성을 유지하는 설계다.


동시성 문제는 공유하는 데이터가 있을 때 시작된다

크리스가 한정 수량 상품을 판매하는 쇼핑몰을 개발한다고 생각해 보자.

상품 재고가 충분한지 확인한 뒤 주문 수량만큼 차감하는 코드를 작성할 수 있다.

async function placeOrder(
  productId: string,
  quantity: number
) {
  const inventory =
    await inventoryRepository.findByProductId(
      productId
    );

  if (inventory.stock < quantity) {
    throw new OutOfStockError();
  }

  await inventoryRepository.updateStock(
    productId,
    inventory.stock - quantity
  );

  return orderRepository.create({
    productId,
    quantity,
  });
}

하나의 요청만 실행된다면 이 코드는 자연스럽게 동작한다. 현재 재고를 읽고, 주문 가능 여부를 확인하고, 남은 수량을 계산해 저장한다.

하지만 재고가 하나 남은 시점에 두 고객이 거의 동시에 하나씩 주문하면 상황이 달라진다.

sequenceDiagram
    participant A as "고객 A"
    participant S as "주문 서버"
    participant D as "데이터베이스"
    participant B as "고객 B"

    A->>S: "1개 주문"
    B->>S: "1개 주문"
    S->>D: "재고 조회"
    D-->>S: "stock = 1"
    S->>D: "다른 요청도 재고 조회"
    D-->>S: "stock = 1"
    S->>D: "재고를 0으로 변경"
    S->>D: "재고를 0으로 변경"

두 요청은 모두 재고가 하나 있다고 판단한다. 각각 주문에 성공하지만 데이터베이스의 최종 재고는 0이 될 수 있다.

숫자만 보면 음수가 아니므로 정상처럼 보인다. 그러나 실제로는 하나의 상품으로 두 개의 주문을 받았다.

문제는 두 작업이 동시에 실행되었다는 사실 자체가 아니다. 두 작업이 같은 재고를 읽고, 서로의 변경을 알지 못한 채 결정을 내렸다는 점이다.


조회와 수정 사이에는 다른 작업이 들어올 수 있다

다음 코드는 세 단계로 보인다.

현재 재고 조회
    ↓
주문 가능 여부 판단
    ↓
새로운 재고 저장

개발자는 이 코드를 하나의 흐름으로 읽지만 데이터베이스에는 서로 다른 명령으로 전달될 수 있다.

SELECT stock
FROM product_inventory
WHERE product_id = $1;

UPDATE product_inventory
SET stock = 0
WHERE product_id = $1;

첫 번째 조회가 끝난 뒤 두 번째 수정이 시작되기 전까지 다른 요청도 같은 데이터를 읽거나 변경할 수 있다.

이처럼 작업 결과가 실행 순서에 따라 달라지는 상황을 경쟁 상태라고 한다. 경쟁 상태는 코드가 한 줄씩 올바르게 작성되었더라도 발생할 수 있다.

따라서 동시성 문제를 검토할 때는 함수만 보지 않고 읽기와 쓰기 사이의 경계를 확인해야 한다.

  • 어떤 데이터를 먼저 읽는가?
  • 읽은 값을 기준으로 어떤 결정을 내리는가?
  • 그 결정이 저장되기 전에 다른 요청이 같은 데이터를 변경할 수 있는가?
  • 여러 변경 중 어느 결과가 최종 값으로 남는가?

“조회한 순간에는 맞았다”는 사실이 “저장할 순간에도 맞다”는 뜻은 아니다.


하나의 조건부 변경으로 만들면 경쟁할 틈을 줄일 수 있다

재고 확인과 차감을 별도의 명령으로 실행하지 않고 하나의 조건부 갱신으로 표현할 수 있다.

UPDATE product_inventory
SET stock = stock - $2
WHERE product_id = $1
  AND stock >= $2
RETURNING stock;

이 쿼리는 재고가 주문 수량 이상일 때만 현재 값에서 수량을 차감한다.

두 요청이 재고 하나를 두고 경쟁하더라도 먼저 실행된 요청이 재고를 0으로 만든다. 다음 요청은 stock >= 1 조건을 만족하지 못하므로 수정된 행을 반환하지 않는다.

애플리케이션에서는 영향을 받은 행의 존재로 성공 여부를 판단할 수 있다.

const updatedInventory =
  await inventoryRepository.decreaseStock({
    productId,
    quantity,
  });

if (!updatedInventory) {
  throw new OutOfStockError();
}

이 코드는 이전에 읽은 재고 값을 애플리케이션에서 다시 계산하지 않는다. 데이터베이스가 현재 값과 조건을 기준으로 차감을 수행한다.

이런 형태를 원자적 변경이라고 부른다. 외부에서는 조건 확인과 차감이 분리되지 않는 하나의 연산처럼 보인다.

가능하다면 다음 패턴보다 원자적 데이터베이스 명령을 먼저 검토하는 편이 낫다.

조회 → 애플리케이션에서 계산 → 저장

원자적 명령이 모든 동시성 문제를 해결하는 것은 아니지만, 하나의 값에 대한 단순한 증감과 조건 검사를 표현하는 데 효과적이다.


데이터베이스 제약은 잘못된 상태가 저장되는 것을 막는다

애플리케이션 코드가 재고를 확인하더라도 데이터베이스 자체에도 최소한의 규칙을 표현할 수 있다.

CREATE TABLE product_inventory (
  product_id UUID PRIMARY KEY,
  stock INTEGER NOT NULL
    CHECK (stock >= 0),
  version INTEGER NOT NULL DEFAULT 0
);

CHECK (stock >= 0) 제약은 어떤 코드 경로를 거치더라도 음수 재고가 저장되지 않도록 한다.

그러나 이 제약만으로 초과 판매가 모두 방지되는 것은 아니다. 앞선 예처럼 두 주문이 모두 성공하고 최종 재고가 0으로 남는다면 음수 값은 발생하지 않는다.

제약 조건은 서비스 규칙의 마지막 방어선이지만, 어떤 규칙을 표현하는지 구분해야 한다.

규칙적절한 보호 방법
재고는 음수가 될 수 없다CHECK 제약
상품별 재고 행은 하나만 존재한다기본 키 또는 UNIQUE
같은 요청으로 주문이 두 번 생성되면 안 된다멱등성 키의 UNIQUE
재고 확인과 차감이 경쟁해서는 안 된다조건부 갱신 또는 잠금
주문 생성과 재고 차감이 함께 성공해야 한다트랜잭션

애플리케이션 검사는 사용자에게 이해하기 좋은 오류를 제공한다. 데이터베이스 제약은 여러 서버와 코드 경로가 동시에 접근하더라도 잘못된 상태가 최종 저장되는 것을 막는다.

두 역할은 서로 대체하기보다 함께 사용해야 한다.


여러 변경이 하나의 주문이라면 함께 성공해야 한다

재고를 차감한 뒤 주문 생성에 실패한다고 생각해 보자.

await inventoryRepository.decreaseStock({
  productId,
  quantity,
});

await orderRepository.create({
  customerId,
  productId,
  quantity,
});

첫 번째 작업은 성공하고 두 번째 작업은 실패할 수 있다. 그러면 재고는 줄었지만 주문은 존재하지 않는다.

재고 차감과 주문 생성이 하나의 비즈니스 작업이라면 같은 트랜잭션 안에서 처리해야 한다.

await database.transaction(async (transaction) => {
  const inventory =
    await transaction.inventory.decreaseStock({
      productId,
      quantity,
    });

  if (!inventory) {
    throw new OutOfStockError();
  }

  await transaction.orders.create({
    customerId,
    productId,
    quantity,
  });
});

이 코드는 재고 차감과 주문 생성을 하나의 성공 또는 실패 경계로 묶는다. 주문 생성이 실패하면 재고 변경도 되돌아가야 한다.

트랜잭션은 동시성 제어의 전부가 아니다. 단순히 두 쿼리를 트랜잭션으로 묶어도 각각이 같은 오래된 값을 읽는다면 경쟁 상태가 남을 수 있다.

따라서 두 가지 질문을 구분해야 한다.

  1. 어떤 변경들이 함께 성공하거나 함께 실패해야 하는가?
  2. 동시에 실행되는 다른 트랜잭션과 어떤 충돌을 막아야 하는가?

첫 번째는 원자성의 문제이고, 두 번째는 격리와 동시성 제어의 문제다.


잠금은 중요한 결정을 내리는 동안 변경 권한을 제한한다

주문 규칙이 여러 데이터와 복잡한 조건을 확인해야 한다면 한 번의 조건부 UPDATE로 표현하기 어려울 수 있다.

이때 재고 행을 읽으면서 잠글 수 있다.

BEGIN;

SELECT stock
FROM product_inventory
WHERE product_id = $1
FOR UPDATE;

UPDATE product_inventory
SET stock = stock - $2
WHERE product_id = $1;

INSERT INTO orders (
  id,
  customer_id,
  product_id,
  quantity
) VALUES ($3, $4, $1, $2);

COMMIT;

SELECT ... FOR UPDATE는 선택한 재고 행을 변경하려는 다른 트랜잭션과 충돌한다. 먼저 잠금을 얻은 주문이 끝날 때까지 다음 주문은 기다리게 된다.

이 방식은 비즈니스 로직을 이해하기 쉽게 만들 수 있지만 비용도 있다.

  • 잠금을 오래 유지하면 대기 요청이 늘어난다.
  • 인기 상품 하나에 주문이 집중되면 병목이 된다.
  • 여러 행을 서로 다른 순서로 잠그면 교착 상태가 생길 수 있다.
  • 트랜잭션 안에서 외부 결제 API를 기다리면 잠금 시간이 길어진다.
  • 실패한 트랜잭션은 다시 시도해야 할 수 있다.

잠금 범위는 “안전할수록 넓게” 정하는 것이 아니다. 서비스 규칙을 지키는 데 필요한 최소 데이터와 최소 시간으로 제한해야 한다.


잠금 순서를 통일하면 교착 상태를 줄일 수 있다

한 주문이 상품 A와 B의 재고를 차례대로 잠근다고 생각해 보자. 다른 주문이 B와 A 순서로 잠그면 두 트랜잭션이 서로를 기다릴 수 있다.

주문 1: 상품 A 잠금 → 상품 B 대기
주문 2: 상품 B 잠금 → 상품 A 대기

이 상태에서는 어느 쪽도 진행할 수 없다. 데이터베이스는 일반적으로 교착 상태를 감지하고 한 트랜잭션을 실패시켜 문제를 해소한다.

여러 상품을 잠가야 한다면 항상 같은 순서로 처리할 수 있다.

const sortedProductIds = [...productIds].sort();

for (const productId of sortedProductIds) {
  await inventoryRepository.lockByProductId(
    productId,
    transaction
  );
}

이 코드는 모든 주문이 정렬된 상품 ID 순서로 잠금을 요청하게 한다. 잠금 획득 순서를 일관되게 만들면 서로 반대 방향으로 기다리는 상황을 줄일 수 있다.

그렇더라도 교착 상태와 직렬화 실패가 완전히 사라진다고 가정해서는 안 된다. 애플리케이션에는 충돌 오류를 분류하고 안전한 경우 트랜잭션 전체를 재시도하는 정책이 필요하다.


충돌이 드물다면 버전으로 변경 여부를 확인할 수 있다

항상 잠금을 먼저 얻는 대신, 데이터를 읽은 뒤 저장할 때 다른 작업이 변경했는지 확인할 수도 있다.

재고 행에 version 필드가 있다고 생각해 보자.

type ProductInventory = {
  productId: string;
  stock: number;
  version: number;
};

크리스가 재고를 읽었을 때 version이 12였다면 수정 조건에 같은 버전을 포함할 수 있다.

UPDATE product_inventory
SET stock = $3,
    version = version + 1
WHERE product_id = $1
  AND version = $2;

다른 요청이 먼저 수정했다면 버전은 13이 된다. 늦게 도착한 요청의 version = 12 조건은 더 이상 일치하지 않으므로 아무 행도 수정하지 않는다.

애플리케이션은 충돌을 명시적으로 처리해야 한다.

const updated =
  await inventoryRepository.updateWithVersion({
    productId,
    expectedVersion: inventory.version,
    nextStock: inventory.stock - quantity,
  });

if (!updated) {
  throw new InventoryConflictError();
}

이 방식을 낙관적 동시성 제어라고 한다. 충돌이 자주 발생하지 않을 것이라고 보고 먼저 잠그지 않지만, 저장 시점에 충돌 여부를 확인한다.

방식적합한 상황주요 비용
조건부 원자적 갱신단순한 재고 증감복잡한 규칙 표현이 어려움
비관적 잠금충돌 가능성이 높고 여러 조건을 함께 판단대기와 교착 상태
낙관적 동시성 제어충돌이 드물고 읽기 시간이 긴 수정충돌 후 재시도 또는 사용자 조정
Serializable 격리복잡한 규칙을 순차 실행과 같은 결과로 보호감시 비용과 직렬화 실패 재시도

특정 방식이 항상 더 좋은 것은 아니다. 충돌 빈도, 업무 규칙, 허용 가능한 대기 시간과 재시도 비용에 따라 선택해야 한다.


재시도는 같은 작업을 두 번 만들지 않을 때만 안전하다

모바일 네트워크가 불안정하면 사용자가 주문 응답을 받지 못할 수 있다.

서버에서는 주문이 생성되었지만 응답 전달만 실패했을 수도 있다. 사용자가 다시 버튼을 누르거나 클라이언트가 자동 재시도하면 같은 주문이 두 번 생성될 수 있다.

버튼을 비활성화하는 것만으로는 충분하지 않다.

  • 사용자가 새로고침할 수 있다.
  • 모바일 앱이 요청을 재전송할 수 있다.
  • 프록시나 작업 큐가 재시도할 수 있다.
  • 여러 기기에서 같은 작업을 요청할 수 있다.
  • 첫 요청은 성공했지만 응답만 사라질 수 있다.

클라이언트가 주문 시도마다 고유한 멱등성 키를 만들도록 할 수 있다.

const idempotencyKey = crypto.randomUUID();

await fetch("/api/orders", {
  method: "POST",
  headers: {
    "Idempotency-Key": idempotencyKey,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    productId,
    quantity,
  }),
});

같은 주문을 재시도할 때는 새로운 키를 만들지 않고 기존 키를 다시 사용해야 한다.

서버는 이 값을 주문과 함께 고유하게 저장할 수 있다.

ALTER TABLE orders
ADD CONSTRAINT orders_idempotency_key_unique
UNIQUE (customer_id, idempotency_key);

이 제약은 한 고객의 같은 요청 키로 주문이 두 번 생성되지 않도록 한다.

이미 처리된 키가 다시 들어오면 이전 주문 결과를 반환할 수 있다.

const existingOrder =
  await orderRepository.findByIdempotencyKey({
    customerId,
    idempotencyKey,
  });

if (existingOrder) {
  return existingOrder;
}

재시도 정책과 멱등성 설계는 함께 만들어야 한다. 충돌이나 일시적 장애를 자동 재시도하면서 중복 방지 장치가 없다면, 복구 로직이 새로운 데이터 오류를 만들 수 있다.


외부 입력은 동시성 규칙에 도달하기 전에도 검증해야 한다

클라이언트가 보낸 상품 ID와 수량을 곧바로 재고 쿼리에 사용해서는 안 된다. 외부 입력은 검증 전까지 신뢰할 수 없다.

import { z } from "zod";

const CreateOrderSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.coerce.number().int().min(1).max(10),
  idempotencyKey: z.string().uuid(),
});

const orderInput =
  CreateOrderSchema.parse(request.body);

이 스키마는 상품 ID와 멱등성 키의 형식, 주문 수량의 범위를 확인한다.

그러나 형식이 올바르다고 주문이 가능한 것은 아니다. 서버는 현재 상품 상태, 구매 제한, 고객 권한과 재고를 다시 확인해야 한다.

const purchaseLimit =
  await purchasePolicy.getLimit({
    customerId,
    productId: orderInput.productId,
  });

if (orderInput.quantity > purchaseLimit) {
  throw new PurchaseLimitExceededError();
}

이 검사는 요청 형식이 아니라 비즈니스 규칙을 확인한다.

프론트엔드에서 표시한 재고와 구매 가능 여부는 사용자 안내를 위한 값이다. 주문 승인 시점의 Source of Truth는 서버와 데이터베이스가 관리하는 현재 재고, 주문 이력과 정책이다.


외부 결제까지 하나의 데이터베이스 잠금으로 묶을 수는 없다

주문에는 재고뿐 아니라 결제 시스템도 참여한다.

외부 결제 API는 쇼핑몰 데이터베이스 트랜잭션에 직접 포함되지 않는다. 결제 요청이 진행되는 동안 재고 행을 오랫동안 잠그면 다른 주문이 계속 대기할 수 있다.

대신 재고 예약이라는 상태를 명시적으로 만들 수 있다.

type InventoryReservationStatus =
  | "reserved"
  | "confirmed"
  | "released"
  | "expired";

주문 흐름은 다음처럼 구성할 수 있다.

재고 예약
  → 결제 요청
  → 결제 성공 시 주문 확정
  → 결제 실패 시 예약 해제
  → 응답이 없으면 만료 후 복구

재고의 Source of Truth는 단순한 stock 숫자 하나가 아닐 수 있다.

판매 가능한 재고
= 전체 재고
- 확정된 주문 수량
- 아직 유효한 예약 수량

예약을 사용하면 잠금 시간을 줄일 수 있지만 새로운 책임이 생긴다.

  • 예약 만료 시점을 누가 판단하는가?
  • 결제 성공 메시지가 중복 전달되면 어떻게 하는가?
  • 결제는 성공했지만 주문 확정에 실패하면 어떻게 복구하는가?
  • 만료와 결제 성공이 동시에 처리되면 어느 상태가 유효한가?
  • 운영자가 문제 주문을 추적할 수 있는가?

분산된 시스템의 동시성은 한 번의 잠금보다 상태 전이, 중복 처리, 재시도와 복구 절차를 함께 설계하는 문제에 가깝다.


동시성 테스트는 순서가 달라져도 규칙이 유지되는지 확인해야 한다

일반적인 단위 테스트는 요청을 하나씩 실행하므로 경쟁 상태를 발견하지 못할 수 있다.

재고가 하나인 상품에 여러 주문을 동시에 시도할 수 있다.

it("재고보다 많은 동시 주문을 승인하지 않는다", async () => {
  await inventoryFixture.create({
    productId,
    stock: 1,
  });

  const results = await Promise.allSettled([
    orderService.placeOrder({
      customerId: firstCustomerId,
      productId,
      quantity: 1,
      idempotencyKey: crypto.randomUUID(),
    }),
    orderService.placeOrder({
      customerId: secondCustomerId,
      productId,
      quantity: 1,
      idempotencyKey: crypto.randomUUID(),
    }),
  ]);

  const fulfilledOrders = results.filter(
    (result) => result.status === "fulfilled"
  );

  expect(fulfilledOrders).toHaveLength(1);
  expect(
    await inventoryRepository.getStock(productId)
  ).toBe(0);
});

이 테스트는 어떤 고객이 성공하는지를 고정하지 않는다. 대신 하나의 재고에서는 하나의 주문만 성공하고 최종 재고가 0이어야 한다는 불변 조건을 확인한다.

동시성 테스트에서는 특정 실행 순서보다 항상 유지되어야 하는 규칙을 검증하는 편이 중요하다.

  • 재고는 음수가 되지 않는다.
  • 승인된 주문 수량은 실제 재고를 넘지 않는다.
  • 같은 멱등성 키로 주문은 하나만 존재한다.
  • 실패한 주문은 재고를 소비하지 않는다.
  • 예약·확정·해제 수량의 합계가 원본 재고와 맞는다.

한 번 성공한 테스트만으로 경쟁 상태가 없다고 단정할 수는 없다. 반복 실행, 실제 데이터베이스 사용과 의도적으로 겹친 요청을 통해 충돌 경로를 확인해야 한다.


동시성을 설계하기 전에 물어봐야 할 질문

어떤 데이터를 여러 작업이 공유하는가

  1. 여러 요청이 같은 재고, 잔액, 좌석이나 상태를 변경하는가?
  2. 읽은 값을 기준으로 새로운 값을 계산하는가?
  3. 조회와 저장 사이에 다른 요청이 개입할 수 있는가?
  4. 서로 다른 서버 인스턴스에서도 같은 데이터에 접근하는가?
  5. 캐시된 값으로 중요한 변경 결정을 내리고 있지 않은가?

반드시 유지해야 하는 규칙은 무엇인가

  1. 재고가 음수가 되지 않는 것만으로 충분한가?
  2. 승인된 전체 주문 수량도 재고 이하여야 하는가?
  3. 같은 요청이 여러 번 실행되어도 결과가 하나여야 하는가?
  4. 어떤 변경들이 함께 성공하거나 함께 실패해야 하는가?
  5. 데이터베이스 제약으로 표현할 수 있는 규칙은 무엇인가?

어떤 충돌 제어가 적합한가

  1. 조건부 원자적 갱신 하나로 규칙을 표현할 수 있는가?
  2. 중요한 결정을 내리는 동안 행 잠금이 필요한가?
  3. 충돌이 드물어 버전 기반 검사가 더 적합한가?
  4. 더 강한 격리 수준이 필요한 복잡한 규칙인가?
  5. 충돌 후 전체 작업을 안전하게 재시도할 수 있는가?

잠금 범위와 시간을 제한했는가

  1. 필요한 행만 잠그는가?
  2. 모든 작업이 같은 순서로 잠금을 얻는가?
  3. 트랜잭션 안에서 외부 API를 기다리고 있지 않은가?
  4. 잠금 대기 시간과 교착 상태를 관찰할 수 있는가?
  5. 인기 데이터 하나가 전체 처리량의 병목이 되지 않는가?

중복 요청과 재시도를 함께 설계했는가

  1. 클라이언트가 같은 요청을 다시 보낼 수 있는가?
  2. 멱등성 키의 범위와 수명은 무엇인가?
  3. 데이터베이스가 키의 고유성을 보장하는가?
  4. 이미 처리된 요청에는 어떤 결과를 반환하는가?
  5. 직렬화 실패나 일시적 오류만 선별해서 재시도하는가?

Source of Truth와 파생 값을 구분했는가

  1. 실제 판매 가능한 재고는 어디에서 결정되는가?
  2. 화면에 표시한 재고가 오래되었을 수 있음을 고려하는가?
  3. 예약 재고와 확정 주문을 어떻게 합산하는가?
  4. 캐시가 잘못되어도 데이터베이스 규칙은 유지되는가?
  5. 충돌 후 다시 읽어야 하는 원본 데이터는 무엇인가?

충돌 경로를 검증하고 관찰하는가

  1. 여러 요청을 실제로 겹쳐 실행하는 테스트가 있는가?
  2. 테스트가 특정 순서보다 불변 조건을 확인하는가?
  3. 충돌, 재시도, 잠금 대기와 교착 상태를 기록하는가?
  4. 초과 판매와 중복 주문을 탐지하는 운영 지표가 있는가?
  5. 장애 후 데이터 일관성을 점검하고 복구할 방법이 있는가?

흔한 실수는 동시성을 실행 속도의 문제로만 보는 것이다

프론트엔드에서 버튼을 비활성화하면 중복 주문이 사라진다고 생각한다

버튼 비활성화는 사용자의 반복 클릭을 줄이지만 네트워크 재시도, 여러 기기와 직접 API 요청을 막지 못한다.

서버와 데이터베이스에서도 중복을 식별해야 한다.

재고를 확인했으므로 차감도 안전하다고 생각한다

조회 이후 실제 수정 전까지 다른 요청이 재고를 변경할 수 있다.

조건 확인과 변경 사이의 경쟁 가능성을 검토해야 한다.

트랜잭션을 사용하면 모든 경쟁 상태가 해결된다고 생각한다

트랜잭션은 함께 성공하거나 실패할 경계를 제공하지만 적절한 조건, 잠금이나 격리 없이 모든 동시 실행을 순서대로 만들지는 않는다.

원자성과 격리를 구분해야 한다.

모든 변경에 강한 잠금을 사용한다

불필요하게 넓거나 긴 잠금은 요청 대기와 교착 상태를 증가시킨다.

충돌 가능성과 비즈니스 중요도에 맞는 방식을 선택해야 한다.

충돌 오류를 일반적인 서버 오류로만 처리한다

일부 충돌은 최신 데이터를 다시 읽고 전체 트랜잭션을 재시도하면 해결할 수 있다.

다만 재시도가 중복 주문이나 중복 결제를 만들지 않도록 멱등성이 먼저 보장되어야 한다.

캐시의 재고를 기준으로 주문을 승인한다

캐시는 빠르지만 최신 재고를 항상 보장하지 않는다.

중요한 변경의 최종 결정은 Source of Truth와 데이터베이스 규칙을 기준으로 내려야 한다.

성공한 요청 수만 보고 초과 판매를 놓친다

최종 재고가 음수가 아니더라도 승인된 주문의 합계가 실제 재고를 넘을 수 있다.

개별 컬럼뿐 아니라 비즈니스 불변 조건을 확인해야 한다.


동시성의 목적은 같은 데이터를 변경하는 작업 사이에서 규칙을 지키는 데 있다

프로그래밍을 처음 배울 때는 동시성을 여러 작업이 같은 시간에 실행되는 것으로 이해해도 충분하다.

await Promise.all([
  createOrder(firstCustomer),
  createOrder(secondCustomer),
]);

그러나 실제 서비스에서는 빠르게 함께 실행하는 것보다 그 결과가 올바른지가 중요하다.

  • 어떤 데이터를 여러 요청이 공유하는가?
  • 조회와 저장 사이에 다른 요청이 개입할 수 있는가?
  • 어떤 규칙이 모든 실행 순서에서 유지되어야 하는가?
  • 조건부 원자적 갱신으로 해결할 수 있는가?
  • 잠금 또는 버전 검사가 필요한가?
  • 여러 변경을 어떤 트랜잭션 경계로 묶어야 하는가?
  • 충돌과 교착 상태가 발생하면 무엇을 재시도하는가?
  • 재시도가 중복 주문이나 결제를 만들지 않는가?
  • 캐시와 화면 값이 아닌 Source of Truth는 어디에 있는가?
  • 실제로 겹친 요청에서도 불변 조건이 유지되는가?

동시성은 여러 작업을 동시에 실행하는 것이 아니다.

동시성은 여러 작업이 같은 상태를 겹쳐 읽고 변경하더라도 서비스가 지켜야 할 규칙과 데이터의 일관성을 유지하는 설계다.


어떤 자료를 참고했는가

profile
Vision eXperience Developer

0개의 댓글