프로그래밍을 처음 배울 때 동시성은 보통 여러 작업이 같은 시간에 실행되는 것이라고 배운다.
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,
});
});
이 코드는 재고 차감과 주문 생성을 하나의 성공 또는 실패 경계로 묶는다. 주문 생성이 실패하면 재고 변경도 되돌아가야 한다.
트랜잭션은 동시성 제어의 전부가 아니다. 단순히 두 쿼리를 트랜잭션으로 묶어도 각각이 같은 오래된 값을 읽는다면 경쟁 상태가 남을 수 있다.
따라서 두 가지 질문을 구분해야 한다.
첫 번째는 원자성의 문제이고, 두 번째는 격리와 동시성 제어의 문제다.
주문 규칙이 여러 데이터와 복잡한 조건을 확인해야 한다면 한 번의 조건부 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는 선택한 재고 행을 변경하려는 다른 트랜잭션과 충돌한다. 먼저 잠금을 얻은 주문이 끝날 때까지 다음 주문은 기다리게 된다.
이 방식은 비즈니스 로직을 이해하기 쉽게 만들 수 있지만 비용도 있다.
잠금 범위는 “안전할수록 넓게” 정하는 것이 아니다. 서비스 규칙을 지키는 데 필요한 최소 데이터와 최소 시간으로 제한해야 한다.
한 주문이 상품 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이어야 한다는 불변 조건을 확인한다.
동시성 테스트에서는 특정 실행 순서보다 항상 유지되어야 하는 규칙을 검증하는 편이 중요하다.
한 번 성공한 테스트만으로 경쟁 상태가 없다고 단정할 수는 없다. 반복 실행, 실제 데이터베이스 사용과 의도적으로 겹친 요청을 통해 충돌 경로를 확인해야 한다.
버튼 비활성화는 사용자의 반복 클릭을 줄이지만 네트워크 재시도, 여러 기기와 직접 API 요청을 막지 못한다.
서버와 데이터베이스에서도 중복을 식별해야 한다.
조회 이후 실제 수정 전까지 다른 요청이 재고를 변경할 수 있다.
조건 확인과 변경 사이의 경쟁 가능성을 검토해야 한다.
트랜잭션은 함께 성공하거나 실패할 경계를 제공하지만 적절한 조건, 잠금이나 격리 없이 모든 동시 실행을 순서대로 만들지는 않는다.
원자성과 격리를 구분해야 한다.
불필요하게 넓거나 긴 잠금은 요청 대기와 교착 상태를 증가시킨다.
충돌 가능성과 비즈니스 중요도에 맞는 방식을 선택해야 한다.
일부 충돌은 최신 데이터를 다시 읽고 전체 트랜잭션을 재시도하면 해결할 수 있다.
다만 재시도가 중복 주문이나 중복 결제를 만들지 않도록 멱등성이 먼저 보장되어야 한다.
캐시는 빠르지만 최신 재고를 항상 보장하지 않는다.
중요한 변경의 최종 결정은 Source of Truth와 데이터베이스 규칙을 기준으로 내려야 한다.
최종 재고가 음수가 아니더라도 승인된 주문의 합계가 실제 재고를 넘을 수 있다.
개별 컬럼뿐 아니라 비즈니스 불변 조건을 확인해야 한다.
프로그래밍을 처음 배울 때는 동시성을 여러 작업이 같은 시간에 실행되는 것으로 이해해도 충분하다.
await Promise.all([
createOrder(firstCustomer),
createOrder(secondCustomer),
]);
그러나 실제 서비스에서는 빠르게 함께 실행하는 것보다 그 결과가 올바른지가 중요하다.
동시성은 여러 작업을 동시에 실행하는 것이 아니다.
동시성은 여러 작업이 같은 상태를 겹쳐 읽고 변경하더라도 서비스가 지켜야 할 규칙과 데이터의 일관성을 유지하는 설계다.