Skip to content

필사 모드: AI로 코드베이스를 옮기는 실전 절차 — 심판을 먼저 세우고, 줄 수가 아니라 리뷰율을 측정한다

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — 순서를 틀리면 규모가 커질수록 손해가 커집니다

2026년 상반기에 대규모 LLM 마이그레이션 사례가 연달아 공개됐습니다. Bun은 53만 5천 줄의 Zig를 11일 만에 Rust로 옮겼고(발표문), Anthropic의 Mike Krieger는 16만 5천 줄의 Python을 주말 하나 동안 TypeScript로 옮겼습니다(방법론 정리에 실린 사례). 두 사례의 언어도 규모도 팀 구성도 다르지만, 절차는 놀랄 만큼 같습니다.

그리고 그 절차에서 가장 중요한 것은 개별 기법이 아니라 순서입니다. 번역을 먼저 시작하고 검증을 나중에 붙이면, 규모가 커질수록 손해가 기하급수로 커집니다. 100개 파일을 잘못 옮기고 나서 규칙이 틀렸음을 알면 100개를 다시 돌리면 되지만, 1,400개를 옮긴 뒤에 알면 그 시점에는 이미 다음 단계의 작업이 그 위에 쌓여 있습니다.

이 글은 공개된 1차 소스(Anthropic의 마이그레이션 키트와 방법론 문서, Bun 발표문)에서 재현 가능한 절차만 뽑아 순서대로 정리한 것입니다. 특정 모델이나 도구에 묶이지 않는 부분만 남겼고, 검증되지 않은 대목은 그렇다고 적었습니다. Bun 사례 자체에 대한 분석은 별도 글에서 다뤘으니, 여기서는 절차만 봅니다.

0단계 — 번역보다 먼저 심판을 세우고, 심판을 먼저 디버깅한다

가장 흔한 실수는 이 단계를 건너뛰는 것입니다. 마이그레이션은 사람 리뷰를 기계 판정으로 대체해서 속도를 얻습니다. 판정자가 없으면 속도를 얻은 게 아니라 검증을 생략한 것입니다.

동작 오라클은 셋 중 하나 이상으로 만듭니다.

이식 가능한 테스트 스위트가 가장 좋습니다. 조건은 하나 — 테스트가 공개 표면만 건드려야 합니다. 내부 함수를 직접 부르거나 프라이빗 상태를 검사하는 테스트는 언어가 바뀌면 같이 버려야 하므로 오라클이 되지 못합니다. Bun이 11일을 만든 결정적 조건이 여기 있었습니다. 테스트가 TypeScript로 작성돼 런타임 구현 언어와 무관했고, 이식 과정에서 삭제되거나 스킵된 테스트가 0개였습니다.

골든 출력은 테스트가 부실한 레거시에서 두 번째 선택지입니다. 실제 입력 묶음을 정해 옛 구현의 출력을 파일로 고정해 두고, 새 구현의 출력과 바이트 단위로 비교합니다. 배치 잡, 리포트 생성기, 파서처럼 입출력이 결정적인 시스템에서 잘 작동합니다.

차등 실행은 셋 중 가장 강력합니다. 같은 입력을 두 구현에 동시에 흘리고 결과를 비교합니다. Krieger의 사례가 이 형태였는데, 실제 시나리오 7개로 패리티 하네스를 만들고 어떤 동작 변화든 버그로 간주하는 규칙을 세웠습니다. "의도한 개선"이라는 예외를 허용하지 않는 것이 핵심입니다. 예외를 허용하는 순간 오라클이 협상 대상이 됩니다.

그리고 여기서 대부분이 놓치는 단계가 하나 더 있습니다 — 심판을 먼저 디버깅합니다. 오라클을 원본 코드에 돌려 전부 통과하는지, 그리고 일부러 망가뜨린 원본에 돌려 제대로 떨어지는지를 확인해야 합니다. 전부 떨어뜨리는 심판은 대개 옳은 게 아니라 고장 난 것입니다.

# 심판 검증: 통과해야 할 때 통과하고, 떨어져야 할 때 떨어지는가
git stash list >/dev/null 2>&1 || exit 1

# 1) 정상 원본에서 전부 초록이어야 한다
./run-oracle.sh --target=legacy || echo "FAIL: 심판이 원본조차 통과시키지 못한다"

# 2) 일부러 망가뜨린 원본에서는 반드시 빨간불이 떠야 한다
#    (비교 연산 하나 뒤집기 같은 최소 변형이면 충분하다)
git apply mutations/flip-one-comparison.patch
./run-oracle.sh --target=legacy && echo "FAIL: 심판이 변형을 못 잡는다. 오라클부터 고쳐야 한다"
git apply -R mutations/flip-one-comparison.patch

이 두 줄을 돌리는 데 드는 시간이, 나중에 "테스트는 다 통과하는데 프로덕션이 깨진다"를 디버깅하는 시간보다 항상 쌉니다.

1단계 — 룰북과 의존성 지도, 그리고 3파일 예행 연습

룰북은 번역 판단을 한 번만 내리기 위한 문서입니다. 키트가 붙인 메타 규칙이 판별 기준으로 그대로 쓸 만합니다 — 두 에이전트가 서로 다르게 답할 수 있는 질문이면 룰북 항목입니다. 오류 처리 관용구 대응, 널 표현, 정수 오버플로 정책, 로깅 포맷, 명명 규칙, 동시성 원시 타입 매핑이 전형적인 항목입니다. Bun의 PORTING.md는 약 600줄이었고, 작성에 든 시간은 코딩 전 약 3시간이었습니다.

의존성 지도는 결정적 스크립트로 만듭니다. 모델에게 "이 파일이 무엇에 의존하나요"를 묻지 마세요. 임포트 구문을 파싱하는 30줄짜리 스크립트가 더 정확하고 재현 가능합니다. 이 지도의 산출물이 작업 순서(리프에서 루트 방향)와 순환 목록입니다. 순환은 미리 알아야 합니다 — 원본 언어가 관대하게 넘긴 순환 의존이 대상 언어에서 수천 개의 모듈 오류로 한꺼번에 터지는 것이 이 작업의 단골 사고입니다.

갭 인벤토리는 대상 언어가 새로 요구하는 정보의 목록입니다. Zig에서 Rust로 갈 때는 소유권과 수명, Python에서 TypeScript로 갈 때는 명시적 인터페이스 계약이 여기 들어갑니다. 원본에 존재하지 않는 정보이므로 번역이 아니라 결정입니다. 결정은 사람이 하고 룰북에 적습니다.

그다음이 예행 연습입니다. 파일 3개만 골라 전체 파이프라인을 돌립니다. 한 에이전트는 룰북을 따라 번역하고, 다른 에이전트는 룰북 없이 그 언어의 시니어처럼 번역하고, 세 번째가 두 결과의 차이를 감사합니다. 여기서 나온 번역물은 전부 버리고 규칙만 고칩니다. Bun은 이 단계에서 두 건을 잡았는데, 그대로 갔다면 1,448개 파일 전체에 번졌을 문제였습니다. 사람 시간을 몰아 넣을 지점이 있다면 여기입니다.

2단계 — 검증 가능한 경계로 자르고, 큐를 디스크에 맡긴다

작업 단위를 고를 때 기준은 크기가 아니라 검증 가능성입니다. 단위 하나를 옮긴 뒤 "됐는지"를 기계가 판정할 수 있어야 합니다. 파일 단위가 잘 맞는 이유는 대상 파일의 존재 여부와 컴파일 성공 여부로 판정이 끝나기 때문입니다.

여기서 구조 보존과 재설계를 갈라야 합니다. 파일 대 파일 대응을 유지하는 구조 보존 이식은 예행 연습, 줄 단위 대조, 파일 단위 큐가 전부 성립합니다. 반면 옮기면서 모듈 경계를 다시 그리기로 하면 이 장치들이 동시에 무너집니다 — 대조할 원본 줄이 없어지고, 단위가 모듈로 커지고, 되돌리기 반경도 같이 커집니다. 옮기기와 다시 그리기를 한 번에 하려는 유혹이 이 절차의 가장 흔한 실패 원인입니다. 순서를 나누세요. 먼저 옮기고, 오라클이 초록인 상태에서 다시 그립니다.

완료 판정은 디스크 상태에 맡깁니다. 이 한 가지 결정이 재개 가능성과 되돌리기 가능성을 동시에 줍니다.

# 큐 = 매니페스트에서 아직 출력 파일이 없는 항목. 그 이상의 상태를 두지 않는다.
awk -F'\t' '{ if (system("[ -f " $2 " ]") != 0) print }' migration/manifest.tsv \
  > migration/queue.tsv

wc -l < migration/queue.tsv        # 남은 작업량
git worktree list                  # 워크스트리마다 별도 큐 슬라이스를 준다

진척도를 에이전트에게 묻는 순간 진척도 자체가 모델 출력이 되고 환각의 대상이 됩니다. 파일 존재 여부는 환각하지 않습니다.

되돌리기 가능성은 세 가지로 유지합니다. 단위마다 커밋을 쪼개서 되돌리기 반경을 파일 하나로 유지하고, 번역 단계와 수정 단계를 별도 브랜치나 워크스트리로 분리하고, 룰북 개정 시점을 커밋 메시지에 남깁니다. 마지막 항목이 중요한 이유는, 규칙이 바뀌면 그 이전에 만들어진 산출물을 재생성해야 할 범위를 특정할 수 있어야 하기 때문입니다.

컴파일러를 루프 어디에 둘지도 이 단계에서 정합니다. TypeScript나 Go처럼 타입 체크가 빠르면 번역 루프 안에 넣어 즉시 피드백을 받고, Cargo처럼 느리면 번역이 다 끝난 뒤 한 번에 돌려 오류 목록을 다음 큐로 씁니다. Bun은 후자였고, 약 1,600개의 컴파일 오류를 12시간 만에 걷어냈습니다.

3단계 — 무엇을 측정하나: 변환한 줄 수는 진척도가 아니다

마이그레이션 리포트에서 가장 자주 보이고 가장 쓸모없는 숫자가 "변환한 줄 수"입니다. 이 숫자는 출력량이지 진척도가 아닙니다. 실제로 의사 결정을 바꾸는 지표는 다음 다섯입니다.

지표정의무엇을 알려 주나
심판 통과율오라클 케이스 중 새 구현이 통과한 비율유일한 완료 정의. Bun은 972개 실패 파일에서 23개, 다시 0개로 내려갔다
규칙 위반 재발률리뷰어 지적 중 이미 룰북에 있는 항목의 재위반 비율값이 안 떨어지면 규칙이 아니라 프로세스가 문제다
디프 리뷰율사람이 실제로 읽은 줄 수 나누기 생성된 줄 수리스크의 실제 크기. 100만 줄이면 이 값은 정직하게 한 자릿수 퍼센트다
단위당 재작업 횟수파일 하나가 큐에 다시 들어간 평균 횟수2회를 넘기면 룰북이 그 영역을 커버하지 못하고 있다
잔여 마커 수TODO(port), BUG(port) 주석의 남은 개수병합 이후에도 남는 실제 부채. 병합일을 완료일로 착각하지 않게 해 준다

이 중 디프 리뷰율을 반드시 계산해서 기록하기를 권합니다. 100만 줄 규모에서 이 값을 정직하게 계산하면 대부분 5퍼센트 아래입니다. 그 숫자가 불편하다면, 불편함이 정확한 반응입니다. 그것이 이 방식이 감수하는 리스크의 크기이고, 의사 결정자에게 보고해야 할 값입니다. 숨기지 말고, 대신 나머지 95퍼센트를 무엇이 판정했는지(컴파일러, 오라클, 적대적 리뷰어)를 같이 적으세요.

리뷰 경제학은 이렇게 정리됩니다. 사람이 읽어야 하는 것은 생성된 코드가 아니라 생성 규칙과 그 규칙이 만든 대표 샘플입니다. 룰북 600줄, 예행 연습 3파일의 디프, 그리고 리뷰어들이 이견을 낸 케이스. 이 셋은 사람이 전부 읽을 수 있고, 읽으면 실제로 결과가 바뀝니다. 반대로 1,448개 파일을 훑는 리뷰는 시간을 쓰고도 결과를 바꾸지 못합니다.

4단계 — 세 가지 실패 모드와 각각의 탐지법

조용한 의미 변형. 컴파일도 되고 테스트도 통과하는데 동작이 미묘하게 다른 경우입니다. 부동소수 반올림, 정렬 안정성, 널과 빈 값의 구분, 오류 전파 순서, 시간대 처리, 정수 오버플로 동작에서 주로 나옵니다. 테스트가 커버하지 않는 경로에서 발생하므로 테스트로는 잡히지 않습니다. 탐지법은 차등 실행 하나뿐입니다 — 프로덕션 트래픽의 샘플이나 실제 입력 로그를 두 구현에 흘리고 출력을 비교하세요. Krieger가 "어떤 동작 변화든 버그"라는 규칙을 세운 이유가 이것입니다. 예외 목록을 만드는 순간 이 실패 모드를 막을 수단이 사라집니다.

환각 API. 존재하지 않는 함수, 잘못된 시그니처, 옛 버전의 인자 순서를 호출하는 코드입니다. 정적 타입 언어로 옮기는 경우에는 컴파일러가 전부 잡아 주므로 실무적으로 큰 문제가 아닙니다. 위험한 것은 대상이 동적 언어일 때입니다. Python이나 Ruby로 옮기면 잘못된 호출이 런타임까지 살아남고, 그 경로에 테스트가 없으면 프로덕션에서 처음 터집니다. 대상이 동적 언어라면 임포트 해석과 시그니처 검사를 별도 정적 분석 단계로 반드시 추가하고, 그 단계를 번역 루프 안에 넣으세요.

엉뚱한 이유로 통과하는 테스트. 셋 중 가장 위험합니다. 대표적인 형태가 셋 있습니다. 첫째, 새 코드를 쓴 것과 같은 에이전트가 테스트도 고친 경우 — 심판과 피고를 겸하게 됩니다. 오라클 파일은 마이그레이션 브랜치에서 쓰기 금지로 두고, 변경이 필요하면 사람 승인 단계를 거치게 하세요. 둘째, 예외를 삼키는 테스트 — 실패 경로가 조용히 통과로 바뀝니다. 셋째, 조기 반환으로 검증부를 건너뛰는 테스트입니다.

# 오라클이 마이그레이션 중에 변형되지 않았는지 매 회차 확인한다
git diff --stat migration-base..HEAD -- tests/ | tail -1

# 통과 개수가 아니라 "실제로 실행된 assertion 수"를 함께 본다.
# 통과 수는 그대로인데 assertion 수가 줄었다면 테스트가 조용히 비었다는 뜻이다.
./run-oracle.sh --report=assertions | tee migration/assertions-$(date +%s).txt

세 실패 모드에 공통된 대응 원칙이 하나 있습니다. 리뷰어가 같은 지적을 세 번 하면 그것은 개별 버그가 아니라 계통 오류이므로, 파일을 고치지 말고 룰북을 고치고 영향받은 배치를 재생성하세요. 개별 파일을 손으로 때우기 시작하면 규칙과 코드가 갈라지고, 그 시점부터 재생성이 불가능해집니다.

마치며 — 이 절차의 값어치는 속도가 아니라 되돌릴 수 있음에 있습니다

정리하면 순서는 이렇습니다.

  1. 동작 오라클을 만들고, 일부러 망가뜨린 코드로 오라클이 떨어지는지부터 확인합니다.
  2. 룰북과 의존성 지도, 갭 인벤토리를 만들고 3파일 예행 연습으로 규칙을 두들깁니다. 번역물은 버리고 규칙만 남깁니다.
  3. 검증 가능한 경계로 잘라 큐를 만들고, 완료 판정을 디스크 상태에 맡겨 중단과 재개를 무료로 만듭니다.
  4. 변환한 줄 수 대신 심판 통과율, 규칙 위반 재발률, 디프 리뷰율, 단위당 재작업 횟수, 잔여 마커 수를 봅니다.
  5. 리뷰어의 반복 지적은 파일이 아니라 규칙에 반영하고, 영향 범위를 재생성합니다.

이 절차가 주는 진짜 이득은 속도가 아닙니다. 전부 버리고 다시 돌릴 수 있다는 것입니다. 룰북과 큐와 오라클이 분리돼 있으면 최악의 경우에도 브랜치를 지우고 규칙을 고쳐 재실행하면 됩니다. 몇 년짜리 마이그레이션이 커리어를 걸어야 하는 프로젝트였던 이유는 되돌릴 수 없어서였지, 어려워서가 아니었습니다.

한 가지만 남기자면 이것입니다 — 번역을 시작하기 전에 무엇이 끝을 판정할지 정하지 못했다면, 그 마이그레이션은 아직 시작할 준비가 되지 않았습니다.

참고 자료

현재 단락 (1/59)

2026년 상반기에 대규모 LLM 마이그레이션 사례가 연달아 공개됐습니다. Bun은 53만 5천 줄의 Zig를 11일 만에 Rust로 옮겼고([발표문](https://bun.com...

작성 글자: 0원문 글자: 6,348작성 단락: 0/59