레거시 데스크톱 프로그램을 Claude Code와 MCP로 연결해보기 — 입문 학습기

4 minute read

사내에서 쓰던 오래된 WinForms 프로그램을 Claude Code 채팅에서 바로 쓸 수 있게 만들어본 경험을 정리한다. MCP도 LangChain도 이번에 처음 다뤄봐서, “무엇을 만들었나”보다 “무엇을 헷갈렸고 어떻게 이해했나”에 초점을 맞춰 기록한다.

왜 시작했나

사내에는 특정 텍스트 데이터를 입력받아 AI 모델(2단계 파이프라인: 1차 모델이 텍스트의 각 부분이 어떤 역할인지 태깅하고, 2차 모델이 카테고리를 분류)로 분석해서, 규칙(Rule) 파일을 자동 생성해주는 오래된 WinForms 프로그램이 있었다. 파일을 열고, 버튼을 눌러야만 쓸 수 있는 GUI 전용 프로그램이었다.

이걸 “채팅창에 텍스트만 붙여넣으면 Claude가 알아서 규칙을 만들어주는” 형태로 바꾸고 싶어서, MCP(Model Context Protocol)로 연동하는 작업을 진행했다. 겸사겸사 낡은 UI 프레임워크(WinForms)도 WPF로 옮겼다.

1. 구조부터 정리: 하나였던 프로그램을 세 조각으로

기존엔 GUI 이벤트 핸들러 안에 “실제 계산 로직”과 “화면 그리는 코드”가 뒤엉켜 있었다. 이걸 역할별로 쪼갰다.

MyTool.Core/   ← 실제 계산 로직 (AI 모델 추론, 규칙 생성). UI에 대해 전혀 모름
MyTool.Cli/    ← 헤드리스 콘솔 앱. stdin으로 텍스트 받아서 Core 호출 → JSON을 stdout에 출력
MyTool.Gui/    ← 기존 GUI (WPF). 이것도 Core를 참조해서 씀

포인트는 Core는 화면이 있는지 없는지 전혀 모른다는 것. GUI든 CLI든 그냥 Core의 함수를 부르기만 한다. 이 분리 덕분에, CLI라는 “UI 없는 실행 파일”을 하나 더 만드는 게 어렵지 않았다.

CLI는 매개변수를 받는 게 아니라 표준입력(stdin)으로 텍스트를 통째로 읽고, 표준출력(stdout)에 JSON 한 줄을 찍는 아주 단순한 구조로 만들었다.

string inputText = Console.In.ReadToEnd();
var result = new RuleEngine().GenerateFromText(inputText);
Console.WriteLine(JsonConvert.SerializeObject(result));

이렇게 만든 이유: 나중에 이 CLI를 실행하는 쪽(파이썬)이 “함수 호출”이 아니라 “다른 프로세스를 실행하고 그 출력을 캡처하는” 방식으로 결과를 받아야 하기 때문이다. 프로세스 경계를 넘을 땐 객체를 그대로 못 넘기고 텍스트로 직렬화해야 한다.

2. MCP가 뭔지 — 한 줄 요약

MCP(Model Context Protocol)는 LLM이 “이런 도구가 있다”를 표준화된 방식으로 알고 호출할 수 있게 해주는 프로토콜이다. 핵심은 LLM은 도구의 실제 코드(함수 몸체)를 절대 보지 않는다는 것 — 오직 이름, 설명, 입력 스키마만 본다.

파이썬으로 서버를 만들 때는 mcp 패키지의 FastMCP라는 고수준 API를 썼다. 데코레이터 하나로 끝난다.

from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my-tool")

@mcp.tool()
def generate_rule(input_text: str) -> dict:
    """텍스트로부터 규칙을 생성한다."""
    proc = subprocess.run(["MyTool.Cli.exe"], input=input_text,
                           capture_output=True, text=True, encoding="utf-8")
    return json.loads(proc.stdout)

if __name__ == "__main__":
    mcp.run()   # 기본은 stdio 방식

@mcp.tool()이 하는 일: 함수의 타입 힌트(input_text: str)로 입력 스키마를 만들고, 독스트링("""...""")을 도구 설명으로 등록한다. 실제로 확인해보니, 여기서 쓰는 """..."""# 주석과 완전히 다르다 — # 주석은 파싱 단계에서 사라지지만, 독스트링은 함수.__doc__이라는 실제 데이터로 남아서 런타임에도 읽을 수 있다. @mcp.tool()이 바로 이 __doc__을 읽어서 LLM에게 넘길 도구 설명을 만든다. 즉, LLM이 “이 도구를 언제 쓸지” 판단하는 근거가 되는 게 바로 이 독스트링이다.

3. 만든 순서 — 한 번에 다 안 만들고 이렇게 쪼갰다

MCP를 처음 다뤄보는 거라, 한 번에 다 만들지 않고 아래 순서로 하나씩 확인하면서 진행했다.

  1. 파이썬 환경 준비: 가상환경 만들고 pip install "mcp[cli]"
  2. MCP 없이 순수 subprocess로 CLI 호출: MCP를 씌우기 전에, 씌울 대상(CLI)이 잘 동작하는지부터 확인. 여기엔 MCP 코드가 한 줄도 없다.
  3. @mcp.tool()로 감싸기: 2번 스크립트 위에 데코레이터 하나만 얹기
  4. 직접 만든 미니 클라이언트로 로컬 테스트: Claude Code 없이, mcp 패키지의 클라이언트 API(ClientSession, stdio_client)로 서버를 실제로 기동시키고 진짜 프로토콜(ListToolsRequest, CallToolRequest)로 호출해봄. 이렇게 하면 Claude Code가 나중에 정확히 뭘 하는지 미리 체감할 수 있다.
  5. Claude Code에 등록: 프로젝트 루트에 .mcp.json 파일을 만들어서 “이 이름의 도구는 이 명령어로 실행하면 된다”고 알려줌
  6. 실제 대화에서 테스트: 새 대화를 열어서 진짜 텍스트를 붙여넣고 확인

각 단계가 이전 단계 코드를 거의 그대로 재사용해서, 뭐가 새로 추가된 건지 명확하게 구분됐던 게 도움이 됐다.

4. 헷갈렸던 것들 정리

stdio vs 소켓/HTTP — 네트워크가 아예 없다

처음엔 “내 컴퓨터의 IP 주소를 쓰는 건가?”라고 생각했는데, 아니었다. .mcp.json에 등록하는 방식은 Claude Code가 파이썬 스크립트를 직접 실행(서브프로세스)시키고, 그 프로세스의 표준입출력 파이프로 대화하는 방식이다. IP도 포트도 없다.

  stdio (지금 쓰는 방식) HTTP/소켓
통신 방법 프로세스 직접 실행 + 파이프 IP:포트로 접속
실행 주체 클라이언트가 서버를 직접 켬 서버를 미리 켜놔야 함
다른 컴퓨터에서 접근 불가능 가능
우리 상황에 적합한가 O (개인 로컬 도구) 팀 전체가 같이 써야 할 때

만약 다른 사람도 이 도구를 쓰게 하려면, 서버를 상시 실행되는 컴퓨터에 HTTP 방식(mcp.run(transport="streamable-http", ...))으로 띄우고, 각자의 설정에 URL을 등록하는 방식으로 바꿔야 한다. 이 경우:

  • CLI 실행 파일과 모델 파일도 그 서버 컴퓨터에 있어야 함 (중앙 배포)
  • 네트워크에 포트를 열어두는 것이므로 인증/방화벽을 신경 써야 함 (지금 stdio 방식은 네트워크 노출이 전혀 없어서 이런 고민 자체가 없음)

.mcp.json은 “지금 연 프로젝트 폴더”에서만 적용된다

윈도우 전체를 뒤져서 .mcp.json을 찾는 게 아니라, Claude Code가 지금 열려있는 프로젝트 폴더의 최상단만 확인한다. 신뢰 여부(승인 상태)도 프로젝트 폴더 경로별로 완전히 독립적으로 관리된다 — A 프로젝트에서 승인해도 B 프로젝트에는 전혀 영향이 없다.

async/await — “동시 실행”이 아니라 “기다리는 방식의 차이”

가장 크게 오해했던 부분. async가 “여러 작업이 뒤섞여서 동시에 실행된다”는 뜻이라고 생각했는데, 아니었다.

# 순차: 하나 끝나야 다음 시작 (총 3초)
await task_a()
await task_b()
await task_c()

# 동시: 셋 다 한꺼번에 시작 (총 1초, asyncio.gather를 써야 함)
await asyncio.gather(task_a(), task_b(), task_c())

직접 두 버전을 만들어서 시간을 재보니 차이가 명확했다 — 그냥 await를 순서대로 쓰면 동기 코드와 똑같이 순서대로 실행된다. asyncio.gather()처럼 명시적으로 “같이 돌려줘”라고 하지 않는 이상, 절대 뒤섞여서 실행되지 않는다. async의 진짜 의미는 “기다리는 동안(I/O 등) 프로그램 전체가 멈추지 않고, 다른 할 일이 있으면 그걸 처리할 수 있다”는 것이지, “순서가 보장 안 된다”는 뜻이 아니었다.

5. 다음 단계 — LangChain은 아직

이번엔 MCP 서버가 CLI를 그대로 중계하는, 순수하게 “결정적 계산기” 역할만 하도록 만들었다(LLM 호출이 서버 내부에 전혀 없음). LangChain은 나중에 다음과 같은 걸 하고 싶을 때 붙일 예정이다:

  • 사용자가 지저분하게 붙여넣은 텍스트에서 필요한 부분만 정리하는 전처리 단계
  • 결과를 자연어로 더 친절하게 설명해주는 후처리 단계
  • 여러 단계를 하나의 파이프라인으로 엮기

지금은 Claude Code 자체가 이 역할(텍스트 해석, 결과 설명)을 이미 잘 해주고 있어서, 당장 필요하지는 않았다.

마무리

MCP는 생각보다 개념이 단순했다 — “함수에 데코레이터 하나 붙이면, 타입 힌트와 독스트링을 읽어서 LLM이 이해할 수 있는 도구 명세로 자동 변환해준다”가 거의 전부였다. 오히려 헷갈렸던 건 MCP 자체보다 그 주변 개념들(동기/비동기, 프로세스 통신 방식, stdio/소켓 차이)이었는데, 하나씩 직접 실행해서 눈으로 확인하면서 넘어가니 훨씬 명확해졌다.

Updated:

Leave a comment