PHP(Laravel) 기반 레거시 API 서버를 Java(Spring Boot)로 옮기는 작업을 Claude Code와 함께 진행한 기록이다
결론부터 말하면 세 가지가 가장 큰 차이를 만들었다. 작업을 한 번에 다 맡기지 않고 단계별로 쪼갠 것, plan.md와 handoff.md를 세션 간 워킹 메모리처럼 운영한 것, PHP와 Java를 동시에 띄워 같은 요청에 대한 응답을 자동으로 비교하는 Python 하네스를 안전망으로 깔아 둔 것. 이 세 가지로 작업 기간을 체감상 5분의 1 수준까지 줄일 수 있었다. 같은 종류의 마이그레이션을 시작할 누군가(사실 나)에게 참고가 될까 싶어 시간 순으로 정리해보았다
한 번에 다 맡기지 말 것 – 600개 껍데기 API의 교훈
본격적인 첫 시도는 욕심에서 출발했다. 라우트는 600개를 넘었고, 인원은 적었고, 일정은 촉박했다. “PHP 프로젝트를 보고 그대로 Java로 변환해 달라”고 한 줄로 던졌다
표면적인 결과는 놀라웠다. 컴파일이 됐고, Swagger UI가 떴고, 600개가 넘는 엔드포인트의 명세가 한 번에 만들어졌다. 그런데 막상 호출하면 500과 400이 줄줄이 쏟아졌다. 동작하는 것은 사실상 하나도 없었다. 600개의 껍데기 API를 사람이 하나씩 까볼 수도 없는 일이다. 그렇게 할 거였으면 처음부터 AI를 쓰지도 않았다
여기서 머리로만 알던 것을 한 번 직접 데였다. “한 번에 모든 작업을 한 프롬프트로 맡기면 잘 안 된다”는 흔한 조언은 실제로 그렇게 동작한다. Swagger가 600개 다 떴다는 사실에 속아서는 안 된다. 응답이 동작하는지는 별개다
CLAUDE.md에 두 줄짜리 계약 보존 규칙을 박아 둔다
코딩보다 형상 정리부터 했다. 신규 Java 프로젝트를 git 저장소와 연결한 다음, 프로젝트 루트에 PHP 레거시 코드를 통째로 복사해 두었다. 같은 백엔드를 바라보는 프론트엔드 세 개 프로젝트(오너 관리 웹, 고객 주문/결제 앱, 매장 단말 웹)도 같은 방식으로 옮겼다. Claude Code가 같은 저장소 트리 안에서 PHP 원본과 프론트의 호출 코드를 모두 참조할 수 있어야 했다
그리고 프로젝트 루트에 CLAUDE.md를 만들어 이 저장소에서 Claude가 따라야 할 규칙을 박았다. 핵심은 두 가지였다
# Migration Rules 1. 기존 API path는 어떤 이유로도 변경하지 않는다. - 리팩토링·네이밍 개선 같은 명분도 허용하지 않는다. 2. Request / Response의 필드명과 타입은 변경하지 않는다. - snake_case가 컨벤션에 맞지 않아도 그대로 둔다. - 새 필드 추가는 허용. 기존 필드의 이름·타입 변경, 삭제는 금지.
이 두 줄짜리 규칙이 없으면 Claude는 “코딩 컨벤션 개선”이라는 명분으로 외부 시스템과의 계약을 슬그머니 깨버린다. 이미 다른 시스템과 연동되어 있는 레거시는 컨벤션보다 계약 보존이 우선이라는 점을 모델에게 명시적으로 알려 줘야 한다
git 위임은 명시적 트리거로만
혼자 작업해도 git 충돌은 일어난다. 여러 세션이 같은 컨트롤러를 동시에 만지거나, 며칠 전 plan과 오늘 plan이 같은 파일을 건드리는 일이 생긴다. 매번 git add, commit, push를 직접 치는 시간도 적지 않다
그래서 git 작업 자체를 Claude Code에게 위임했지만, 자동 커밋과 자동 푸시는 두지 않았다. 한두 번의 사고로 큰 비용을 만들 수 있기 때문이다. 사용자가 /review 같은 명시적 트리거를 호출할 때만 커밋과 푸시를 묶어서 수행하도록 설계했다. 그 외에는 working tree에 변경만 남기고 세션을 종료한다. 충돌 자체는 양쪽 변경 의도를 보여주면 Claude Code가 의외로 일관된 머지를 잘 해 주는 편이었다
plan.md와 handoff.md를 워킹 메모리로 쓴다
한 세션에서 너무 오래 일을 시키면 컨텍스트가 길어져 정확도가 떨어진다. 직전에 합의한 결정과 모순되는 코드를 만들기도 하고, 같은 파일을 두 번 다른 방향으로 고치기도 한다. 세션을 끊어가며 이어서 작업할 방법이 필요했다
그래서 plans/ 디렉터리 아래에 plan.md와 handoff.md를 시간 순으로 누적했다. 운영 사이클은 다음과 같다
[handoff-yyyy-mm-dd-1.md]
↓ 읽고 다음 사이클 plan 짜기
[plan.md]
↓ /clear 또는 새 세션
새 세션 부팅
↓ step 1, 2, 3...
회귀 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 측 수정
handoff.md에는 한 사이클이 끝났을 때 다음 세션이 그대로 이어받을 수 있도록 정리한다
# Handoff 2026-04-15 - 작업 개요 표 - 커밋 시간 순 한 줄 목록 - 재사용 가능한 핵심 패턴 코드 스니펫 - 도메인별 회귀 통과/실패 표 - 이번 사이클에 새로 등재된 의도적 차이(intentional divergence) - 후속 권장 작업 - 충돌 해결 메모
운영에서 가장 중요했던 한 가지는, plan.md와 handoff.md를 같은 파일을 덮어쓰는 식으로 다루지 않은 것이다. 매 사이클마다 새 파일로 만들어 시간 순 흔적을 남겼다. 잘못된 길로 갔던 사이클의 흔적까지 보존된다. “그때 우리는 이렇게 시도해서 실패했다”는 기록이 있어야 같은 실수를 두 번 하지 않는다
작업 표면적부터 줄인다
레거시 API가 600개를 넘었지만 프론트가 실제로 호출하는 것은 일부였다. 프론트 세 개 프로젝트의 호출 코드를 grep과 라우트 매칭으로 분석해 실제 사용 API 약 150개로 줄였다. 이 작업만으로 일감이 4분의 1로 줄었다
그리고 그 150개에 대해 “껍데기 컨트롤러”부터 만들었다. 비즈니스 로직은 비워 두고 path와 HTTP 메서드와 요청 모델과 응답 모델만 PHP에 맞춰서 만들어 두었다. Java 환경에서 Swagger가 뜨고 통신 자체가 성립하는 단계까지 가는 게 첫 목표였다
이 부분은 Claude Code가 굉장히 잘했다. 라우트 정의 파일을 읽혀 주면 PHP 컨트롤러의 시그니처에 맞는 Spring 컨트롤러 스켈레톤을 빠르게 찍어 줬다. 다만 Swagger가 떴다는 사실과 그 API가 실제로 동작한다는 사실은 별개다. 호출하면 거의 다 500이나 400이었다
PHP 측 OpenAPI를 별도로 작성한다
다음 단계로 넘어가기 전에 미리 정리하고 가야 할 게 있었다. PHP 측의 명세 부재였다. Java에는 Swagger가 있는데 PHP에는 OpenAPI 명세가 없었다. A에서 B로 옮기는 작업에서 두 시스템을 같이 펼쳐 놓고 비교할 수 있는 문서가 없으면 매번 추측이 끼어든다
프론트 세 개 프로젝트가 실제 호출하는 path를 PHP 라우트와 매칭한 뒤, 그 결과를 토대로 PHP 측 OpenAPI YAML을 별도로 작성했다. 이걸 프로젝트 루트에 둔 다음, “비즈니스 로직은 비어 있어도 좋으니 일단 Request/Response 형식만 PHP에 정확히 맞추자”는 목표로 한 바퀴를 돌렸다
이 단계가 끝나자 통신 자체는 거의 모든 엔드포인트에서 성공했다. 비즈니스 로직이 비어 있어 응답 값이 정답이 아닐 뿐, 적어도 계약은 깨지지 않게 됐다. 다음 단계로 넘어갈 수 있는 상태가 만들어진 것이다
AI 자체 테스트만으로는 신뢰가 잠기지 않는다
처음에는 “Claude의 자체 테스트”에 의존했다. Claude Code로 자체 테스트를 돌렸고, 거기서 나온 버그 리포트를 다시 가공해 plan.md에 녹였다. 두 바퀴를 돌렸을 때, 처음보다는 분명히 나아졌다. 그래도 운영에 쓸 수 있는 수준은 아니었다
결정적으로, “AI가 만든 코드를 AI가 검증한다”는 구도 자체가 신뢰성을 잠그지 못했다. 두 바퀴 정도 돌려 보고 이 방식의 한계가 분명하다고 봤다
정합성 테스트(Parity Test)가 안전망이다
전환점은 외부에서 왔다. 마이그레이션에서 자주 쓰이는 안전망 방식들을 살피던 중, 두 시스템을 동시에 띄우고 같은 요청에 대해 응답을 비교하는 방식을 도입했다. 골든 마스터(Golden Master) 대조 테스트, 또는 두 구현체의 응답을 직접 비교하는 차분(differential) 테스트라고 부르는 접근이다
여기에는 단순한 회귀(regression)보다 “두 시스템의 출력 동등성을 입력 단위로 잠그는 정합성(parity) 테스트”라는 표현이 더 정확하다. 핵심은 한 줄로 압축된다
PHP를 정답지로 두고 같은 요청을 Java에 던졌을 때 응답이 같아질 때까지만 고친다. 다르면 무조건 Java가 틀린 것이고, Java를 PHP에 맞춘다. 반대 방향은 금지.
이 방향성을 박아 둔 것이 시행착오에서 가장 큰 차이를 만들었다
두 서버를 동시에 띄운다
먼저 PHP 서버를 로컬에서 띄울 환경이 필요했다. 이 시점에 Docker와 Docker Desktop을 설치했다
# PHP 레거시 (정답지) — 8000번 포트 docker compose up -d # Java Spring Boot (마이그레이션 대상) — 8080번 포트 ./gradlew bootRun
Python 하네스의 구조
api-diff 디렉터리를 프로젝트 루트에 만들고, 두 서버에 동일 요청을 동시에 던지고 응답을 비교하는 Python 하네스를 작성했다
api-diff/
├── config.yaml # 두 서버 URL, 인증 방식, 환경 의존 무시 경로
├── cases/
│ ├── orders/
│ │ ├── list.yaml
│ │ └── create.yaml
│ ├── owners/
│ │ └── login.yaml
│ └── ... # 한 엔드포인트당 한 YAML
├── diff_runner.py # 두 서버 동시 호출 + DeepDiff 비교
└── reports/
├── report.html # 사람용
├── report.md # PR 요약용
└── report.json # CI 파싱용
diff_runner.py는 두 서버를 동시에 호출해 응답을 비교하고, DeepDiff로 구조 차이를 뽑아 HTML과 마크다운과 JSON 세 가지 리포트를 떨어뜨린다
fail 케이스 자동 분류
비교 결과는 자동으로 다섯 가지 카테고리로 분류했다. 이 분류 덕분에 “어디서부터 봐야 하는지”가 즉시 판단됐다
| 카테고리 | 의미 |
|---|---|
| 명명 규칙 차이 | snake_case와 camelCase가 의심되는 차이 |
| 필드 누락 | PHP에는 있는데 Java 응답에 없는 필드 |
| 추가 필드 | Java에만 있고 PHP에는 없는 필드 |
| 값 불일치 | 같은 경로에 다른 값이 들어 있음 |
| 타입 불일치 | 같은 위치에 int와 string처럼 다른 타입 |
DTO 스켈레톤도 자동 생성
부수 도구로 PHP 응답 JSON을 먹여 Java record DTO 스켈레톤을 자동으로 찍어 주는 extract_fields.py도 만들었다. 600개를 일일이 손으로 옮기지 않게 해 준 일등공신 중 하나다
baseline 잠금 규칙
이 시점부터 plan.md는 “API 단위 step”으로 바뀌었다. 한 step은 한 엔드포인트의 PHP-Java 응답 동등성 확보가 목표가 됐다
한 사이클이 끝나면 회귀 통과 수가 떨어지지 않았는지를 종료 조건으로 잠갔다. “지금 GET 통과가 19/36이면 다음 세션 끝에서도 19 이상이어야 한다”는 단순한 규칙을 매 세션 첫 10분에 baseline으로 찍고 시작했다
Apache vhost 설정으로 운영 환경을 재현한다
통과율이 올라가는데도 실제 프론트에서 화면을 띄워 보면 통신이 어긋나는 건이 계속 나왔다. 원인은 운영의 Apache vhost 설정이었다
각 프론트 도메인이 어떤 path를 PHP로 보내고 어떤 path를 Java로 보내는지가 운영의 vhost(가상 호스트) ProxyPass 규칙으로 결정되고 있었다. 로컬 기동만으로는 이 매트릭스를 재현할 수 없었다
[프론트 도메인 A] ─┐
├─ Apache vhost ─┬─ /api/legacy/* → PHP (8000)
[프론트 도메인 B] ─┘ └─ /api/v2/* → Java (8080)
API 서버용 vhost.conf와 웹 서버용 vhost.conf 두 개를 운영에서 받아 프로젝트 루트에 옮긴 뒤, 이걸 파싱해서 “어느 프론트 도메인 → 어느 path prefix → 어느 백엔드”의 표를 JSON으로 만들었다. 이 표가 있어야 비로소 “Java가 책임지는 경로 집합”이 정의됐다. 이 작업으로 통과율이 또 한 단계 올라갔다
의도적 차이(intentional divergence)는 따로 등재한다
운영 중 자리잡은 개념이다. 모든 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으로도 들어오는데 그 중 하나만 받게 만들어진 컨트롤러
이 단계는 한 번에 마법처럼 고치는 단계가 아니다. 사람이 발견한 사실을 plan.md로 옮기고, 한 step씩 고치고, 회귀를 돌리고, handoff.md에 적어 다음 세션이 같은 함정에 빠지지 않도록 보존하는 작업을 반복한다
이때부터 작업의 무게중심이 “AI가 한 번에 처리”에서 “사람이 발견하고 AI가 손이 되어 빠르게 고치고 자동 회귀로 잠그는 협업”으로 옮겨갔다. 적어도 내 경우엔 이 흐름이 가장 잘 맞았다
효과가 컸던 Claude Code 운영 패턴
같은 마이그레이션을 돌릴 때 그대로 가져다 써도 좋을 패턴으로 다른 작업을 할 때도 도움이 될 듯 해서 적어봐야겠다
절대 변경 금지 영역을 path 단위로 박아 둔다
다른 담당자가 병행 작업 중인 결제 도메인, 외부 벤더 규격을 따라야 하는 외부 POS 연동, PHP 원본의 알려진 버그 패턴 같은 영역을 CLAUDE.md에 명시적으로 적어 두면, Claude가 그 구역을 건드리려다 멈추는 빈도가 눈에 띄게 늘었다. 모델이 “이건 만지면 안 된다”는 신호를 텍스트가 아니라 저장소의 규칙으로 보게 만드는 게 중요하다
사이클이 끝날 때마다 “다음 작업 추천”을 시킨다
Claude에게 다음과 같이 시키면 사람이 매번 새로 고민하지 않아도 된다
현재 git 상태와 최신 handoff를 보고, 다음 사이클로 가장 합리적인 작업 세 가지를 우선순위대로 추천해주세요.
새 세션 부팅용 프롬프트를 미리 만들어 둔다
세션이 끊어질 때 다음 세션의 첫 프롬프트를 Claude에게 미리 만들어 달라고 한다
최신 handoff X와 plan Y를 읽고, A 도메인의 step 3부터 이어서 시작해라. 첫 5분 안에 회귀 baseline을 찍고 그 후에 수정에 들어가주세요.
이 프롬프트 한 장이 있으면 새 세션이 처음부터 맥락을 다시 묻지 않는다
자체 회귀로 신뢰도를 잠근다
수정 → 회귀 → 통과 수 측정 → handoff 기록의 사이클을 한 step마다 닫아 두면, 나중에 같은 코드가 다시 깨졌을 때 어느 사이클에서 회귀가 들어왔는지 추적할 수 있다
잘 안 됐던 것들
솔직히 정리해 두는 편이 도움이 된다
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와 함께 한 바퀴 돌려 볼 생각이다