“PHP 프로젝트를 Java로 변환해 달라”고 한 줄 던졌더니 600개 엔드포인트가 담긴 Swagger가 떴다. 호출하면 전부 500과 400이었다. 한 번에 다 맡기면 안 된다는 말은 들었지만, 직접 데이고 나서야 이유를 알게 됐다
첫 번째 실수: 껍데기 600개를 만들었다
라우트 600개짜리 PHP(Laravel) 레거시를 Java(Spring Boot)로 옮기는 작업이었다. 인원은 적고 일정은 촉박했다. 처음 시도는 “PHP 프로젝트 전체를 보고 그대로 Java로 변환해 달라”는 한 줄이었다. Swagger가 600개 엔드포인트와 함께 떴고, 컴파일도 됐다. 그런데 막상 호출하면 동작하는 게 거의 없었다. 600개의 껍데기를 하나씩 까볼 수도 없는 일이었다. 여기서 처음으로 확실히 알게 됐다. Swagger가 뜬다는 것과 API가 동작한다는 것은 완전히 다른 문제다
CLAUDE.md에 계약 보존 규칙을 먼저 박아 둔다
코딩보다 형상 정리를 먼저 했다. Java 프로젝트를 git 저장소와 연결한 뒤, PHP 레거시 코드와 프론트엔드 세 개 프로젝트(오너 관리 웹, 고객 주문/결제 앱, 매장 단말 웹)를 같은 저장소 트리 안에 복사했다. Claude Code가 PHP 원본과 프론트의 호출 코드를 같은 컨텍스트 안에서 참조할 수 있어야 했다
그다음 프로젝트 루트에 CLAUDE.md를 만들었다. 내용의 핵심은 두 줄이다
# Migration Rules 1. 기존 API path는 어떤 이유로도 변경하지 않는다. - 리팩토링·네이밍 개선 같은 명분도 허용하지 않는다. 2. Request / Response의 필드명과 타입은 변경하지 않는다. - snake_case가 컨벤션에 맞지 않아도 그대로 둔다. - 새 필드 추가는 허용. 기존 필드의 이름·타입 변경, 삭제는 금지.
이 규칙이 없으면 Claude는 “코딩 컨벤션 개선”이라는 명분으로 외부 시스템과의 계약을 슬그머니 깬다. 이미 다른 시스템과 연동된 레거시에서는 컨벤션보다 계약 보존이 먼저라는 사실을 명시적으로 알려줘야 한다
작업 표면적부터 줄인다
600개 전부를 옮기려 했지만, 프론트 세 개가 실제로 호출하는 API는 그보다 훨씬 적었다. 호출 코드를 grep과 라우트 매칭으로 분석하니 약 150개로 줄었다. 일감이 4분의 1이 됐다. 그 150개에 대해 비즈니스 로직은 비워 두고 path, HTTP 메서드, 요청/응답 모델만 PHP에 맞춰 스켈레톤을 만들었다. Java 환경에서 Swagger가 뜨고 통신이 성립하는 단계까지가 첫 목표였다.PHP에는 OpenAPI 명세가 없었기 때문에, 프론트가 호출하는 path와 PHP 라우트를 매칭해 PHP 측 OpenAPI YAML을 별도로 작성했다. 이 단계가 끝나자 통신 자체는 거의 성공했다. 응답이 정답은 아니었지만, 계약이 깨지지 않는 상태가 만들어졌다
PHP 측 OpenAPI를 별도로 작성한다
스켈레톤을 만들면서 바로 막힌 게 있었다. Java에는 Swagger가 있는데 PHP에는 OpenAPI 명세 자체가 없었다. A에서 B로 옮기는 작업에서 두 시스템을 나란히 펼쳐 비교할 문서가 없으면 매번 추측이 끼어든다. 프론트 세 개 프로젝트가 실제로 호출하는 path를 PHP 라우트와 매칭한 뒤, 그 결과를 토대로 PHP 측 OpenAPI YAML을 별도로 작성했다. 이걸 프로젝트 루트에 두고 “비즈니스 로직은 비어 있어도 좋으니 Request/Response 형식만 PHP에 정확히 맞추자”는 목표로 한 바퀴를 돌렸다. 이 단계가 끝나자 통신 자체는 거의 모든 엔드포인트에서 성공했다. 응답 값이 정답은 아니었지만, 계약은 깨지지 않는 상태가 됐다. 다음 단계로 넘어갈 수 있는 기반이 만들어진 것이다
AI가 만든 코드를 AI가 검증하면 신뢰가 잠기지 않는다
처음에는 Claude Code의 자체 테스트에 의존했다. 두 바퀴를 돌리면 처음보다 나아지긴 했다. 그래도 운영에 쓸 수 있는 수준은 아니었다. 문제는 구도 자체였다. AI가 만든 코드를 AI가 검증하면 틀린 방향을 틀린 채로 잠그게 된다. 외부 기준이 필요했다
Python 하네스로 PHP를 정답지로 쓴다
두 서버를 동시에 띄우고 같은 요청의 응답을 비교하는 방식을 도입했다. 방향은 하나로 고정했다
PHP를 정답지로 두고, 같은 요청을 Java에 던졌을 때 응답이 같아질 때까지만 고친다.
다르면 무조건 Java가 틀린 것이다. Java를 PHP에 맞춘다. 반대 방향은 금지.
이 방향성을 박아 둔 것이 가장 큰 차이를 만들었다
api-diff/
├── config.yaml # 두 서버 URL, 인증 방식, 환경 의존 무시 경로
├── cases/
│ ├── orders/
│ │ ├── list.yaml
│ │ └── create.yaml
│ └── ...
├── diff_runner.py # 두 서버 동시 호출 + DeepDiff 비교
└── reports/
├── report.html
├── report.md
└── report.json
diff_runner.py는 두 서버를 동시에 호출해 DeepDiff로 구조 차이를 뽑는다. 결과는 다섯 가지로 자동 분류된다
| 카테고리 | 의미 |
|---|---|
| 명명 규칙 차이 | snake_case / camelCase 불일치 |
| 필드 누락 | PHP에는 있는데 Java에 없는 필드 |
| 추가 필드 | Java에만 있는 필드 |
| 값 불일치 | 같은 경로에 다른 값 |
| 타입 불일치 | 같은 위치에 다른 타입 |
이 분류가 있어야 “어디서부터 봐야 하는지”가 즉시 판단된다. 부수 도구로 PHP 응답 JSON을 입력받아 Java record DTO 스켈레톤을 찍어 주는 extract_fields.py도 만들었다. 150개를 손으로 옮기지 않게 해준 일등공신이다
plan.md와 handoff.md를 워킹 메모리로 쓴다
한 세션에서 너무 오래 일을 시키면 직전 결정과 모순되는 코드가 나온다. 같은 파일을 두 번 다른 방향으로 고치기도 했다. plans/ 디렉터리 아래에 사이클마다 새 파일을 만들었다
[handoff-yyyy-mm-dd-1.md]
↓ 읽고 다음 plan 짜기
[plan.md]
↓ 새 세션 시작
회귀 baseline 측정 → 수정 → 회귀 통과 확인
↓
[handoff-yyyy-mm-dd-2.md] 작성 후 종료
plan.md의 한 step은 작은 Jira 티켓처럼 작성했다
## Step 3: /api/orders POST 응답 동등성 확보 - 목적: PHP 응답과 Java 응답의 필드 구조를 1:1로 맞춘다 - 전제조건: api-diff baseline 통과 19/36 확인 - 실행: OrderController.create의 ResponseDto 필드 정렬, snake_case 유지 - 산출물: OrderResponse.java, 회귀 리포트 1건 - 검증: diff_runner.py --case orders/create.yaml 통과 - 실패 시 대응: intentional-divergence.md 등재 또는 Java 측 수정
중요한 운영 규칙이 하나 있다. plan.md와 handoff.md를 같은 파일로 덮어쓰지 않는다. 매 사이클마다 새 파일로 만들어 시간 순 흔적을 남긴다. “그때 이렇게 시도해서 실패했다”는 기록이 있어야 같은 실수를 두 번 하지 않는다
사이클 종료 조건은 baseline 통과 수다
한 사이클이 끝날 때 회귀 통과 수가 떨어지지 않았는지를 종료 조건으로 쓴다. “지금 GET 통과가 19/36이면 다음 세션 끝에서도 19 이상이어야 한다”는 단순한 규칙을 매 세션 첫 10분에 찍어 두고 시작했다
의도적 차이는 별도로 등재한다
모든 diff는 기본적으로 “Java가 틀렸다”는 의미로 다룬다. 단, PHP 원본의 명백한 버그가 코드 레벨로 증명 가능하고 Java가 이미 정상 동작하는 경우에 한해서만 intentional-divergence.md에 등재한다. 이 등재는 회귀 비교의 ignore 규칙과 1:1로 매핑된다
모든 diff │ ├─ intentional-divergence.md에 등재됨? ──[Yes]── ignore 규칙 적용 │ └[No]── Java를 고친다
이 규칙이 없으면 Claude는 “Java가 더 옳아 보인다”는 이유로 PHP를 따라가지 않을 핑계를 만든다. 마이그레이션 단계에서 그게 결정적인 회귀 원인이 된다
마지막 1할은 사람이 해야 한다
회귀 통과율이 올라간 뒤에도 프론트 개발자가 직접 화면을 눌러보면 잡히는 버그가 있었다. 자동 비교로는 잡기 어려운 종류들이다
- 화면 진입 시 라디오 분기가 깨지는 문제
- 모달의 prefill이 빈 값으로 뜨는 문제
- DB에 저장은 되는데 GET 응답이 항상 placeholder로 고정되는 silent fail
- 같은 엔드포인트가 multipart, JSON body, query string 세 가지 형식으로 들어오는데 하나만 받는 컨트롤러
이 단계부터 작업의 무게중심이 바뀌었다. “AI가 한 번에 처리”에서 “사람이 발견하고 AI가 손이 되어 빠르게 고치고 회귀로 잠그는 협업”으로!
잘 안 됐던 것들
솔직히 정리해 두는 편이 도움이 된다. Swagger가 600개 다 떴다는 사실에 속아서는 안 된다. 응답이 동작하는지는 별개다. 한 세션에 너무 많은 일을 몰아주면 컨텍스트가 흐려져 직전 결정과 모순되는 코드를 만든다. 도중에 바꾸려고 하지 말고 세션을 끊는 게 나았다. “Java가 더 깔끔해 보인다”는 욕심은 마이그레이션 단계에서는 비용이다. 계약 보존이 먼저고 정리는 나중이다. 프론트가 실제로 보내는 형식을 가정만으로 처리하면 무조건 한 번은 깨진다. 디버그 로그로 실제 입력을 한 번이라도 찍어 보는 게 빠른 길이다
DB의 NULL row를 신경 쓰지 않으면 Java가 entity 로드 단계에서 죽는다. 회피책으로 wrapper 타입(Integer, Long 등)으로 도망가면 setter가 NULL을 통과시켜 INSERT 단계에서 NULL이 들어가는 더 큰 사고로 이어진다. 결국 SQL 레벨에서 COALESCE로 NULL을 안전한 기본값으로 변환해 주는 read transformer를 entity에 강제하는 방법이 정답이었다. 이런 디테일은 한 번 데인 다음에 학습된다. 그 학습이 사라지지 않게 plan.md와 handoff.md에 패턴 단위로 보존해 두는 게 운영의 핵심이었다
정리
한 줄로 요약하면, AI는 “거의 다 된 상태”까지 데려다 주는 데 압도적으로 유리하지만 운영에 들어갈 마지막 1할은 사람이 결정해야 하고, 그 “거의 다”의 비용을 결정적으로 줄이는 건 결국 워킹 메모리(plan.md / handoff.md)와 정합성 테스트(Python 하네스)와 한 줄짜리 정답지(PHP가 정답)이다
같은 종류의 마이그레이션을 시작하려는 누군가(사실 나)에게 이대로 정리해 둔다
- 한 번에 다 시키지 말고 단계로 쪼갠다
- 단계마다 산출물을 만들어 둔다
plan.md와handoff.md를 분리해 시간 순으로 누적한다- 응답 비교를 자동화하고, 통과 수가 떨어지지 않는 상태를 사이클 종료 조건으로 잠근다
- 정답지를 한쪽으로 정해 두고, 의도적 예외만 별도 문서에 등재한다
- 자동 커밋과 자동 푸시는 명시적 트리거로만 둔다
시행착오를 모두 다시 겪을 필요는 없다
추신
이 글에서 말한 작업은 PHP에서 Java로 런타임을 옮긴 마이그레이션이지, 레거시의 구조나 설계를 최신화한 작업은 아니다. 계약 보존을 우선에 둔 만큼 Java로 옮긴 코드도 PHP 시절의 형태를 거의 그대로 따르고 있다. 시간이 되는 대로 Java를 25 버전으로 끌어올리고 코드 자체를 정리하는 작업도 다시 Claude Code와 함께 한 바퀴 돌려 볼 생각이다