AI 보조 게임 개발

Unity 에디터를 Claude Code에 연결하기: MCP 브리지로 AI가 씬을 직접 읽고 고치게 만들기

kr-gamedev 2026. 6. 17. 09:03

AI 코딩 도구를 게임 개발에 써보면 곧바로 벽에 부딪힌다. 가장 큰 맹점은 AI가 "결과를 못 본다"는 것이다. Claude Code가 적 컴포넌트 코드를 멀쩡하게 짜줘도, 정작 실행 중인 씬에서 그 프리팹의 체력이 왜 0인지, Light가 몇 개나 켜져 있는지, NavMeshAgent의 speed가 어떻게 세팅됐는지는 전혀 모른다. 그래서 우리가 매번 인스펙터 값을 손으로 복사해 채팅창에 떠먹여 줘야 한다. 이 왕복이 생산성을 갉아먹는다.

2024년 말 등장한 MCP(Model Context Protocol)가 바로 이 간극을 메운다. MCP는 AI 클라이언트가 외부 도구를 표준화된 방식으로 호출하게 해주는 프로토콜이다. 이걸로 Claude Code를 "실행 중인 Unity 에디터"에 직접 연결하면, AI가 씬 계층을 조회하고 컴포넌트 값을 읽고 심지어 일괄 수정까지 하게 만들 수 있다. 코드 생성기에서 진짜 페어 개발자로 한 단계 올라가는 셈이다.

이 글에서는 Unity 에디터 안에 작은 TCP 브리지를 심고, 그 앞에 MCP 서버를 세워 Claude Code와 양방향으로 통신하는 워크플로우를 처음부터 만든다. 핵심 트릭은 백그라운드 소켓 스레드에서 받은 명령을 에디터 메인 스레드로 안전하게 넘기는 마샬링이다.

전체 그림: 4단계 파이프라인

연결 구조는 화살표 네 개로 정리된다.

Claude Code  ──stdio(MCP)──  MCP 서버(Node)  ──TCP(localhost)──  Unity 에디터 브리지

Claude Code는 MCP 서버를 자식 프로세스로 띄우고 stdio로 도구를 호출한다. MCP 서버는 그 호출을 localhost TCP 패킷으로 바꿔 Unity로 던진다. Unity 안의 브리지는 패킷을 받아 에디터 API를 실행하고 결과를 되돌려준다. 각 구간이 표준 프로토콜이라 어느 한쪽만 갈아끼워도 나머지는 그대로 굴러간다.

1. Unity 에디터 브리지 심기

가장 까다로운 부분은 스레드다. 소켓 수신 콜백은 스레드풀에서 돌지만, Unity API는 오직 메인 스레드에서만 호출할 수 있다. 그래서 수신은 백그라운드에서 하되 실행은 메인 스레드 펌프로 넘긴다. EditorApplication.update가 그 펌프 역할을 한다.

#if UNITY_EDITOR
using System;
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Text;
using UnityEditor;

[InitializeOnLoad]
static class EditorBridge
{
    static readonly ConcurrentQueue<(string json, TcpClient client)> Queue = new();
    static readonly TcpListener Listener = new(IPAddress.Loopback, 17923);

    static EditorBridge()
    {
        Listener.Start();
        Listener.BeginAcceptTcpClient(OnAccept, null);
        EditorApplication.update += Pump;          // 메인 스레드 펌프 등록
    }

    static void OnAccept(IAsyncResult ar)           // 스레드풀 스레드
    {
        var client = Listener.EndAcceptTcpClient(ar);
        var buf = new byte[8192];
        int n = client.GetStream().Read(buf, 0, buf.Length);
        Queue.Enqueue((Encoding.UTF8.GetString(buf, 0, n), client));
        Listener.BeginAcceptTcpClient(OnAccept, null);
    }

    static void Pump()                              // 에디터 메인 스레드
    {
        while (Queue.TryDequeue(out var job))
        {
            string reply = CommandRouter.Handle(job.json);
            var bytes = Encoding.UTF8.GetBytes(reply);
            job.client.GetStream().Write(bytes, 0, bytes.Length);
            job.client.Close();
        }
    }
}
#endif

ConcurrentQueue가 두 스레드 사이의 안전한 우편함이다. 수신 스레드는 넣기만, 메인 스레드는 꺼내 실행하기만 한다.

2. 명령 라우터: AI가 에디터를 읽고 쓰게

이제 들어온 명령을 실제 에디터 동작으로 분기한다. 읽기는 SerializedObject로 안전하게 덤프하고, 쓰기는 반드시 Undo.RecordObjects로 되돌리기를 등록한다. 분기는 모던 C#의 switch 식으로 깔끔하게 처리한다.

using System.Linq;
using System.Text;
using UnityEditor;
using UnityEngine;

static class CommandRouter
{
    [System.Serializable]
    class Request { public string cmd; public string target; public string type; public float value; }

    public static string Handle(string json)
    {
        var req = JsonUtility.FromJson<Request>(json);
        return req.cmd switch
        {
            "get_hierarchy" => Hierarchy(),
            "get_component" => DumpComponent(req.target, req.type),
            "set_light"     => SetLights(req.value),
            _               => $"{{\"error\":\"unknown:{req.cmd}\"}}"
        };
    }

    static string Hierarchy()
    {
        var names = Object.FindObjectsByType<GameObject>(FindObjectsSortMode.None)
                          .Select(go => $"\"{go.name}\"");
        return "[" + string.Join(",", names) + "]";
    }

    static string DumpComponent(string target, string type)
    {
        var go = GameObject.Find(target);
        if (go == null) return "{\"error\":\"not found\"}";

        var so = new SerializedObject(go.GetComponent(type));
        var sb = new StringBuilder("{");
        var p = so.GetIterator();
        while (p.NextVisible(true))
            sb.Append($"\"{p.name}\":\"{p.propertyType}\",");
        return sb.Append("}").ToString();
    }

    static string SetLights(float intensity)
    {
        var lights = Object.FindObjectsByType<Light>(FindObjectsSortMode.None);
        Undo.RecordObjects(lights, "AI: set light intensity");   // 되돌리기 등록 필수
        foreach (var l in lights) l.intensity = intensity;
        return $"{{\"updated\":{lights.Length}}}";
    }
}

FindObjectsByType(FindObjectsSortMode.None)는 구버전 FindObjectsOfType를 대체한 최신 API다. 정렬을 생략해 더 빠르다.

3. MCP 서버로 Claude Code에 노출

마지막으로 TCP 브리지를 MCP 도구로 감싼다. Node 스텁이면 충분하다.

// unity-mcp/server.js
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import net from "node:net";

const callUnity = (payload) => new Promise((resolve) => {
  const sock = net.createConnection(17923, "127.0.0.1", () =>
    sock.write(JSON.stringify(payload)));
  let buf = "";
  sock.on("data", (d) => (buf += d));
  sock.on("end", () => resolve(buf));
});

const server = new McpServer({ name: "unity-bridge", version: "1.0.0" });

server.tool("unity_get_hierarchy", "씬의 모든 GameObject 이름", {}, async () => ({
  content: [{ type: "text", text: await callUnity({ cmd: "get_hierarchy" }) }],
}));

await server.connect(new StdioServerTransport());

등록은 한 줄이면 끝난다.

claude mcp add unity-bridge -- node /path/to/unity-mcp/server.js

이제 채팅에서 "지금 씬에 라이트 몇 개야?"라고 물으면 Claude Code가 unity_get_hierarchy 같은 도구를 알아서 호출한다.

실전 활용과 주의점

가장 자주 쓰는 패턴은 디버깅이다. "이 적이 왜 안 죽지?"라고 물으면 AI가 get_component로 EnemyHealth를 직접 덤프해 maxHp가 0임을 짚어낸다. 사람이 인스펙터를 캡처해 붙일 필요가 없다. 반복 작업도 강력하다. "모든 라이트 강도 0.5로 낮춰줘" 한마디면 set_light가 일괄 수정하고, 마음에 안 들면 Ctrl+Z로 되돌린다.

다만 지켜야 할 안전선이 있다. 브리지는 반드시 #if UNITY_EDITOR로 묶어 빌드에서 빠지게 하고, 리스너는 localhost에만 바인딩해 외부 접근을 막는다. 도메인 리로드가 일어나면 이전 리스너가 포트를 붙잡고 있을 수 있으니 재초기화 시 정리 가드를 둔다. 쓰기 명령에는 예외 없이 Undo를 등록하고, 실서비스용으로 키운다면 길이 접두어 기반 프레이밍으로 부분 수신 문제를 막아야 한다.

정리

MCP 브리지의 본질은 AI에게 "눈과 손"을 달아주는 것이다. 코드만 뱉던 도구가 실제 씬 상태를 읽고 직접 고치는 동료로 바뀐다. 구현에서 잊지 말아야 할 세 기둥은 분명하다. 첫째 소켓 스레드에서 메인 스레드로의 마샬링, 둘째 SerializedObject를 통한 안전한 읽기, 셋째 Undo 등록을 동반한 쓰기. 이 셋만 지키면 작은 TCP 리스너 하나로 Claude Code가 당신의 Unity 프로젝트 안으로 들어온다.