Unity C#과 UniTask로 대규모 LLM API 요청과 GC 줄이기

Unity 클라이언트에 LLM API 요청이 몰릴 때 생기는 동시성 폭주, 프레임 드롭, GC 스파이크를 살펴봅니다. UniTask 기반 동시성 제한과 취소, 버퍼 재사용으로 요청 흐름을 안정화하는 방법을 정리합니다.

✓ 복사 완료!
챕터비동기 & 멀티스레딩
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Unity C#과 UniTask로 대규모 LLM API 요청과 GC 줄이기

핵심 요약

대규모 LLM API 요청에서 병목을 만드는 것은 async/await 자체가 아닙니다. 제한 없이 요청을 동시에 보내는 구조, 요청마다 생기는 문자열·배열 할당, 메인 스레드에서 처리하는 후처리가 더 큰 문제입니다. Unity에서는 UniTask로 동시 요청 수를 제한하고 CancellationToken으로 화면 수명과 요청을 연결합니다. StringBuilder나 버퍼 재사용으로 임시 할당도 줄일 수 있습니다.

핵심은 네트워크 요청의 병렬 수를 제한하고 응답 파싱은 백그라운드에서 처리한 뒤 Unity API를 사용할 때만 메인 스레드로 돌아오는 것입니다.

대규모 LLM API 요청에서 병목이 생기는 이유

NPC 대화, 퀘스트 생성, 번역, 개인화 추천처럼 LLM을 호출하는 기능이 늘어나면 짧은 시간에 요청이 한꺼번에 시작될 수 있습니다. 호출부마다 await를 붙이는 것만으로는 이 상황을 제어할 수 없습니다.

문제흔한 원인Unity 클라이언트에서 보이는 증상
동시성 폭주WhenAll로 모든 요청을 즉시 시작연결 대기 증가, 타임아웃, 재시도 폭증
GC 스파이크JSON 문자열 연결, byte[]·람다·컬렉션의 반복 생성프레임 타임 급증, 순간적인 끊김
메인 스레드 점유응답 파싱과 UI 갱신이 한곳에 섞임입력 지연, UI 갱신 지연
폐기된 화면의 응답 반영화면 전환 뒤에도 요청이 계속 실행잘못된 UI 갱신, 예외, 불필요한 비용

특히 Task.WhenAll(requests)는 작업을 비동기로 기다리게 할 뿐, 요청 수를 적당히 조절해 주지는 않습니다. 예를 들어 요청 100개를 한 번에 만들면 네트워크 스택과 서버의 Rate Limit, 클라이언트의 재시도 로직이 동시에 압박을 받습니다.

동시 요청 폭주와 제한된 UniTask 큐의 처리 흐름

Unity에서 Async/Await에 UniTask를 함께 고려하는 이유

UniTask는 Unity 실행 환경에 맞춘 awaitable 타입과 PlayerLoop 연동 기능을 제공합니다. 클라이언트 코드에서는 다음 차이가 특히 실무적인 의미를 가집니다.

구분TaskUniTask
기본 사용 목적범용 .NET 비동기 작업Unity 친화적 비동기 작업
Unity 프레임 대기별도 처리 필요UniTask.Yield, Delay 등으로 명확히 표현
할당 관리호출 구조에 따라 Task 객체 할당 가능많은 Unity 패턴에서 불필요한 할당을 줄이기 쉬움
취소 연동CancellationToken 사용동일하게 사용하며 GameObject 수명 연동 API 활용 가능
주의점예외를 관찰하지 않으면 누락될 수 있음UniTaskVoid 남용 시 예외 추적이 어려움

UniTask를 쓴다고 모든 할당이 사라지는 것은 아닙니다. HTTP 본문 생성, JSON 역직렬화, 응답 텍스트 처리에서 생기는 할당은 따로 측정하고 줄여야 합니다. UniTask는 비동기 흐름을 Unity에 맞게 구성하는 도구이지 GC 최적화 자체는 아닙니다.

LLM API 요청 수를 제한하는 방법

가장 먼저 적용할 패턴은 동시 실행 수를 정해 두는 요청 게이트입니다. 요청이 아무리 많이 쌓여도 실제로 네트워크에 나가는 작업은 maxConcurrency 이하로 유지합니다.

1. 요청 게이트 만들기

using System;
using System.Threading;
using Cysharp.Threading.Tasks;

public sealed class LlmRequestGate
{
    private readonly SemaphoreSlim semaphore;

    public LlmRequestGate(int maxConcurrency)
    {
        semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
    }

    public async UniTask<T> RunAsync<T>(
        Func<CancellationToken, UniTask<T>> operation,
        CancellationToken cancellationToken)
    {
        await semaphore.WaitAsync(cancellationToken);

        try
        {
            return await operation(cancellationToken);
        }
        finally
        {
            semaphore.Release();
        }
    }
}

finally에서 Release()를 호출해야 취소나 예외가 발생해도 슬롯이 돌아옵니다. 이 호출을 빼먹으면 시간이 지날수록 슬롯이 줄어들고 결국 모든 요청이 대기 상태에 빠질 수 있습니다.

2. API 클라이언트의 단일 진입점에 게이트 적용하기

using System.Text;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine.Networking;

public sealed class LlmApiClient
{
    private readonly LlmRequestGate gate = new(maxConcurrency: 4);

    public UniTask<string> CompleteAsync(
        string prompt,
        CancellationToken cancellationToken)
    {
        return gate.RunAsync(
            token => SendRequestAsync(prompt, token),
            cancellationToken);
    }

    private async UniTask<string> SendRequestAsync(
        string prompt,
        CancellationToken cancellationToken)
    {
        var requestJson = BuildRequestJson(prompt);
        var body = Encoding.UTF8.GetBytes(requestJson);

        using var request = new UnityWebRequest(
            "https://api.example.com/v1/chat/completions",
            UnityWebRequest.kHttpVerbPOST);

        request.uploadHandler = new UploadHandlerRaw(body);
        request.downloadHandler = new DownloadHandlerBuffer();
        request.SetRequestHeader("Content-Type", "application/json");

        await request.SendWebRequest().ToUniTask(cancellationToken: cancellationToken);

        if (request.result != UnityWebRequest.Result.Success)
        {
            throw new System.InvalidOperationException(request.error);
        }

        return request.downloadHandler.text;
    }

    private static string BuildRequestJson(string prompt)
    {
        return "{\"model\":\"game-dialogue\",\"input\":\"" + EscapeJson(prompt) + "\"}";
    }

    private static string EscapeJson(string value)
    {
        var builder = new StringBuilder(value.Length);
        foreach (char c in value)
        {
            switch (c)
            {
                case '\\': builder.Append("\\\\"); break;
                case '\"': builder.Append("\\\""); break;
                case '\b': builder.Append("\\b"); break;
                case '\f': builder.Append("\\f"); break;
                case '\n': builder.Append("\\n"); break;
                case '\r': builder.Append("\\r"); break;
                case '\t': builder.Append("\\t"); break;
                default:
                    if (c < 0x20)
                        builder.Append("\\u").Append(((int)c).ToString("X4"));
                    else
                        builder.Append(c);
                    break;
            }
        }

        return builder.ToString();
    }
}

maxConcurrency의 적정값을 기기 성능만 보고 정할 수는 없습니다. API 제공자의 동시 요청 제한, 평균 응답 시간, 재시도 정책, 게임에서 허용할 수 있는 대기 시간을 함께 봐야 합니다. 처음에는 2~4개 정도로 시작하고 텔레메트리에서 대기 시간과 오류율을 확인하면서 조정하는 편이 안전합니다.

3. 기능별 우선순위 나누기

게임플레이를 진행하는 데 필요한 요청과 미리 생성해도 되는 요청을 같은 큐에 넣으면 낮은 우선순위 작업이 중요한 응답을 밀어낼 수 있습니다. 대화 진행용 큐와 사전 생성용 큐를 나누거나 하나의 큐 안에서 우선순위별 대기열을 운영합니다.

flowchart LR
    A[UI·게임플레이 요청] --> B[고우선순위 큐]
    C[사전 생성·분석 요청] --> D[저우선순위 큐]
    B --> E[동시성 게이트: 4]
    D --> E
    E --> F[LLM API]
    F --> G[백그라운드 파싱]
    G --> H[메인 스레드 UI 반영]

GC 스파이크를 줄이는 방법

LLM 응답에는 대개 JSON과 긴 텍스트가 들어옵니다. 호출 빈도가 높을수록 네트워크를 기다리는 시간보다 문자열을 조립하고 파싱하는 과정에서 생기는 할당이 프레임 안정성을 더 크게 해칠 수 있습니다.

요청 JSON을 매번 문자열 연결로 만들지 않기

아래 코드는 짧지만 호출 빈도가 높으면 중간 문자열을 계속 생성합니다.

var json = "{\"model\":\"game-dialogue\",\"input\":\"" + prompt + "\"}";

복잡한 페이로드는 프로젝트에서 사용하는 JSON 직렬화기로 DTO를 직렬화하는 편이 안전합니다. 직접 조립해야 한다면 StringBuilder를 재사용할 수 있지만 여러 요청이 동시에 실행될 수 있으므로 하나의 인스턴스를 공유해서는 안 됩니다.

using System.Text;

private static string BuildRequestJson(string prompt)
{
    var builder = new StringBuilder(prompt.Length + 64);
    builder.Append("{\"model\":\"game-dialogue\",\"input\":\"");
    builder.Append(EscapeJson(prompt));
    builder.Append("\"}");
    return builder.ToString();
}

이 코드도 최종 string은 생성합니다. 목표는 할당을 0으로 만드는 것이 아니라 중간 문자열을 줄이는 데 있습니다. 경우에 따라서는 API 요청 형식에 맞는 DTO와 검증된 JSON 라이브러리를 사용해 이스케이프 오류를 막는 것이 더 큰 개선으로 이어집니다.

응답에서 필요한 필드만 모델링하기

응답 JSON 전체를 범용 트리 구조로 변환하면 객체 수가 크게 늘어날 수 있습니다. 게임 클라이언트가 실제로 사용하는 값만 담은 DTO를 정의하고 원문 로그는 꼭 필요할 때만 보관합니다.

[System.Serializable]
public sealed class LlmResponse
{
    public string text;
    public int inputTokens;
    public int outputTokens;
}

개발 빌드에서 원문 응답을 기록하더라도 릴리스 환경에서는 매 호출마다 긴 문자열 전체를 로그에 넘기지 않도록 주의해야 합니다. 로그 문자열 보간도 할당을 만들 수 있습니다.

컬렉션과 클로저 할당 측정하기

다음 코드가 핫패스에 있다면 반복 호출할 때 할당이 얼마나 생기는지 확인해야 합니다.

var tasks = prompts.Select(prompt => client.CompleteAsync(prompt, token)).ToArray();
await UniTask.WhenAll(tasks);

이 방식이 항상 잘못된 것은 아닙니다. 다만 LINQ, 배열 생성, 동시 요청 시작이라는 세 가지 일이 한 번에 일어납니다. 요청 수가 많거나 매 프레임에 가깝게 호출된다면 명시적인 큐와 재사용 가능한 작업 목록으로 바꾸는 편이 동작을 예측하기 쉽습니다.

취소 토큰과 화면 수명 연결하기

UI 패널이 닫힌 뒤에도 LLM 응답을 기다리면 비용이 계속 발생하고 상태 오류도 생길 수 있습니다. 요청의 취소 토큰을 화면이나 GameObject의 수명에 연결하면 이 문제를 줄일 수 있습니다.

using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

public sealed class DialoguePanel : MonoBehaviour
{
    private readonly LlmApiClient client = new();

    public async UniTask RefreshDialogueAsync(string prompt)
    {
        var token = this.GetCancellationTokenOnDestroy();

        try
        {
            var responseJson = await client.CompleteAsync(prompt, token);
            var response = ParseResponse(responseJson);

            await UniTask.SwitchToMainThread(token);
            ApplyDialogue(response.text);
        }
        catch (OperationCanceledException)
        {
            // 화면 종료 또는 새 요청으로 인한 정상 취소다.
        }
    }

    private static LlmResponse ParseResponse(string json)
    {
        return JsonUtility.FromJson<LlmResponse>(json);
    }

    private void ApplyDialogue(string text)
    {
        // TMP_Text 등 Unity UI를 여기서 갱신한다.
    }
}

같은 패널에서 새 요청이 이전 요청을 대체한다면 CancellationTokenSource를 따로 보관하고 새 요청을 시작하기 전에 이전 소스를 취소하고 폐기합니다. CancellationTokenSource도 리소스이므로 수명이 끝날 때 반드시 Dispose()해야 합니다.

프레임 드롭을 줄이는 LLM 응답 처리 순서

  1. 입력 단계: 프롬프트를 바로 전송하지 말고 같은 대상의 연속 입력은 디바운스합니다. 최신 요청만 남기는 방법도 있습니다.
  2. 전송 단계: 중앙 LlmRequestGate를 통과시켜 동시 요청 수와 재시도 횟수를 제한합니다.
  3. 처리 단계: 응답의 무거운 변환 작업은 가능한 한 Unity API와 분리하고 UI·GameObject에 접근하기 직전에만 메인 스레드로 전환합니다.
  4. 반영 단계: 대상 화면이 아직 살아 있고 요청 버전도 최신일 때만 결과를 적용합니다.
  5. 관측 단계: 요청 대기 시간, API 지연 시간, 취소 수, 오류 코드, GC Alloc를 함께 기록합니다.

최신 요청만 반영하는 버전 번호 패턴은 자동완성, 번역 미리보기, NPC 대화 후보처럼 결과가 쉽게 바뀌는 기능에 유용합니다.

private int requestVersion;

public async UniTask UpdatePreviewAsync(string prompt, CancellationToken token)
{
    var currentVersion = ++requestVersion;
    var response = await client.CompleteAsync(prompt, token);

    if (currentVersion != requestVersion)
    {
        return;
    }

    ApplyPreview(response);
}

성능은 무엇으로 측정해야 할까?

최적화 전후를 비교할 때 평균 응답 시간만 봐서는 부족합니다. Unity Profiler의 CPU Usage와 Memory 모듈에서 프레임별 GC Alloc와 GC 발생 구간을 확인하고 네트워크 로그에서는 큐 대기 시간과 실제 API 왕복 시간을 나눠서 기록해야 합니다.

지표의미개선 방향
큐 대기 시간게이트 진입 전 대기한 시간동시성 수·우선순위·요청량 조정
API 왕복 시간서버와 네트워크가 소비한 시간타임아웃·재시도·프롬프트 크기 점검
프레임당 GC Alloc해당 프레임의 관리 힙 할당문자열·LINQ·컬렉션·로그 점검
취소율더 이상 필요 없는 응답의 비율디바운스·최신 요청 우선 적용
429/5xx 비율Rate Limit 또는 서버 오류 비율백오프와 동시성 제한 강화

동시성 수를 올린 뒤 평균 API 시간은 그대로인데 429 오류나 큐 재시도만 늘었다면 처리량이 좋아진 것이 아닙니다. 서버와 클라이언트에 부하를 옮겼을 뿐입니다.

자주 묻는 질문

UniTask.WhenAll을 쓰면 요청 제한도 자동으로 되나요?

아닙니다. UniTask.WhenAll은 전달된 작업이 끝날 때까지 기다리는 역할만 합니다. 작업을 만드는 즉시 요청이 시작되는 구조라면 모두 동시에 실행될 수 있으므로 SemaphoreSlim 같은 별도 제한 장치가 필요합니다.

모든 LLM 요청을 메인 스레드 밖에서 실행해야 하나요?

HTTP 대기 자체는 메인 스레드를 오래 점유하지 않지만 Unity 객체와 UI에는 메인 스레드에서 접근해야 합니다. 무거운 텍스트 처리와 Unity API 호출의 경계를 분명히 나누는 것이 좋습니다.

async void나 UniTaskVoid를 UI 이벤트에 써도 되나요?

이벤트 진입점처럼 반환값을 받을 곳이 없는 경우에는 사용할 수 있지만 예외 처리를 직접 해야 합니다. 가능한 비즈니스 로직은 UniTask를 반환하게 만들고 호출 위치에서 await해 오류·취소·로딩 상태를 관리하는 편이 안전합니다.

정리

대규모 LLM 연동이 안정적으로 동작하려면 요청을 무조건 많이 병렬 실행해서는 안 됩니다. 중앙 동시성 게이트와 기능별 우선순위, 수명에 연결된 취소, 최신 결과만 반영하는 정책, 할당량 측정이 함께 필요합니다.

먼저 요청 수를 2~4개 수준으로 제한하고 Profiler에서 GC Alloc와 프레임 시간을 측정합니다. 이후 오류율과 큐 대기 시간을 보면서 동시성, 페이로드 크기, 재시도 정책을 한 번에 하나씩 조정하면 플레이 경험을 해치지 않는 LLM 기능에 가까워질 수 있습니다.