배너

프로젝트 전체에서 사용되는 패키지, 어떻게 마이그레이션 할까?

#Frontend
김덕원 · 토스 Frontend Developer
2024년 6월 4일

프로젝트가 커지고 시간이 지날수록 그동안 잘 사용했던 기반 기술이 더 이상 최선의 선택이 아닌 순간이 와요. 이런 기술을 바꾸지 않고 방치하면 개발 측면에서는 빌드 시간 및 복잡도 증가, DX 하락 등 예측하기 어려운 여러 문제가 일어날 수 있어요. 개발 생산성이 떨어지고 제품 운영 및 개선의 발목을 잡기도 하고요.

그렇지만 제품을 안정적으로 운영하면서 기반 기술을 마이그레이션하기 또한 쉽지 않은데요. 오늘은 제가 속한 토스의 TUBA 팀에서는 이런 문제를 어떻게 해결했는지 소개드릴게요.

토스의 만능 어드민, TUBA

TUBA(Toss User Behavior Analyzer)는 2018년에 출시된 토스 임직원용 종합 데이터 솔루션이에요. TUBA에는 A/B 테스트, 메세지, 유저 세그먼트 등 총 22개의 하위 제품이 있어요. PO, PM, 디자이너, 개발자 외에도 Account Manager와 같은 비즈니스 직군, Customer Hero와 같은 고객 응대 직군까지 다양한 임직원이 매일 업무를 위해 사용하는 제품이에요!

하지만 오래되고 큰 프로젝트인 만큼 TUBA에 여러 문제가 발생했는데요.

첫 번째 시련: 너무 오래 걸리는 빌드

첫 번째로는 빌드 속도가 매우 느려졌어요. TUBA는 yarn workspace를 활용해서 아래와 같이 frontend 하위 서비스와 frontend-common 공통 모듈을 나누어 관리하고 있는데요.

frontend
├─ Service-A
├─ Service-B
└─ ...
frontend-common
├─ components
├─ containers
├─ models
└─

frontend 패키지가 frontend-common의 패키지를 사용하고 있어요.

{
  "dependencies": {
    "@tuba/components": "workspace:frontend-common/components",
    "@tuba/containers": "workspace:frontend-common/containers",
    "@tuba/models": "workspace:frontend-common/models"
  }
}

문제는 frontend-common에 있는 공통 코드를 수정하면 전체 프로젝트 코드의 최신화가 필요했어요. 그렇기 때문에 작은 코드 수정이 있어도 frontend 하위에 있는 22개의 서비스를 모두 새로 빌드해서 배포해야 됐죠.

너무 많은 프로젝트를 동시에 빌드하다보니 메모리 이슈로 빌드가 실패하기 시작했어요. 우선 급한 빌드를 내보내야 하니 동시에 1개만 빌드되도록 변경했는데요. 무려 빌드 타임이 2시간 15분으로 나왔어요.

Next 제거 의사결정

느린 빌드 타임의 원인을 파악해 본 결과, next 12에서 Module Federation과 함께 SSG로 빌드하면 SWC를 적용하더라도 빌드 속도와 메모리 사용량이 이상적으로 높다는 것을 발견했어요.

마침 이전부터 next의 필요성에 대해서 팀원들과 논의를 했었는데요. 아래와 같이 의견이 모아져서 next의 장점보다 단점이 더 크다는 결론이 났고, next에서 webpack으로 마이그레이션을 시작했어요.

  1. TUBA는 내부 임직원 전용 서비스이기 때문에 SSR 등으로 첫 로딩 속도를 줄여도 이점이 크지 않다.
  2. 대부분의 next 기능을 사용하고 있지 않고, SSG 빌드용 도구처럼 사용되고 있다.

Next 제거하기

하지만 막상 next를 제거하려고 보니 프로젝트 전반적으로 next를 사용하는 코드가 많았고, 무엇보다 공통 코드에서 사용되다 보니 점진적 마이그레이션을 하기 어려웠어요. 그래서 next-polyfill이라는 대체 구현 패키지를 만들어서 next에서 사용하고 있는 인터페이스를 최대한 비슷하게 구현했어요. Next 패키지를 사용하지 않아도 대체 구현 패키지를 통해서 필요한 기능을 코드에 쓸 수 있게 된거죠.

// frontend-common/next-polyfill/head.tsx
import { ReactNode } from 'react';
import { Helmet } from 'react-helmet';

interface Props {
  children: ReactNode;
}

/**
 * # react-router-dom의 <Helmet /> 을 사용해주세요.
 * @deprecated
 */
export default function Head({ children }: Props) {
  return <Helmet>{children}</Helmet>;
}

위의 Head가 가장 간결하고 좋은 예시인데요. App, useRouter 등 TUBA 프로젝트에서 사용하는 모든 컴포넌트 및 hooks를 비슷한 방식으로 구현했어요.

이후 점진적으로 각 프로젝트의 package.json을 아래와 같이 수정하여 기존 next 패키지를 대체했어요. 그 결과 기존 코드를 크게 수정하지 않고도 빌드 시간이 약 86% 감소됐어요.

- "next": "^12.2.5",
+ "next": "workspace:frontend-common/next-polyfill",

두 번째 시련: 지원이 종료된 패키지

이번 시련은 현재 진행형인데요. Recoil이 사실상 지원이 종료된 것 같다는 소식이 들려왔어요. 뿐만 아니라 간간히 Recoil의 버그도 발생하고 있었고요. 특정 상황에서의 메모리 누수 문제 등 패키지의 불안정성이 계속 증가하는 것이 느껴졌어요.

패키지 마이그레이션 의사결정

그래서 Recoil을 대체할 수 있는 패키지를 찾기 시작했는데요. 아래 두 가지 조건을 필수로 생각했어요.

  1. 활발하게 관리되는 패키지
  2. Recoil에서 넘어갔을 때 코드의 영향을 최소화할 수 있는 패키지

위 조건에 부합하는 패키지가 Jotai라는 생각이 들었고, 팀원들도 동의하여 마이그레이션 방안을 모색하게 되었어요. 활발하고 관리가 되고 있으면서 Recoil에서 사용하던 상태관리 방식을 그대로 가져올 수도 있기 때문이죠.

마이그레이션 스크립트 작성하기

TUBA에서는 모든 상태 관리를 Recoil로 하고 있었기에 엄청나게 많이 사용하고 있었는데요. 아래 그림과 같이 909개의 파일에서 Recoil을 import하고 있었어요. 실제 코드에서의 사용까지 합치면 사용 범위가 더 넓었고요.

Recoil의 구조상 React lifecycle 외부에서 데이터에 접근하기 어려워요. 그래서 Recoil과 Jotai와의 호환 레이어를 만들어 점진적 마이그레이션을 구상하기도 어려웠었는데요. 이 문제를 해결하고자 ts-morph 패키지를 활용했어요.

ts-morph는 TypeScript의 AST(추상 구문 트리)로 코드를 분석하고 조작하는 기능을 제공하는 라이브러리에요. AST는 소스 코드의 구조를 트리 형태로 표현한 것으로, 컴파일러나 인터프리터가 코드를 분석하고 실행하는 데 사용돼요. AST의 자세한 설명은 ESLint와 AST로 코드 퀄리티 높이기 아티클을 참고해보세요.

먼저 ts-morph 패키지로 TypeScript 코드를 분석했어요. 그 다음, Jotai 함수와 1:1로 대응되는 Recoil 함수를 찾아서 모두 Jotai 함수로 변경했어요. 마지막으로 대응되지 않는 Recoil 함수는 직접 Jotai로 구현했어요. 이런 방식을 사용하면 개발자가 직접 코드를 수정하지 않고 기계적으로 코드 변경을 자동화할 수 있게 된답니다!

예시를 하나 살펴볼게요. useRecoilValue()는 Recoil에서 상태 변경을 읽을 수 있는 함수인데요. Jotai에 있는 useAtomValue() 함수와 같은 역할을 합니다. ts-morph 패키지로 모든 파일에서 useRecoilValue()를 찾고 분석한 다음, useAtomValue() 함수로 변경했어요.

import { SyntaxKind } from 'ts-morph';
import { Context } from '../model/context';

export default function migrateUseRecoilValue({ sourceFile }: Context) {
  // 1. 소스 파일에서 useRecoilValue 호출 부분만 찾아내기
  const declarations = sourceFile
    .getDescendants()
    .filter(
      node =>
        node.isKind(SyntaxKind.CallExpression) &&
        node.getFirstChildByKind(SyntaxKind.Identifier)?.getText() === 'useRecoilValue'
    )
    .map(node => node.asKind(SyntaxKind.CallExpression));

	// 2. 찾은 부분을 모두 useAtomValue로 변경하기
  for (const declaration of declarations) {
    declaration.getExpression().replaceWithText('useAtomValue');
  }
}

위와 같은 마이그레이션 코드를 전체 코드 대상으로 실행했고, 마이그레이션 완료까지 54초 밖에 걸리지 않았어요. 또한 변경된 모든 파일을 대상으로 ESLint를 실행시켜 lint 에러도 없애고자 했는데요. 이것도 45초가 소요되었어요. 즉, 약 100초만에 900개의 파일을 마이그레이션 할 수 있었어요. 아래 검색 결과에 보이듯이 909개 파일에 잡히던 Recoil이 모두 제거됐어요(검색에 집힌 1개의 결과는 주석입니다).

물론 모든 케이스를 완벽하게 대응할 수는 없었기 때문에 타입 에러 등이 발생했는데요. 발생한 에러를 하나씩 수정해줬습니다. 케이스들을 자세히 만들 수록, 발생하는 타입 에러가 줄어들어요. 계속 스크립트를 돌려보면서 빠진 것 같은 케이스들이 있다면 추가해주시면서 진행하는 것을 추천드려요.

오늘은 토스 내부 임직원이 사용하는 TUBA 서비스에서 next와 Recoil 패키지를 마이그레이션한 경험을 알려드렸는데요. 프로젝트 전반적으로 사용되는 기술을 리팩토링을 고려하고 있거나, deprecated된 패키지 코드를 제거 및 마이그레이션하고 싶다면 제 글이 도움이 되셨길 바래요.

댓글 0댓글 관련 문의: toss-tech@toss.im
연관 콘텐츠
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.