Unity 인게임 디버그 콘솔 구현: 런타임 변수 수정 명령어 설계 가이드

Unity C#에서 개발자 전용 인게임 디버그 콘솔을 구현하고 명령어 파싱·런타임 변수 수정·자동 완성·빌드 보안을 최소 구조로 설계하는 실전 가이드입니다.

✓ 복사 완료!
챕터인게임 디버그 도구 & 콘솔
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Unity 인게임 디버그 콘솔 구현: 런타임 변수 수정 명령어 설계 가이드

핵심 요약

인게임 개발자 전용 디버그 콘솔은 명령어 등록부, 공백·따옴표를 처리하는 토큰 파서, 화이트리스트 기반 런타임 변수 접근으로 구성하면 충분하다. set player.speed 8.5처럼 문자열로 들어온 명령을 안전하게 검증한 뒤 게임 상태를 보유한 단일 디버그 API를 통해서만 수정해야 릴리스 빌드 노출과 임의 리플렉션 접근을 막을 수 있다.

인게임 디버그 콘솔은 왜 필요한가?

에디터의 Inspector와 로그만으로도 많은 문제를 확인할 수 있다. 하지만 실제 조작 흐름, 네트워크 상태, 모바일 성능, 특정 세이브 데이터처럼 실행 중인 게임에서만 재현되는 문제는 인게임 콘솔이 훨씬 빠르다.

대표적인 명령은 다음과 같다.

목적예시 명령어기대 효과
플레이어 상태 확인get player.hp현재 값을 즉시 확인
게임플레이 튜닝set player.speed 8.5재빌드 없이 이동 속도 비교
테스트 이동teleport 12 0 -4맵의 특정 지점으로 이동
콘텐츠 해금give gold 10000보상·상점 흐름 검증
성능 계측perf fps프레임 시간과 FPS 확인

콘솔은 치트 기능을 만드는 장치가 아니라 재현과 검증의 왕복 시간을 줄이는 클라이언트 도구다. 따라서 게임 코드 전체를 열어 두기보다 필요한 상태만 명시적으로 노출하는 편이 낫다.

Unity 게임 화면 위에 오버레이된 개발자 전용 콘솔과 명령어 입력 예시

Unity 디버그 콘솔을 어떻게 최소 구조로 구현할까?

1. UI 입력과 로그 출력을 분리한다

Unity UI Toolkit 또는 uGUI 어느 쪽이든 입력창과 출력창만 있으면 된다. 핵심은 UI가 직접 게임 변수를 바꾸지 않고 DebugConsole.Execute()에 문자열을 넘기는 것이다.

using System;
using System.Collections.Generic;
using UnityEngine;

public sealed class DebugConsole : MonoBehaviour
{
    [SerializeField] private PlayerDebugApi player;
    private readonly Dictionary<string, Action<string[]>> commands = new();

    private void Awake()
    {
        commands["get"] = Get;
        commands["set"] = Set;
        commands["teleport"] = Teleport;
    }

    public string Execute(string input)
    {
        var tokens = CommandTokenizer.Tokenize(input);
        if (tokens.Length == 0) return string.Empty;

        if (!commands.TryGetValue(tokens[0].ToLowerInvariant(), out var command))
            return $"알 수 없는 명령어: {tokens[0]}";

        try
        {
            command(tokens);
            return "OK";
        }
        catch (ArgumentException e)
        {
            return e.Message;
        }
    }

    private void Get(string[] args)
    {
        if (args.Length != 2) throw new ArgumentException("사용법: get player.hp");
        Debug.Log($"{args[1]} = {player.Get(args[1])}");
    }

    private void Set(string[] args)
    {
        if (args.Length != 3) throw new ArgumentException("사용법: set player.speed 8.5");
        player.Set(args[1], args[2]);
    }

    private void Teleport(string[] args)
    {
        if (args.Length != 4) throw new ArgumentException("사용법: teleport x y z");
        player.Teleport(args[1], args[2], args[3]);
    }
}

명령 실행 결과를 Debug.Log에만 남기면 게임 화면의 콘솔 UI에는 보이지 않는다. 실제 구현에서는 Execute()가 ConsoleResult 같은 결과를 반환하도록 확장할 수 있지만 초기 버전은 문자열 로그 목록에 명령과 결과를 함께 추가하는 정도가 가장 단순하다.

2. 명령어 인자를 항상 검증한다

float.Parse()는 현재 기기의 문화권 설정에 따라 소수점 처리 결과가 달라질 수 있다. 디버그 명령어는 로그, 문서, 자동화 스크립트에서 동일하게 동작해야 하므로 CultureInfo.InvariantCulture로 해석한다.

using System;
using System.Globalization;
using UnityEngine;

public sealed class PlayerDebugApi : MonoBehaviour
{
    [SerializeField] private PlayerController player;

    public string Get(string path) => path switch
    {
        "player.hp" => player.Health.ToString(CultureInfo.InvariantCulture),
        "player.speed" => player.MoveSpeed.ToString(CultureInfo.InvariantCulture),
        _ => throw new ArgumentException($"읽을 수 없는 변수: {path}")
    };

    public void Set(string path, string value)
    {
        if (!float.TryParse(value, NumberStyles.Float,
                CultureInfo.InvariantCulture, out var number))
            throw new ArgumentException($"숫자 형식이 아닙니다: {value}");

        switch (path)
        {
            case "player.hp":
                player.Health = Mathf.Clamp(number, 0f, player.MaxHealth);
                break;
            case "player.speed":
                player.MoveSpeed = Mathf.Clamp(number, 0f, 30f);
                break;
            default:
                throw new ArgumentException($"수정할 수 없는 변수: {path}");
        }
    }

    public void Teleport(string x, string y, string z)
    {
        if (!float.TryParse(x, NumberStyles.Float, CultureInfo.InvariantCulture, out var px) ||
            !float.TryParse(y, NumberStyles.Float, CultureInfo.InvariantCulture, out var py) ||
            !float.TryParse(z, NumberStyles.Float, CultureInfo.InvariantCulture, out var pz))
            throw new ArgumentException("좌표는 숫자 3개여야 합니다.");

        player.transform.position = new Vector3(px, py, pz);
    }
}

여기서 PlayerController의 필드나 프로퍼티를 리플렉션으로 전부 노출하지 않는 것이 중요하다. PlayerDebugApi는 콘솔이 수정 가능한 값을 선언하는 화이트리스트이며 값 범위와 부작용을 한곳에서 통제한다.

3. 따옴표를 지원하는 토크나이저를 둔다

아이템 이름이나 씬 이름처럼 공백이 들어가는 인자는 단순한 Split(' ')으로 처리할 수 없다. 아래 정도의 작은 파서면 give item "health potion" 3을 처리할 수 있다.

using System.Collections.Generic;
using System.Text;

public static class CommandTokenizer
{
    public static string[] Tokenize(string input)
    {
        var tokens = new List<string>();
        var token = new StringBuilder();
        var quoted = false;

        foreach (var c in input.Trim())
        {
            if (c == '"')
            {
                quoted = !quoted;
            }
            else if (char.IsWhiteSpace(c) && !quoted)
            {
                if (token.Length > 0)
                {
                    tokens.Add(token.ToString());
                    token.Clear();
                }
            }
            else
            {
                token.Append(c);
            }
        }

        if (quoted) return new[] { "__ERROR__: 닫히지 않은 따옴표" };
        if (token.Length > 0) tokens.Add(token.ToString());
        return tokens.ToArray();
    }
}

위 예제처럼 오류를 문자열 토큰으로 섞는 방식은 실서비스 코드에는 권장하지 않는다. 실제로는 bool TryTokenize(string input, out string[] tokens, out string error) 형태로 분리하면 오류 처리가 명확해진다. 다만 콘솔의 첫 구현에서는 파서의 역할을 과하게 일반화할 필요가 없다.

런타임 변수 수정 명령어는 어떻게 안전하게 설계할까?

변수 경로와 타입 변환 규칙을 문서화하면 QA와 기획자가 같은 명령어를 반복해서 사용할 수 있다.

명령 형식예시검증 규칙
get <경로>get player.hp읽기 허용 경로인지 확인
set <경로> <값>set player.speed 8.5타입 변환과 허용 범위 검증
toggle <기능>toggle godmode명시적으로 등록된 기능만 변경
give <종류> <수량>give gold 1000종류와 수량 상한 검증

값 검증은 단순히 크래시를 막는 수준이 아니다. 예를 들어 체력이 음수가 되면 사망 처리와 UI 갱신이 꼬일 수 있고 이동 속도가 과도하면 충돌 검증이 실패할 수 있다. 콘솔에서도 일반 플레이 경로와 같은 불변 조건을 지켜야 한다.

flowchart LR
    A[콘솔 입력] --> B[토큰 파싱]
    B --> C{등록된 명령어인가?}
    C -- 아니오 --> D[오류 출력]
    C -- 예 --> E[인자 수·타입·범위 검증]
    E -- 실패 --> D
    E -- 성공 --> F[Debug API 실행]
    F --> G[게임 상태 변경 및 결과 출력]

개발자 콘솔을 릴리스 빌드에서 어떻게 보호할까?

가장 안전한 기본 정책은 릴리스 빌드에서 콘솔 UI와 명령 등록을 만들지 않는 것이다. Unity의 DEVELOPMENT_BUILD와 UNITY_EDITOR 전처리 심볼을 이용하면 된다.

private void Update()
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
    if (Input.GetKeyDown(KeyCode.BackQuote))
        ToggleConsole();
#endif
}

다만 전처리 심볼만으로 충분하지 않은 경우도 있다. 원격 QA, 스테이징 빌드, 라이브 운영 도구처럼 배포 후 접근이 필요한 환경에서는 다음 기준을 적용한다.

  1. 콘솔을 활성화할 빌드 채널을 분리한다.
  2. 서버 권한이 필요한 명령은 클라이언트에서 직접 상태를 변경하지 않고 인증된 운영 API로 요청한다.
  3. 명령 실행 기록에 사용자, 시간, 명령어를 남긴다.

싱글플레이 테스트용 godmode와 네트워크 재화 변경 명령은 같은 위험도로 다루면 안 된다. 특히 서버 권위 구조에서는 set inventory.gold 1000이 클라이언트 메모리만 바꾸거나 서버와 불일치를 일으키지 않도록 서버 측 검증이 필요하다.

명령어 등록부와 화이트리스트 Debug API를 거쳐 게임 상태를 변경하는 Unity 콘솔 구조

자동 완성과 명령 히스토리는 언제 추가해야 할까?

명령어가 10개 이하이고 개발자가 몇 명뿐이라면 우선 추가하지 않아도 된다. 콘솔 사용 빈도가 높아져 오타와 명령어 탐색이 반복될 때만 아래 기능을 붙인다.

  • Tab: 현재 접두사와 일치하는 명령어 또는 변수 경로 완성
  • ↑, ↓: 이전 실행 명령어 이동
  • help: 명령어, 사용법, 예시 출력
  • help set: 특정 명령어의 세부 사용법 출력

자동 완성의 후보는 이미 존재하는 commands.Keys와 PlayerDebugApi의 노출 경로에서 만들면 된다. 별도의 명령어 데이터베이스를 추가하기보다 등록 시 도움말 문자열을 같이 보관하는 편이 유지보수 비용이 적다.

자주 묻는 질문 (FAQ)

Unity 디버그 콘솔에 리플렉션을 사용해도 될까?

프로토타입에서는 가능하지만 권장하지 않는다. 공개 필드와 메서드가 의도치 않게 노출되고 타입 검증, 권한, 자동 완성 규칙이 복잡해진다. 수정 가능한 변수만 명시한 Debug API가 더 안전하다.

런타임 변수 변경값을 저장해야 할까?

기본값은 저장하지 않는 편이 좋다. 콘솔 변경은 실험용 상태이므로 씬 재시작 또는 게임 재시작 시 초기화하는 것이 재현성을 높인다. 튜닝값을 영구화해야 한다면 별도의 개발용 설정 파일과 적용 명령을 둔다.

Unreal Engine에서도 같은 구조를 쓸 수 있나?

가능하다. Unreal의 UCheatManager, 콘솔 변수(CVar), Exec 함수는 명령 진입점으로 활용할 수 있다. 다만 게임플레이 상태 변경은 UFUNCTION(Exec)를 무분별하게 늘리기보다 Unity와 마찬가지로 허용 목록과 권한 검증을 유지하는 편이 좋다.

정리

인게임 디버그 콘솔의 핵심은 화려한 UI가 아니라 안전한 경계다. 문자열 명령을 파싱한 뒤 등록된 명령어만 실행하고 런타임 변수는 타입·범위 검증을 거친 Debug API로만 수정하자. 이 구조만으로도 플레이테스트 중 수치 조정, 상태 확인, 위치 이동에 드는 반복 시간을 크게 줄일 수 있다.