AI로 레거시 코드 리팩터링하기: characterization test로 현재 동작을 먼저 잠그는 순서
입력 조합 90개를 기록해 두고 리팩터링 결과와 비교하자, 반올림 결과가 달라진 2건이 드러났다

핵심 요약
- characterization test는 옳고 그름이 아니라 현재 동작을 기록하며, 버그처럼 보이는 동작도 일단 그대로 잠근다.
- 입력을 넓게 조합해 결과와 오류를 모두 저장하면 대표 값 테스트가 놓치는 차이를 찾을 수 있다.
- 예제에서 toFixed(2)와 Math.round 방식은 90건 중 2.675 입력 2건에서 결과가 달랐다.
- 달라진 결과는 의도한 개선인지 회귀인지 판단하고, 동작 변경은 리팩터링과 별도 커밋으로 분리한다.
테스트가 거의 없는 오래된 코드를 AI에 "깔끔하게 다시 짜 달라"고 맡기면, 결과는 읽기 쉬워지지만 동작이 미묘하게 달라질 수 있다. 문제는 그 차이를 확인할 방법이 없다는 것이다. characterization test는 "이 코드가 옳은가"가 아니라 "지금 이 코드가 실제로 무엇을 하는가"를 기록하는 테스트다. 이 글은 청구 금액 계산 함수를 예로, 현재 동작을 먼저 잠그고 그 위에서 AI 리팩터링 결과를 검증하는 순서를 실제 실행 결과로 정리한다.
왜 동작부터 잠가야 하나
- 테스트 부재: 리팩터링 후 회귀를 감지할 방법이 없다.
- 암묵적 계약: 외부 시스템이 기대하는 결과(반올림 방식, 예외 메시지)가 문서화되어 있지 않다.
- 버그처럼 보이는 동작: 다른 시스템이 그 동작에 기대고 있을 수 있어, 리팩터링 중에 몰래 고치면 안 된다.
그래서 characterization test는 버그처럼 보이는 동작도 일단 그대로 기록한다. 고칠지 말지는 동작을 잠근 뒤 별도 변경으로 결정한다.
예제: 의도가 남아 있지 않은 청구 금액 계산
// 레거시 코드(예시): 의도가 주석으로 남아 있지 않은 청구 금액 계산
export function calc(a, c, t) {
var x = a;
if (c == 'WELCOME10') x = x * 0.9;
else if (c == 'VIP') { if (a > 100) x = x - 15; else x = x * 0.95; }
else if (c) throw new Error('bad coupon');
if (t) x = x * 1.1;
return Number(x.toFixed(2));
}변수 이름은 a, c, t이고, 빈 문자열 쿠폰을 넣으면 오류 없이 통과하며, 반올림은 toFixed(2)에 맡긴다. AI에 "읽기 쉽게, 금액 반올림은 정확하게"라고 요청하면 다음과 같은 결과가 나올 법하다.
// AI가 "읽기 쉽게, 금액 반올림은 정확하게" 요청을 받고 만든 리팩터링 결과(예시)
const COUPONS = {
WELCOME10: (amount) => amount * 0.9,
VIP: (amount) => (amount > 100 ? amount - 15 : amount * 0.95),
};
export function calc(amount, coupon, withTax) {
if (coupon && !COUPONS[coupon]) throw new Error('bad coupon');
let total = coupon ? COUPONS[coupon](amount) : amount;
if (withTax) total *= 1.1;
return Math.round(total * 100) / 100;
}코드를 눈으로 비교하면 같은 동작처럼 보인다. 쿠폰 처리, 세금, 예외 조건이 모두 대응된다.
현재 동작을 기록하는 스크립트
대표 값 몇 개만 테스트하면 이 차이를 놓친다. 그래서 금액(0, 소수점 셋째 자리 값, 기준 금액 직전·직후 등), 쿠폰(없음, 빈 문자열, 정상 2종, 알 수 없는 쿠폰), 세금 여부를 모두 조합해 결과나 오류 메시지를 그대로 저장한다. 이 방식을 golden master라고도 부르며, Vitest의 toMatchSnapshot이나 toMatchFileSnapshot으로도 같은 일을 할 수 있다. 여기서는 구조를 보이기 위해 의존성 없이 만들었다.
// 사용법: node characterize.mjs record <모듈> → golden.json에 현재 동작 기록
// node characterize.mjs verify <모듈> → 기록과 다른 결과를 모두 출력
// 입력 조합을 넓게 만들어 "지금 실제로 무엇을 하는가"를 그대로 잠근다(옳고 그름은 판단하지 않는다).
import { readFileSync, writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
const [mode, mod] = process.argv.slice(2);
const { calc } = await import(pathToFileURL(mod).href);
const amounts = [0, 1.005, 2.675, 10, 99.99, 100, 100.01, 150.555, 1000];
const coupons = [undefined, '', 'WELCOME10', 'VIP', 'UNKNOWN'];
const taxes = [false, true];
const observe = () => {
const out = {};
for (const a of amounts) for (const c of coupons) for (const t of taxes) {
const key = JSON.stringify([a, c ?? null, t]);
try { out[key] = { value: calc(a, c, t) }; } catch (e) { out[key] = { error: e.message }; }
}
return out;
};
if (mode === 'record') {
const out = observe();
writeFileSync('golden.json', JSON.stringify(out, null, 1));
console.log(`기록 ${Object.keys(out).length}건 → golden.json`);
} else {
const golden = JSON.parse(readFileSync('golden.json', 'utf8'));
const now = observe();
const diffs = Object.keys(golden).filter((k) => JSON.stringify(golden[k]) !== JSON.stringify(now[k]));
diffs.forEach((k) => console.log(`DIFF ${k} 기록 ${JSON.stringify(golden[k])} → 현재 ${JSON.stringify(now[k])}`));
console.log(`${Object.keys(golden).length}건 중 다른 결과 ${diffs.length}건`);
process.exitCode = diffs.length ? 1 : 0;
}실행 결과: 90건 중 2건이 달라졌다
2026-10-09에 Node.js 24.15로 실행했다. 먼저 레거시 코드로 기록하고, 같은 코드로 검증해 기록이 안정적인지 확인한 뒤, 리팩터링 결과를 검증했다.
$ node characterize.mjs record invoice-legacy.mjs && node characterize.mjs verify invoice-legacy.mjs
기록 90건 → golden.json
90건 중 다른 결과 0건$ node characterize.mjs verify invoice-refactored.mjs
DIFF [2.675,null,false] 기록 {"value":2.67} → 현재 {"value":2.68}
DIFF [2.675,"",false] 기록 {"value":2.67} → 현재 {"value":2.68}
90건 중 다른 결과 2건
exit=12.675를 반올림한 결과가 2.67에서 2.68로 바뀌었다. 2.675는 이진 부동소수점으로 정확히 표현되지 않아 실제 값이 2.6749999…에 가깝고, toFixed(2)와 Math.round(x * 100) / 100이 이 값을 다르게 처리한다. 반면 1.005는 두 방식 모두 1.00이 되어 차이가 없었다. 쿠폰·세금·예외 조합 88건은 모두 같았기 때문에, 대표 값 몇 개만 확인했다면 "동작이 같다"고 결론 냈을 것이다.
달라진 결과를 어떻게 처리할까
| 판단 | 조건 | 처리 |
|---|---|---|
| 의도한 개선 | 새 결과가 업무 규칙상 맞고, 영향받는 쪽과 합의됨 | 리팩터링과 분리된 "동작 변경" 커밋으로 올리고 기록을 다시 만든다 |
| 조용한 회귀 | 새 결과를 설명할 수 없거나 외부 시스템이 옛 결과에 맞춰져 있음 | 리팩터링을 되돌리고 옛 반올림 방식을 유지한다 |
| 판단 보류 | 규칙을 아는 사람이 없음 | 리팩터링에서는 옛 동작을 유지하고, 규칙 확인을 별도 이슈로 남긴다 |
금액 계산이라면 근본적으로는 부동소수점 대신 최소 단위 정수(원, 센트)로 계산하는 것이 맞다. 다만 그것도 동작 변경이므로 리팩터링과 섞지 않는다.
AI에 리팩터링을 맡기는 순서
- 대상 함수의 공개 입출력과 예외 조건을 AI에 추측하게 해 표로 받는다. 이 표로 입력 조합을 만든다.
- 레거시 코드로 기록을 만들고, 같은 코드로 검증해 0건인지 확인한다(시간·난수 의존이 있으면 고정한다).
- 리팩터링 범위를 함수 하나로 좁히고, 공개 시그니처·반환 타입·예외는 바꾸지 말라고 명시한다.
- 변경 후 검증 명령을 실행하게 하고 결과를 그대로 붙이게 한다.
- 다른 결과가 나오면 위 표로 판단하고, 동작 변경은 별도 커밋으로 분리한다.
커밋은 "한 번에 한 가지 의도"를 지킨다. 테스트 추가, 이름 정리, 구조 분해, 동작 변경을 각각 나누면 문제가 생겼을 때 어느 변경이 원인인지 바로 찾을 수 있다. 새 기능을 테스트 먼저 만드는 흐름은 AI 코딩 어시스턴트와 TDD에서, 커버리지가 놓치는 규칙을 찾는 방법은 AI 생성 코드에 테스트 붙이기에서 다룬다.
초보자가 자주 실수하는 포인트
- 모듈 전체를 한 번에 리팩터링하게 해 차이가 어디서 생겼는지 모른다.
- 버그처럼 보이는 동작을 리팩터링 중에 몰래 고친다.
- 기록을 만든 뒤 같은 코드로 다시 검증해 안정적인지 확인하지 않는다.
- 결과가 다르면 기록부터 갱신해 회귀를 덮는다.
체크리스트
- 대상 함수의 입력 조합과 예외 조건을 표로 정리했다.
- 레거시 코드로 기록을 만들고 재검증 결과가 0건이다.
- 리팩터링 범위와 바꾸면 안 되는 것을 요청에 적었다.
- 달라진 결과마다 개선·회귀·보류를 판단했다.
- 동작 변경을 별도 커밋으로 분리했다.
자주 묻는 질문
스냅샷 테스트와 무엇이 다른가요?
방식은 같습니다. 스냅샷 테스트는 출력 하나를 저장하는 경우가 많고, characterization test는 리팩터링 대상의 입력 조합을 넓게 만들어 현재 동작 전체를 잠근다는 목적이 다릅니다. Vitest의 toMatchSnapshot으로도 구현할 수 있습니다.
시간이나 난수에 의존하는 함수는 어떻게 기록하나요?
현재 시각과 난수 생성기를 인자로 받거나 테스트에서 고정한 뒤 기록합니다. 고정하지 않으면 같은 코드로 재검증해도 결과가 달라져 기록을 믿을 수 없습니다.
참고 자료 · 검증 기준
위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.
이 글은 위 참고 자료를 바탕으로 정리했으며, 내용은 운영 과정에서 순차적으로 보완될 수 있습니다.