Claude Code로 레거시 코드를 안전하게 현대화하는 방법
Claude Code로 레거시 코드에 테스트를 추가하고 TypeScript로 단계적 개선하는 실전 절차와 함정, 검증 예제.
레거시 현대화는 큰 변경으로 시작하면 실패하기 쉽다
레거시 코드는 단순히 오래된 코드가 아닙니다. 테스트가 부족하고, 원래 의도를 아는 사람이 적고, 작은 수정도 결제, 주문, 운영 화면, 고객 지원 흐름에 영향을 줄 수 있는 코드입니다. Claude Code는 이런 코드베이스를 이해하고 바꾸는 데 도움이 되지만, 대량 재작성 버튼처럼 쓰면 위험합니다.
안전한 순서는 먼저 조사하고, 그다음 현재 동작을 테스트로 고정하고, 마지막에 작은 단위로 리팩터링하는 것입니다. 여기서 characterization test는 “이 동작이 이상적이다”가 아니라 “지금 시스템이 이렇게 동작하니 실수로 바꾸지 말자”를 기록하는 테스트입니다. harness는 에이전트가 안전하게 일하도록 세우는 발판입니다. 테스트, 권한, 실행 명령, 프로젝트 규칙, 리뷰 체크리스트가 모두 이 발판에 포함됩니다.
공식 Claude Code common workflows는 코드 탐색, 리팩터링, 테스트, PR 작성, worktree 병렬 작업을 단계별 흐름으로 설명합니다. 레거시 코드도 같은 관점이 필요합니다. Claude Code는 속도를 올려 주지만, 위험의 경계와 최종 승인 책임은 사람에게 남아 있습니다.
실무에서 자주 만나는 세 가지 유스케이스
우선순위는 “보기 싫은 코드”가 아니라 “실패했을 때 비용이 큰 코드”에서 정하는 편이 좋습니다.
| 유스케이스 | 목표 | Claude Code가 할 수 있는 일 | 사람이 확인해야 할 것 |
|---|---|---|---|
| 주문, 청구, 결제 로직 | 금액과 고객 상태 오류 방지 | 현재 동작 정리, 테스트 추가, 경계값 탐색 | 세금, 할인, 반올림, 규정, 업무 규칙 |
| JavaScript에서 TypeScript로 이전 | 이후 변경의 안전성 향상 | 타입 정의 추가, any 줄이기, 타입 오류 수정 | 공개 API 호환성, 빌드 조건 |
| 콜백 지옥이나 거대 함수 분리 | 읽기 쉽고 유지보수 가능한 구조 만들기 | 책임 분리, 이름 제안, diff 설명 | 예외 처리, 재시도, 부작용, 로그 |
ClaudeCodeLab에서도 오래된 발행 스크립트, 결제 연결 코드, 콘텐츠 변환 코드에 같은 방식을 적용합니다. 핵심은 Claude Code가 빠르게 작성하는 것이 아니라, 변경이 리뷰 가능한 크기이고 각 위험에 테스트나 수동 확인이 붙어 있는지입니다.
flowchart LR
A[조사] --> B[테스트로 동작 고정]
B --> C[작게 리팩터링]
C --> D[타입 추가와 의존성 정리]
D --> E[사람이 위험 검토]
E --> B
첫 단계는 읽기 전용 감사
첫 프롬프트에서는 파일을 수정하지 말라고 명확히 말해야 합니다. 처음에 필요한 것은 패치가 아니라 시스템 지도입니다.
@src/legacy 와 @test 를 읽어 주세요.
아직 파일은 수정하지 마세요.
아래 형식으로 감사 결과를 작성해 주세요.
1. 주요 파일과 책임
2. 외부 I/O, DB, API, 파일 쓰기, 부작용
3. 반드시 호환성을 유지해야 하는 동작
4. 부족한 테스트와 위험한 분기
5. 가장 안전한 작은 변경 순서
명확하지 않은 규칙은 추측하지 말고 "사람 확인 필요"라고 적어 주세요.
공식 How Claude Code works는 Claude Code가 파일을 읽고, 명령을 실행하고, 코드를 편집할 수 있다는 점을 설명합니다. 그래서 첫 단계는 이해와 계획으로 제한해야 합니다. 특히 오래된 시스템에서는 자연스러워 보이는 수정이 외부 시스템이 의존하던 반환값을 바꿀 수 있습니다.
복사해서 실행할 수 있는 최소 예제
아래 예제는 주문 처리 코드를 작게 줄인 것입니다. 실제 프로젝트에는 DB나 외부 결제 API가 붙겠지만 절차는 같습니다.
mkdir legacy-modernization-demo
cd legacy-modernization-demo
npm init -y
npm install -D vitest typescript @types/node
npm pkg set type="module"
npm pkg set scripts.test="vitest run"
npm pkg set scripts.typecheck="tsc --noEmit"
mkdir -p src/legacy test
기존 구현은 검증, 계산, 결과 조립이 한 파일에 있습니다. 완전히 망가진 예제가 아니라 현장에서 자주 보는 “동작하지만 손대기 불편한” 코드입니다.
// src/legacy/orderProcessor.js
export function processOrder(order) {
if (!order || !Array.isArray(order.items) || order.items.length === 0) {
return { status: "error", message: "items is required" };
}
const subtotal = order.items.reduce((sum, item) => {
return sum + item.price * item.qty;
}, 0);
const discount = order.customer?.type === "vip" ? subtotal * 0.1 : 0;
return {
status: "confirmed",
total: subtotal - discount,
items: order.items,
discount
};
}
먼저 현재 동작을 테스트로 고정합니다. 이상적인 설계를 테스트하는 것이 아니라, 지금 깨뜨리면 안 되는 계약을 기록합니다.
// test/orderProcessor.test.ts
import { describe, expect, it } from "vitest";
import { processOrder } from "../src/legacy/orderProcessor.js";
describe("processOrder legacy behavior", () => {
it("calculates total for a regular customer", () => {
const result = processOrder({
items: [
{ id: "A1", qty: 2, price: 1000 },
{ id: "B2", qty: 1, price: 500 }
],
customer: { id: "C1", type: "regular" }
});
expect(result).toMatchObject({
status: "confirmed",
total: 2500,
discount: 0
});
});
it("applies a 10 percent VIP discount", () => {
const result = processOrder({
items: [{ id: "A1", qty: 1, price: 10000 }],
customer: { id: "C2", type: "vip" }
});
expect(result.status).toBe("confirmed");
expect(result.total).toBe(9000);
expect(result.discount).toBe(1000);
});
it("returns an error when items are empty", () => {
const result = processOrder({
items: [],
customer: { id: "C3", type: "regular" }
});
expect(result.status).toBe("error");
expect(result.message).toContain("items");
});
});
npm test가 통과한 뒤에만 Claude Code에 편집을 맡깁니다.
@src/legacy/orderProcessor.js 와 @test/orderProcessor.test.ts 를 읽어 주세요.
기존 테스트가 계속 통과하도록 TypeScript로 옮겨 주세요.
조건:
- 공개 함수 이름 processOrder 유지
- status, total, discount, message 동작 유지
- 먼저 타입 정의를 추가하고 그다음 책임을 분리
- 변경 후 npm test 와 npm run typecheck 실행
- 각 diff가 어떤 호환성을 지켰는지 설명
TypeScript 이후의 구조
개선 후에는 타입, 검증, 계산, 조립을 나눕니다. 목적은 추상화 자체가 아니라 금액 관련 로직을 리뷰하기 쉬운 단위로 만드는 것입니다.
// src/orderTypes.ts
export type CustomerType = "regular" | "vip";
export type OrderItem = {
id: string;
qty: number;
price: number;
};
export type OrderInput = {
items: OrderItem[];
customer: {
id: string;
type: CustomerType;
};
};
export type OrderResult =
| {
status: "confirmed";
total: number;
items: OrderItem[];
discount: number;
}
| {
status: "error";
message: string;
};
// src/validators.ts
import type { OrderInput } from "./orderTypes";
export function validateOrder(order: OrderInput | null | undefined): string | null {
if (!order || !Array.isArray(order.items) || order.items.length === 0) {
return "items is required";
}
return null;
}
// src/calculators.ts
import type { CustomerType, OrderItem } from "./orderTypes";
export function calculateSubtotal(items: OrderItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
export function calculateDiscount(subtotal: number, customerType: CustomerType): number {
return customerType === "vip" ? subtotal * 0.1 : 0;
}
// src/orderProcessor.ts
import { calculateDiscount, calculateSubtotal } from "./calculators";
import type { OrderInput, OrderResult } from "./orderTypes";
import { validateOrder } from "./validators";
export function processOrder(order: OrderInput): OrderResult {
const validationMessage = validateOrder(order);
if (validationMessage) {
return { status: "error", message: validationMessage };
}
const subtotal = calculateSubtotal(order.items);
const discount = calculateDiscount(subtotal, order.customer.type);
return {
status: "confirmed",
total: subtotal - discount,
items: order.items,
discount
};
}
테스트 import를 ../src/orderProcessor로 바꾸고 npm test, npm run typecheck를 다시 실행합니다. 이 정도 크기의 변경이면 리뷰어가 흐름을 따라갈 수 있습니다. 같은 PR에 디렉터리 이동, 의존성 업그레이드, 포매팅, 도메인 용어 변경까지 섞으면 검토 품질이 떨어집니다.
의존성 업그레이드는 별도 트랙으로
리팩터링과 major 버전 업그레이드를 동시에 하는 것도 흔한 실패입니다. 테스트가 실패했을 때 원인이 타입 이전인지, API 변경인지, 번들러 설정인지, 비즈니스 로직 변경인지 구분하기 어렵습니다.
먼저 조사만 시키는 편이 안전합니다.
package.json 과 lockfile 을 읽어 주세요.
아직 업데이트하지 마세요.
아래 항목을 표로 정리해 주세요.
- 패키지
- 현재 버전
- 권장 목표 버전
- major 업그레이드 여부
- 공식 migration guide URL
- 영향을 받을 가능성이 큰 파일
- 업데이트 전에 추가해야 할 테스트
삭제, 마이그레이션, 배포처럼 부작용이 큰 작업은 권한을 보수적으로 설정해야 합니다. 공식 Claude Code permissions 문서를 팀에서 먼저 읽어 두면 좋습니다. 에이전트가 빠르게 실행하는 것보다 중요한 것은 필요한 승인 지점을 지나치지 않는 것입니다.
구체적인 함정
첫 번째 함정은 테스트 없이 리팩터링하는 것입니다. 코드가 깔끔해져도 반올림, 할인 조건, 오류 메시지가 바뀌면 회귀입니다.
두 번째는 Claude Code의 제안을 업무 규칙으로 받아들이는 것입니다. 더 관용적인 반환 타입이 기존 클라이언트에도 안전하다는 뜻은 아닙니다.
세 번째는 거대한 PR입니다. 타입 이전, 로직 분리, 의존성 변경, 파일 이동, 포매팅 변경은 가능한 한 나눕니다.
네 번째는 오류 처리를 너무 빨리 “개선”하는 것입니다. 오래된 시스템에서는 이상해 보이는 null, 문자열, HTTP 상태 코드가 계약일 수 있습니다.
다섯 번째는 문서화를 뒤로 미루는 것입니다. Claude Code에게 호환성 메모, 수동 검증 절차, 롤백 방법을 PR 설명에 쓰게 하면 이후 유지보수가 쉬워집니다.
리뷰 흐름과 다음 단계
이 글은 리팩터링 자동화 가이드, Claude Code와 TDD, 문서 자동 생성과 함께 읽으면 실무 적용이 쉽습니다. 팀 도입 시에는 CLAUDE.md best practices에 금지 영역, 테스트 명령, 도메인 용어, 리뷰 규칙을 적어 두세요.
ClaudeCodeLab은 Claude Code 교육, CLAUDE.md 템플릿, 레거시 시스템 현대화 상담을 제공합니다. 목표는 AI로 한 번에 갈아엎는 것이 아니라, 테스트와 권한과 사람의 리뷰가 함께 작동하는 반복 가능한 흐름을 만드는 것입니다.
실제로 확인한 결과
본문의 절차대로 legacy-modernization-demo를 만들고, 오래된 JavaScript 주문 처리기를 작성한 뒤 Vitest로 세 가지 동작을 고정했습니다. 그다음 TypeScript 구조로 옮기고 테스트와 타입 검사를 다시 실행했습니다. 가장 효과적이었던 부분은 읽기 전용 감사 프롬프트와 편집 프롬프트를 분리한 점입니다. diff가 작아졌고, 총액 계산, VIP 할인, 빈 주문 오류가 유지되는지 확인하기 쉬웠습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.