개발 도중 프로젝트 전체에서 사용하던 컴포넌트를 동시에 마이그레이션하는 일이 필요했습니다.
vscode의 replace 기능이나 개발자의 수작업으로도 해결 가능하겠지만,
그럴 경우 공수가 많이 들고 실수 또한 잦아지게 됩니다.
또한 대상 컴포넌트가 사용되고 있는 곳의 개수가 몇 천, 몇 만 단위인 경우 마이그레이션은 거진 불가능에 가깝습니다.
자동화를 통해서 마이그레이션을 단 하나의 스크립트로 해결하는 방법은 없을까요?
이 글에서는...
ts-morph를 사용해 대규모 컴포넌트 마이그레이션을 자동화하는 방법을 소개합니다.
AST(추상 구문 트리)는 소스 코드의 구조를 트리 형태로 표현하는 개념입니다.
간단히 두 숫자를 더해 만든 변수가 있다고 가정해보겠습니다.
const sum = 1 + 2;
이 코드를 AST로 변환하면 다음과 같이 변형됩니다. (예시입니다)
Program
├── VariableDeclaration (const)
│ ├── Identifier (sum)
│ ├── BinaryExpression (+)
│ ├── NumericLiteral (1)
│ ├── NumericLiteral (2)
이렇게 소스 코드를 트리 형식으로 변환하여, 값을 조금 더 쉽게 참조하고 변형할 수 있도록 돕는 것이 AST입니다.
AST는 크게 세 가지의 단계를 거치게 됩니다.
토큰화 : 소스 코드를 최소 단위인 토큰으로 분리하여, 코드 구문을 파악합니다.
파싱 : 토큰들을 문법 규칙에 따라 트리 구조로 변환합니다.
트리 생성 : AST라는 트리 구조를 생성하고, 이를 활용하여 코드를 변경할 수 있도록 돕습니다.
해당 세 단계를 거치고, 개발자가 원하는 코드를 수정하면 AST는 수정된 트리를 기반으로 새로운 코드를 만들어 저장합니다.
ts-morph는 TypeScript 기반 라이브러리로, 위에서 설명드린 AST를 개발자가 더 쉽게 접근할 수 있도록 도와줍니다. 변수명을 바꾸거나, 새로운 코드를 생성하거나, 함수의 호출을 변경하는 등 자유도가 매우 높다는 것이 장점입니다.
ts-morph를 사용하면 JSX 구문의 컴포넌트들도 변경할 수 있습니다.
저는 현재의 마이그레이션 이후에도 추후 기호에 맞게 함수를 조합해서 사용할 수 있도록,
플러그인 방식으로 코드를 설계했습니다.
import { Project, SourceFile } from "ts-morph";
type CodemodPluginType = (sourceFile: SourceFile) => void;
interface GenerateCodemodType {
targetPath: string;
plugins: Array<CodemodPluginType>;
}
/* generate 함수 */
export const generateCodemod = ({
targetPath,
plugins,
}: GenerateCodemodType) => {
const runCodemod = () => {
const project = new Project({ tsConfigFilePath: "tsconfig.json" });
const sourceFileList: Array<SourceFile> =
project.getSourceFiles(targetPath);
sourceFileList.forEach((sourceFile) => {
plugins.forEach((plugin) => {
plugin(sourceFile);
});
sourceFile.saveSync();
});
};
return { runCodemod };
};
import { type SourceFile } from "ts-morph";
import { ConvertPropsType } from "./types";
/* plugin 함수 */
export const convertImportPathPlugin =
({ before, after }: ConvertPropsType) =>
(sourceFile: SourceFile) => {
const importDeclarationList = sourceFile.getImportDeclarations();
importDeclarationList.forEach((importDecl) => {
const moduleSpecifierValue = importDecl.getModuleSpecifierValue();
if (moduleSpecifierValue === before) {
importDecl.setModuleSpecifier(after);
}
});
};
위와 같이 generate 함수와 plugin 함수를 두고, codemod script가 필요할 때
이를 섞어서 조합할 수 있습니다.
import { generateCodemod } from "../core/generateCodemod";
import { convertComponentNamePlugin } from "../plugins/convertComponentNamePlugin";
import { convertImportNamePlugin } from "../plugins/convertImportNamePlugin";
import { convertImportPathPlugin } from "../plugins/convertImportPathPlugin";
import { convertPropsNamePlugin } from "../plugins/convertPropsNamePlugin";
const { runCodemod } = generateCodemod({
targetPath: "src/**",
plugins: [
convertImportPathPlugin({
before: "@/components/v3/Legacy/Legacy",
after: "@/components/v3/New/New",
}),
convertImportNamePlugin({
importPath: "@/components/v3/New/New",
before: "Legacy",
after: "New",
}),
convertComponentNamePlugin({
before: "Legacy",
after: "New",
}),
convertPropsNamePlugin({
componentName: "New",
target: [{ before: "sssss", after: "prop2" }],
}),
],
});
저는 import path와 import명, 실제 사용되는 컴포넌트명과 props를 바꾸는 네 가지의 플러그인을 개발하여 사용했습니다.
이제 해당 코드를 실행하면 targetPath 내부에 해당하는 모든 코드들은 다음처럼 변경됩니다.
import Legacy from "@/components/v3/Legacy/Legacy";
const Page2 = () => {
return (
<div>
<Legacy prop1="test" sssss={999} />
</div>
);
};
export default Page2;
{/* import명 변경, import path 변경 */}
import New from "@/components/v3/New/New";
const Page2 = () => {
return (
<div>
{/* props명 변경, 컴포넌트명 변경 */}
<New prop1="test" prop2={999} />
</div>
);
};
export default Page2;
codemod를 통해 개발자의 반복 작업에 대한 피로도와 리소스를 줄이고, 변경된 작업에 한하여 UI 확인만 진행하는 등 더욱 효율적으로 업무를 처리할 수 있게 되었습니다.
저와 비슷하게 대규모 프로젝트에서 코드를 마이그레이션해야 한다면, ts-morph를 사용해 스크립트로 자동화해보시는 걸 추천드립니다.