레거시 코드 언제 일일히 바꾸고 앉아 있나 고민하다 ts-morph로 조금 더 편하게 리팩토링한 경험을 공유합니다.

개발을 하다보면 여러 이유로 레거시 코드는 반드시 생깁니다. 저는 잘못된 설계로 인해 건들기 어려운 괴물 코드는 어쩔 수 없더라도 상대적으로 개선하기 쉬운 것들은 제깍제깍 수정해 나가야 한다고 생각합니다.

이번 글은 프로젝트의 '간단한데 까다로운' 레거시 코드를 빠르게 수정하는 방법에 대해 고민한 내용입니다. 자동화 도구를 통해 좀 더 효율적으로 해보고자 했던 제 몸부림을 보시죠 😭

'간단한데 까다로운' 레거시 코드

네이밍 변경, 인터페이스 변경 등은 대부분 '모두 찾아 바꾸기'로 쉽게 처리할 수 있습니다. 정규표현식만 잘 쓰면 조금 까다로운 경우도 핸들링이 가능하죠.

문제는 이걸로 대응이 안되는 것들도 있다는 것입니다. 뭔가 간단한 반복 작업으로 수정하면 되긴 하는데, 단순히 텍스트 치환만으론 부족한 경우들이 꼭 있습니다.

예를 들어, 컬러 팔레트 상수 네이밍을 변경했다고 해봅시다.

// AS-IS
import { palette } from "ui-library"
 
const MyComponent = () => {
  return <div style={{ color: palette.primary }}>Hello, world!</div>
}
 
// TO-BE
import { colors } from "new-ui-library"
 
const MyComponent = () => {
  return <div style={{ color: colors.blue10 }}>Hello, world!</div>
}
// AS-IS
import { palette } from "ui-library"
 
const MyComponent = () => {
  return <div style={{ color: palette.primary }}>Hello, world!</div>
}
 
// TO-BE
import { colors } from "new-ui-library"
 
const MyComponent = () => {
  return <div style={{ color: colors.blue10 }}>Hello, world!</div>
}

겉보기엔 간단해 보이지만 생각보다 골치아픈 변경이죠. 색상의 가짓수가 수십개라면 이걸 언제 다 일일히 바꾸고 있나 싶어집니다. 뭐가 뭘로 바뀌었는지 찾는것도 정말 귀찮고요.

그렇다고 코드의 발전을 막을 순 없으니 결국 '차차 수정하고 일단 쓰자'는 식으로 작업이 진행되며 양 모듈 모두 쓰이게 됩니다. 기약 없는 계획은 점점 뒤로 밀리며 기억은 희미해집니다. 두 방법 모두 사용이 가능한 탓에 새로운 상수의 도입이 손에 익지 않은 개발자는 과거의 모듈로 코드를 더 생산하기도 합니다.

후에 합류한 개발자의 머리는 복잡해집니다. '이 두 상수의 차이점은 무엇일까?' '어느쪽을 써야하는 걸까?' 설명에 들어가는 소통 비용도 늘어나고, 아무튼 이런 코드들은 가능할 때 빨리 없애줘야 합니다.

코드를 코드로 분석하기

문제를 좀 더 편하게 해결하려면 우선 코드의 의미를 해석하고, 특정 의미를 가진 문이나 식별자, 값 등에 접근, 수정할 수 있는 도구가 있으면 되겠다는 생각이 들었습니다. ESLint Plugin을 개발할 때 AST를 사용해 auto fix를 만들었던 경험이 떠올랐고, 여기에서 힌트를 얻었습니다.

AST(Abstract Syntax Tree)는 tree로 표현된 프로그램의 의미론적 구조입니다. 컴파일러, 인터프리터 등이 가장 처음으로 하는게 코드의 AST를 생성하는 것입니다. 이를 잘 사용하면 코드를 코드로 분석할 수 있게 됩니다.

조금 더 고민해보니 우리 코드를 가지고 AST를 끊임없이 만들고 있는 TypeScript를 활용하면 되겠다 싶었습니다.

TypeScript Compiler API로 시도했지만

TypeScript는 TS를 JS로 변환하기 위해 AST를 생성합니다.

실제로 TS 코드가 어떤 모습의 AST로 변환 되는지 TypeScript AST Viewer로 확인해보세요. 참고로 JSX도 확인됩니다.

고맙게도 TypeScript는 Compiler API라는 API를 제공해줍니다. 이걸 활용하면 변경이 필요한 부분을 찾고 수정하도록 일종의 커스텀 트랜스파일러를 만들 수 있겠다는 생각이 들었습니다.

좋은 아이디어였지만 AST를 돌면서 코드를 수정하는 작업이 생각보다 쉽지 않았습니다.

예를 들어, '특정 import문이 있을 때 해당 문을 수정하고, 그 밑에 import한 모듈을 수정하는' 작업을 하려고 한다면, 지금 iterate 하고 있는 노드를 벗어나 다른 노드를 찾고 수정하는 과정이 너무 불편하더라고요.

게다가 트랜스파일 과정에서 세미콜론을 붙여주는 등 코드 스타일에도 관여했습니다. 따라서 Compiler API로 트렌스파일을 진행하면 모든 파일에 diff가 생겨 prettier를 반드시 한번 더 돌려줘야 했습니다. 그렇게 해도 임의로 추가한 공백이 사라지는 등의 이슈가 있었죠.

아쉽게도 코드 스타일에 관여하는 기능 완벽히 끄긴 어려워보였습니다. 더 찾으면 해결할 수 있었을지도 모르겠지만, 이 시점에서 저는 '코드 수정'에만 집중할 수 있는 라이브러리를 찾아보기로 했습니다.

이런 작업에 시간을 많이 쏟을순 없으니 좀 더 편하고 직관적으로 쓸 수 있는 도구가 필요했습니다.

ts-morph

ts-morph는 TypeScript Compiler API를 래핑한 라이브러리입니다. TS 코드를 간편하게 수정할 수 있도록 도와줍니다.

앞서 예시로 들었던 간단하지만 까다로운 레거시 코드 수정은 ts-morph로 쉽게 해결할 수 있었습니다.

import { Project } from "ts-morph"
 
import { palette } from "ui-library"
import { colors } from "new-ui-library"
 
const project = new Project()
project.addSourceFilesAtPaths("src/**/*.ts")
const sourceFiles = project.getSourceFiles()
 
for (const file of sourceFiles) {
  file.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression).forEach((node) => {
    const text = node.getText()
    if (text.startsWith("palette.")) {
      const originalColor = palette[text.split(".")[1]]
      const newColorPropertyName = Object.entries(colors).find(
        ([_, value]) => value === originalColor,
      )[0]
 
      const newText = `colors.${newColorPropertyName}`
 
      node.replaceWithText(newText)
    }
  })
 
  await file.save()
}
import { Project } from "ts-morph"
 
import { palette } from "ui-library"
import { colors } from "new-ui-library"
 
const project = new Project()
project.addSourceFilesAtPaths("src/**/*.ts")
const sourceFiles = project.getSourceFiles()
 
for (const file of sourceFiles) {
  file.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression).forEach((node) => {
    const text = node.getText()
    if (text.startsWith("palette.")) {
      const originalColor = palette[text.split(".")[1]]
      const newColorPropertyName = Object.entries(colors).find(
        ([_, value]) => value === originalColor,
      )[0]
 
      const newText = `colors.${newColorPropertyName}`
 
      node.replaceWithText(newText)
    }
  })
 
  await file.save()
}

파일 내 코드의 AST 순회를 딱히 고려하지 않고 선언적으로 'palette 객체 요소에 접근하는 문을 찾아서 이 코드로 바꿔'라고 적으면 되니 정말 편합니다.

좀 더 자세한 내용은 공식 문서를 참고해주세요. 여기에서는 ts-morph의 사용법에 대해 더 깊게 다루지는 않겠습니다.

아쉬운 점

ts-morph는 문서화가 정말 부족합니다. 공식 문서가 정말 빈약하고, 사용 예시도 구글링을 통해 찾기 어려울 정도로 레퍼런스가 잘 없습니다.

JSX에 접근해 수정하려고 했을 때는 공식 문서에 아무런 설명이 없어서 시행착오를 직접 겪어가며 코드를 짰는데, 이 과정에서 생각보다 시간이 들어갔습니다.

혹시 사용하신다면 깃허브 레포 issue가 가장 풍부한 정보를 가지고 있으니 그쪽을 보시는게 좋겠습니다. 앞서 공유드린 AST Viewer도 아주 유용하니 꼭 같이 사용하세요.

마치며

조금이라도 편하고 빠르게 레거시 코드를 없애고자 삽질했던 경험을 돌이켜보니 재밌는 시도를 했다는 생각에 블로그에 글을 남겨봤습니다.

ts-morph를 사용하며 가장 만족스러웠던건 transformer 코드만 잘 짜면 변경사항이 아무리 거대해도 실수없이 수정됐다는 안도감을 준다는 것이였습니다.

하지만 아직 팀원분들께 변환 코드를 공유하거나 이 라이브러리의 사용을 적극적으로 권유하진 못하고 사용해봤다는 정도만 말씀드렸습니다. 일단 변환 코드 작성에 시간을 들여봤자 아무 의미 없다는 생각에 그리 공을 들여 짜지 않았기도 했고, 해당 라이브러리의 빈약한 문서화로 약간의 헛일커브(익히려면 필요한 헛수고의 양이라는 뜻 내가 지음)가 있다는게 걸림돌이였습니다.

자동화에 들일 시간과 수동으로 바꿨을 때 걸릴 시간을 비교해보는것도 중요했습니다. 일일히 바꾸는게 더 빠를 수도 있으니까요.

혼자 계속 써보면서 이걸 팀으로서 어떻게 체계적으로 사용하면 좋을지, 그럴 필요는 있을지 고민해보는 중입니다.