블로그로 돌아가기

조사된 아이템을 위한 안전한 게임 인벤토리 시스템 Database Integration 구축 방법

게시일 2026년 6월 25일
조사된 아이템을 위한 안전한 게임 인벤토리 시스템 Database Integration 구축 방법

핵심 요약

본 글은 Unreal Engine C++과 데이터베이스 연동을 활용하여 보안성이 뛰어나고 신뢰할 수 있는 게임 인벤토리 시스템을 구축하는 방법을 설명합니다. 클라이언트 권한(Client-authoritative) 상태 전이의 취약점을 방지하기 위한 서버 권한(Server-authoritative) 검증 방식과 멱등성(Idempotent) 토큰을 활용한 이중 획득 방지 로직을 구현 코드와 함께 보여줍니다. 관계형(SQL) 및 문서형(NoSQL) 데이터베이스 스키마 설계 및 API 엔드포인트 구현을 비교 분석합니다. 마지막으로 [horizOn](https://horizon.pm) 클라우드 데이터베이스를 도입하여 멀티플레이어 백엔드 인프라 구축 비용과 복잡성을 줄이는 방안을 소개합니다.

플레이어들은 희귀 아이템을 복사하기 위해 어떤 취약점이라도 악용할 것이며, 클라이언트 권한(Client-authoritative) 상태 전이는 그들이 가장 좋아하는 타겟입니다. 공포 게임, 어드벤처 게임, RPG에서 열쇠를 줍고, 3D로 회전시켜 단서를 찾고, 이를 플레이어 인벤토리에 추가하는 등의 오브젝트 조사(Inspect) 액션은 클래식한 메커니즘입니다. 만약 게임 클라이언트가 서버 검증 없이 아이템이 인벤토리에 추가되는 시점을 직접 결정한다면, Cheat Engine이나 Fiddler 같은 간단한 패킷 인젝션 툴이 클라이언트를 속여 플레이어가 보지도 못한 아이템에 대한 '아이템 추가' 신호를 보내게 만들 수 있습니다. 이를 방지하기 위해 개발자는 클라이언트 사이드 상호작용을 서버 권한(Server-authoritative) 로직 및 안전한 클라우드 퍼시스턴스(Persistence)와 연결하는 강력한 게임 인벤토리 시스템 Database Integration을 구현해야 합니다.

The Inspection-to-Inventory Lifecycle

안전한 인벤토리 동기화를 구축하려면 먼저 아이템 조사(Inspection) 라이프사이클을 세분화해야 합니다. 이 시퀀스는 물리 월드 액터(Actor), 로컬 클라이언트 사이드 조사 상태, 서버 권한 인벤토리 컴포넌트(Component), 그리고 데이터베이스 스토리지를 조정합니다.

  1. 상호작용 감지(Interaction Detection): 플레이어가 게임 월드의 물리 액터(AInspectableActor)에 접근합니다. 라인 트레이스(Line trace)나 콜리전 볼륨(Collision volume)이 해당 오브젝트를 상호작용 가능 상태로 표시합니다.
  2. 조사 모드 전환(Inspection Mode Transition): 플레이어가 상호작용 키를 누릅니다. 클라이언트는 로컬 조사 상태로 진입하여 캐릭터 움직임을 제한하고, 오브젝트를 전용 스크린 공간 좌표 컨테이너로 회전시키며, 2D 설명 오버레이를 렌더링합니다.
  3. 검증 단계(Verification Stage): 플레이어가 "획득(Take)"을 클릭합니다. 클라이언트가 인벤토리에 아이템을 직접 추가하는 대신, 서버에 상호작용 토큰 요청을 전송합니다.
  4. 서버 검증(Server Validation): 서버는 플레이어가 액터의 물리적 상호작용 반경(약 250 Unreal 유닛) 내에 있는지, 그리고 해당 액터가 활성 상태인지 확인합니다.
  5. Database Integration: 서버는 인벤토리 배열에 아이템을 추가하고, 업데이트 사항을 영구 데이터베이스에 기록한 뒤, 월드 액터에 대한 파괴 이벤트를 브로드캐스트합니다.

만약 Multiplayer 타이틀을 개발 중이라면, 서버에서 이 상태를 관리하는 것이 매우 중요합니다. 많은 팀들이 커스텀 인벤토리 컴포넌트의 리플리케이션(Replication)과 네트워크 권한(Network authority)을 올바르게 구성하지 않아 Unreal Engine에서 액터 컴포넌트 오너가 바뀌어 발생하는 멀티플레이어 인벤토리 악몽 문제를 겪고는 합니다.

Writing the Unreal Engine C++ Inspection Logic

이 플로우를 구현하기 위해 인터페이스(IInspectableInterface), 조사 가능한 액터(AInspectableActor), 그리고 리플리케이트되는 플레이어 인벤토리 컴포넌트(UInventoryComponent)의 세 가지 컴포넌트를 생성할 것입니다.

액터가 조사 명령을 받는 방법을 정의하는 인터페이스 헤더 파일입니다:

// InspectableInterface.h
#pragma once

#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "InspectableInterface.generated.h"

UINTERFACE(MinimalAPI)
class UInspectableInterface : public UInterface
{
	GENERATED_BODY()
};

class HORIZON_GAME_API IInspectableInterface
{
	GENERATED_BODY()

public:
	virtual void OnInspectStarted(APlayerController* InspectingPlayer) = 0;
	virtual void OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake) = 0;
};

다음으로 AInspectableActor 클래스를 구현해 보겠습니다. 이 클래스는 월드의 물리 오브젝트를 처리하며 고유 식별자, 최대 상호작용 거리, 그리고 상태를 저장합니다.

// InspectableActor.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "InspectableInterface.h"
#include "InspectableActor.generated.h"

UCLASS()
class HORIZON_GAME_API AInspectableActor : public AActor, public IInspectableInterface
{
	GENERATED_BODY()

public:
	AInspectableActor();

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
	FName ItemID;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
	FString ItemDisplayName;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
	float MaxInteractionDistance;

	virtual void OnInspectStarted(APlayerController* InspectingPlayer) override;
	virtual void OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake) override;

protected:
	virtual void BeginPlay() override;

private:
	bool bIsBeingInspected;
	TWeakObjectPtr<APlayerController> CurrentInspectingPlayer;
};

조사 가능한 액터의 구현 파일입니다:

// InspectableActor.cpp
#include "InspectableActor.h"
#include "GameFramework/PlayerController.h"

AInspectableActor::AInspectableActor()
{
	PrimaryActorTick.bCanEverTick = false;
	bIsBeingInspected = false;
	MaxInteractionDistance = 250.0f;
	ItemID = "item_default";
	ItemDisplayName = "Generic Item";
}

void AInspectableActor::BeginPlay()
{
	Super::BeginPlay();
}

void AInspectableActor::OnInspectStarted(APlayerController* InspectingPlayer)
{
	if (!InspectingPlayer || bIsBeingInspected) return;

	APawn* PlayerPawn = InspectingPlayer->GetPawn();
	if (!PlayerPawn) return;

	float Distance = FVector::Dist(PlayerPawn->GetActorLocation(), GetActorLocation());
	if (Distance > MaxInteractionDistance)
	{
		UE_LOG(LogTemp, Warning, TEXT("Player too far to inspect %s"), *GetName());
		return;
	}

	bIsBeingInspected = true;
	CurrentInspectingPlayer = InspectingPlayer;
}

void AInspectableActor::OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake)
{
	if (InspectingPlayer != CurrentInspectingPlayer.Get()) return;

	if (bWantsToTake && HasAuthority())
	{
		UActorComponent* InvComp = InspectingPlayer->GetComponentByClass(UInventoryComponent::StaticClass());
		if (InvComp)
		{
			UInventoryComponent* Inventory = Cast<UInventoryComponent>(InvComp);
			if (Inventory)
			{
				Inventory->Server_TryAddInspectedItem(this);
			}
		}
	}
	
	bIsBeingInspected = false;
	CurrentInspectingPlayer.Reset();
}

이제 플레이어의 인벤토리 리스트를 관리하고 네트워크를 통해 클라이언트 플레이어들에게 이 데이터를 복제(Replicate)하는 UInventoryComponent를 생성해 보겠습니다.

// InventoryComponent.h
#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InventoryComponent.generated.h"

class AInspectableActor;

USTRUCT(BlueprintType)
struct FInventoryItem
{
	GENERATED_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
	FName ItemID;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
	int32 Quantity;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
	FString InspectedTimestamp;
};

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class HORIZON_GAME_API UInventoryComponent : public UActorComponent
{
	GENERATED_BODY()

public:
	UInventoryComponent();

	UFUNCTION(Server, Reliable, WithValidation)
	void Server_TryAddInspectedItem(AInspectableActor* TargetActor);

	bool AddItemToLocalState(FName InItemID, int32 Quantity);
	void SaveInventoryToDatabase();

protected:
	UPROPERTY(Replicated, BlueprintReadOnly, Category = "Inventory")
	TArray<FInventoryItem> Items;

	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};

그리고 RPC 리플리케이션 로직과 검증(Validation)을 정의하는 구현 파일입니다:

// InventoryComponent.cpp
#include "InventoryComponent.cpp"
#include "InspectableActor.h"
#include "Net/UnrealNetwork.h"

UInventoryComponent::UInventoryComponent()
{
	SetIsReplicatedByDefault(true);
}

void UInventoryComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
	DOREPLIFETIME(UInventoryComponent, Items);
}

bool UInventoryComponent::Server_TryAddInspectedItem_Validate(AInspectableActor* TargetActor)
{
	if (!TargetActor) return false;

	APawn* OwnerPawn = Cast<APawn>(GetOwner());
	if (!OwnerPawn) return false;

	// Server-side distance check
	float Distance = FVector::Dist(OwnerPawn->GetActorLocation(), TargetActor->GetActorLocation());
	return Distance <= TargetActor->MaxInteractionDistance;
}

void UInventoryComponent::Server_TryAddInspectedItem_Implementation(AInspectableActor* TargetActor)
{
	if (!TargetActor) return;

	AddItemToLocalState(TargetActor->ItemID, 1);
	SaveInventoryToDatabase();

	TargetActor->Destroy();
}

bool UInventoryComponent::AddItemToLocalState(FName InItemID, int32 Quantity)
{
	for (FInventoryItem& Item : Items)
	{
		if (Item.ItemID == InItemID)
		{
			Item.Quantity += Quantity;
			return true;
		}
	}

	FInventoryItem NewItem;
	NewItem.ItemID = InItemID;
	NewItem.Quantity = Quantity;
	NewItem.InspectedTimestamp = FDateTime::UtcNow().ToString();
	Items.Add(NewItem);
	return true;
}

void UInventoryComponent::SaveInventoryToDatabase()
{
	// Database integration trigger goes here
}

Designing the Inventory Database Schema

서버가 플레이어가 실제로 아이템을 조사했음을 검증하고 나면, 이 상태 변경 사항을 영속화해야 합니다. 관계형 데이터베이스(SQL)와 문서 기반 저장소(NoSQL) 중 어느 것을 선택하느냐에 따라 데이터베이스 스키마 구조가 달라집니다.

평가 기준 관계형 (PostgreSQL) 문서 저장소 (NoSQL / MongoDB)
데이터 구조 정규화된 테이블, 엄격한 외래 키 중첩된 키-값 문서 및 배열
트랜잭션 안전성 기본적으로 완전한 ACID 준수 지원 단일 문서로 제한된 원자성 작업
쿼리 복잡도 높음 (아이템 조회 시 SQL JOIN 쿼리 필요) 낮음 (플레이어 프로필 직접 조회)
확장성 수직적 (확장 시 수동 샤딩 필요) 수평적 (기본 내장 샤딩 기능 지원)

Relational Schema (PostgreSQL)

관계형 데이터베이스에서는 데이터 중복을 피하기 위해 플레이어, 글로벌 아이템 메타데이터, 그리고 활성 인벤토리 리스트를 분리하는 것이 좋습니다. 이를 위해서는 세 개의 테이블이 필요합니다:

CREATE TABLE players (
    player_id VARCHAR(64) PRIMARY KEY,
    username VARCHAR(100) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE game_items (
    item_id VARCHAR(64) PRIMARY KEY,
    display_name VARCHAR(100) NOT NULL,
    item_type VARCHAR(50) DEFAULT 'QuestItem'
);

CREATE TABLE inventory_items (
    id SERIAL PRIMARY KEY,
    player_id VARCHAR(64) REFERENCES players(player_id) ON DELETE CASCADE,
    item_id VARCHAR(64) REFERENCES game_items(item_id),
    quantity INTEGER CHECK (quantity > 0),
    inspected_timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(player_id, item_id)
);

Document Schema (NoSQL)

문서형 데이터베이스에서는 플레이어의 핵심 문서 내에 인벤토리를 중첩 배열로 저장합니다. 이를 통해 Backend는 단 한 번의 쿼리로 플레이어의 전체 상태를 조회할 수 있습니다:

{
  "_id": "usr_90a82b3d11ef",
  "username": "SurvivorX",
  "inventory": [
    {
      "item_id": "key_rusted_01",
      "quantity": 1,
      "inspected_timestamp": "2026-06-25T00:02:20Z"
    },
    {
      "item_id": "document_diary_03",
      "quantity": 1,
      "inspected_timestamp": "2026-06-25T00:05:12Z"
    }
  ],
  "last_updated": "2026-06-25T00:05:12Z"
}

The Sync Dilemma: Client-Side vs Server-Authoritative Saves

이 상태를 동기화하는 방식은 플레이어의 게임 경험뿐만 아니라 치트 시도에 대한 방어력에도 영향을 미칩니다. 만약 클라이언트 사이드 코드가 데이터베이스에 직접 저장한다면, 플레이어는 메모리 주소를 변조하여 데이터베이스에 임의의 값을 기록할 수 있습니다.

이를 방지하기 위해 다음과 같은 서버 권한(Server-authoritative) 검증을 구현해야 합니다:

  1. Interaction Tokens: 플레이어가 아이템 조사를 시작하면, 서버는 60초간 유효한 임시 상호작용 토큰을 생성하고 이를 활성 월드 액터 인스턴스에 등록합니다.
  2. Double-Spend Protection: 플레이어가 아이템을 획득하면 서버는 토큰을 소모합니다. 중복된 획득 패킷이 수신되면 서버는 이를 거부합니다.
  3. Synchronous Replication: 인벤토리 변경 사항이 클라이언트 화면에 즉시 반영되도록 합니다.

나아가, 접속 중인 게임 클라이언트의 인벤토리 UI에 실시간으로 시각적 업데이트를 전달할 때 과도한 HTTP 폴링(Polling) 메커니즘에 의존하는 것은 서버의 틱 레이트(Tick rate)를 저하시킬 수 있습니다. 대신 실시간 인벤토리 동기화를 위해 HTTP 폴링을 중단하고 전용 Websockets 연결을 구현하는 방법이 좋습니다.

Implementing the Backend Verification Code

자체 커스텀 서버를 구축하기로 결정했다면, 데이터베이스 업데이트를 안전하게 처리하는 API 엔드포인트가 필요합니다. 아래는 PostgreSQL을 사용하는 Node.js Express Backend 코드 스니펫입니다. 이 스크립트는 커넥션 풀링(Connection pooling), 트랜잭션 격리(Transaction isolation), 그리고 토큰 소모를 처리하여 아이템 획득을 기록합니다:

const express = require('express');
const { Pool } = require('pg');
const app = express();
app.use(express.json());

const dbPool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

app.post('/api/v1/inventory/add', async (req, res) => {
  const { playerId, itemId, interactionToken } = req.body;
  
  if (!playerId || !itemId || !interactionToken) {
    return res.status(400).json({ error: 'Missing required parameters' });
  }
  
  try {
    // 1. Verify interaction token exists and is active
    const tokenResult = await dbPool.query(
      'SELECT status FROM interactions WHERE token = $1 AND player_id = $2',
      [interactionToken, playerId]
    );
    
    if (tokenResult.rows.length === 0 || tokenResult.rows[0].status !== 'active') {
      return res.status(400).json({ error: 'Invalid or expired interaction token' });
    }

    // 2. Begin transaction
    await dbPool.query('BEGIN');
    
    // Consume the token to prevent double-spending
    await dbPool.query(
      "UPDATE interactions SET status = 'consumed' WHERE token = $1",
      [interactionToken]
    );

    // Insert or increment inventory record
    await dbPool.query(
      `INSERT INTO inventory_items (player_id, item_id, quantity, inspected_timestamp) 
       VALUES ($1, $2, 1, NOW()) 
       ON CONFLICT (player_id, item_id) 
       DO UPDATE SET quantity = inventory_items.quantity + 1`,
      [playerId, itemId]
    );

    await dbPool.query('COMMIT');
    return res.status(200).json({ success: true });
  } catch (error) {
    await dbPool.query('ROLLBACK');
    console.error('Database transaction error:', error);
    return res.status(500).json({ error: 'Internal Server Error' });
  }
});

이러한 인프라를 직접 구축하는 것은 개발자에게 매우 큰 부담입니다. 로드 밸런서(Load balancer) 설정, 데이터베이스 클러스터 프로비저닝, 커스텀 클라이언트 사이드 재시도 매니저 작성, 보안 인증 프로토콜 구축 등을 모두 직접 처리해야 합니다. 이는 게임 루프 코드를 단 한 줄도 작성하기 전에 최소 4~6주의 인프라 개발 시간이 소요되는 작업입니다.

Simplifying State Persistence with horizOn

커스텀 Express 미들웨어를 작성하고, PostgreSQL 커넥션 풀을 관리하며, 네트워크 유실 문제로 골머리를 앓는 대신, 이 복잡한 작업들을 horizOn에 위임할 수 있습니다. horizOn을 사용하면 게임 개발을 위해 특별히 설계된 완전 관리형 데이터베이스 솔루션을 활용할 수 있습니다.

클라우드 데이터베이스 기능을 활용하면 상태 검증을 위한 백엔드 스크립트를 따로 작성할 필요가 없습니다. 클라이언트 라이브러리를 사용하여 서버 권한(Server-authoritative) Unreal Engine 서버 인스턴스에서 직접 영구 데이터베이스 쓰기를 트리거할 수 있습니다:

void UInventoryComponent::SaveInventoryToCloud(FName InItemID, int32 InQuantity)
{
	TSharedPtr<FJsonObject> RequestData = MakeShareable(new FJsonObject());
	RequestData->SetStringField("player_id", PlayerID);
	RequestData->SetStringField("item_id", InItemID.ToString());
	RequestData->SetNumberField("quantity", InQuantity);

	// Single call to [horizOn](https://horizon.pm)'s secure database client
	FHorizonClient::Get()->Database("inventories")
		->Upsert(RequestData)
		->OnSuccess(this, &UInventoryComponent::OnSaveSuccess)
		->OnFailure(this, &UInventoryComponent::OnSaveFailure)
		->Execute();
}

이는 데이터베이스를 트랜잭션 방식으로 업데이트하고, 플레이어가 클라이언트에서 레코드를 조작(Spoof)할 수 없도록 보장하며, 플레이어에게 일시적인 인터넷 끊김이 발생하더라도 로컬에서 업데이트를 자동으로 캐싱합니다.

Best Practices for Persistent Game Inventories

고성능 인벤토리 시스템을 구축하려면 다음과 같은 모범 사례(Best Practices)를 구현하십시오:

  1. 서버에서의 거리 기반 검증 강제: 아이템을 추가하기 전에 폰(Pawn)과 조사 대상 액터 사이의 FVector::Dist를 확인합니다. 거리가 물리적으로 불가능한 수준이라면 의심스러운 클라이언트 활동으로 로그를 남깁니다.
  2. 멱등성(Idempotent) 트랜잭션 토큰 활용: 조사가 시작될 때 서버에서 고유한 트랜잭션 UUID를 생성합니다. 이는 네트워크 패킷이 레이턴시(Latency) 스파이크로 인해 재시도될 때 발생하는 중복 증가 버그를 방지합니다.
  3. 서버 보정을 수반하는 낙관적 로컬 업데이트(Optimistic Local Updates) 사용: 클라이언트 사이드에서 즉시 플레이어 인벤토리 화면을 업데이트하여 UI 반응성을 높이되, 서버가 성공적인 쓰기 확인을 반환할 때까지 '승인 대기 중(Pending Confirmation)' 상태로 유지합니다.
  4. 상태 이상 징후 로깅: 활성 세션에서 조사됨으로 표시되지 않은 아이템을 클라이언트가 얼마나 자주 획득하려 시도하는지 모니터링합니다. 이는 치트 인젝션 툴을 사용하는 주요 지표입니다.

멀티플레이어 백엔드를 확장할 준비가 되셨나요? 지금 바로 horizOn을 무료로 체험해 보거나 개발자 문서를 확인하여 시작해 보세요.


Source: How do you put an item into the inventory after inspecting it?

이 대시보드는 다음에 의해 애정을 담아 만들어졌습니다 Projectmakers

© 2026 projectmakers.de

unknown-v1.96.0 / unknown-v--