2024년 6월 26일
멀티 레포를 모노 레포로 마이그레이션하기

Intro

엘리스에서 인턴 생활 중, 모노레포를 건의하고 이를 실제로 적용한 경험을 이야기하고자 합니다.

1. 모노레포로 마이그레이션을 결정하기까지

기존에 저희 엘리스에는 7갸의 랜딩 페이지가 각 멀티레포로 구성되어 있었습니다. 하루는, 7개의 랜딩 페이지의 헤더 디자인이 수정하는 업무를 배정받았습니다. 당시, 7개의 레포를 하나씩 들어가 수정하고, MR을 올리고, 메인테이너인 사수님께 어푸르브를 받고, 그제서야 머지를 했습니다. 이 과정에서 7개의 레포를 왔다갔다 하며 작업을 해야 했고, 이는 상당히 번거로운 작업이었습니다.

이에, 제가 최근에 사이드 프로젝트에서 도입한 '모노레포'를 생각해냈습니다. 이를 먼저 사수님과 이야기해본 후, 사수님은 이를 긍정적으로 받아들이셨습니다. 그리고, 팀장님과 팀원들에게 동의를 구해야했습니다. 제안을 하기에 앞서, 모노레포로 마이그레이션 하면 좋은 이유들에 대해 생각해보고, 문서화를 했습니다.

1. 동료들과의 소통

당시 공유했던 글입니다. 건의 - 모노 레포 적용

팀장님과 팀원들은 동일한 불편함을 느꼈었고, 크게 공감하며 적극 수용해주었습니다. 이와 함께 우려 사항도 쏟아졌습니다. 이러한 우려 사항에 대해 글로 남기고, 하나씩 해결해보면 어떨까 해서, 이를 문서화했습니다.

당시 이야기했던 우려 사항과, 이에 대한 대처 방안입니다.

2. 우려사항과 대처 방안

  1. 새로운 스프린트를 언제 시작할 지 모르기에 빨리 끝내야 한다.

    • 모노레포에서 완전한 마이그레이션 이후 배포를 진행하기 전까지는 기존 레포에서 배포를 할 수 있도록 합니다. 그리고, 한 개의 레포씩 점진적으로 마이그레이션할 수 있습니다.
  2. 각 프로젝트 별로 각각 배포를 진행할 수 있어야 한다.

    • 태그를 추가하여 각 프로젝트 별로 배포를 할 수 있도록 합니다.
  3. 각 프로젝트 별로 각각의 CI/CD를 설정할 수 있어야 한다.

    • CI는 각 프로젝트 별로 변경사항이 생긴 프로젝트만 실행하도록 하고, CD는 태그를 추가하여 배포를 할 수 있도록 합니다. gitlab-ci changes 옵션을 사용합니다.
  4. 각 프로젝트 별로 사용 중인 패키지의 버전이 다르다.

    • 공통으로 관리할 패키지는 루트의 package.json에서 관리하고, 각 프로젝트 별로 사용 중인 패키지의 버전은 각 프로젝트의 package.json에서 관리합니다.

이와 같은 우려 사항에 대한 대처 방안을 조사하고 팀원들과 팀장님에게 이야기한 후, 곧장 사수님과 어떤 모노레포 관리 도구를 사용할 지 조사를 시작했습니다.

3. 모노레포 관리 도구 결정

당시 공유했던 글입니다. 모노레포 관리 도구 결정에 대한 리서치

모노레포 관리 도구를 결정하는 데 있어서 가장 중요하게 고려한 요소는 star수와 최근까지 커밋이 올라오는가였습니다. star수가 많을 수록, 그리고 최근까지 커밋이 올라오는 레포일 수록 안정적이라고 판단했습니다. 최근까지도 유지/보수를 하고 있고, 업데이트를 하고 있구나 라는 것을 알 수 있기 때문입니다.

위 요소를 고려했을 때 NX와 Turbo Repo를 후보로 남겨두었습니다. NX는 22.6k의 star, 그리고 Turbo Repo는 25.4k의 star수를 가지고 있습니다. 두 도구의 특징을 살펴보면 NX는 Google에서 개발을 하였고, Turbo Repo는 Vercel에서 개발을 하였습니다. NX는 Node.js 기반으로 작성되어 있고, Turbo Repo는 Rust 기반으로 작성되어 있습니다. 그리고, NX는 VS Code 확장 프로그램을 지원하고, Turbo Repo는 지원하지 않습니다.

최종적으로, 비교적 낮은 학습 곡선으로 팀 적응이 빠를 것이라는 점, 그리고 현재 프로젝트들이 Next.js기반이므로 Vercel 생태계와의 완벽하게 호환된다는 점에서 Turbo Repo를 선택했습니다.


2. 본격적인 마이그레이션

1. TurboRepo 설정

  1. TurboRepo 설치

    npm install turbo -g
  2. TurboRepo 설정 파일(turbo.json) 생성

    // turbo.json
    {
      "$schema": "https://turborepo.org/schema.json",
      "pipeline": {
        "build": {
          "dependsOn": ["^build"],
          "outputs": ["dist/**"]
        },
        "lint": {
          "outputs": []
        },
        "test": {
          "outputs": []
        }
      }
    }
  3. 패키지의 scripts에 turbo 명령어 추가

    // package.json
    {
      "scripts": {
        "build": "turbo run build",
        "lint": "turbo run lint",
        "test": "turbo run test"
      }
    }

2. 폴더 구조

/ (프로젝트 루트)
├── .gitlab
├── .turbo
├── .vercel
├── .vscode
├── apps  // 각 애플리케이션
│   ├── career
│   ├── coderland-tutor
│   ├── // ..
├── dist
├── gitlab
├── node_modules
├── packages // 공통 패키지
│   ├── components
│   ├── hooks
│   └── utils
├── scripts
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .gitlab-ci.yml
├── .npmrc
├── .nvmrc
├── .prettierrc
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── README.md
├── tsconfig.eslint.json
└── tsconfig.json

가장 루트에는 공통적으로 관리할 .gitlab, .vercel, .vscode, .turbo 등의 폴더와 파일이 위치합니다. 그리고, 각 서비스는 apps 폴더에 위치하고, 공통으로 관리할 패키지는 packages 폴더에 위치합니다. packages폴더에는 공통으로 관리하는 컴포넌트를 담은 components, 훅을 담은 hooks, 유틸 함수를 담을 utils폴더가 존재합니다.

3. Typescript, ESLint, Prettier 설정

1) Typescript

Typescript는 루트의 tsconfig.json 파일에 공통으로 적용할 옵션들을 추가하고, 각 패키지에서 이를 확장하여 사용했습니다. 각 패키지에서 사용하고 있던 typescript 버전이 달라, 이를 각 패키지에서 설치하여 관리했습니다.

  1. 루트: 공통되는 Typescript 설정을 관리합니다.
    // tsconfig.json
    {
      "compilerOptions": {
        "target": "es2017",
        "lib": ["dom", "dom.iterable", "esnext"],
        "allowJs": true,
        "skipLibCheck": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "noEmit": true,
        "esModuleInterop": true,
        "module": "esnext",
        "moduleResolution": "node",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "jsx": "preserve",
        "jsxImportSource": "@emotion/react",
        "incremental": true,
        "paths": {
          "src/*": ["./src/*"]
        },
        "baseUrl": "."
      },
      "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
      "exclude": ["node_modules"]
    }
  2. 각 패키지: 루트의 tsconfig.json을 확장하고, 각 패키지에 알맞게 설정을 추가합니다.
    {
      "extends": "../../tsconfig.json",
      "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
      "exclude": ["node_modules"],
      "compilerOptions": {
        "paths": {
          "src/*": ["./src/*"]
        },
        "baseUrl": "."
      }
    }

2) ESLint

ESLint는 가장 가까운 .eslintrc.* 설정 파일을 찾아서 사용합니다. 각 패키지 별로 설정된 ESLint 설정이 달랐기에, 루트에서 관리하고 각 패키지 별로 이를 확장하여 사용했습니다.

  1. 루트
       // .eslintrc.js
       module.exports = {
         extends: ['@elice/eslint-config'], // @elice/eslint-config를 가져온다.
         ignorePatterns: ['apps/**', 'packages/**'], // apps, packages 폴더는 무시한다.
         root: true,  // 현재 eslintrc파일이 위치한 곳이 eslint를 실행할 최상단이라는 것을 명시해줍니다.
         parserOptions: {
           project: true, // tsconfig.json을 참조한다.
         },
       };
  2. 각 패키지
    module.exports = {
      extends: ['@elice/eslint-config/next.js'], // @elice/eslint-config-next를 가져온다.
      root: true, // 현재 eslintrc파일이 위치한 곳이 eslint를 실행할 최상단이라는 것을 명시해줍니다.
    };

3) Prettier

prettier는 가장 가까운 .prettierrc 파일을 찾아서 사용합니다. 만약 패키지마다 prettier 설정을 다르게 하고 싶다면, 각 패키지에 .prettierrc 파일을 추가하면 됩니다. 하지만, 모두 공통적으로 적용하고 싶다면, 루트에만 .prettierrc 파일을 두면 됩니다. 프로젝트 전체에서 동일한 코드 스타일을 유지하는 것이 중요하기 때문에 루트 .prettierrc 파일 하나만을 사용하는 것을 추천합니다.

// .prettierrc
"@elice/prettier-config"

4. CI/CD 설정

GitLab CI는 .gitlab-ci.yml 파일을 참조하여 실행합니다. 이 파일은 루트에 위치해야 하고, 기존에 각 레포 별로 상이하던 CI와 CD 설정을 하나의 파일로 통일하여 관리해야 했습니다.

1) CI 설정

CI는 lint 검사와 build를 수행합니다. lint와 build 과정을 모듈화하고, 이를 .gitlab-ci.yml에서 가져와 사용하도록 했습니다.

// gitlab/actions/lint.yml
.lint:
  stage: lint
  rules:
    - if: $CI_COMMIT_TAG   # tag가 있을 때는 실행하지 않습니다. (tag는 배포를 위한 것이므로 lint를 실행하지 않습니다.)
      when: never
    - if: $CI_PIPELINE_SOURCE == "push" # push 이벤트가 발생했을 때만 실행
      changes:
        - "apps/$APP_NAME/**/*" # 해당 패키지의 파일이 변경되었을 때만 실행
  before_script:
    - yarn config set cache-folder .yarn # yarn cache를 .yarn폴더에 저장합니다.
    - yarn install --silent --frozen-lockfile # yarn install을 수행합니다.
  script:
    - yarn lint --filter $APP_NAME
// gitlab/actions/build.yml
.build:
  stage: build
  rules:
    - if: $CI_COMMIT_TAG   
      when: never
    - if: $CI_PIPELINE_SOURCE == "push"
      changes:
        - "apps/$APP_NAME/**/*"
  before_script:
    - yarn config set cache-folder .yarn
    - yarn install --silent --frozen-lockfile
  script:
    - yarn build --filter $APP_NAME

changes 옵션을 활용해서, 변경이 발생한 패키지만 lint 및 build 검사를 수행하도록 했습니다.

.gitlab-ci.yml
include:
  - /gitlab/actions/lint.yml
  - /gitlab/actions/build.yml
# ..
lint-coderland-tutor:
  variables: 
    APP_NAME: coderland-tutor # APP_NAME을 coderland-tutor로 설정합니다.
  extends: 
    - .lint
    - .build
# ..

2) CD 설정

저희는 Vercel CLI를 이용하여 배포를 하고 있었습니다. 태그를 추가하면 배포를 실행하도록 하였습니다.

// gitlab/actions/vercel.yml
.vercel-install:
  before_script:
    - yarn global add vercel # vercel을 전역으로 설치합니다.
 
.vercel-staging:
  stage: release
  extends: .vercel-install
  script:
    - app_shorthand_name=$(echo $CI_COMMIT_BRANCH | cut -d "/" -f 2) # 브랜치 이름에서 앱의 이름을 추출합니다.
    - vercel_project_name="elice-$app_shorthand_name-landing" # vercel 프로젝트 이름을 설정합니다.
    - staging_host="$vercel_project_name-staging.vercel.app" # 스테이징 호스트를 설정합니다.
    
    - yarn generate-vercel-json $app_shorthand_name # 스크립트를 실행하여 vercel.json을 생성합니다.
    - deployment_url=$(VERCEL_ORG_ID=$VERCEL_ORG_ID VERCEL_PROJECT_ID=$vercel_project_name vercel deploy -y -t $VERCEL_TOKEN -S $VERCEL_SCOPE) # vercel에 배포를 합니다.
    - vercel alias set $deployment_url $staging_host -t $VERCEL_TOKEN -S $VERCEL_SCOPE  # vercel에 alias를 설정합니다.
 
.vercel-production:
  stage: release
  extends: .vercel-install
  script:
    - vercel_project_name="elice-$APP_SHORTHAND_NAME-landing"
    - yarn generate-vercel-json $APP_SHORTHAND_NAME
    - deployment_url=$(VERCEL_ORG_ID=$VERCEL_ORG_ID VERCEL_PROJECT_ID=$vercel_project_name vercel deploy -t $VERCEL_TOKEN -S $VERCEL_SCOPE)
    - vercel alias set $deployment_url $PRODUCTION_HOST -t $VERCEL_TOKEN -S $VERCEL_SCOPE 
// gitlab-ci.yml
include:
  - /gitlab/actions/vercel.yml
# ..
lease-coderland-tutor-production:
  # TODO: add stage
  needs: []
  extends: 
    - .vercel-production
  variables:
    PRODUCTION_HOST: tutor.coderland.io
    APP_SHORTHAND_NAME: coderland-tutor
  rules:
    - if: $CI_COMMIT_TAG =~ /^coderland-tutor@\d+\.\d+\.\d+$/ # e.g. coderland-tutor@1.240401.0
# ..

5. TurboRepo의 활용

  1. 병렬 실행과 캐싱

    TurboRepo는 병렬 실행과 캐싱을 통해 빌드 시간을 크게 단축할 수 있습니다. 동일한 작업이 반복될 때, 캐시를 사용하여 중복된 작업을 피할 수 있습니다.

    turbo run build --parallel
  2. 의존성 기반 실행

    TurboRepo는 의존성 그래프를 사용하여 필요한 작업만을 효율적으로 실행합니다. 예를 들어, 특정 패키지에 변경 사항이 발생했을 때, 해당 패키지와 관련된 작업만을 실행합니다.

    turbo run build --filter=<package-name>
  3. CI/CD 파이프라인 최적화

    CI/CD 파이프라인에서 TurboRepo를 사용하면 변경된 패키지에 대한 작업만을 실행함으로써 시간을 절약할 수 있습니다. GitLab CI/CD 설정에 TurboRepo를 적용하여, 각 패키지의 변경 사항에 따라 빌드 및 배포 작업을 수행했습니다.

    // gitlab-ci.yml
    build:
      stage: build`
      script:
        - turbo run build --filter=<package-name>

효과

1. 공통 컴포넌트 사용

각 레포 별로 공통 컴포넌트를 사용할 수 있습니다. 기존에는 각 서비스에서 공통 컴포넌트를 사용하기 위해 해당 컴포넌트를 복사하여 사용해야 했습니다. 하지만, 모노레포로 마이그레이션하면서 공통 컴포넌트를 package/ui 폴더에 넣어두고 각 서비스에서 이를 가져다가 사용할 수 있게 되었습니다.

2. 기능 추가 용이성

마이그레이션 이후, 실제로 모든 서비스에 이벤트 배너를 추가해야 하는 업무를 배정받았습니다. 공통 컴포넌트를 만들어 각 서비스에 추가한 후 한 번의 MR과 머지로 7개의 서비스에 반영이 가능해졌습니다.

단점

1. 초기 설정의 복잡함

이제는 설정을 어느 정도 완료하여 안정적인 상태에 접어든 상태지만, 마이그레이션 과정에서 각각의 레포지토리에서 독립적으로 관리되던 설정들을 통합하고 공통된 설정 파일들을 구성하는 과정에서 많은 에러를 마주했습니다. 이 과정에서 팀원 두 명이 2주라는 적지 않은 시간을 소모해야 했습니다.

2. 더 많은 신경을 써야 한다.

각 패키지별로 레포지토리를 관리할 때는 다른 프로젝트에 대해서는 신경 쓸 필요가 없었지만, 이제 하나의 변경 사항이 모든 프로젝트에 영향을 미칠 수 있습니다. 특히 공용 패키지를 수정할 때 모든 패키지에 영향을 줄 수 있기 때문에, 변경 사항을 주의 깊게 관리해야 합니다.

트러블슈팅

1. couldn’t find package @elice/mui-elements required by ~ on the ‘npm’ registry

@elice/mui-elements는 npm이 아니라, elice gitlab 레포에 존재합니다. 따라서 .npmrc 파일을 추가해줘야 합니다. .npmrc에는 해당 레포의 위치와 토큰값을 설정합니다.

2. @elice/prettier-config@1.220803.0 The engine “node” is incompatible with this module. Expected version “~16.14”. Got “20.5.1”

기존에 각 패키지 별로 사용하고 있던 prettier config의 버전이 달라, 각 버전 별로 요구하는 Node 버전이 달라 발생한 에러입니다. 이는 prettier와 prettier-config를 루트에서 관리하면서 해결했습니다.

3. Parsing error: ESLint was configured to run on <tsconfigRootDir>/apps/elice-project-landing/babel.config.js using parserOptions.project: /../elice-landing-mono/apps/elice project-landing/tsconfig.json However, that TSConfig does not include this file.

ESLint는 현재 파일(babel.config)을 구성 안에 넣었는데, tsconfig에서는 해당 파일을 포함하지 않고 있습니다.

  • 원인: tsconfig에서 현재 파일(babel.config.js)를 포함하지 않기에 발생한 에러입니다.
  • 해결: tsconfig.json > include 배열에 **/*.js를 추가하여 모든 파일을 적용하도록 합니다.
  • 결론: 위의 에러의 원인은 eslint에서 현재 parserOptions:{project:true}를 사용하고 있기 때문입니다. 이 옵션은 eslint가 가장 가까운 tsconfig.json 파일을 찾아간다는 의미입니다. 즉, 해당 tsconfig.json에서 eslint를 적용할 모든 파일들을 추가해야 합니다. eslint를 적용하지 않으려면, .eslintignore 파일에 추가해야 합니다.

마무리하며

모노레포로의 마이그레이션은 두 명의 개발자가 꼬박 2주 정도 걸리는 상당히 큰 프로젝트였습니다. 단순한 설정 변경 이상의 도전 과제들을 안겨주었습니다. 초기 설정의 복잡함, 빌드 및 테스트 시간의 증가, 패키지 버전 관리의 어려움 등 다양한 문제를 직면했지만, 이 과정에서 많은 것을 배울 수 있었던 거 같습니다.

멀티레포의 불편함을 인식하고 모노레포로의 전환을 팀장에게 제안하여 실제 적용까지 이끌어낸 것은 매우 보람찬 경험이었습니다. 이 변화로 팀원들의 생산성이 크게 향상되었고, 개발 환경이 더욱 편리해질 것이라는 기대에 큰 뿌듯함을 느낄 수 있었습니다.

앞으로도 팀원들의 생산성을 높여주는, 그리고 도전적인 프로젝트를 진행하며 가파르게 성장하는 개발자가 되고 싶다는 생각을 했습니다.