리팩토링을 하려면 먼저 코드의 구조를 파악해야 합니다. 어떤 변수가 어디서 변하는지, 어떤 연산이 부수효과를 일으키는지, 어떤 값이 어떤 입력에 의존하는지. 이걸 사람이 눈으로 읽어서 판단하면 빠뜨리는 부분이 생기고, AI에게 "이 코드 리팩토링해줘"라고 던지면 프롬프트 표현에 따라 결과가 달라집니다.
이 문제를 고민하다가 React Compiler를 스터디하게 됐습니다. React Compiler는 메모이제이션을 자동 삽입하는 도구로 알려져 있지만, 그 내부를 들여다보면 본질은 정적 분석기입니다. SSA 변환, 부수효과 분류, 의존성 추적, 스코프 분리—코드 생성 직전까지의 파이프라인은 소스 코드의 구조를 정밀하게 분석하는 과정입니다.
여기서 아이디어가 나왔습니다. 코드 생성 단계를 빼면, 컴파일러의 출력은 순수한 구조화된 분석 데이터가 됩니다. 이 데이터를 AI에게 넘기면, 프롬프트 표현이나 대화 컨텍스트가 아니라 항상 동일한 분석 데이터를 근거로 리팩토링합니다. 같은 코드를 넣으면 같은 분석이 나오고, 리팩토링 전후를 다시 분석에 넣어 수치로 비교할 수 있습니다.
React Compiler는 메모이제이션을 위해 여러 단계의 정적 분석을 수행합니다.
| 분석 단계 | 핵심 동작 | 추출되는 데이터 |
|---|---|---|
| SSA 변환 | 모든 변수에 고유 버전 부여 | 변수별 할당 횟수, φ 함수 위치 |
| Effect 분석 | 각 연산의 부수효과 분류 | Read/Mutate/Freeze 분류표 |
| Reactive 분석 | props/state 의존성 추적 | 의존성 체인, 파생 관계 |
| Scope 생성 | 독립적 단위 분리 | scope별 의존성, 포함 연산 |
이 분석 단계들은 모두 코드 생성(메모이제이션 삽입) 이전에 수행됩니다. 동일한 소스 코드에 대해 항상 같은 결과를 내는 결정적(deterministic) 분석이라는 점이 핵심입니다.
React Compiler의 파이프라인은 이렇게 구성됩니다.
Source → AST → HIR → SSA → Effect 분석 → Reactive 분석 → Scope 생성 → Code 생성
↑
여기서 멈추면
분석 데이터만 남는다
babel-plugin-react-compiler의 logger 옵션에서 debugLogIRs 콜백을 등록하면, 파이프라인의 각 단계가 완료될 때마다 HIRFunction/ReactiveFunction 등 내부 IR 데이터를 그대로 받아볼 수 있습니다. React Compiler Playground도 이 방식으로 중간 결과를 시각화합니다.
// babel-plugin-react-compiler의 logger.debugLogIRs 활용
const snapshots = [];
const pluginOptions = {
logger: {
debugLogIRs(result) {
// 매 파이프라인 단계마다 호출됨
snapshots.push({ stage: result.name, kind: result.kind, value: result.value });
},
logEvent(filename, event) { /* CompileSuccess, CompileError 등 */ },
},
};
// 파이프라인 완료 후, snapshots에 각 단계의 IR이 누적됨
// snapshots에서 원하는 단계의 HIRFunction/ReactiveFunction을 꺼내 분석
다음 코드를 분석 대상으로 넣어봅시다.
function OrderSummary({ items, coupon, user }) {
let total = 0;
let discountedTotal = 0;
let displayItems = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.inStock) {
total += item.price * item.quantity;
displayItems.push({
...item,
subtotal: item.price * item.quantity,
});
}
}
if (coupon && coupon.isValid) {
discountedTotal = total * (1 - coupon.discount);
} else {
discountedTotal = total;
}
const isVip = user.purchaseCount > 10;
const vipLabel = isVip ? 'VIP' : 'Standard';
const greeting = `${user.name}님 (${vipLabel})`;
return (
<div>
<h1>{greeting}</h1>
<ItemList items={displayItems} />
<span>{discountedTotal}원</span>
</div>
);
}
이 코드를 분석하면 다음과 같은 구조화된 데이터를 얻습니다.
total이 4번 할당됨(초기값, 루프 헤더 φ, 루프 내 누적, inStock 분기 합류 φ). discountedTotal은 3번 할당(if 분기, else 분기, 합류 φ). 루프 변수 i와 displayItems까지 포함하면 φ 함수가 총 6개 존재displayItems.push가 Mutate로 분류됨(배열 in-place 수정). total += ...은 원시값 재할당이므로 Mutate가 아니라 새 값 생성(SSA에서 새 버전 할당). props 접근은 Read, 객체 리터럴 생성({...item})은 Create, JSX 전달은 Freezetotal과 displayItems는 items에서 파생, discountedTotal은 items와 coupon에서 파생, greeting은 user에서 파생items/coupon에 의존하는 연산(scope_0), user에 의존하는 연산(scope_1), 그리고 두 scope의 결과를 결합하는 JSX 반환(scope_2)으로 총 3개의 scope가 생성됨. scope_0과 scope_1은 서로 독립적이 데이터 각각이 리팩토링의 근거가 됩니다. φ 함수가 나온 변수는 reduce나 삼항 연산자로 단일 할당으로 바꿀 수 있고, Mutate로 분류된 push는 map으로, 원시값 재할당(+=)은 reduce로 대체할 수 있고, 독립된 scope는 코드 구조에 그대로 반영할 수 있습니다.
이 분석 데이터를 AI에 넘기면, AI는 분석을 직접 수행할 필요 없이 해석과 적용에 집중합니다.
다음은 React Compiler가 OrderSummary 컴포넌트를 분석한 결과야.
[분석 데이터 붙여넣기]
이 분석을 바탕으로 함수형 프로그래밍 원칙에 맞게 리팩토링해줘.
핵심은 컴파일러가 내놓는 분석 데이터가 결정적(deterministic)이라는 점입니다. 같은 소스 코드를 넣으면 프롬프트 표현, AI 모델 버전, 대화 컨텍스트와 무관하게 항상 같은 분석 결과가 나옵니다. 리팩토링의 근거가 되는 데이터가 고정되어 있으므로 AI의 출력도 일관성을 가집니다.
또한 리팩토링 결과를 다시 분석에 넣으면 수치로 비교 평가할 수 있습니다.
| 지표 | 리팩토링 전 | 리팩토링 후 |
|---|---|---|
| 재할당 변수 수 | 2 (total, discountedTotal) | 0 |
| φ 함수 수 | 6 | 0 |
| Mutate 연산 | 1 (push) | 0 |
| 원시값 재할당 | 1 (+=) | 0 |
| 독립 scope | 3 (코드 구조에 미반영) | 3 (코드 구조에 반영) |
분석 → 리팩토링 → 재분석의 루프를 돌면, 리팩토링이 실제로 코드 품질을 개선했는지 객관적 수치로 확인할 수 있습니다.
분석 데이터를 기반으로 얻은 리팩토링 결과입니다. 각 변환의 근거가 분석 데이터에서 직접 나옵니다.
function OrderSummary({ items, coupon, user }) {
// scope_0 [deps: items, coupon]
const inStockItems = items.filter((item) => item.inStock);
const displayItems = inStockItems.map((item) => ({
...item,
subtotal: item.price * item.quantity,
}));
const total = inStockItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const discountedTotal =
coupon?.isValid ? total * (1 - coupon.discount) : total;
// scope_1 [deps: user]
const vipLabel = user.purchaseCount > 10 ? 'VIP' : 'Standard';
const greeting = `${user.name}님 (${vipLabel})`;
return (
<div>
<h1>{greeting}</h1>
<ItemList items={displayItems} />
<span>{discountedTotal}원</span>
</div>
);
}
| 변환 | 컴파일러 분석 근거 |
|---|---|
total: let + += → reduce | SSA: total_0 → total_1(φ) → total_2(+=) → total_3(φ) — 4번 할당, 원시값 재할당 제거 |
displayItems: push → map | Effect: displayItems.push → Mutate — 배열 in-place 변이 제거 |
discountedTotal: if/else → 삼항 | SSA: discountedTotal = φ(d_if, d_else) — 분기 병합 제거 |
isVip 인라인 | Scope: scope_1에서 단일 사용, 중간 바인딩 불필요 |
| scope 경계 주석 | Scope: scope_0 [items, coupon], scope_1 [user] 독립, scope_2에서 결합 |
let이 전부 사라졌고, 변이 연산이 없어졌고, 각 값이 선언과 동시에 확정됩니다. React Compiler가 다시 이 코드를 처리하면, 이미 scope가 분리되어 있으므로 최적화가 더 효과적으로 적용됩니다.
React Compiler의 분석 파이프라인에서 코드 생성을 빼면, 코드의 구조적 특성을 정확하게 기술하는 분석 데이터가 남습니다. 이 데이터를 AI에게 넘기면, 프롬프트나 컨텍스트에 의존하지 않고 항상 동일한 근거 위에서 리팩토링할 수 있습니다. 리팩토링 전후를 다시 분석에 넣어 수치로 비교하면 개선 여부를 객관적으로 평가할 수 있습니다. 컴파일러는 분석을, AI는 해석과 적용을 — 각자 잘하는 일을 나누는 것입니다.