인벤토리 스택 분할·병합 동시 요청에서 아이템 복제를 막는 낙관적 동시성 제어
멀티플레이 게임 서버에서 인벤토리 스택 분할·병합 요청이 동시에 처리될 때 수량이 복제되는 원인을 분석하고 PostgreSQL의 version CAS, 원자적 트랜잭션, 멱등 키로 재시도까지 안전하게 처리하는 구현법과 테스트 기준을 정리합니다.

핵심 요약
인벤토리 스택 분할·병합에서 수량 복제가 발생하는 핵심 원인은 여러 요청이 같은 수량을 읽은 뒤 검증 없이 덮어쓰기 때문입니다. PostgreSQL의 version 컬럼을 이용한 Compare-and-Swap 조건부 갱신과 하나의 트랜잭션으로 수량 변경을 원자화하고 operation_id 또는 Idempotency-Key를 함께 사용하면 동시 요청과 네트워크 재시도에도 아이템 수량을 복제하지 않을 수 있습니다.
인벤토리 수량은 왜 복제되는가?
가장 흔한 구현은 다음과 같은 읽기-수정-쓰기 흐름입니다.
- 서버가 스택 수량을 조회한다.
- 메모리에서 분할 또는 병합 결과를 계산한다.
- 계산한 전체 수량을 데이터베이스에 저장한다.
예를 들어 수량이 10인 스택에 대해 클라이언트가 분할 요청과 병합 요청을 거의 동시에 보냈다고 가정해 보겠습니다. 두 요청이 모두 수량 10을 읽고 각자 결과를 계산하면 다음과 같은 문제가 생깁니다.
- 분할 요청은 기존 스택을 6으로 만들고 새 스택에 4를 생성한다.
- 병합 요청은 오래된 스냅샷을 기준으로 대상 스택을 갱신한다.
- 두 요청 중 하나가 다른 요청의 변경을 덮어쓰거나 새 스택 생성만 남긴다.
수량 변경을 안전하게 처리하려면 모든 작업이 다음 불변식을 지켜야 합니다.
| 불변식 | 의미 |
|---|---|
| 수량 보존 | 분할·병합 전후의 전체 아이템 수량이 같아야 한다. |
| 버전 일치 | 읽은 시점의 version과 현재 행의 version이 같을 때만 갱신한다. |
| 원자성 | 수량 차감과 새 스택 생성 또는 대상 스택 증가와 원본 삭제가 함께 성공해야 한다. |
| 멱등성 | 같은 논리 작업의 재시도는 효과를 한 번만 적용해야 한다. |
| 소유권 검증 | 클라이언트가 전달한 스택이 인증된 플레이어의 것인지 서버가 확인해야 한다. |
분할의 수량 관계는 다음과 같습니다.
병합은 원본 스택을 제거하면서 대상 스택에 수량을 더합니다.
낙관적 동시성 제어는 어떻게 동작하는가?
낙관적 동시성 제어는 요청마다 잠금을 오래 유지한다고 가정하지 않습니다. 대신 읽을 때의 버전 번호를 기억하고 쓰기 시점에 해당 버전이 아직 유효한지 검사합니다.
UPDATE inventory_stack
SET quantity = quantity - :split_qty,
version = version + 1
WHERE stack_id = :source_id
AND owner_id = :owner_id
AND version = :expected_version
AND quantity > :split_qty
RETURNING stack_id, owner_id, item_def_id, max_stack, quantity, version;
두 요청이 같은 expected_version을 사용하면 먼저 성공한 요청이 version을 증가시킵니다. 나중 요청의 UPDATE는 조건을 만족하지 못하므로 영향을 받은 행 수가 0이 됩니다. 서버는 이를 HTTP 409 Conflict로 변환하고 클라이언트가 최신 인벤토리를 다시 조회하도록 합니다.
이 방식은 단순히 version을 증가시키는 것만으로는 충분하지 않습니다. 반드시 UPDATE의 WHERE 절에 이전 버전을 포함해야 합니다.
| 잘못된 방식 | 안전한 방식 |
|---|---|
UPDATE ... SET quantity = 6 | UPDATE ... SET quantity = quantity - 4 WHERE version = 12 |
| 조회 후 애플리케이션에서 전체 행 덮어쓰기 | 데이터베이스에서 조건부 갱신 |
| 실패한 요청을 무조건 재실행 | 충돌과 재시도를 구분 |
| 클라이언트가 보낸 현재 수량 신뢰 | 서버 조회값과 DB 조건 사용 |
PostgreSQL 스키마는 어떻게 설계할까?
다음은 PostgreSQL 15 이상을 기준으로 한 최소 스키마입니다. quantity와 version은 같은 행의 상태로 관리하며 데이터베이스 제약 조건으로 음수 수량과 최대 스택 초과를 차단합니다.
CREATE TABLE inventory_stack (
stack_id uuid PRIMARY KEY,
owner_id uuid NOT NULL,
item_def_id bigint NOT NULL,
quantity integer NOT NULL,
max_stack integer NOT NULL,
version bigint NOT NULL DEFAULT 0,
CHECK (quantity > 0),
CHECK (max_stack > 0),
CHECK (quantity <= max_stack)
);
CREATE TABLE inventory_operation (
operation_id uuid PRIMARY KEY,
owner_id uuid NOT NULL,
operation_type text NOT NULL,
request_hash text NOT NULL,
result jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
inventory_operation은 같은 작업의 재시도를 판별하는 멱등성 저장소입니다. operation_id가 같더라도 owner_id, operation_type, request_hash가 다르면 동일 작업으로 처리해서는 안 됩니다.
인벤토리 스택 분할을 어떻게 원자적으로 구현할까?
분할은 원본 스택의 수량을 차감하고 새 스택을 생성하는 두 가지 변경으로 구성됩니다. 두 작업 사이에 커밋이 발생하면 안 되므로 PostgreSQL 트랜잭션 하나로 처리합니다.
1단계: 멱등 작업을 등록한다
클라이언트 또는 서버가 생성한 operation_id를 먼저 등록합니다.
BEGIN;
INSERT INTO inventory_operation
(operation_id, owner_id, operation_type, request_hash)
VALUES
(:operation_id, :owner_id, 'split', :request_hash)
ON CONFLICT (operation_id) DO NOTHING;
삽입 결과가 0행이면 이미 처리 중이거나 완료된 작업입니다. 이때 기존 작업의 요청 해시가 같은지 확인하고 완료된 결과가 있으면 그 결과를 그대로 반환합니다. 해시가 다르면 같은 키를 다른 요청에 재사용한 것이므로 409 Conflict로 거부합니다.
2단계: 원본 수량을 버전 조건과 함께 차감한다
WITH debited AS (
UPDATE inventory_stack
SET quantity = quantity - :split_qty,
version = version + 1
WHERE stack_id = :source_id
AND owner_id = :owner_id
AND version = :expected_source_version
AND quantity > :split_qty
RETURNING owner_id, item_def_id, max_stack
)
INSERT INTO inventory_stack
(stack_id, owner_id, item_def_id, quantity, max_stack, version)
SELECT
:new_stack_id, owner_id, item_def_id, :split_qty, max_stack, 0
FROM debited
RETURNING stack_id, owner_id, item_def_id, quantity, version;
RETURNING 결과가 1행이면 분할이 성공한 것입니다. 결과가 없으면 다음 중 하나입니다.
- 원본 스택이 삭제되었다.
- 소유자가 다르다.
expected_source_version이 오래되었다.- 분할 수량이 0 이하이거나 원본 수량 이상이다.
이 경우 새 스택을 별도로 생성해서는 안 됩니다. 트랜잭션을 롤백하고 충돌 또는 입력 오류를 반환해야 합니다. 원본 차감과 새 스택 생성 중 하나만 성공한 상태는 허용하지 않습니다.
인벤토리 스택 병합은 어떻게 두 행을 안전하게 갱신할까?
병합은 분할보다 어렵습니다. 대상 스택을 증가시키는 동시에 원본 스택을 삭제해야 하므로 두 행을 하나의 쓰기 집합으로 다뤄야 합니다.
권장 흐름은 다음과 같습니다.
- 인증된 플레이어의 스택인지 확인한다.
- 원본과 대상이 같은 아이템 정의인지 확인한다.
- 원본과 대상의
version,quantity를 읽는다. - 두 스택 ID를 정렬한 순서로 짧게 잠근다.
- 대상 증가와 원본 삭제를 같은 트랜잭션에서 수행한다.
- 두 변경 결과가 모두 1행인지 확인한 뒤 커밋한다.
두 행을 잠그는 부분은 낙관적 버전 검증에 교착 방지 기능을 더한 하이브리드 방식입니다. PostgreSQL의 행 잠금은 트랜잭션 동안만 유지하고 충돌의 의미는 여전히 version 조건으로 판정합니다.
BEGIN;
SELECT stack_id, owner_id, item_def_id, quantity, max_stack, version
FROM inventory_stack
WHERE owner_id = :owner_id
AND stack_id IN (:source_id, :target_id)
ORDER BY stack_id
FOR UPDATE;
이후 애플리케이션에서 아이템 종류와 최대 스택을 검증하고 다음 SQL을 실행합니다.
UPDATE inventory_stack
SET quantity = quantity + :source_quantity,
version = version + 1
WHERE stack_id = :target_id
AND owner_id = :owner_id
AND version = :expected_target_version
AND quantity = :expected_target_quantity
AND quantity + :source_quantity <= max_stack;
DELETE FROM inventory_stack
WHERE stack_id = :source_id
AND owner_id = :owner_id
AND version = :expected_source_version
AND quantity = :source_quantity;
두 명령의 영향받은 행 수가 각각 1인지 확인합니다. 하나라도 0이면 ROLLBACK해야 합니다. 대상 갱신만 성공한 뒤 원본 삭제가 실패한 상태로 커밋하면 수량 복제가 발생합니다.
행 잠금을 사용하지 않는 순수 낙관적 구현을 선택한다면 트랜잭션 격리 수준을 SERIALIZABLE로 설정하고 PostgreSQL의 serialization failure인 SQLSTATE 40001을 전체 트랜잭션 단위로 재시도해야 합니다. 재시도 횟수에는 상한을 두고 지수 백오프를 적용합니다.
네트워크 재시도에서 같은 작업을 한 번만 적용하려면?
데이터베이스 트랜잭션이 성공했지만 응답이 네트워크에서 유실될 수 있습니다. 클라이언트가 새 operation_id로 재시도하면 서버는 이를 새로운 분할로 오인할 수 있습니다.
API는 다음처럼 멱등 키를 포함하는 편이 안전합니다.
POST /v1/inventory/operations/split
Idempotency-Key: 8c7b2d8e-3d9b-4cb7-9a34-f02ed7f6dfb
{
"sourceStackId": "source-uuid",
"splitQuantity": 4,
"expectedSourceVersion": 12
}
서버는 요청 본문을 정규화한 뒤 해시를 계산하고 inventory_operation.request_hash에 저장합니다. 같은 멱등 키로 다시 요청되면 다음 규칙을 적용합니다.
| 상태 | 서버 동작 |
|---|---|
| 같은 키와 같은 요청 해시, 결과 있음 | 저장된 성공 응답을 반환 |
| 같은 키와 같은 요청 해시, 처리 중 | 중복 실행하지 않고 처리 상태 반환 |
| 같은 키와 다른 요청 해시 | 키 재사용으로 판단하고 409 반환 |
| 새로운 키 | 정상적인 동시성 검증 후 실행 |
멱등성은 동시성 제어를 대체하지 않습니다. 두 개의 서로 다른 작업이 같은 스택을 수정하는 경우에는 여전히 version 조건이 필요합니다.
서버 보안 검증에서 놓치기 쉬운 부분은 무엇인가?
수량 복제는 데이터베이스 경합뿐 아니라 권한 검증 누락으로도 발생할 수 있습니다.
owner_id는 요청 본문이 아니라 인증 토큰과 서버 세션에서 가져온다.item_def_id, 현재 수량, 최대 스택 크기는 클라이언트 값을 신뢰하지 않는다.- 분할 수량은 정수 범위와
0 < splitQuantity < source.quantity조건을 검증한다. - 병합 시 원본과 대상의 아이템 정의, 소유자, 컨테이너를 모두 확인한다.
- 클라이언트가 보낸
version은 권한 증명이 아니라 충돌 감지용 전제 조건으로만 사용한다. - 모든 성공·실패 작업에
operation_id, 플레이어 ID, 스택 ID, 결과를 감사 로그로 남긴다. - Redis 분산 잠금만으로 수량 정합성을 보장하지 말고 최종 판정은 인벤토리의 영속 저장소에서 수행한다.
동시성 테스트는 어떻게 작성할까?
단위 테스트만으로는 경합을 재현하기 어렵습니다. 실제 PostgreSQL 테스트 데이터베이스에서 여러 트랜잭션을 동시에 시작하는 통합 테스트가 필요합니다.
| 테스트 시나리오 | 기대 결과 |
|---|---|
| 같은 원본에 분할 요청 2개 | 한 건만 성공하고 다른 한 건은 409 또는 직렬화 재시도 후 충돌 |
| 같은 멱등 키로 분할 재요청 | 새 스택이 추가로 생성되지 않고 같은 결과 반환 |
| 분할과 병합이 같은 스택을 동시에 수정 | 한 트랜잭션만 해당 버전을 통과하고 전체 수량 보존 |
| 병합 대상이 동시에 다른 스택과 병합 | 한 건만 대상 버전을 갱신하고 다른 건은 재조회 필요 |
| 대상 최대 수량 초과 | 트랜잭션 전체 롤백 |
| 다른 플레이어의 스택 ID 사용 | 권한 오류 또는 존재하지 않는 리소스로 응답 |
각 테스트가 끝난 뒤에는 애플리케이션 응답뿐 아니라 데이터베이스의 실제 상태를 검사해야 합니다. 특히 다음 검증이 중요합니다.
또한 같은 operation_id에 연결된 생성 스택이 최대 하나인지 모든 스택의 quantity가 양수인지 version이 감소하지 않았는지도 확인합니다.
자주 묻는 질문 (FAQ)
version 컬럼만 추가하면 아이템 복제를 막을 수 있나요?
아닙니다. UPDATE ... WHERE version = :expected_version 같은 조건부 갱신과 원자적 트랜잭션이 함께 필요합니다. 네트워크 재시도로 같은 작업이 다시 들어오는 문제는 별도의 멱등 키로 처리해야 합니다.
분할·병합 요청이 충돌하면 서버가 자동으로 재시도해야 하나요?
같은 operation_id의 네트워크 재시도는 자동으로 안전하게 처리할 수 있습니다. 그러나 오래된 version을 가진 서로 다른 작업을 무조건 재실행하면 플레이어가 의도하지 않은 결과가 나올 수 있으므로 409를 반환하고 최신 인벤토리를 다시 조회하게 하는 편이 안전합니다.
Redis 락과 데이터베이스 버전 검사를 함께 써야 하나요?
Redis 락은 트래픽을 줄이고 충돌 빈도를 낮추는 보조 수단입니다. 락 만료, 네트워크 분리, 장애 복구를 고려하면 아이템 수량의 최종 정합성은 데이터베이스 트랜잭션과 버전 조건이 보장해야 합니다.
정리
인벤토리 스택 분할·병합의 핵심은 수량을 계산하는 코드가 아니라 상태 변경의 경계를 정확히 정의하는 데 있습니다. PostgreSQL의 version 기반 CAS로 오래된 읽기의 덮어쓰기를 거부하고 분할·병합 전체를 하나의 트랜잭션으로 묶으며 operation_id와 요청 해시로 재시도를 멱등 처리하면 동시 요청에서도 수량 복제를 막을 수 있습니다.
멀티플레이 게임 서버에서는 여기에 소유권 검증, HTTP 409 Conflict 정책, SERIALIZABLE 재시도 또는 결정적 행 잠금, 동시성 통합 테스트까지 포함해야 운영 환경에서 재현하기 어려운 아이템 복제 버그를 안정적으로 차단할 수 있습니다.



