PostgreSQL·MongoDB 인게임 플레이어 인벤토리 DB 트랜잭션 최적화 가이드
멀티플레이 게임에서 중복 지급, 아이템 소실, 락 경합을 막는 PostgreSQL·MongoDB 인벤토리 트랜잭션 설계를 정리합니다. 행 잠금, 낙관적 동시성, 멱등성 키, 재시도와 관측 지표까지 실전 기준으로 설명합니다.

인게임 인벤토리 트랜잭션의 핵심은 아이템 변경과 재화 변경을 하나의 원자적 작업으로 묶고, 같은 요청이 다시 와도 결과가 한 번만 반영되게 만드는 것이다. PostgreSQL은 짧은 트랜잭션과 행 잠금 또는 버전 검사를 MongoDB는 단일 문서 원자 갱신과 조건부 업데이트를 우선 적용한다. 두 DB 모두 멱등성 키, 제한된 재시도, 정합성 검증이 없으면 중복 지급과 아이템 소실을 완전히 막을 수 없다.
인게임 인벤토리 트랜잭션은 왜 깨지는가?
대표적인 실패는 두 요청이 같은 플레이어 상태를 읽고 각각 변경한 뒤 늦게 저장한 값이 앞선 변경을 덮어쓰는 경우다. 예를 들어 강화 재료가 10개일 때 두 요청이 동시에 재료 8개를 사용하면 검증과 차감이 분리된 구현에서는 재료가 음수가 되거나 강화가 두 번 실행될 수 있다.
인벤토리 처리에서 지켜야 할 불변 조건은 다음과 같다.
| 조건 | 예시 |
|---|---|
| 수량 범위 | 아이템 수량과 재화 잔액은 0 미만이 될 수 없다. |
| 용량 제한 | 보유 슬롯 수와 스택 수량은 최대치를 넘지 않는다. |
| 원자성 | 아이템 지급, 재화 차감, 거래 기록 생성은 함께 성공하거나 함께 실패한다. |
| 멱등성 | 같은 결제 콜백이나 RPC 재전송은 한 번만 반영한다. |
| 순서성 | 동일 플레이어의 상태 변경은 서버가 정의한 순서로 적용한다. |
특히 네트워크 재전송은 예외가 아니라 정상적인 상황으로 취급해야 한다. 타임아웃이 발생했다고 해서 서버 작업이 실패한 것은 아니다. 클라이언트는 같은 요청을 다시 보낼 수 있으므로 서버는 request_id를 기준으로 이미 완료된 작업을 식별해야 한다.

PostgreSQL 인벤토리 트랜잭션은 어떻게 구현할까?
PostgreSQL 16 기준으로 플레이어별 인벤토리 행을 분리해 저장한다면 SELECT ... FOR UPDATE로 대상 행을 잠근 뒤 검증과 변경을 같은 트랜잭션에서 수행하는 방식이 가장 직관적이다. 단, 외부 API 호출이나 긴 계산을 트랜잭션 안에 넣으면 잠금 시간이 늘어나므로 금지한다.
Step 1. 멱등성 키를 먼저 확보한다
다음 테이블은 같은 요청의 중복 실행을 차단한다. player_id, request_id 조합의 유니크 제약이 최종 방어선이다.
CREATE TABLE inventory_commands (
player_id BIGINT NOT NULL,
request_id UUID NOT NULL,
status TEXT NOT NULL CHECK (status IN ('processing', 'completed', 'failed')),
result JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (player_id, request_id)
);
이미 completed인 명령이면 저장된 결과를 그대로 반환한다. 중복 요청을 오류로만 끝내면 클라이언트가 성공 여부를 알 수 없으므로 완료 결과를 재사용하는 편이 안전하다.
Step 2. 조건 검증과 수량 차감을 하나의 SQL로 실행한다
수량 차감은 읽기-검증-쓰기의 세 단계로 나누지 말고 조건부 UPDATE 한 번으로 처리할 수 있다.
UPDATE player_items
SET quantity = quantity - $3,
updated_at = now()
WHERE player_id = $1
AND item_id = $2
AND quantity >= $3
RETURNING quantity;
반환 행이 없으면 재료가 부족하거나 대상 아이템이 없는 것이다. 이 패턴은 필요한 행만 잠그고 애플리케이션이 읽은 오래된 수량을 다시 저장하는 실수를 막는다.
여러 아이템과 재화를 함께 바꿔야 한다면 잠금 순서를 고정한다. 예를 들어 항상 player_id 오름차순, 그다음 item_id 오름차순으로 잠그면 플레이어 간 거래에서 데드락 가능성을 낮출 수 있다.
BEGIN;
SELECT item_id, quantity
FROM player_items
WHERE player_id = $1 AND item_id = ANY($2)
ORDER BY item_id
FOR UPDATE;
UPDATE player_wallet
SET gold = gold - $3
WHERE player_id = $1 AND gold >= $3;
-- 영향 받은 행 수를 확인한 뒤 아이템 지급 및 거래 로그를 기록한다.
COMMIT;
Step 3. 데드락과 직렬화 실패만 제한적으로 재시도한다
READ COMMITTED는 조건부 UPDATE와 행 잠금이 중심인 일반 인벤토리에 보통 충분하다. 반면 여러 행의 집합 규칙을 강하게 보장해야 한다면 SERIALIZABLE을 고려할 수 있지만 40001 직렬화 실패 재시도가 필요하다.
재시도 대상은 PostgreSQL의 40001과 40P01처럼 일시적 충돌로 제한한다. 2~3회 이내의 지수 백오프와 지터를 적용하고 유니크 제약 위반이나 수량 부족은 즉시 사용자 오류로 반환한다.
MongoDB 인벤토리 트랜잭션은 언제 더 빠른가?
MongoDB 7.0에서 인벤토리 전체를 플레이어당 하나의 문서에 넣을 수 있다면 단일 문서 갱신은 원자적이다. 이 경우 다중 문서 트랜잭션보다 findOneAndUpdate() 또는 updateOne()의 조건부 갱신이 대체로 비용이 낮다.
const result = await db.collection('players').updateOne(
{
_id: playerId,
items: {
$elemMatch: {
itemId: materialItemId,
quantity: { $gte: consumeCount }
}
},
gold: { $gte: goldCost },
appliedRequestIds: { $ne: requestId }
},
{
$inc: {
'items.$.quantity': -consumeCount,
gold: -goldCost,
version: 1
},
$push: {
appliedRequestIds: {
$each: [requestId],
$slice: -100
}
}
}
);
if (result.modifiedCount !== 1) {
throw new Error('재료가 부족하거나 중복 요청 또는 상태 변경을 확인해야 합니다.');
}
다만 appliedRequestIds를 무한히 쌓으면 문서가 커지고 최대 BSON 문서 크기인 16 MiB에 가까워질 수 있다. 최근 요청 ID만 문서에 보관하고 장기 감사 기록과 결제 멱등성 키는 별도 컬렉션에 유니크 인덱스로 보관하는 구성이 낫다.
MongoDB 다중 문서 트랜잭션은 언제 필요한가?
플레이어 문서 하나에서 아이템과 재화가 모두 바뀌면 단일 문서 원자 갱신을 사용한다. 반대로 구매 기록, 길드 창고, 다른 플레이어 인벤토리처럼 여러 문서나 컬렉션을 함께 갱신해야 하면 세션 트랜잭션이 필요하다.
await session.withTransaction(async () => {
const buyerUpdate = await db.collection('players').updateOne(
{ _id: buyerId, gold: { $gte: price } },
{ $inc: { gold: -price, version: 1 } },
{ session }
);
if (buyerUpdate.modifiedCount !== 1) {
throw new Error('구매자의 재화가 부족하거나 상태가 변경되었습니다.');
}
const orderUpdate = await db.collection('marketOrders').updateOne(
{ _id: orderId, status: 'open' },
{ $set: { status: 'sold', buyerId, soldAt: new Date() } },
{ session }
);
if (orderUpdate.modifiedCount !== 1) {
throw new Error('주문이 이미 처리되었거나 판매할 수 없습니다.');
}
}, {
readConcern: { level: 'snapshot' },
writeConcern: { w: 'majority' },
readPreference: 'primary'
});
트랜잭션 내부의 각 갱신 결과를 반드시 검사해야 한다. 예를 들어 구매자 골드 차감이 0건이거나 주문 상태 전환이 0건이면 예외를 던져 전체 작업을 중단해야 한다.
PostgreSQL과 MongoDB 중 인벤토리에 무엇을 선택해야 할까?
| 기준 | PostgreSQL | MongoDB |
|---|---|---|
| 관계형 거래와 감사 로그 | 강점. 외래 키, 제약 조건, SQL 분석에 유리하다. | 별도 모델링과 인덱스 설계가 필요하다. |
| 플레이어 상태 단일 문서 | 행을 여러 테이블로 나누면 조인이 필요할 수 있다. | 단일 문서 원자 갱신이 간결하고 빠르다. |
| 플레이어 간 거래 | 행 잠금과 트랜잭션 제어가 명확하다. | 다중 문서 트랜잭션 비용을 고려해야 한다. |
| 스키마 변경이 잦은 아이템 메타데이터 | JSONB를 혼합해 유연성을 확보할 수 있다. | 문서 모델이 자연스럽지만 문서 비대화를 관리해야 한다. |
선택의 기준은 DB 종류 자체보다 데이터 경계다. 한 플레이어의 모든 가변 인벤토리 상태가 하나의 문서에 안정적으로 들어가면 MongoDB의 단일 문서 갱신이 유리하다. 거래, 우편, 길드 창고, 결제 감사처럼 관계와 교차 검증이 많다면 PostgreSQL이 운영 난이도를 낮추는 경우가 많다.
인벤토리 DB 락 경합은 어떻게 줄일까?
flowchart TD
A[클라이언트 요청: request_id] --> B{멱등성 기록 존재?}
B -->|완료됨| C[저장된 결과 반환]
B -->|없음| D[짧은 DB 트랜잭션 시작]
D --> E[조건부 차감·지급]
E -->|검증 실패| F[롤백 및 오류 반환]
E -->|성공| G[명령 결과·감사 로그 저장]
G --> H[커밋 후 결과 반환]
락 경합을 줄이는 방법은 복잡한 캐시보다 쓰기 범위를 좁히는 데서 시작한다.
- 플레이어별 명령을 서버 메모리 또는 메시지 큐 파티션에서 순차화한다. 단, DB의 멱등성과 조건 검증을 대체해서는 안 된다.
- 트랜잭션 시작 전 아이템 정의와 가격표를 읽고 검증한다. DB 잠금을 잡은 뒤에는 필요한 변경만 실행한다.
- 인벤토리 전체 JSON을 매번 덮어쓰지 않는다. 수량 차감처럼 작은 변경은 SQL
UPDATE또는 MongoDB$inc로 반영한다. - 거래 로그는 인벤토리 핵심 갱신과 같은 트랜잭션에 기록하되 분석용 집계는 CDC나 비동기 소비자로 분리한다.
- 플레이어별 직렬 처리 큐가 장애로 중단될 수 있으므로 최종 정합성은 DB 제약 조건과 멱등성 키로 보장한다.
관측도 필요하다. transaction_duration_ms, 데드락 또는 재시도 횟수, 조건부 업데이트 0건 비율, 중복 request_id 비율, 인벤토리 음수 검증 실패 수를 대시보드에 둔다. 특정 이벤트에서 재시도와 0건 갱신이 동시에 급증하면 부하보다 클라이언트 중복 전송이나 서버 명령 순서 문제일 가능성도 점검해야 한다.
자주 묻는 질문 (FAQ)
Redis 락만으로 인벤토리 중복 지급을 막을 수 있나?
아니다. Redis 락 만료, 네트워크 분할, 프로세스 장애가 있으므로 최종 방어는 DB의 조건부 갱신, 유니크 제약, 멱등성 기록이어야 한다. Redis는 경합을 줄이는 보조 수단으로 사용한다.
PostgreSQL에서 SERIALIZABLE 격리가 항상 더 안전한가?
정합성 규칙에는 도움이 되지만 직렬화 실패와 재시도 비용이 늘어난다. 단일 플레이어의 재화 차감처럼 조건부 UPDATE로 표현 가능한 작업은 READ COMMITTED와 명확한 행 잠금으로 충분한 경우가 많다.
MongoDB 배열에 아이템을 모두 넣어도 되나?
아이템 종류와 배열 크기가 제한적이고 플레이어 단위 갱신이 주된 경우에는 적합하다. 아이템 수가 매우 많거나 개별 아이템을 자주 조회·정렬·거래한다면 아이템을 별도 컬렉션으로 분리하고 필요한 다중 문서 트랜잭션 비용을 측정해야 한다.
정리
인벤토리 최적화의 우선순위는 DB 교체가 아니다. 먼저 요청 ID 기반 멱등성을 만들고 잔액·수량 검증과 변경을 하나의 조건부 쓰기 또는 짧은 트랜잭션으로 묶는다. 이후 실제 락 대기 시간과 재시도 비율을 측정해 PostgreSQL의 잠금 범위, MongoDB의 문서 경계, 서버 명령 직렬화 범위를 조정하면 안전성과 처리량을 함께 확보할 수 있다.



