추상구문트리(AST)와 Codemod로 기존 코드를 안전하게 마이그레이션하기
추상구문트리가 무엇인지 알아보고 Babel을 활용해서 실제 AST Codemod를 구현해 레거시 코드를 마이그레이션 한 경험을 공유합니다.
들어가며
프론트엔드 프로젝트를 운영하다 보면 새로운 기능을 만드는 시간만큼 기존 코드를 정리하는 데 많은 시간이 들어간다.
특히 프로젝트 초기에 만들어진 패턴이 전역적으로 퍼져 있다면 단순 리팩토링보다 구조 전체를 교체하는 작업에 가까워진다.
이번에 진행했던 마이그레이션은 기존 다국어 코드를 신규 다국어 코드로 전환하는 작업을 진행했다.
기존 프로젝트에서는 다국어 처리를 위해 다음과 같은 전역 함수 패턴을 사용하고 있었다.
다국어함수("PRODUCT.TITLE") || "상품명"이를 React Context 기반의 useTranslation 훅을 사용하는 형태로 전환해야 했다.
const { t } = useTranslation()
t("PRODUCT.TITLE", "상품명")겉으로 보면 단순 문자열 치환처럼 보이지만, 실제 코드는 컴포넌트, 커스텀 훅, 컬럼 정의 객체, 유틸 함수, 상수 파일 등 다양한 위치에 흩어져 있었고, 변환 대상은 약 200개 파일, 7,000줄 이상이었다.
여기서 문자열을 단순 치환하면 문제가 생긴다.
useTranslation()은 React Hook이기 때문에 아무 위치에서나 호출할 수 없고, 기존 코드 역시 fallback이 있는 경우와 없는 경우처럼 여러 형태로 존재했다.
그렇다고 7,000줄을 수작업으로 수정하는 것도 불필요한 비용이 너무 컸다.
몇 가지 방법을 찾던 중 인프런의 기존 서비스 국제화(i18n) 작업 쉽게 덜어내기 (새 탭에서 열림) 글을 읽었고, 대규모 i18n 마이그레이션을 AST 기반으로 자동화한 접근 방식에서 힌트를 얻었다.
이번 글에서는 AST와 Babel을 이용해 레거시 다국어 코드를 Codemod로 자동 변환한 과정을 기록하였다.
왜 문자열 치환이 아니라 AST였을까?
처음 바꿔야 했던 코드는 다음과 같은 형태였다.
다국어함수("PRODUCT.TITLE") || "상품명"이를 단순하게 생각하면 정규표현식으로도 처리할 수 있을 것처럼 보인다.
하지만 실제 코드에는 다음과 같은 변형이 존재한다.
다국어함수("PRODUCT.TITLE")다국어함수(
"PRODUCT.TITLE"
) || "상품명"const title = someCondition
? 다국어함수("PRODUCT.TITLE") || "상품명"
: "-"포맷이나 코드 위치가 달라질수록 문자열 기반 규칙은 점점 복잡해진다.
반면 AST(Abstract Syntax Tree)는 코드를 문자열이 아니라 문법 구조로 표현한다.
예를 들어 다음 코드를 보자.
function add(a, b) {
return a + b
}AST를 단순화하면 다음과 같다.
Program
└── FunctionDeclaration
├── id: Identifier("add")
├── params
│ ├── Identifier("a")
│ └── Identifier("b")
└── body: BlockStatement
└── ReturnStatement
└── BinaryExpression("+")
├── left: Identifier("a")
└── right: Identifier("b")실제 AST 구조는 AST Explorer (새 탭에서 열림)에서 직접 확인할 수 있다.
AST를 다루다 보면 LogicalExpression, CallExpression, Identifier 같은 노드 타입을 자주 만나게 된다.
이런 타입은 ESTree (새 탭에서 열림)나 Babel Types 문서 (새 탭에서 열림)에서 확인할 수 있다. 이번 작업에서는 Babel을 사용했기 때문에 실제 구현에서는 Babel Types 문서를 주로 참고했다.
예를 들어 변환 대상이었던
다국어함수("PRODUCT.TITLE") || "상품명"은 AST에서 핵심적으로 다음 구조를 가진다.
LogicalExpression (operator: "||")
├── left: CallExpression
│ └── callee: Identifier("다국어함수")
└── right: StringLiteral("상품명")따라서 "다국어함수("라는 문자열을 찾는 대신,
LogicalExpression인가?
↓
operator가 "||"인가?
↓
left가 CallExpression인가?
↓
호출 함수 이름이 "다국어함수"인가?처럼 코드의 의미 구조를 기준으로 변환 대상을 찾을 수 있다.
줄바꿈이나 괄호가 달라져도 AST의 핵심 구조는 동일하기 때문에 이번 작업에는 이 방식이 더 적합했다.
Babel로 Codemod 파이프라인 만들기
AST 기반 변환에는 크게 네 단계가 필요하다.
코드
↓
Parser
↓
AST
↓
Traverse / Transform
↓
Generator
↓
새로운 코드Babel은 이 과정을 하나의 툴체인 안에서 제공한다.
Parser
@babel/parser는 소스 코드를 AST로 변환한다.
const parser = require("@babel/parser")
const ast = parser.parse(code, {
sourceType: "module",
plugins: ["typescript", "jsx"],
})프로젝트에 TSX 코드가 있었기 때문에 typescript, jsx 플러그인을 활성화했다.
Traverse
@babel/traverse는 생성된 AST를 순회하면서 특정 노드를 찾는다.
const traverse = require("@babel/traverse").default
traverse(ast, {
CallExpression(path) {
// 함수 호출 탐색
},
})Visitor 형태로 원하는 노드 타입만 지정할 수 있기 때문에 전체 AST 구조를 직접 순회할 필요가 없다.
Types
@babel/types는 노드 타입을 검사하고 새로운 AST 노드를 만드는 데 사용한다.
const t = require("@babel/types")
if (t.isCallExpression(node)) {
// CallExpression인 경우
}
if (t.isIdentifier(node.callee, { name: "다국어함수" })) {
// 다국어함수(...) 호출인 경우
}기존 노드를 검사하는 것뿐 아니라 새로운 CallExpression, VariableDeclaration 같은 노드도 직접 생성할 수 있다.
Generator
마지막으로 수정된 AST를 다시 코드로 변환한다.
const generate = require("@babel/generator").default
const output = generate(ast).code이렇게 소스 코드를 읽고, 원하는 패턴을 찾아 수정한 뒤 다시 코드로 출력하는 자동화 스크립트를 만들었다.
이런 방식으로 대규모 코드 패턴을 자동 변경하는 도구를 보통 Codemod라고 부른다.
실행 맥락 판별하기
이번 작업에서 가장 위험했던 부분은 다국어함수()를 t()로 바꾸는 것 자체가 아니었다.
새로운 t()를 얻기 위해서는 다음 Hook을 호출해야 했다.
const { t } = useTranslation()문제는 useTranslation()이 React Hook이라는 점이다.
다음과 같이 컴포넌트 안에서는 사용할 수 있다.
function ProductTitle() {
const { t } = useTranslation()
return <h1>{t("PRODUCT.TITLE", "상품명")}</h1>
}하지만 파일 최상위 상수에 그대로 적용할 수는 없다.
export const STATUS_LABEL = t("ACTIVE", "활성")따라서 모든 파일을 일괄 변환하지 않고, 먼저 React 실행 맥락으로 판단되는 파일을 분리했다.
초기 판별 기준은 JSX, React import, 컴포넌트 또는 Hook 형태의 함수 존재 여부였다.
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 = /^[A-Z]/.test(name)
const isHook = name.startsWith("use")
if (isComponent || isHook) {
hasComponentLikeFunction = true
}
},
})
return (hasJSX || hasReactImport) && hasComponentLikeFunction
}이 로직만으로 모든 React 코드를 완벽하게 판별하려는 목적은 아니었다.
자동 변환에서 애매한 파일까지 공격적으로 수정하기보다 확실하게 변환 가능한 범위를 좁히는 안전장치로 사용했다.
fallback이 있는 패턴부터 변환하기
첫 번째 대상은 다음 형태였다.
다국어함수("PRODUCT.TITLE") || "상품명"목표는 다음과 같다.
t("PRODUCT.TITLE", "상품명")AST에서는 전체 표현식이 LogicalExpression이기 때문에 해당 노드를 기준으로 찾았다.
LogicalExpression(path) {
const { node } = path
if (node.operator !== "||") return
if (
!t.isCallExpression(node.left) ||
!t.isIdentifier(node.left.callee, {
name: "다국어함수",
})
) {
return
}
const newCall = t.callExpression(
t.identifier("t"),
[node.left.arguments[0], node.right]
)
path.replaceWith(newCall)
}여기서 중요한 부분은 node.right를 문자열이라고 가정하지 않았다는 점이다.
예를 들어 fallback이 다음처럼 변수일 수도 있다.
다국어함수("PRODUCT.TITLE") || defaultTitle함수 호출일 수도 있다.
다국어함수("PRODUCT.TITLE") || getDefaultTitle()AST에서는 둘 다 Expression이기 때문에 우항 노드를 그대로 두 번째 인자로 옮기면 된다.
t("PRODUCT.TITLE", defaultTitle)t("PRODUCT.TITLE", getDefaultTitle())문자열 패턴을 분석하는 대신 기존 코드의 표현식 자체를 재사용한 것이다.
fallback이 없는 단독 호출 변환하기
두 번째는 단독 호출 형태였다.
다국어함수("PRODUCT.TITLE")목표는 단순하다.
t("PRODUCT.TITLE")이 경우 루트 노드는 LogicalExpression이 아니라 CallExpression이다.
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인 경우를 제외한 이유는 앞에서 처리한 fallback 패턴을 다시 변환하지 않기 위해서다.
즉, 두 패턴을 각각 다른 AST 구조로 분리해 처리했다.
다국어함수("KEY")
→ CallExpression
다국어함수("KEY") || "fallback"
→ LogicalExpression
└── CallExpression실제로 t를 사용하는 함수에만 Hook 주입하기
다국어 함수 호출을 t()로 변환했다면 해당 함수에는 useTranslation()도 필요하다.
하지만 모든 함수에 무조건 다음 코드를 추가하면 불필요한 Hook 호출이 생긴다.
const { t } = useTranslation()그래서 함수 내부에 실제 t() 호출이 생긴 경우만 Hook을 추가하도록 했다.
개념적으로는 다음 순서다.
함수 탐색
↓
내부에서 t()를 사용하는지 확인
↓
사용하지 않으면 종료
↓
useTranslation() 선언 생성
↓
함수 body 최상단에 삽입AST 노드는 다음과 같이 생성했다.
const hookDecl = t.variableDeclaration("const", [
t.variableDeclarator(
t.objectPattern([
t.objectProperty(
t.identifier("t"),
t.identifier("t"),
false,
true
),
]),
t.callExpression(
t.identifier("useTranslation"),
[]
)
),
])생성되는 코드는 다음과 같다.
const { t } = useTranslation()그리고 함수 body의 최상단에 삽입했다.
path.node.body.body.unshift(hookDecl)변환 전 코드가
function ProductTitle() {
return (
<h1>
{다국어함수("PRODUCT.TITLE") || "상품명"}
</h1>
)
}이었다면 최종 결과는 다음과 같다.
function ProductTitle() {
const { t } = useTranslation()
return (
<h1>
{t("PRODUCT.TITLE", "상품명")}
</h1>
)
}실제 프로젝트에서는 함수 선언뿐 아니라 Arrow Function 형태의 컴포넌트와 커스텀 훅도 존재했기 때문에 변환 가능한 함수 형태를 별도로 확장해서 처리했다.
import도 자동으로 처리하기
Hook을 추가했다면 useTranslation import도 필요하다.
import { useTranslation } from "@/i18n"여기서도 무조건 새로운 import를 추가하면 중복이 생긴다.
기존에 같은 모듈의 import가 있다면 specifier만 추가하고,
import { Trans } from "@/i18n"다음처럼 합쳐야 한다.
import { Trans, useTranslation } from "@/i18n"해당 모듈의 import가 없다면 새 ImportDeclaration을 생성한다.
이 과정 역시 AST에서 ImportDeclaration과 ImportSpecifier를 검사하는 방식으로 처리했다.
결과적으로 Codemod는 단순히 함수 호출 하나만 바꾸는 것이 아니라 다음 세 작업을 하나의 변환 단위로 처리했다.
다국어함수(...) 변환
↓
필요한 함수에 useTranslation() 주입
↓
필요한 파일에 import 추가한번에 치환하지말고 단계적으로 검증하기
AST를 사용한다고 해서 변환이 자동으로 안전해지는 것은 아니다.
잘못 만든 AST 변환은 잘못된 코드를 훨씬 빠른 속도로 대량 생성한다.
그래서 적용 범위를 단계적으로 넓혔다.
처음에는 단일 파일에 실행했다.
node transform-i18n.js src/components/ProductTitle.tsx생성된 diff를 확인한 뒤 특정 디렉터리로 범위를 넓혔다.
node transform-i18n.js src/components --recursive이를 위해 --recursive 옵션을 추가해 지정한 디렉터리 하위의 .js, .jsx, .ts, .tsx 파일을 순회하도록 만들었다.
각 단계에서 변환 결과를 확인하고 TypeScript 검사와 기존 테스트를 함께 실행했다.
특히 자동화 대상에서 애매한 코드를 억지로 처리하지 않는 것을 중요하게 봤다.
변환 규칙에 맞지 않는 코드는 그대로 남겨두고 이후 수동으로 확인하는 편이, 잘못된 변환을 만들어내는 것보다 비용이 낮았다.
결과
Codemod를 적용하면서 약 200개 이상의 파일, 7,000줄 이상의 레거시 다국어 코드를 한 번에 변환했다.
수작업이었다면 파일마다 다음 작업을 반복해야 했다.
기존 다국어 함수 호출을 찾고, fallback 여부를 확인하고, t()로 변경하고, 해당 함수에 useTranslation()을 추가하고, import를 정리한 뒤 Hook을 사용할 수 없는 위치는 다시 별도로 처리해야 했다.
Codemod에서는 이 판단을 코드로 한 번 정의한 뒤 동일한 규칙을 전체 코드베이스에 반복 적용했다.
무엇보다 유용했던 점은 속도보다는 변환 기준 자체가 코드로 남았다는 것이었다.
어떤 코드를 변경하고 어떤 코드를 제외할지 규칙이 명확했고, 결과가 잘못됐다면 Git diff를 확인한 뒤 스크립트를 수정하고 처음부터 다시 실행할 수 있었다.
마무리
조금이라도 더 빨리 레거시 코드를 걷어내고 싶어서 이것 저것 시도해보다가 재미있는 시도를 한거 같아서 기록해두었다.
AST 기반 자동 리팩토링은 초반 진입 장벽이 있지만, 한 번 기준을 세워두면 이후 비슷한 대규모 변경 작업에서 꽤 괜찮은 도구가 될 것 같다. 다음에 또 다른 레거시 패턴을 마주하더라도 같은 방식으로 접근하여 문제를 해결할 수 있을것 같다.