pnpm을 사용한 모노레포로 프론트엔드 생산성 늘리기

안녕하세요! 더스윙에서 프론트엔드 개발을 담당하고 있는 정주형이라고 합니다.

이번 포스팅에서는 개발팀에서 모노레포를 도입한 배경과 과정, 그리고 어떤 것이 달라졌는지에 대해서 소개해 드리고자 합니다.


The Cost of Interrupted Work

어바인에 위치한 캘리포니아 대학의 교수 Gloria Mark는 48명의 대학생을 대상으로 한 연구를 진행했습니다. 연구의 내용은 다음과 같았습니다. 업무에서의 맥락 전환(Context switching)이 실제로 어떤 영향을 끼칠까? 실험의 결과는 다음과 같았습니다.

만약 맥락 전환으로 인해 업무가 방해받으면 다시 본래의 일로 돌아가는데 평균 23분 15초가 걸리게 됩니다. 그리고 본래 업무로의 복귀는 생산성을 떨어트리며 높은 스트레스를 수반합니다.

에어팟을 끼는 이유.jpg

여러분이 개발자라고 가정해보겠습니다. 오늘 비슷하지만 다른 3가지의 작업을 할당받았습니다. 세가지의 작업을 하루에 끝낸다고 가정했을때, 여러분은 세번의 맥락 전환을 겪게 됩니다. 근무시간 8시간 중 3번의 전환을 겪었으니, 23*3 69분만큼의 시간을 손해를 보게 되었습니다.

앗, 해당 시간은 단순히 다시 집중하는데 걸리는 시간이고, 환경을 설정하거나 히스토리를 파악하는 시간은 포함하지도 않았습니다. 실제로는 더 많은 시간을 손해보고 있는 셈입니다.

개발자의 경우 평균적으로 주당 4번의 맥락 전환을 경험한다고 합니다. 맥락 전환 비용으로 주당 1~2시간을, 월에는 4~8시간을 내고 있는 셈입니다. 끔찍하죠?


더스윙의 프론트엔드팀도 맥락 전환에 따른 문제를 겪고 있었습니다. 지금까지 7년이 넘는 시간동안 8개가 넘어가는 모빌리티 서비스를 운영하면서 지오펜스, 인사, 계약 등등 백오피스 앱들이 자연스럽게 많아지게 되었죠. 세어보니 20여개의 백오피스 어드민이 존재하고 있는 상황이었습니다.

모든 앱이 액티브하지는 않았지만 계속해서 수정사항이나 디버깅이 필요했습니다. 각 팀원들은 A 레포에서 작업을 하다가, B 레포의 PR을 체크하고, C 레포의 변경사항을 pull 받고.. 계속해서 이를 반복하는 식이었습니다.

크지 않은 작업들이 주어진다 하더라도 금새 피곤함을 느낄 수 밖에 없었죠. 서비스가 커지고 늘어날수록 컨텍스트가 많아지고, 이에 비례해서 맥락 전환 비용은 커지고, 생산성은 낮아지는 좋지 못한 구조였습니다. 프론트엔드 팀이 실제로 겪고 있었던 문제들을 정리하자면 다음과 같았습니다.

  • 잦은 맥락 전환에 따른 생산성 저하
  • 다른 패키지의 변경사항을 파악하기 힘듬
  • DRY하지 못한 코드의 증가
  • 미묘하게 다른 일관되지 못한 DX
  • 점점 증가하는 인프라 관련 관리 포인트

비단 맥락 전환만이 문제가 아니였죠. 서비스의 크기가 커져가면서 점점 다른 문제들도 생기기 시작하고 있었습니다. 결국 이러한 문제를 해결하기 위해 프론트엔드 팀에서는 모노레포를 도입하여 기존 어드민들을 마이그레이션 하기로 결정하였습니다.

왜 모노레포인가?

맥락 전환에 대한 이야기로 잠깐 돌아가 보겠습니다. 맥락 전환 비용이 문제라면, 사실 해결책은 간단합니다. 맥락을 없애버리는 것이죠. 하나의 소스코드 위에서(monolith) 개발한다면 위 단점들을 한큐에 해결할 수 있습니다. 전환할 맥락 자체가 없고, 관리 포인트조차 하나죠.

그러나 이러한 구조는 유연성이 부족합니다. 관심사가 분리되지 않고, 배포, 리팩토링 등이 어려워지죠. 거대한 프로그램에서 한 부분만 수정했는데 모두 배포할 수는 없으니까요. 그렇다고 해서 여러개의 모듈로 구성된 멀티레포로 돌아가자면, 위에 서술한 문제가 다시 생길 것이 뻔합니다.

그렇다면 모노리스의 장점과, 모듈로 개발하는 장점만 쏙쏙 뺄 수는 없을까요? 관심사를 분리해서 각 모듈들을 자율적으로 개발할 수 있으면서, 맥락 전환과 관리 포인트들은 줄이는 방법이요. 네, 모노레포가 이러한 문제를 해결할 수 있습니다.

A monorepo is a single repository containing multiple distinct projects, with well-defined relationships

모노레포란 여러개의 구분된 프로젝트를 의미있는 관계로 엮어놓은 하나의 단일 레포지토리입니다. 모노레포가 가져다주는 장점들은 다음과 같습니다.

  • 먼저 하나의 레포지토리에서 연결된 여러 프로젝트를 작업하므로 맥락 전환 비용이 줄어듭니다.
  • 개발자에게 일관된 개발 경험을 줄 수 있습니다.
  • 관리해야 할 인프라(배포 프로세스 등등)가 줄어듭니다.
  • 변화를 트래킹하기 쉬워집니다.
monorepo.tools

와! 이렇게 좋은데 왜 모두 모노레포를 쓰지 않는거죠? 모노레포는 분명 명확한 장점을 지니고 있지만 모든 상황에서 통용되는 것은 아닙니다. 적합한 상황 안에서 비로소 그 효과를 발휘하게 됩니다. 적합한 상황이란 대체로 아래와 같습니다.

  • 모노레포에 속할 제품군들은 의미적으로 연결이 되어 있어야 함
  • 코드 재사용이 유용하고 또 용이해야 함
  • 인프라 등 프로젝트 구조등이 비슷해야 함

예를 들어 언어가 다르고, 의미적으로 연결되지 않는 웹 클라이언트와 서버를 하나의 레포로 몰아넣었다고 생각해 봅시다. 해당 작업은 사실상 정리 이외의 큰 의미를 두기 어렵습니다. 언어도 다르니 공통적으로 재사용할 무언가를 찾기 힘들고, 둘 사이의 뚜렷한 연결점이 없으니 변화를 트래킹하는게 의미가 없게 되겠죠.

오히려 나이브한 관계들을 가진 앱들을 몰아넣음으로써 불필요한 코드 충돌, 저장소 크기의 증가 등 예상치 못한 단점이 발생할 수도 있습니다. 생산성을 위해서 도입한 기술이 오히려 생산성을 망친다 생각해 보세요!

그래서 사용해도 되나요?

은총알은 없다. 더스윙 프론트엔드 팀원들이 공통적으로 가진 믿음입니다. 그래서 항상 새로운 기술을 도입하기 전에 금요일마다 모여서 치열한 논쟁을 벌이곤 합니다. 많은 새로운 기술들이 엄청난 하입과 함께 나왔지만 수많은 기술 부채와 백로그만을 남긴채 사라졌기 때문입니다.

모노레포는 제품과 개발자 모두에게 큰 영향을 끼치는 작업이었기 때문에, 기술을 도입하기 적절한 상황인지 많은 점검을 해 보았습니다. 다행히 백오피스 어드민들은 대부분 비슷한 구조를 가지고 있었기에 적합해 보였죠.

  • 각 앱의 코드베이스가 크리 크지 않았습니다.
  • 모두 리액트를 사용한 클라이언트 웹앱으로 컴포넌트까지 공유할 수 있는 상황이었습니다.
  • 모두 동일한 인프라 구조를 사용중이었습니다.

모노레포를 적용함으로써 얻는 것이 더 크다고 판단하였고 결국 논의 끝에 새로운 레포를 구축해서, 기존 20여개의 어드민을 마이그레이션 하기로 결정하게 되었습니다.

연장 고르기

팀이 어떤 문제를 가졌는지도 정의가 되었고, 어떤 방식으로 해결할지도 정했습니다. 이젠 도구를 정할 차례였습니다.

모노레포 생태계는 2025년 기준으로 꽤 성숙한 상태라서 Nx, Lerna, Bazle, TurboRepo 등등 여러 툴들이 존재하고 있었습니다. 이 툴들은 캐싱부터 로컬 태스트 오케스트레이션, 의존성 시각화 등등을 멋진 기능들을 지원하고 있었습니다.

그러나 팀은 불필요하게 복잡하게 만들지 말 것이라는 말에 따라 가장 미니멀하고 심플한 솔루션을 골랐습니다. 바로 pnpm입니다. 이유는 간단했습니다. 사용하기에 가장 간단하고, 가장 많이 사용되고 있었기 때문입니다.

State of JavaScript 2024: Monorepo Tools

물론 pnpm은 Nx, Turborepo와 같은 다른 모노레포 툴처럼 많은 기능을 빌트인으로 제공해주지는 않습니다. 그러나 그만큼 학습 비용이 적고, 빠르게 적용할 수 있다는 장점이 있었습니다.

만약 nx와 같은 툴을 골랐다고 생각해 봅시다. 팀원들 모두가 해당 툴을 학습해야 합니다. 그리고 이러한 학습 비용은 새로운 팀원이 들어올때마다 늘어날 것입니다. 러닝커브가 그리 높지 않다고 하더라도, 각 툴들이 제공하는 고급 기능이 필요한 상황도 아니었구요. 늘 그렇듯, 고급 기능이 필요할 때 해당 툴을 사용하는 편이 더 낫기 마련입니다.

그래서 pnpm을 선택했습니다. 그래프에서 보이듯 사용하는 곳이 많으니 레퍼런스가 많고, 이미 npm과 pnpm을 혼용하는 상황에서 추가적인 설정 없이 바로 적용할 수 있다는 점이 특히 매력적이었죠.

기존 앱 마이그레이션 하기

문제를 정의했고, 팀원간의 합의를 마쳤으며 이제 정말로 pnpm을이용해 기존 앱들을 하나의 레포에 넣을 차례입니다. 해당 마이그레이션 작업을 위해 태스크를 나누었습니다.

  1. 가장 먼저 새로 루트가 될 모노레포를 생성합니다.
  2. 각 개발자들이 동일한 환경에서 개발할 수 있도록 환경을 통일합니다.
  3. 기존 배포 프로세스를 수정해서 모노레포 환경에서도 그대로 배포될 수 있도록 합니다.
  4. 기존 앱의 소스 코드를 가져와서 환경과 배포 프로세스를 적용합니다.

루트 프로젝트 생성

가장 먼저 할 일은 루트가 될 프로젝트를 생성하는 것이었습니다. pnpm을 고른 이유가 가장 간단한 솔루션이어서라고 말씀드렸는데, 정말로 간단합니다.

pnpm init 후 yaml 파일로 된 pnpm-workspace 파일을 생성하면 됩니다. 그리고 패키지들을 명시하면 되죠. 해당 모노레포의 경우 단순하게 두가지로 패키지를 나누었습니다.

기존의 앱들이 들어갈 apps, 그리고 설정과 같이 공통 파일과 모듈들이 들어갈 shared가 바로 그것입니다.

packages:
  - apps/*
  - shared/*

개발 환경 통일하기

이제 루트 프로젝트를 만들었으니 각 어드민들을 넣을 차례입니다. 단지 소스 코드를 옮기기만 하면 의미가 없으니, 개발 환경을 통일시키기 위해 각 설정파일들을 모듈화 하였습니다.

처음에는 최상위 레벨에서 설정들을 한 번에 관리하려 했으나, 개별적인 룰을 가진 앱들을 모두 수정해야 하는 문제가 있었습니다. 20개가 넘는 어드민 프로젝트들이 있었기에 많은 시간과 노력이 필요한 상황이었죠.

이 문제를 해결하기 위해 설정들을 모듈화하는 방식을 택했습니다. 각 하위 앱들이 공통 설정을 기본으로 사용하면서도, 필요한 경우 앱별로 로컬 룰을 추가할 수 있게 되었습니다. 다소 엄격함은 줄었지만, 그만큼 유연성을 확보할 수 있었습니다.

eslint-config를 예로 들어 보겠습니다.

// shared/eslint-config

// package.json
{
  "name": "@theswing/eslint-config",
  "version": "1.0.0",
  "main": "index.js",
  "license": "proprietary",
  "private": true,
  "dependencies": {
    "eslint": "^8.31.0",
    "eslint-config-prettier": "^8.10.0",
    "eslint-import-resolver-typescript": "^3.6.3",
    "eslint-plugin-import": "^2.31.0",
    "eslint-plugin-prettier": "^5.0.0",
    "eslint-plugin-react": "^7.37.1",
    "eslint-plugin-react-hooks": "^4.6.0",
    "@typescript-eslint/eslint-plugin": "^5.46.1",
    "prettier": "3.3.3"
  },
  "devDependencies": {
    "@rushstack/eslint-patch": "^1.3.3"
  },
  "peerDependencies": {
    "eslint": "^8.0.0",
    ...
  }
}

// index
module.exports = {
  root: true,
  extends: [
    'plugin:@typescript-eslint/recommended',
    'plugin:import/errors',
    'plugin:import/warnings',
    'plugin:import/typescript',
    'plugin:react/recommended',
    'plugin:react-hooks/recommended',
    'plugin:prettier/recommended',
    'prettier',
  ],
  rules: {
    'prettier/prettier': 'warn',
    'react/jsx-closing-tag-location': 'error',
    'react/prop-types': 'off',
    'react/jsx-curly-brace-presence': [
      'error',
      {
        props: 'always',
        propElementValues: 'always',
      },
    ],
    'react/react-in-jsx-scope': 'off',
    'react/function-component-definition': [
      'warn',
      {
        namedComponents: 'function-declaration',
      },
  ...

다음과 같이 eslint 설정에 필요한 의존성과 플러그인들을 모아 하나의 config 패키지 만듭니다. 이제 해당 패키지는 마치 개별 패키지마냥 각 앱에서 사용할 수 있게 되었습니다.

   "typescript": "^4.6.3",
    "vite": "^2.9.9",
    "vite-plugin-imp": "^2.1.8",
    "@theswing/eslint-config": "workspace:*",
    "@theswing/prettier-config": "workspace:*"
  }

// 해당 앱의 eslintrc
module.exports = {
  extends: ['@theswing/eslint-config'],
  rules: {
     ...로컬 앱 설정
  },
};

배포 프로세스 수정하기

모노레포를 적용함에 있어서 배포 프로세스에는 두가지 수정 작업이 필요했습니다. 첫번째 작업은 우선 각각의 devops를 하나로 통일하는 것이었습니다. 20개의 워크플로우를 관리한다면 모노레포를 사용하는 의미가 없으니 말이죠.

두번째 수정은 변경된 파일을 감지하여 해당 파일이 어떤 패키지에 속하는지 알아야 했습니다. 그리고 나서 해당 패키지만 배포할 수 있어야 했죠. 여러 PR들이 한곳에 올라올테니, 자동으로 라벨을 붙여주면 더 좋을 것이구요.

  1. 특정 기능 작업을 완료
  2. PR 생성
  3. 라벨러가 작업된 위치를 파악하여 자동 라벨 생성
  4. master로 push될 경우 해당 패키지 주소로 배포하기

위 작업들은 github actionreusable workflowlabeler를 사용해서 수월하게 해결할 수 있었습니다.

name: Deploy
on:
  push:
    paths:
      - 'apps/앱의 경로/**'
    branches:
      - dev
      - stage
      - master
  workflow_dispatch:
jobs:
  call-reusable-deploy-workflow:
    uses: ./.github/workflows/deploy.yml
    with:
      DOMAIN_NAME: >-
        ${{실제 도메인 주소}}
      BUILD_COMMAND: 빌드 커맨드
      BUILD_SRC: 빌드 주소
    secrets: inherit

각 앱의 워크플로우가 푸시 할 경우 앱의 변화를 감지하고, 공통 워크플로우를 여러 인풋과 함께 호출합니다.

name: Deploy
on:
  workflow_call:
    inputs:
      DOMAIN_NAME:
        description: '배포할 도메인 이름'
        required: true
        type: string
      BUILD_COMMAND:
        description: '하위 패키지 빌드 명령어'
        required: true
        type: string
      BUILD_SRC:
        description: '빌드 결과물 경로'
        required: true
        type: string
env:
  AWS_REGION: ap-northeast-2
  AWS_ROLE_NAME: ${{ secrets.AWS_ACTION_ROLE }}
  NODE_VERSION: ${{ vars.NODE_VERSION }}
permissions:
  id-token: write # This is required for requesting the JWT
  contents: read # This is required for actions/checkout
concurrency:
  group: ${{ github.ref }}
  cancel-in-progress: false
jobs:
  deploy:
    runs-on: [self-hosted, linux]
    environment: CloudFront
    steps:
      - name: Clean up
        run: rm -rf ~/.aws
      - name: Set up Node.js
        uses: actions/setup-node@v2.5.1
        with:
          node-version: lts/*
          ...

이제 인풋 값에 따라서 패키지가 빌드, 배포되게 됩니다.

name: 'Pull Request Labeler'
on: pull_request

jobs:
  labeler:
    permissions:
      contents: read
      pull-requests: write
    runs-on: ubuntu-latest
    steps:
      - uses: actions/labeler@v5
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}

'라벨 명':
  - changed-files:
      - any-glob-to-any-file: 'apps/앱의 경로/**'

PR을 올리면 동일한 방식으로 해당 PR이 어떤 패키지를 수정했는지 감지하여 자동으로 라벨을 붙이게 됩니다. 사용하는 개발자 입장에서는 바뀐게 없습니다. 기존에 하던 대로 기능을 개발한 후 마스터에 머지하면 끝이죠.

기존 앱 가져오기

개발 환경이 설정되었고, 배포 프로세스 또한 정리되었습니다. 남은 것은 이제 기존의 어드민들을 해당 레포로 가져오는 것 뿐입니다. 실제 마이그레이션의 경우 소스 코드를 가져온 뒤, 린트와 포맷팅을 적용하고 배포 테스트를 마치는 방식으로 진행하였습니다.

작업을 마치며

이렇게 해서 20여개의 어드민을 하나의 모노레포에 넣을 수 있었습니다. 분명 쉽지는 않은 작업이었습니다. 홑따옴표를 쓸 것이나 쌍따옴표를 쓸 것이냐-부터 시작해서 여러번의 컨벤션 협의를 거쳤어야 했습니다. 예상치 못한 사이드 이펙트로 롤백을 해야 했던 적도 있었죠.

그러나 분명한 건 해당 작업을 통해 매우 많은 장점들을 얻게 되었다는 것입니다.

멀티레포 지옥에서 해방 😄

생산성의 향상

가장 먼저 맥락 전환 비용이 줄어들었습니다. 더이상 새로운 작업이 주어졌을때 레포지토리를 찾아서 클론부터 할 필요가 없어졌습니다. 데스크톱을 옮겨 다닐 필요가 없으니 집중도와 생산성이 둘 다 올라갔죠. 이전에 다른 어드민에서 비슷한 로직을 본 것 같다면, IDE에서 검색하면 끝입니다.

기존에는 컨텍스트가 많아질수록 생산성이 줄어드는 좋지 않은 구조였다면, 이제는 정 반대로 바뀌었습니다. 남는 시간으로 다시 모노레포를 더 좋게 만들고, 이를 통해서 더 많은 시간을 확보하는 그야말로 무한동력이 되었죠.

더이상 레거시가 무섭지 않아요

2년전, 3년전 코드를 보는 것은 그리 즐거운 일만은 아니었습니다. 작업 히스토리를 파악했어야 했고, 수많은 주석들을 읽으면서 수정할 부분을 찾아야 하는 고된 작업이였죠.

거기에 현재와 너무 달라진 코드로 인해서 작업을 하기 위한 준비 작업들에 시간을 더 쏟는 일들이 비일비재했습니다. 그러나 모노레포를 적용한 후 부터는 그러한 시간들이 사라졌습니다. 언제 어디서 그 누구든 동일한 개발 환경 속에서 빠르게 원하는 부분에 작업을 할 수 있게 되었습니다.

예상하지 못했던 이점

생산성 향상이라는 이유로 적용한 모노레포였지만, 코드의 퀄리티가 더 올라간다는 점도 굉장한 이점이었습니다. 변화가 바로바로 트래킹되는 구조이다보니 모두가 더 많이 PR을 보게 되고, 더 많이 리뷰를 하게 되었죠.

공통으로 사용되는 라이브러리의 버전 업도 더 쉽게 고려해볼 수 있게 되었습니다. 이전에는 모두 적용할 수 없으니 일단 멈췄던 작업도 루트 레벨에서 버전을 바꾸면 끝이니 더 쉬워졌죠. 버전 관리가 쉬워지니 다시 또 제품의 퀄리티도 올라간 것은 물론입니다.

아직 끝이 아니다

모노레포를 잘 적용했고, 문제를 성공적으로 해결하였습니다. 그러나 모노레포도 결국 트레이드 오프를 가진 기술이기에 때가 되면 새로운 도전 과제들이 생길 것입니다. 그때에도 우리는 팀 차원의 논의를 통해 훌륭히 해결해 나갈 것이라 믿습니다.

한편 진정한 의미에서 모노레포를 도입했다고 말하기에는 아직 이릅니다. 공통 모듈들의 추상화, JSON 설정만으로 어드민을 구성할 수 있는 시스템 구축, 빌드 시간 최적화 등 앞으로 해결해야 할 과제들이 많이 남아있죠.

프론트엔드 팀은 이러한 과제들을 해결하기 위해 계속해서 연구하고 실험하며 어드민 모노레포에 기여하고 있습니다. 이 과정에서 이뤄지는 많은 논의와 토론들이 결국 더 좋은 제품을 만드는 밑거름이 될 것이라 믿으며 글을 마치도록 하겠습니다. 읽어주셔서 감사합니다.


🚖 함께 할 동료를 찾고 있어요 🚖


SWING은 더 나은 도시를 만들기 위해 노력하는 팀입니다. 데이터, 기술, 사용자 중심의 혁신을 통해 이동 경험을 바꾸고자 하는 분들을 기다리고 있습니다.  

여정에 함께하고 싶다면, 지금 바로 아래 링크를 통해 지원해주세요!

👉 SWING 채용 공고 확인하기 👈