구 언어 함수 하나를 모델에 넘겨 새 언어로 옮겨 달라고 하면, 대개 깔끔하고 해당 언어다운 코드가 돌아온다. 네이밍 규칙도 맞고 테스트도 통과한다. 이 작업을 200번쯤 반복하면 마이그레이션이 끝났다고 선언하고 싶어진다.
하지만 여기에는 자주 섞이는 두 단어가 있다. 함수 하나가 제대로 번역됐는지는 모델이 잘하는 일이다. 반면 시스템 전체가 정말 마이그레이션됐는지는 전혀 다른 문제다. 이는 집합 연산이며, 모델에게 맡기면 안 되고 특히 그럴듯하게 속기 쉬운 작업이기도 하다.
실제 Go-to-Python 마이그레이션에서 나온 장부를 보자. 이 장부의 정산은 반드시 스크립트가 해야 하고, 모델은 결과를 설명하는 데만 써야 하는 이유도 함께 살펴본다.
먼저 봐야 할 숫자는 세 개다
기존 Go 레지스트리에는 23개 플랫폼과 329개 명령이 있었다. 새 Python 쪽은 argparse 선언에서 명령 집합을 추출하는데, 이 집합과 기존 레지스트리의 정확한 교집합은 201개였다. 즉 기존에만 존재하는 명령이 128개라는 뜻이다. Python으로 옮겨지지도 않았고 stub으로 남겨지지도 않은 명령들이다.
329 = 201 + 128. 기술적으로는 별 내용 없는 뺄셈이지만, 마이그레이션이 끝났는지 답해 주는 계산은 이것뿐이다. 함수 하나씩 번역하는 동안에는 절대 보이지 않는다. 누락은 부재 오류다. 에러도, 예외도, 실패한 테스트도 없다. 있어야 할 이름 하나가 없을 뿐이다. 애초에 채팅창에 들어온 적이 없으니, 초록 체크마크 200개를 보고 있어도 발견할 수 없다.
stub과 호환 프록시를 남기면 안 되는 이유
마이그레이션 중간에는 아직 처리하지 못한 명령에 자리만 채워 두고 싶어진다. raise NotImplementedError stub을 넣거나, 기존 바이너리로 전달하는 호환 프록시를 두어 “엔드포인트 카탈로그는 완전하다”고 만들고 싶은 유혹이다. 하지 말아야 한다. 빈 껍데기는 빈자리를 남기는 것보다 비용이 크며, 이유는 세 가지다.
우선 stub은 대사를 망가뜨린다. 명령 이름이 새쪽 집합에 들어가면 diff는 0이 되고, 작업이 끝났다고 착각한다. 빈자리는 정직한 빨간불이지만, stub은 “128개 남음”을 “전부 존재함”으로 칠해 버리는 초록색 거짓말이다.
호환 프록시는 정리하지 못한 의존성을 영구화한다. 프록시가 기존 Go 바이너리로 호출을 전달하는 한, 이전 런타임은 절대 제거할 수 없다. 마이그레이션의 목적은 낡은 스택을 걷어내는 데 있는데, 전달 프록시는 “임시 호환성”이라는 이름으로 그 스택을 들여와 평생 남긴다.
반쯤 만든 엔드포인트는 호출자도 속인다. Agent든 사람이든 카탈로그를 보고 작동한다고 믿고 호출한다. 그러다 runtime_unavailable를 만나거나, 더 나쁘게는 빈 결과를 조용히 반환하는 가짜 성공을 받는다.
정직한 공백이 사실 가장 싸다. diff가 즉시 빨간불을 켜고, 남은 양을 모두가 볼 수 있기 때문이다. 이는 앱 리버스 엔지니어링의 증거 기준과 같은 원칙이다. “아직 사용할 수 없음”이라고 표시하는 편이 반쯤 만든 것을 배포하는 것보다 언제나 저렴하다.
선언에서 뽑아내는 대사 스크립트
대사의 핵심은 한 줄로 요약된다. 양쪽 명령 집합은 모두 선언에서 추출하고, 사람이 직접 옮겨 적지 않는다. “마이그레이션 완료 목록”을 수기로 작성하는 순간 코드와 어긋날 제3의 진실 공급원을 추가하는 셈이다. 2주만 지나도 가장 먼저 틀어질 부분이 된다.
새 Python 쪽의 단일 진실 공급원은 각 플랫폼의 cli.py에 있는 argparse 선언이다. catalog 모듈이 하위 명령을 순회해 {platform/command} 집합을 내보내며, 결과는 python -m reverse describe --format json로 출력된다. 선언이 단일 진실 공급원이 될 수 있는 이유와 카탈로그가 이를 완전히 자동으로 추출하는 방식은 interface-as-code 글에서 다뤘다. 기존 Go 쪽은 이미 platform -> command 맵, 즉 바이너리에 컴파일된 불변 allowlist이므로 같은 형태의 JSON을 내보내기는 간단하다.
두 JSON 파일만 준비되면 나머지는 집합 연산이다.
# Both sides' command sets derive from declarations, not transcription.
# Transcribe by hand and you've added a third source of truth that will drift.
import json
from collections import Counter
def ids(path):
doc = json.load(open(path))
return {f"{p['name']}/{c['name']}"
for p in doc["platforms"] for c in p["commands"]}
old = ids("go-registry.dump.json") # old registry: immutable platform->command allowlist
new = ids("python-catalog.dump.json") # python -m reverse describe --format json
missing = old - new # old side only: each one needs a keep-or-drop verdict
added = new - old # new side only: new capability, logged separately
kept = old & new # intersection: migrated, but still check for semantic drift
assert missing | kept == old # every old-side item classified, none dropped
by_platform = Counter(pc.split("/")[0] for pc in missing) # goes straight into the README table
실행에는 몇 밀리초면 충분하다. 비용은 0이고, 결정론적이며, 100% 정확하다. missing이 바로 그 128개 명령이다. 플랫폼별로 집계하면 다음 표가 나온다.
플랫폼 | 미마이그레이션 명령 |
|---|---|
xiaohongshu | 33 |
tiktok | 30 |
hotspot | 21 |
douyin | 19 |
8 | |
7 | |
bilibili | 5 |
zhihu | 3 |
1 | |
netease_music | 1 |
합계 | 128 |
이 단계에 모델이 들어설 자리는 없다.
모델에게 목록 대조를 맡기면 비싸고 틀린다
스크립트를 건너뛰고 두 목록을 채팅창에 붙여 넣은 뒤 “329개 중 이 201개에 없는 항목은 무엇인가?”라고 물으면, 거의 예외 없이 세 가지 일이 벌어진다.
항목을 빠뜨린다. 목록이 길어지면 모델은 원소 단위로 집합 차집합을 계산하지 않고 “대충 맞아 보이는지”를 샘플링한다. 뒤쪽 항목은 희석되고, 완전해 보이지만 십여 개가 모자란 답을 받는다. 없는 항목을 만들어 내기도 한다. 양쪽에 모두 존재하는 항목을 누락으로 보고하거나, 실제 누락 항목을 마이그레이션 완료로 센다. 차이를 계산하는 것이 아니라 대사 보고서가 어떻게 생겼는지 흉내 내기 때문이다. 재현성도 없다. 같은 입력으로 두 번 물으면 누락 목록이 달라진다. 실행할 때마다 결과가 달라지는 “대사”는 대사가 아니다.
비용 면에서도 이득이 없다. 스크립트는 몇 밀리초면 끝나지만, 모델 비교에는 수십만 토큰과 여러 차례의 자체 검증이 든다. 비싸고 느리고 신뢰할 수도 없다. 집합 연산은 집합 연산을 하는 도구에 맡기자는 말은, 이 글에서 가장 이견이 적을 주장일 것이다.
모델의 역할: 마이그레이션 여부가 아니라 차이의 이유를 설명하기
스크립트는 128개의 “마이그레이션되지 않음”이라는 사실을 준다. 하지만 사실이 곧 결론은 아니다. 각각에는 유지할지 제거할지 판단이 필요하고, 판단에는 근거가 필요하다. 이 지점이 모델의 영역이다.
각 항목이 왜 옮겨지지 않았는지를 설명하게 하라. 죽은 코드인가? 업스트림 엔드포인트가 폐기됐는가? 나중으로 미룬 것인가? 가장 까다로운 경우는 삭제된 것이 아니라 다른 명령에 통합된 경우다. 이름은 사라졌지만 기능은 남아 있다. 이런 “삭제가 아니라 통합” 관계는 누락 목록만 봐서는 찾을 수 없다. 양쪽 레지스트리를 함께 읽으며 대응 관계를 맞춰야 한다.
교집합에 있는 201개도 안전하지 않다. 마이그레이션됐다고 의미가 보존된 것은 아니다. 같은 이름의 명령이지만 기본값이 조용히 바뀌었을 수 있고, 페이지네이션 의미가 바뀌었을 수도 있으며, 오류 코드 두 개가 하나로 합쳐졌을 수도 있다. 이것이 semantic drift다. 빈자리보다 더 교묘한 이유는 diff가 초록색이고 missing에 아예 나타나지 않기 때문이다. drift 검증에는 모델이 두 구현을 읽고 “행동이 동등한가”를 판단해야 하며, 마지막에는 차등 테스트, 즉 4단계 워크플로의 3단계에서 다루는 fixture 비교로 확인한다. 이미 성공한 번역을 보고도 “여기서 동작이 바뀌었다”고 지적하는 능력은 fingerprinting 글의 반증 섹션이 말하는 바로 그것이다. 약한 모델은 그저 “성공적으로 마이그레이션되었습니다”라고 되풀이할 뿐이다.
역할 분담은 명확하다. “존재하는가”는 스크립트가 판단하고, “남겨야 하는가, 바뀌었는가”는 추론이 필요한 문제다. 이번 사례에서는 diff가 빨간불로 표시한 명령 중 4개가 검토 결과 필요하다고 판정됐고, 일급 신규 명령으로 복원됐다. 스크립트가 판정하고, 모델이 설명하고, 사람이 결정한다. 세 계층이 각각 제자리를 지키는 구조다.
단계별로 어떤 모델을 쓸 것인가
아래 네 계층은 모두 설명 계층에 속한다는 점을 기억해야 한다. 판정 계층인 diff에는 모델을 전혀 쓰지 않는다. 이것이 이 글과 다른 “AI 마이그레이션” 글의 경계선이다.
단계 | 필요한 역량 | 추천 | model id |
|---|---|---|---|
양쪽 레지스트리를 한 번에 읽고, “삭제가 아니라 다른 곳에 통합됨” 대응 관계 찾기 | 긴 컨텍스트, 양쪽의 전체 선언을 동시에 읽는 능력 | Kimi K3 |
|
128개 누락 항목의 1차 유지·제거 분류, 구조화된 초안 작성 | 저렴한 비용, 높은 동시성으로 수백 건 호출 | Claude Sonnet 5 |
|
semantic drift 판단: 마이그레이션됐지만 동작이 바뀌었는지, 양쪽 구현 읽기 | 강한 추론력, “이 부분은 바뀌었다”고 말할 의지 | Claude Opus 5 |
|
마이그레이션됐지만 fixture가 맞지 않을 때, 파라미터나 응답 형태 기준으로 원인 설명 | 중간 수준의 추론 기반 귀속 분석 | GPT-5.6 Sol |
|
가장 시험해 볼 가치가 큰 것은 세 번째 계층이다. semantic drift 판단은 “이미 성공한 번역에 반론을 제기할 수 있는가”를 정확히 시험하며, 이 지점에서 모델 교체가 결과를 가장 크게 바꾼다. 절차는 다음과 같다.
실제 보유한 이중 언어 마이그레이션 하나를 골라 스크립트로
missing집합을 구한다. 이 단계에는 모델을 전혀 쓰지 않는다.그중 10~15개에 대해 정답 라벨을 직접 붙여 대조군을 만든다. 제거, 유지, 다른 곳에 통합, 연기 같은 분류다.
claude-opus-5와 저가 계층에 똑같이 “항목별 유지·제거 사유를 설명하라”는 프롬프트를 넣고 두 가지를 본다. 유지·제거 근거가 구체적인 코드 사실을 가리키는지, 아니면 “아마 deprecated일 수 있음” 같은 흐릿한 말만 하는지. 그리고 각각이 다른 곳에 통합된 대응 관계를 몇 개 찾아내는지다.발견한 숨은 대응 관계의 수가 그 모델에게 1차 분류를 맡길 수 있는지 판단하는 근거가 된다.
진짜 마찰은 모델 선택이 아니라 전환 비용이다
세 벤더의 모델 네 개를 쓰려면 SDK도 세 개, 인증 방식도 세 개, 오류 형식도 세 개가 된다. 계층을 바꿀 때마다 클라이언트를 세 번씩 다시 쓰는 것은 가치가 없으니, 대부분은 처음부터 끝까지 모델 하나만 쓴다. 그 결과 가장 강한 추론 계층이 필요한 semantic drift 검토에서도 모호한 말만 하는 저가 계층을 사용하고, 초록색 drift를 그대로 통과시킨다.
AIReiter는 이 계층을 평탄화한다. 키 하나와 OpenAI 호환 인터페이스 하나로 네 계층을 모두 사용할 수 있고, 요청 본문의 model 필드만 바꾸면 된다.
# Semantic-drift review / per-item keep-or-drop: the reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "<both implementations + this command migration status, ask if behavior is equivalent>"}]
}'
# First pass on 128 missing items in bulk: change the model field, leave the rest
# "model": "claude-sonnet-5"
# Diff attribution when a fixture won't match:
# "model": "gpt-5.6-sol"
이미 OpenAI SDK를 사용 중이라면 base_url을 https://aireiter.com/api/v1로 지정하면 된다. 나머지는 바꿀 필요가 없다. Anthropic SDK에서는 같은 키로 POST /api/v1/messages를 호출한다.
가격 측면에서도 이 흐름에 맞는다. 1차 분류는 수백 개 항목을 한꺼번에 처리하고 마이그레이션 라운드마다 다시 실행하므로, 높은 동시성의 claude-sonnet-5가 가장 저렴하다. semantic drift 검토는 어려운 사례 십여 개를 claude-opus-5에 반복해서 묻는 작업이라 항목당 가장 비싸다. 둘 다 Claude 계층이므로 30% 할인은 가장 밀도가 높고 가장 비용이 큰 구간에 정확히 적용된다. gpt-5.6-sol은 diff 원인 귀속에 쓰며, GPT는 반값이다.
가입 없이 사용해 보기: 먼저 누락 항목 몇 개를 직접 실행해 “다른 곳에 통합됨” 사례를 찾아내는지 확인한 뒤 연동 여부를 결정하면 된다.
마무리
“번역됨”은 함수 하나만 볼 때 생기는 착시다. “마이그레이션됨”은 diff로 판정한다. 집합 연산은 스크립트에, 설명은 모델에, 결정은 사람에게 맡겨야 한다. 이 순서는 바꿀 수 없으며, 특히 모델이 판정까지 하게 해서는 안 된다.
또 하나 쉽게 빼먹는 단계가 있다. 누락 목록은 README에 넣어 장기적으로 보이게 해야 한다. 128이라는 숫자는 0이 될 때까지, 또는 각각에 “X 때문에 마이그레이션하지 않음”이라는 기록이 남을 때까지 계속 공개돼야 한다. 어떤 PR 토론에만 존재하는 대사는 대사가 아니다. 다음 담당자는 그 사실을 볼 수 없고 128개를 다시 밟게 된다. 이것이 이 글과 interface-as-code 글, 그리고 통합 응답 Model을 만들지 않는 이유를 다룬 글이 공유하는 기반이다. 단일 진실 공급원이 스스로 말하게 하고, 결론을 사람들의 기억 속에 흩어 놓지 말아야 한다.