MongoDB 기반 대용량 플레이어 인벤토리 JSON 스키마 최적화 가이드
MongoDB 게임 서버에서 플레이어 인벤토리가 커질 때 발생하는 16MB 문서 제한, 배열 갱신 경합, 느린 조회를 줄이는 JSON 스키마 설계를 다룹니다. 임베드·분리 기준, 인덱스와 트랜잭션 적용법을 예제로 설명합니다.

핵심 요약
MongoDB에서 대용량 플레이어 인벤토리를 플레이어 문서 하나에 배열로 계속 누적하면 16MB BSON 문서 제한, 문서 재배치 비용, 동시 장비 변경 시 갱신 경합이 발생한다. 기본 프로필과 자주 함께 읽는 장착 슬롯은 임베드하고 반복·성장하는 아이템 스택은 playerId 기준의 별도 컬렉션으로 분리한 뒤 복합 인덱스와 원자적 업데이트를 적용하는 방식이 안정적이다.
대용량 플레이어 인벤토리에서 병목은 왜 발생하는가?
초기에는 아래처럼 플레이어 문서에 인벤토리 배열을 넣는 구조가 가장 단순하다.
{
"_id": "player_8f2c",
"level": 42,
"gold": 183000,
"inventory": [
{ "itemId": 1001, "quantity": 99, "slot": 0 },
{ "itemId": 1002, "quantity": 1, "slot": 1 }
]
}
하지만 이벤트 보상, 제작 재료, 거래소 보관함, 우편 첨부물처럼 아이템 수가 계속 증가하면 문제가 커진다.
| 문제 | 원인 | 서버 영향 |
|---|---|---|
| 16MB 문서 제한 | MongoDB의 BSON 문서 최대 크기 | 한 플레이어의 저장이 실패할 수 있음 |
| 배열 갱신 비용 | 배열 요소 탐색과 문서 크기 변화 | 쓰기 지연과 스토리지 단편화 증가 |
| 핫 문서 경합 | 여러 요청이 같은 플레이어 문서를 갱신 | 거래, 장착, 보상 처리의 재시도 증가 |
| 불필요한 전송 | 로비 진입마다 전체 인벤토리 반환 | 네트워크 대역폭과 역직렬화 비용 증가 |
특히 단일 문서 갱신은 원자적이지만 이것이 높은 동시성에서 항상 빠르다는 뜻은 아니다. 같은 playerId 문서를 반복해서 쓰면 해당 문서가 핫스팟이 된다.
MongoDB 인벤토리 JSON 스키마는 어떻게 분리할까?
1. 플레이어 프로필에는 작고 자주 읽는 상태만 둔다
장착 슬롯, 재화, 인벤토리 버전처럼 로비와 전투 시작에서 자주 필요한 데이터는 players 컬렉션에 둔다.
{
"_id": "player_8f2c",
"accountId": "account_31aa",
"level": 42,
"currencies": {
"gold": 183000,
"gem": 250
},
"equipment": {
"weapon": { "instanceId": "itm_a91", "templateId": 3001 },
"helmet": null
},
"inventoryVersion": 184,
"updatedAt": { "$date": "2026-08-09T12:00:00.000Z" }
}
equipment는 슬롯 수가 제한되어 있고 플레이어 프로필과 함께 읽히므로 임베드에 적합하다. 반대로 획득량이 계속 늘어나는 모든 소유 아이템을 이 문서에 넣지 않는 것이 핵심이다.
2. 스택형 아이템은 플레이어별 청크 문서로 분리한다
소모품과 재료는 여러 슬롯을 한 문서에 묶되 문서 크기와 갱신 범위를 통제할 수 있도록 chunk을 둔다.
{
"_id": "player_8f2c:0",
"playerId": "player_8f2c",
"chunk": 0,
"items": [
{ "templateId": 1001, "quantity": 99, "slot": 0 },
{ "templateId": 1002, "quantity": 17, "slot": 1 }
],
"version": 184,
"updatedAt": { "$date": "2026-08-09T12:00:00.000Z" }
}
청크 크기는 아이템 하나의 평균 BSON 크기, 예상 최대 슬롯 수, 응답 페이징 단위를 기준으로 정한다. 예를 들어 한 청크를 100~300 슬롯으로 제한하면 전체 인벤토리를 읽지 않고 필요한 페이지나 탭만 조회하기 쉽다.
3. 개별 상태가 중요한 아이템은 인스턴스 문서로 저장한다
강화 수치, 랜덤 옵션, 내구도, 잠금 상태처럼 아이템마다 데이터가 달라지는 장비는 별도 itemInstances 컬렉션이 적합하다.
{
"_id": "itm_a91",
"ownerPlayerId": "player_8f2c",
"templateId": 3001,
"location": { "type": "inventory", "slot": 12 },
"upgradeLevel": 8,
"durability": 73,
"affixes": [
{ "stat": "criticalRate", "value": 0.034 },
{ "stat": "attack", "value": 18 }
],
"locked": false,
"updatedAt": { "$date": "2026-08-09T12:00:00.000Z" }
}
| 데이터 종류 | 권장 저장 위치 | 이유 |
|---|---|---|
| 골드, 젬, 장착 슬롯 | players 문서 | 작고 자주 함께 읽음 |
| 재료, 소모품 스택 | inventoryChunks 문서 | 증가량과 페이징을 관리하기 쉬움 |
| 강화 장비, 랜덤 옵션 장비 | itemInstances 문서 | 개별 갱신과 거래 추적에 유리 |
| 우편 보상, 거래 대기 아이템 | 별도 도메인 컬렉션 | 인벤토리와 다른 수명 주기 보유 |
인벤토리 조회와 갱신을 최적화하는 3단계
1. 조회 패턴부터 고정한다
스키마는 ‘모든 데이터를 한 번에 읽을 수 있는가’가 아니라 ‘가장 흔한 요청이 적은 문서와 인덱스로 끝나는가’로 판단한다.
- 로비 진입:
players와 현재 탭의inventoryChunks만 조회한다. - 아이템 상세 보기:
itemInstances를_id로 조회한다. - 장착 변경: 프로필의
equipment와 대상 인스턴스만 갱신한다. - 거래: 소유자와 위치를 조건에 포함해 아이템 인스턴스를 이동한다.
2. 복합 인덱스를 요청 단위로 만든다
db.inventoryChunks.createIndex({ playerId: 1, chunk: 1 }, { unique: true })
db.itemInstances.createIndex({ ownerPlayerId: 1, "location.type": 1, "location.slot": 1 })
db.itemInstances.createIndex({ ownerPlayerId: 1, templateId: 1 })
inventoryChunks의 (playerId, chunk) 유니크 인덱스는 청크 중복 생성을 막고 플레이어의 특정 탭을 빠르게 찾는다. 인덱스는 쓰기 비용도 늘리므로 실제 쿼리에 없는 필드는 습관적으로 추가하지 않는다. 배포 전에는 explain("executionStats")로 COLLSCAN 여부와 totalDocsExamined를 확인한다.
3. 수량 변경에는 조건부 원자 업데이트를 사용한다
재료를 소비할 때는 먼저 읽고 애플리케이션에서 계산한 뒤 덮어쓰기보다 수량 조건을 포함한 $inc가 안전하다.
const result = await db.inventoryChunks.updateOne(
{
playerId: "player_8f2c",
chunk: 0,
items: {
$elemMatch: {
templateId: 1001,
quantity: { $gte: 3 }
}
}
},
{
$inc: { "items.$.quantity": -3, version: 1 },
$set: { updatedAt: new Date() }
}
)
if (result.modifiedCount !== 1) {
throw new Error("재료가 부족하거나 인벤토리 상태가 변경되었습니다.")
}
이 패턴은 같은 청크 안에서 수량이 음수가 되는 경쟁 상태를 막는다. 단, $ 위치 연산자는 조건에 맞는 첫 배열 요소만 갱신한다. 동일한 templateId가 여러 스택으로 존재할 수 있다면 슬롯을 함께 조건에 넣거나 스택 병합 규칙을 서버에서 명확히 정해야 한다.
MongoDB 트랜잭션은 언제 사용해야 할까?
같은 문서의 재화 차감이나 단일 아이템의 상태 변경에는 단일 문서 원자 업데이트를 우선 사용한다. 반면 거래처럼 두 플레이어의 문서와 아이템 소유권이 함께 바뀌는 작업은 MongoDB 다중 문서 트랜잭션이 필요할 수 있다.
await client.withSession(async (session) => {
await session.withTransaction(async () => {
const moved = await db.itemInstances.updateOne(
{ _id: "itm_a91", ownerPlayerId: "player_8f2c", locked: false },
{
$set: {
ownerPlayerId: "player_b722",
"location.type": "inventory",
"location.slot": 4,
updatedAt: new Date()
}
},
{ session }
)
if (moved.modifiedCount !== 1) {
throw new Error("이동할 수 없는 아이템입니다.")
}
})
})
트랜잭션은 정합성을 높이지만 잠금 대기와 재시도 비용이 있다. 모든 보상 지급에 무조건 적용하기보다 여러 문서가 반드시 함께 성공하거나 실패해야 하는 경계에서만 사용한다.
인벤토리 동시성 제어와 보안에서 놓치기 쉬운 점
클라이언트는 아이템 수량, 소유자 ID, 강화 수치의 최종 값을 신뢰할 수 있는 형태로 보내면 안 된다. 클라이언트는 ‘아이템 사용 요청’처럼 의도를 보내고 서버가 소유 여부, 쿨다운, 수량, 아이템 정의를 검증한 뒤 MongoDB 업데이트를 수행해야 한다.
낙관적 동시성 제어가 필요한 UI에는 version 필드를 사용한다. 클라이언트가 받은 버전을 갱신 조건에 넣고 성공 시 1 증가시키면 오래된 화면에서 발생한 장착 변경을 감지할 수 있다.
db.players.updateOne(
{ _id: "player_8f2c", inventoryVersion: 184 },
{
$set: { "equipment.weapon": { instanceId: "itm_a91", templateId: 3001 } },
$inc: { inventoryVersion: 1 }
}
)
modifiedCount가 0이면 서버는 최신 인벤토리를 다시 내려주거나 요청을 재시도 정책에 따라 처리해야 한다. 이 방식은 분산 락보다 구현과 운영이 단순한 경우가 많다.
자주 묻는 질문 (FAQ)
플레이어마다 아이템이 적어도 처음부터 컬렉션을 분리해야 하나요?
아니다. 최대 슬롯이 작고 아이템 상태가 단순하며 항상 전체를 함께 읽는 게임이라면 임베드 배열이 더 단순하다. 문서 크기 증가와 동시 갱신 가능성이 확인될 때 분리한다.
배열 필드에 인덱스를 만들면 충분하지 않나요?
배열 인덱스는 특정 아이템 검색에는 도움이 되지만 문서 크기 증가와 핫 문서 경합을 해결하지는 못한다. 조회 패턴과 갱신 패턴을 함께 보고 청크 또는 인스턴스 분리를 결정해야 한다.
아이템 수량을 Number 대신 문자열로 저장해도 되나요?
수량, 내구도, 강화 단계처럼 계산하는 값은 BSON 정수형 int32 또는 int64로 저장한다. 문자열은 범위 비교와 $inc를 어렵게 만들고 데이터 검증도 복잡하게 한다.
정리
MongoDB 인벤토리 최적화의 핵심은 모든 아이템을 한 플레이어 문서에 담지 않는 것이다. 작고 자주 읽는 프로필은 임베드하고 성장하는 스택은 청크로 개별 상태와 소유권이 중요한 장비는 인스턴스로 분리한다. 이후 실제 요청 경로에 맞춘 복합 인덱스, 조건부 원자 업데이트, 필요한 범위의 트랜잭션을 조합하면 대용량 멀티플레이어 환경에서도 인벤토리 저장 구조를 예측 가능하게 운영할 수 있다.



