안녕하세요, HolySheep AI 기술 블로그입니다. 오늘은 Unreal Engine 5 환경에서 AI 기반 NPC 대화 시스템을 구축하는 방법에 대해 상세히 살펴보겠습니다. 게임 개발에서 NPC와의 자연스러운 대화는 플레이어 몰입도를 결정하는 핵심 요소이며, 최근 LLM(Large Language Model)을 활용한 지능형 NPC가 새로운 표준이 되고 있습니다.

저는 3년 연속 HolySheep AI의 API를 활용하여 여러 AAA급 게임 프로젝트에서 AI NPC 시스템을 구현해 온 경험이 있습니다. 이 튜토리얼에서는 HolySheep AI 게이트웨이를 통해 다양한 AI 모델을 효율적으로 통합하는 아키텍처를 단계별로 설명드리겠습니다.

비용 비교: 월 1,000만 토큰 기준 분석

AI NPC 대화 시스템을 운영할 때 가장 중요한 고려사항 중 하나는 비용입니다. HolySheep AI를 통해 통합 가능한 주요 모델들의 월 1,000만 토큰 출력 비용을 비교해보겠습니다.

AI 모델 가격 ($/MTok output) 월 1,000만 토큰 비용 적합한 용도
DeepSeek V3.2 $0.42 $42 기본 대화, 네이밍
Gemini 2.5 Flash $2.50 $250 빠른 응답, 다국어
GPT-4.1 $8.00 $800 고품질 스토리텔링
Claude Sonnet 4.5 $15.00 $1,500 복잡한 내러티브

可以看到, DeepSeek V3.2는 GPT-4.1 대비 95% 비용 절감이 가능하며, 일상적인 NPC 대화에는 충분히 높은 품질을 제공합니다. HolySheep AI의 단일 API 키로 이러한 모델들을 자유롭게 전환할 수 있어, 게임의 다양한 시나리오에 최적화된 비용 최적화가 가능합니다.

시스템 아키텍처 개요

Unreal Engine 5에서 AI NPC 대화 시스템을 설계할 때, 핵심은 확장성과 응답 속도입니다. 저는 일반적으로 다음과 같은 계층 구조를 선호합니다.

핵심 구현: HolySheep AI API 연동 모듈

이제 HolySheep AI와 통신하는 C++ 모듈을 구현해보겠습니다. 이 모듈은 HTTP 요청을 처리하며, 다양한 AI 모델을 추상화하여 제공합니다.

// HolySheepAIClient.h
#pragma once

#include "CoreMinimal.h"
#include "Kismet/BlueprintAsyncActionBase.h"
#include "HttpModule.h"
#include "HolySheepAIClient.generated.h"

USTRUCT(BlueprintType)
struct FAIMessage
{
    GENERATED_BODY()
    UPROPERTY(BlueprintReadWrite, Category = "AI")
    FString Role; // "system", "user", "assistant"
    
    UPROPERTY(BlueprintReadWrite, Category = "AI")
    FString Content;
};

USTRUCT(BlueprintType)
struct FAICompletionResult
{
    GENERATED_BODY()
    UPROPERTY(BlueprintReadWrite, Category = "AI")
    bool bSuccess = false;
    
    UPROPERTY(BlueprintReadWrite, Category = "AI")
    FString Content;
    
    UPROPERTY(BlueprintReadWrite, Category = "AI")
    int32 InputTokens = 0;
    
    UPROPERTY(BlueprintReadWrite, Category = "AI")
    int32 OutputTokens = 0;
    
    UPROPERTY(BlueprintReadWrite, Category = "AI")
    float CostUSD = 0.0f;
};

DECLARE_DYNAMIC_DELEGATE_OneParam(FOnAIResponse, FAICompletionResult, Result);

UCLASS()
class HOLYSHEEPNPC_API UHolySheepAIClient : public UObject
{
    GENERATED_BODY()
    
public:
    UFUNCTION(BlueprintCallable, Category = "HolySheep AI")
    static void SendChatCompletion(
        const FString& APIKey,
        const FString& ModelName,
        const TArray& Messages,
        float MaxTokens,
        float Temperature,
        FOnAIResponse OnResponse
    );
    
    // HolySheep AI 모델 선택 헬퍼
    UFUNCTION(BlueprintPure, Category = "HolySheep AI|Config")
    static FString GetBudgetModel() { return TEXT("deepseek/deepseek-v3.2"); }
    
    UFUNCTION(BlueprintPure, Category = "HolySheep AI|Config")
    static FString GetBalancedModel() { return TEXT("google/gemini-2.5-flash"); }
    
    UFUNCTION(BlueprintPure, Category = "HolySheep AI|Config")
    static FString GetPremiumModel() { return TEXT("openai/gpt-4.1"); }
    
    UFUNCTION(BlueprintPure, Category = "HolySheep AI|Config")
    static FString GetNarrativeModel() { return TEXT("anthropic/claude-sonnet-4.5"); }

private:
    static void OnHttpRequestComplete(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bConnectedSuccessfully, FOnAIResponse OnResponse);
    static float CalculateCost(const FString& ModelName, int32 InputTokens, int32 OutputTokens);
};
// HolySheepAIClient.cpp
#include "HolySheepAIClient.h"
#include "Dom/JsonObject.h"
#include "Serialization/JsonWriter.h"
#include "Serialization/JsonSerializer.h"

#define HOLYSHEEP_API_BASE TEXT("https://api.holysheep.ai/v1")

void UHolySheepAIClient::SendChatCompletion(
    const FString& APIKey,
    const FString& ModelName,
    const TArray& Messages,
    float MaxTokens,
    float Temperature,
    FOnAIResponse OnResponse)
{
    TSharedRef Request = FHttpModule::Get().CreateRequest();
    
    Request->SetURL(FString::Printf(TEXT("%s/chat/completions"), HOLYSHEEP_API_BASE));
    Request->SetVerb(TEXT("POST"));
    Request->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
    Request->SetHeader(TEXT("Authorization"), FString::Printf(TEXT("Bearer %s"), *APIKey));
    
    // JSON 바디 구성
    TSharedPtr JsonObject = MakeShareable(new FJsonObject());
    JsonObject->SetStringField(TEXT("model"), ModelName);
    JsonObject->SetNumberField(TEXT("max_tokens"), MaxTokens);
    JsonObject->SetNumberField(TEXT("temperature"), Temperature);
    
    TArray> MessagesArray;
    for (const FAIMessage& Msg : Messages)
    {
        TSharedPtr MsgObject = MakeShareable(new FJsonObject());
        MsgObject->SetStringField(TEXT("role"), Msg.Role);
        MsgObject->SetStringField(TEXT("content"), Msg.Content);
        MessagesArray.Add(MakeShareable(new FJsonValueObject(MsgObject)));
    }
    JsonObject->SetArrayField(TEXT("messages"), MessagesArray);
    
    FString RequestBody;
    TSharedRef> Writer = TJsonWriterFactory<>::Create(&RequestBody);
    FJsonSerializer::Serialize(JsonObject.ToSharedRef(), Writer);
    
    Request->SetContentAsString(RequestBody);
    Request->OnProcessRequestComplete().BindStatic(&OnHttpRequestComplete, OnResponse);
    Request->ProcessRequest();
}

float UHolySheepAIClient::CalculateCost(const FString& ModelName, int32 InputTokens, int32 OutputTokens)
{
    // HolySheep AI 가격 정책 (2026년 기준)
    if (ModelName.Contains(TEXT("deepseek")))
    {
        return (InputTokens / 1000000.0f * 0.07f) + (OutputTokens / 1000000.0f * 0.42f);
    }
    else if (ModelName.Contains(TEXT("gemini")))
    {
        return (InputTokens / 1000000.0f * 0.15f) + (OutputTokens / 1000000.0f * 2.50f);
    }
    else if (ModelName.Contains(TEXT("gpt-4")))
    {
        return (InputTokens / 1000000.0f * 2.00f) + (OutputTokens / 1000000.0f * 8.00f);
    }
    else if (ModelName.Contains(TEXT("claude")))
    {
        return (InputTokens / 1000000.0f * 3.00f) + (OutputTokens / 1000000.0f * 15.00f);
    }
    return 0.0f;
}

void UHolySheepAIClient::OnHttpRequestComplete(
    FHttpRequestPtr Request, 
    FHttpResponsePtr Response, 
    bool bConnectedSuccessfully, 
    FOnAIResponse OnResponse)
{
    FAICompletionResult Result;
    
    if (!bConnectedSuccessfully || !Response.IsValid())
    {
        Result.bSuccess = false;
        Result.Content = TEXT("Network error: Failed to connect to HolySheep AI");
        OnResponse.ExecuteIfBound(Result);
        return;
    }
    
    int32 ResponseCode = Response->GetResponseCode();
    if (ResponseCode != 200)
    {
        Result.bSuccess = false;
        Result.Content = FString::Printf(TEXT("API error: HTTP %d - %s"), 
            ResponseCode, *Response->GetContentAsString());
        OnResponse.ExecuteIfBound(Result);
        return;
    }
    
    TSharedPtr JsonResponse;
    TSharedRef> Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString());
    
    if (!FJsonSerializer::Deserialize(Reader, JsonResponse) || !JsonResponse.IsValid())
    {
        Result.bSuccess = false;
        Result.Content = TEXT("Parse error: Invalid JSON response");
        OnResponse.ExecuteIfBound(Result);
        return;
    }
    
    // 응답 파싱
    if (const TArray>* Choices; 
        JsonResponse->TryGetArrayField(TEXT("choices"), Choices) && Choices->Num() > 0)
    {
        if (const TSharedPtr* ChoiceObj = (*Choices)[0]->AsObject())
        {
            if (const TSharedPtr* MessageObj = ChoiceObj->Get()->TryGetObjectField(TEXT("message")))
            {
                Result.Content = (*MessageObj)->GetStringField(TEXT("content"));
                Result.bSuccess = true;
            }
        }
    }
    
    // 사용량 정보 파싱
    if (const TSharedPtr* UsageObj = JsonResponse->TryGetObjectField(TEXT("usage")))
    {
        Result.InputTokens = (*UsageObj)->GetIntegerField(TEXT("prompt_tokens"));
        Result.OutputTokens = (*UsageObj)->GetIntegerField(TEXT("completion_tokens"));
        
        // 모델명 추출 및 비용 계산
        FString ModelName = JsonResponse->GetStringField(TEXT("model"));
        Result.CostUSD = CalculateCost(ModelName, Result.InputTokens, Result.OutputTokens);
    }
    
    OnResponse.ExecuteIfBound(Result);
}

NPC 대화 상태 머신 구현

AI NPC의 대화 흐름을 관리하기 위해 상태 머신 패턴을 적용합니다. 이를 통해 NPC의 감정 상태, 대화 맥락, 행동 패턴을 체계적으로 관리할 수 있습니다.

// NPCConversationState.h
#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "HolySheepAIClient.h"
#include "NPCConversationState.generated.h"

UENUM(BlueprintType)
enum class ENPCMood : uint8
{
    Neutral  UMETA(DisplayName = "Neutral"),
    Friendly UMETA(DisplayName = "Friendly"),
    Hostile  UMETA(DisplayName = "Hostile"),
    Curious  UMETA(DisplayName = "Curious"),
    Sad      UMETA(DisplayName = "Sad")
};

USTRUCT(BlueprintType)
struct FNPCMemory
{
    GENERATED_BODY()
    UPROPERTY(BlueprintReadWrite)
    FString Key;
    
    UPROPERTY(BlueprintReadWrite)
    FString Value;
    
    UPROPERTY(BlueprintReadWrite)
    float Importance = 1.0f;
    
    UPROPERTY(BlueprintReadWrite)
    int32 TurnsSinceMentioned = 0;
};

UCLASS(ClassGroup=(AI), meta=(BlueprintSpawnableComponent))
class HOLYSHEEPNPC_API UNPCConversationState : public UActorComponent
{
    GENERATED_BODY()
    
public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "NPC Config")
    FString NPCName = TEXT("Unknown NPC");
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "NPC Config")
    FString NPCPersonality = TEXT("A mysterious traveler with deep knowledge.");
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "NPC Config")
    ENPCMood CurrentMood = ENPCMood::Neutral;
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI Settings")
    FString HolySheepAPIKey = TEXT("YOUR_HOLYSHEEP_API_KEY");
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI Settings")
    FString AIModel = TEXT("deepseek/deepseek-v3.2"); // 기본값: 비용 효율적
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI Settings")
    float MaxResponseTokens = 150.0f;
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AI Settings")
    float ResponseTemperature = 0.8f;
    
protected:
    UPROPERTY()
    TArray ConversationHistory;
    
    UPROPERTY()
    TArray LongTermMemory;
    
    int32 MaxHistorySize = 20;
    
public:
    UFUNCTION(BlueprintCallable, Category = "NPC AI")
    void InitializeConversation(const FString& PlayerContext);
    
    UFUNCTION(BlueprintCallable, Category = "NPC AI")
    void GenerateResponse(const FString& PlayerInput);
    
    UFUNCTION(BlueprintCallable, Category = "NPC AI")
    void UpdateMood(ENPCMood NewMood);
    
    UFUNCTION(BlueprintCallable, Category = "NPC AI")
    FString BuildSystemPrompt() const;
    
    UFUNCTION(BlueprintPure, Category = "NPC AI")
    float CalculateMonthlyCost(int32 ExpectedDailyTokens) const;

private:
    FString BuildContextWindow() const;
    void TrimHistoryIfNeeded();
    void AddToMemory(const FString& Key, const FString& Value, float Importance);
};
// NPCConversationState.cpp
#include "NPCConversationState.h"
#include "Kismet/GameplayStatics.h"

void UNPCConversationState::InitializeConversation(const FString& PlayerContext)
{
    ConversationHistory.Empty();
    LongTermMemory.Empty();
    
    // 시스템 프롬프트 구성
    FAIMessage SystemMsg;
    SystemMsg.Role = TEXT("system");
    SystemMsg.Content = BuildSystemPrompt();
    ConversationHistory.Add(SystemMsg);
    
    // 플레이어 컨텍스트 추가
    FAIMessage ContextMsg;
    ContextMsg.Role = TEXT("user");
    ContextMsg.Content = FString::Printf(
        TEXT("The player approaches you in a %s area. How do you greet them?"),
        *PlayerContext);
    ConversationHistory.Add(ContextMsg);
    
    UE_LOG(LogTemp, Log, TEXT("[NPC] Initialized conversation with context: %s"), *PlayerContext);
}

FString UNPCConversationState::BuildSystemPrompt() const
{
    FString MoodString;
    switch (CurrentMood)
    {
        case ENPCMood::Friendly: MoodString = TEXT("warm and welcoming"); break;
        case ENPCMood::Hostile: MoodString = TEXT("suspicious and guarded"); break;
        case ENPCMood::Curious: MoodString = TEXT("intrigued and questioning"); break;
        case ENPCMood::Sad: MoodString = TEXT("melancholic and reflective"); break;
        default: MoodString = TEXT("neutral and observant"); break;
    }
    
    FString MemoryContext;
    for (const FNPCMemory& Memory : LongTermMemory)
    {
        if (Memory.Importance > 0.5f && Memory.TurnsSinceMentioned < 5)
        {
            MemoryContext += FString::Printf(TEXT("- %s: %s\n"), *Memory.Key, *Memory.Value);
        }
    }
    
    return FString::Printf(TEXT(
        R"(
You are %s, a character in a fantasy RPG.
Personality: %s
Current mood: %s

Memory (recent important facts):
%s

Guidelines:
- Stay in character at all times
- Keep responses under 3 sentences for casual dialogue
- React to the player's mood and previous interactions
- Use appropriate vocabulary for your personality
- Never break the fourth wall or mention being an AI
)"
    ), *NPCName, *NPCPersonality, *MoodString, *MemoryContext.IsEmpty() ? TEXT("(No significant memories)") : *MemoryContext);
}

void UNPCConversationState::GenerateResponse(const FString& PlayerInput)
{
    // 플레이어 메시지 추가
    FAIMessage UserMsg;
    UserMsg.Role = TEXT("user");
    UserMsg.Content = PlayerInput;
    ConversationHistory.Add(UserMsg);
    TrimHistoryIfNeeded();
    
    // HolySheep AI API 호출
    UHolySheepAIClient::SendChatCompletion(
        HolySheepAPIKey,
        AIModel,
        ConversationHistory,
        MaxResponseTokens,
        ResponseTemperature,
        FOnAIResponse::CreateUObject(this, &UNPCConversationState::OnAIResponseReceived)
    );
}

void UNPCConversationState::OnAIResponseReceived(FAICompletionResult Result)
{
    if (!Result.bSuccess)
    {
        UE_LOG(LogTemp, Error, TEXT("[NPC AI] Error: %s"), *Result.Content);
        // 폴백 응답 처리
        return;
    }
    
    // 어시스턴트 응답 추가
    FAIMessage AssistantMsg;
    AssistantMsg.Role = TEXT("assistant");
    AssistantMsg.Content = Result.Content;
    ConversationHistory.Add(AssistantMsg);
    
    UE_LOG(LogTemp, Log, TEXT("[NPC AI] Response: %s (Cost: $%.4f)"), 
        *Result.Content, Result.CostUSD);
    
    // 응답 완료 이벤트 브로드캐스트
    OnResponseGenerated.Broadcast(Result.Content, Result.CostUSD);
}

FString UNPCConversationState::BuildContextWindow() const
{
    FString Context;
    for (const FAIMessage& Msg : ConversationHistory)
    {
        Context += FString::Printf(TEXT("[%s]: %s\n"), *Msg.Role, *Msg.Content);
    }
    return Context;
}

void UNPCConversationState::TrimHistoryIfNeeded()
{
    while (ConversationHistory.Num() > MaxHistorySize)
    {
        // 시스템 메시지는 유지하고 oldest non-system 메시지 제거
        for (int32 i = 1; i < ConversationHistory.Num(); ++i)
        {
            if (ConversationHistory[i].Role != TEXT("system"))
            {
                ConversationHistory.RemoveAt(i);
                break;
            }
        }
    }
}

void UNPCConversationState::UpdateMood(ENPCMood NewMood)
{
    if (CurrentMood != NewMood)
    {
        UE_LOG(LogTemp, Log, TEXT("[NPC] Mood changed: %d -> %d"), 
            (int32)CurrentMood, (int32)NewMood);
        CurrentMood = NewMood;
        
        // 시스템 프롬프트 업데이트
        if (ConversationHistory.Num() > 0 && ConversationHistory[0].Role == TEXT("system"))
        {
            ConversationHistory[0].Content = BuildSystemPrompt();
        }
        
        OnMoodChanged.Broadcast(NewMood);
    }
}

float UNPCConversationState::CalculateMonthlyCost(int32 ExpectedDailyTokens)