추상구문트리(AST)와 Codemod로 기존 코드를 안전하게 마이그레이션하기
추상구문트리가 무엇인지 알아보고 Babel을 활용해서 실제 AST Codemod를 구현해 레거시 코드를 마이그레이션 한 경험을 공유합니다.
들어가며
대규모 프론트엔드 프로젝트를 운영하다 보면 새로운 기능을 만드는 시간보다 기존 코드를 정리하는 시간이 더 길어진다. 특히 프로젝트 초기에 만들어진 패턴이 전역적으로 퍼져 있는 경우라면, 단순한 리팩토링이 아니라 구조전체를 바꾸는 일에 가깝다.
이번에 진행한 작업은 전역적으로 사용되던 다국어함수("CODE") || "fallback" 패턴을 React Context 기반의 useTranslation 훅과 t("CODE", "fallback") 형태로 전환해야했다.
겉으로 보기에는 단순 치환처럼 보이지만 이 패턴은 컴포넌트, 커스텀 훅, 컬럼 정의 객체, 상수 파일, 유틸 함수 등 다양한 실행 맥락에 흩어져 있었고 바꿔야할 코드 라인이 무려 7000줄 이상이었다. 이렇기에 무턱대고 문자열을 치환하는 방식이은 은 런타임 에러를 유발할 수 있다고 생각했고 그렇다고 이 7000줄 이상의 코드라인을 무작정 수작업으로 처리하기엔 너무 불필요한 리소스가 많이 들었다.
몇가지 방법을 찾아보다 인프런의 기존 서비스 국제화(i18n) 작업 쉽게 덜어내기 글을 읽게 되었고, 대규모 i18n 마이그레이션을 자동화한 접근 방식에서 힌트를 얻었다. 특히 AST 기반으로 코드를 분석하고 변환하는 방식이 이번 문제에 적합하다고 판단했다.
이번 글에선 AST가 무엇인지, Codemod를 구현하기 위해 Babel이 어떤 역할을 하는지, 그리고 Codmod를 통해 레거시 다국어 코드를 안전하게 자동 변환한 과정을 기록해보자.
AST란 무엇인가?
AST는 Abstract Syntax Tree의 약자다. 소스 코드를 단순한 문자열이 아니라 문법적 의미 단위로 분해한 트리 구조다.
예를 들어 다음 코드를 보자.
다국어함수("PRODUCT_TITLE") || "상품명"문자열로 보면 하나의 텍스트 덩어리지만, AST로 파싱하면 전혀 다른 구조가 된다.
LogicalExpression (||)
├─ left: CallExpression
│ ├─ callee: Identifier(다국어함수)
│ └─ arguments: ["PRODUCT_TITLE"]
└─ right: StringLiteral("상품명")이 구조를 활용해서 단순히 "다국어함수(" 이라는 문자열을 찾는 것이 아니라,
- 논리 연산자인 경우에만
- OR 연산일 때만
- 좌항이
다국어함수호출일 때만
이런 조건을 문법 수준에서 정확하게 걸 수 있다.
AST의 핵심 장점은 코드의 겉모습이 아니라 의미 구조를 기준으로 다룰 수 있다는 점이다.
괄호가 추가되거나 줄바꿈이 바뀌거나 포맷이 달라져도 AST는 동일하게 유지된다. 이것이 정규표현식 기반 치환과 근본적으로 다른 지점이라고 생각한다.
그리고 코드에서 추상구문트리로 변환하려면 파서(parser)가 필요하다. JavaScript 생태계에서 가장 널리 쓰이는 파서는 Babel, Esprima, Acorn 등이 있다. 나는 Babel을 사용하기로 했는데 그 이유는 파서뿐만 아니라
코드 → AST → 변환 → 새로운 코드의 파이프라인을 만들 수 있어서 Codemod를 만들기에 적합하다고 생각했다.
Babel은 무엇을 제공하는가?
AST를 다루기 위해서는 파싱, 순회, 변환, 코드 재생성 과정이 필요하다. Babel은 이 전 과정을 모두 제공하는 툴체인이다.
Babel이 제공하는 핵심 기능은 네 단계로 나눌 수 있다.
1. Parser
소스 코드를 파싱해서 AST로 변환해주는 역할을 한다.
const parser = require("@babel/parser")
const code = `
const add = (a, b) => a + b;
`;
const ast = parser.parse(code, {
sourceType: "module",
plugins: ["typescript", "jsx"]
})2. Traverse
traverse는 parser가 생성한 AST를 순회하며 특정 노드를 탐색하는 역할을 한다.
이 traverse 는 Visitor 패턴을 사용해서 특정 노드타입에 대해 실행할 함수를 정의할 수 있다.
const traverse = require("@babel/traverse").default
traverse(ast, {
CallExpression(path) {
// 특정 함수 호출을 탐지
}
})3. Types
traverse가 “어디를 방문할지(탐색)”를 담당한다면, types는 방문한 노드가 “무엇인지 확신하고”, “어떤 형태로 바꿀지 노드를 구성”하게 해준다.
const t = require("@babel/types")
if (t.isCallExpression(node)) {
// node는 CallExpression임이 보장됨
}
if (t.isIdentifier(node.callee, { name: "다국어함수" })) {
// callee가 Identifier이고 이름이 "다국어함수"인 경우만 통과
}4. Generator
수정된 AST를 다시 코드로 출력한다.
const generate = require("@babel/generator").default
const output = generate(ast).code이 네 단계를 조합하면 “코드를 의미 단위로 읽고, 수정하고, 다시 작성하는 자동화 파이프라인”을 구축할 수 있다.
이 자동화 작업을 구조적으로 수행하는 방식을 Codemod라고 부른다.
Codemod란 무엇인가
Codemod는 Code Modification의 줄임말이다. 대규모 코드베이스에서 특정 패턴을 자동으로 변환하기 위한 스크립트를 의미한다.
Codemod는 단순히 치환이 아니라, 문법을 이해한 상태에서 코드를 수정하는 일종의 프로그래밍된 리팩토링이다. 일반적으로 다음과 같은 상황에서 사용된다.
- 라이브러리 API 변경
- 전역 패턴 교체
- 타입 시스템 전환
- 레거시 코드 제거
- 네이밍 규칙 변경
Codemod의 핵심은 안전성과 재현성이다. 수천 개 파일을 수작업으로 수정하는 것은 불가능하다. 하지만 Codemod는 동일한 규칙을 모든 파일에 일관되게 적용한다.
이번 레거시 코드를 걷어내는 것 역시 단순한 리팩토링 작업이 아니라 API 교체였기 때문에 Codemod 접근이 적합했다.
변환 대상 파악하기
이번 변환에서 가장 중요한 점은 실행 맥락을 구분하는것이였다.
새로 변환할 useTranslation()은 React 훅이다. 즉, React 컴포넌트나 커스텀 훅 내부에서만 호출 가능하다.
다음 코드를 확인해보자.
function ProductTitle() {
const { t } = useTranslation()
return <h1>{t("TITLE", "상품명")}</h1>
}위 코드는 상품 제목을 보여주는 React 컴포넌트이다. 위 코드에서는 새로운 다국어 훅인 useTranslation()을 사용해도 문제가없다.
하지만 다음 코드는 런타임 에러를 발생시킨다.
export const STATUS_LABEL = t("ACTIVE", "활성")따라서 단순히 다국어함수()을 t()로 바꾸는 것이 아니라, 이 파일이 React 실행 맥락인지 먼저 판단해야 했다.
1단계: 컴포넌트와 훅 판별하기
파일 단위 안전성 검증 함수는 다음과 같이 구성했다.
function isComponentFile(ast) {
let hasJSX = false
let hasReactImport = false
let hasComponentLikeFunction = false
traverse(ast, {
JSXElement() {
hasJSX = true
},
ImportDeclaration(path) {
if (path.node.source.value.toLowerCase() === "react") {
hasReactImport = true
}
},
FunctionDeclaration(path) {
const name = path.node.id?.name
if (!name) return
const isComponent = name[0] === name[0].toUpperCase()
const isHook = name.startsWith("use")
if (isComponent || isHook) {
hasComponentLikeFunction = true
}
}
})
return (hasJSX || hasReactImport) && hasComponentLikeFunction
}이 함수는 다음 세 가지 단서를 조합한다.
- JSX 존재 여부
- react import 여부
- 컴포넌트 혹은 use로 시작하는 함수 존재 여부
이 기준을 통과한 파일만 변환 대상으로 삼았다.
2단계: 다국어함수 → t 변환
변환은 크게 두 가지 케이스로 나눴다.
- fallback이 있는 논리식 형태
- fallback 없이 단독 호출하는 형태
이 둘을 분리한 이유는 AST 구조가 완전히 다르기 때문이다.
다국어함수("KEY")는CallExpression다국어함수("KEY") || "기본값"은LogicalExpression안에CallExpression이 들어있는 구조
즉, 겉보기에는 “||가 있냐 없냐” 차이지만, AST 관점에서는 루트 노드 타입이 달라진다.
따라서 이 두가지 케이스도 분리해야 안전하게 처리할 수 있다.
논리식 포함 케이스 (fallback 있는 경우)
다국어함수("PRODUCT.TITLE") || "상품명"이 코드는 텍스트로 보면 “왼쪽이 falsy면 오른쪽을 쓰는 패턴”이다. 즉, 기존 로직에서 fallback을 구현하기 위해 ||를 사용해왔다.
LogicalExpression(path) {
const { node } = path
// 1) "||" 인 경우만 처리한다.
// - &&, ?? 같은 다른 논리식은 변환 대상이 아니다.
if (node.operator !== "||") return
// 2) 좌항이 "다국어함수(...)" 호출인지 확인한다.
// - left가 CallExpression이어야 하고
// - callee가 Identifier("다국어함수")여야 한다.
//
// 예: 다국어함수("A") || "a" ✅
// 예: other("A") || "a" ❌
// 예: obj.다국어함수("A") || "a" ❌ (MemberExpression 케이스면 별도 대응 필요)
if (
!t.isCallExpression(node.left) ||
!t.isIdentifier(node.left.callee, { name: "다국어함수" })
) {
return
}
// 3) 기존 노드를 재사용해서 t(key, fallback) 형태로 재조립한다.
//
// - key는 좌항 호출의 첫 번째 인자: node.left.arguments[0]
// - fallback은 논리식 우항: node.right
//
// 여기서 중요한 점:
// node.right가 단순 문자열이 아니라 함수 호출, 변수, 템플릿 리터럴 등
// "표현식(Expression)"이면 무엇이든 그대로 fallback 인자로 옮길 수 있다.
const newCall = t.callExpression(
t.identifier("t"),
[node.left.arguments[0], node.right]
)
// 4) 원래 LogicalExpression 전체를 새로운 CallExpression로 교체한다.
// 즉, (다국어함수(...) || fallback) 라는 구조 자체를 t(...)로 바꿔치기한다.
path.replaceWith(newCall)
}
이 방식은 괄호나 줄바꿈과 관계없이 동일하게 작동한다. 의미 구조를 기준으로 변환하기 때문에 포맷 변화에 영향을 받지 않는다.
단독호출케이스
다국어함수("PRODUCT.TITLE")CallExpression(path) {
const { node, parent } = path
if (
t.isIdentifier(node.callee, { name: "다국어함수" }) &&
!t.isLogicalExpression(parent)
) {
const newCall = t.callExpression(
t.identifier("t"),
[node.arguments[0]]
)
path.replaceWith(newCall)
}
}부모가 LogicalExpression인 경우를 제외하는 이유는 중복 변환을 방지하기 위함이다.
3단계: useTranslation 자동 주입
t()가 생성되면, 이제 useTranslation을 주입해야 한다. 하지만 모든 함수에 이 코드를 무조건 삽입하면 안 된다.
t()를 실제로 사용하지 않는 함수에도 훅이 들어가면 불필요한 호출이 생긴다.- React Hooks Rule(조건부 호출 금지)을 깨지 않도록 항상 함수 최상단에 삽입해야 한다.
그래서 해당 함수 내부에서 실제로 t()가 사용되는 경우에만, 그리고 함수 body의 가장 첫 줄에 훅을 삽입하도록 했다.
FunctionDeclaration(path) {
// 1. 이 함수 내부에서 t()가 실제로 사용되는지 검사
// componentUsesT는 하위 노드를 순회하며
// CallExpression 중 callee가 Identifier("t")인 경우가 있는지 확인한다.
if (!componentUsesT(path)) return
// 2. const { t } = useTranslation() AST 노드 생성
// 아래 구조를 AST로 직접 조립한다.
//
// const { t } = useTranslation()
//
// - VariableDeclaration (const)
// - VariableDeclarator
// - id: ObjectPattern { t }
// - init: CallExpression(useTranslation)
//
const hookDecl = t.variableDeclaration("const", [
t.variableDeclarator(
// { t } 구조 생성 (ObjectPattern)
t.objectPattern([
t.objectProperty(
t.identifier("t"), // key
t.identifier("t"), // value
false,
true // shorthand: true → { t: t } 대신 { t }
)
]),
// useTranslation() 호출 생성
t.callExpression(
t.identifier("useTranslation"),
[]
)
)
])
// 3. 함수 body의 가장 앞에 삽입
// React Hooks Rule에 따라 조건문 내부가 아니라
// 항상 함수 최상단에 위치해야 한다.
path.node.body.body.unshift(hookDecl)
}
이렇게 하면 불필요한 훅 호출이 생기지 않는다.
왜 Codemod가 적합했는가
이번 작업은 단순 치환이 아니었다.
- 실행 맥락을 구분해야 했고
- 의미를 유지해야 했으며
- 대규모 파일에 일관되게 적용되어야 했고
- 되돌릴 수 있어야 했다
Codemod는 이 네 조건을 모두 충족한다. 특히 “의미 보존”이라는 측면에서 AST 기반 접근은 압도적으로 안전하다.
테스트
AST 기반 코드 변환은 한 번에 전체 코드베이스에 적용하기보다, 작은 범위에서 여러 번 검증하며 점진적으로 확장하는 방식이 안전하다. 그래서 테스트 역시 파일 단위 → 디렉터리 단위 순서로 진행했다.
node transform-i18n.js src/components --recursive이를 위해 CLI 옵션으로 --recursive 플래그를 추가해 지정한 디렉터리 하위의 모든 파일을 재귀적으로 순회하며 변환하도록 했다.

이 결과로 약 7000줄의 코드를 한번에 변환할 수 있었다.
마무리
AST 기반 자동 리팩토링은 초반 진입 장벽이 있지만, 한 번 기준을 세워두면 이후 비슷한 대규모 변경 작업에서 꽤 괜찮은 도구가 될 것 같다. 다음에 또 다른 레거시 패턴을 마주하더라도 같은 방식으로 접근하여 문제를 해결할 수 있을것 같다.
참고자료
https://toss.tech/article/improving-code-quality-via-eslint-and-ast
https://toss.tech/article/ast-funnel-visualization
https://yceffort.kr/2021/05/ast-for-javascript