모노레포 사용기 (turborepo)

개똥이개똥이
8 min read

Why Monorepo?

프로젝트에 따라서 레포지토리를 하나만 활용하는 것이 아닌 여러개의 레포를 관리해야할 때가 있다. 가량 지금 진행하고 있는 사이드 프로젝트 Deebo에는 간단한 프로젝트 소개 및 QnA 내용이 담긴 랜딩페이지와 프로덕트(서비스) 페이지, 어드민 페이지 등으로 나누어 지게 되는데 각각 개별적인 레포를 활용해 만들 것이기 때문에 작업 시 여러개의 에디터를 띄워놓거나 하는 등의 불편함을 감내해야했다.

만약 이 때 모노레포를 활용한다면 어떻게 될까?

간단하게 모노레포를 활용하게 되면 1개의 레포로 이 모든 걸 담을 수 있다. 또 단순히 하나의 레포에 여러 개의 복수 레포를 담아 사용하는 것이라면 이런 번거로운 일을 할 필요 없을텐데 여러 레포에서 공용으로 활용되는 컴포넌트 들을 내부 패키지로 만들어 관리할 수 있다는 큰 장점이 있고, API 역시 공용으로 사용되는 것들을 한번만 만들어 공유하고 관리할 수 있다는 장점이 있다.

장점 정리

첫째. 여러 개의 레포를 하나로 모아 관리할 수 있다.
둘째. 공용으로 사용되는 컴포넌트를 패키지화 하여 내부적으로 공유할 수 있다.
셋째. API 역시 두번째 장점과 같이 공용으로 활용되는 것을 내부적으로 공유할 수 있다.
넷째. 의존성 관리가 용이하다.
다섯째. 프로젝트 별로 다른 코딩 규칙들을 하나로 완전히 통합할 수 있다.
여섯째. 동일한 개발환경을 세팅할 수 있다.

이 좋은 걸 왜 안써?

이런 다양한 장점에도 모노레포를 사용할 일은 그닥 많지 않다. 사이드 프로젝트의 경우에는 같은 프로젝트 내에서 레포를 여러개 분리해 활용해야하는 경우가 그리 많지 않고 기업의 경우에는 모노레포를 도입하는데에 들어가는 비용을 굳이 감당할 이유가 없기 때문일 것이라고 본다.

하지만 빅테크를 위시하여 정말 많은 서비스 회사가 모노레포를 사용해 관리하고 있고 일일이 수동으로 구축해야했던 15년 이전과는 달리 15년 부터는 Lerna 같은 모노레포 관리도구의 등장과 2020년 많은 이들이 사용하게 된 터보레포가 등장하며 그 진입 난이도가 낮아졌다.

결국 구축 시 진입장벽 때문에 사용하지 않는다는 말은 핑계에 불과하게 된 시대이므로, 그 외의 단점들을 정리하도록 한다.

단점 정리

첫째. 저장소의 크기가 증가한다.
둘째. 빌드/테스트 시간이 증가한다.
셋째. 버전관리가 복잡해지고 프로젝트 별 접근 권한을 관리하는데에도 더 많은 비용이 들어간다.


포스트 글 아래에 구축 방법을 첨부해놓았다. 만약 설치방법/세팅방법이 궁금하다면 참고해보자.

폴더구조

my-monorepo/
├── apps/
│   ├── docs/
│   └── web/
├── packages/
│   ├── eslint-config-custom/
│   ├── tsconfig/
│   └── ui/
├── package.json
└── turbo.json

위 구조를 보면 apps 와 package 폴더가 눈에 띈다. 프로젝트 아래로는 크게 apps와 packages 그리고 각종 설정 파일들이 위치하고 있는데, 주목해야할 것들은 apps와 packages 폴더이다.

이름에서 직관적으로 보이듯 apps에는 실제 우리가 개발할 서비스 레포들이 위치하고 packages 폴더에는 모노레포 전역에서 활용될 내부 패키지들이 위치한다.

모노레포 구조의 일반적 역할

  • apps/: 웹 앱, 관리자 대시보드, API 서버 등

  • packages/: 공유 컴포넌트, 유틸리티 함수, 데이터 모델 등

이런 모노레포의 구조의 장점 중 하나로는 전혀 다른 기술 스택으로 프로젝트를 만들어 관리할 수 있다는 점도 있다. deebo-service 는 Next.js로 landing-page 는 vite + React 로 되어있다.

의존성 관리

모노레포(turborepo)에서는 기본적으로 모든 패키지의 설치가 루트의 node_modules에 설치가 되며 apps 폴더 내에 있는 개별 프로젝트는 이를 참조하는 방식으로 패키지를 사용하게 된다. 이를 hoisting이라고 부르며, 내부 프로젝트에는 .bin 폴더만 존재하고 있다.

이런 방식의 장점은 중복으로 패키지가 설치되는 것을 막을 수 있고 이로 인해 전체적인 패키지 설치시간이 단축될 수 있다. 또한 공통의 버전을 활용해 버전 충돌 문제를 줄일 수 있다는 장점이 있다.

다만 서로 다른 패키지가 다른 버전의 동일한 패키지를 사용하게 되면 문제가 발생할 수 있고 package.json에 선언되지 않은 패키지도 사용할 수 있는 등 문제를 야기시킬 수 있다. 그러므로 충분한 문서화가 수반되어야 한다.

패키지 설치 방법

개별 프로젝트의 package.json 에 설치 원하는 라이브러리를 작성한 다음 루트 경로에서 install 하게 되면 workspace에서 사용할 수 있게 된다.

workspaces

{
  "name": "my-monorepo",
  "private": true,
  "workspaces": [
    "packages/*",
    "apps/*"
  ]
}

위 package.json 파일을 보면 workspaces 항목이 있다. 이 workspaces는 모노레포 내에서 독립적인 위치를 가지는 단위라고 할 수 있는데 apps/* 를 보면 apps 폴더 내에 있는 모든 폴더(프로젝트)들은 하나의 독립된 워크스페이스로 인정한다는 말이다.

모노레포 설계의 가장 핵심적인 구성요소이며 turborepo 등은 이런 구성을 훨씬 쉽게 할 수 있도록 해준다. 만약 이런 툴을 활용하지 않는다면 yarn berry나 npm, pnpm 등을 통해서도 구축할 수 있다.

내부 패키지 사용 방법

일반적으로 우리는 npm 과 같이 외부 라이브러리 저장소에서 라이브러리를 설치하게 된다. 하지만 모노레포에서는 외부 패키지 외에도 내부 패키지를 쉽게 관리하고 만들 수 있다는 장점이 있다. 프로젝트의 서비스들 전반에 공용으로 활용되는 컴포넌트 등을 하나의 패키지로 만들어 어디서든 사용할 수 있도록 하는 등 재사용성이 높아지고 개발 편의성이 높아진다. (이 때문에 마이크로 프론트엔드 아키텍쳐에서 모노레포를 빼놓을 수 없는 건가보다.)

사용법은 간단한데,

  1. 우선 루트의 packages 폴더 내부에 폴더를 만들고 package.json을 생성해 패키지를 정의한다.

     {
         // 이름과 버전은 꼭 관리해주자.
       "name": "@repo/ui",
       "version": "0.0.0",
       "private": true,
       "exports": {
           // export 설정을 해주면 워크스페이스에서 import 해 활용가능하다
         "./button": "./src/button.tsx",
         "./card": "./src/card.tsx",
         "./code": "./src/code.tsx",
         "./catchPhrase": "./src/catchPhrase.tsx"
       },
       "scripts": {
         "lint": "eslint . --max-warnings 0",
         "generate:component": "turbo gen react-component",
         "build": "tailwindcss -i ./src/index.css -o ./dist/index.css --minify"
       },
       "devDependencies": {
             // 대충 의존성
       },
       "dependencies": {
             // 대충 의존성
       }
     }
    
  2. 공용 UI 를 위한 패키지일 경우 일반적인 프로젝트 세팅과 동일하게 해주면 된다.

  3. export 구문을 통해 꼭 import 로 접근할 수 있게 해줘야한다.

  4. 의존성이 필요하다면 package.json에 추가하고 루트에서 install 하도록 한다.

  5. 워크스페이스의 package.json에 패키지를 추가하고 똑같이 루트에서 install 하도록 한다.

     // 워크스페이스 내 패키지 추가 명령어
     yarn workspace <app-name> add <package-name>
    

내부 패키지의 구조


turborepo를 활용하면서 겪었던, 느꼈던 점

  1. 모노레포의 장점에 대해서는 잘 알 수 있었으나, 굉장히 제한된 환경에서 사용하고 있기 때문에 큰 이슈를 맛보지는 못하고 있다. 개발이 진행되어가면서 문제가 발생할 수 있을테니 그 부분은 추가적으로 작성할 수 있도록 하겠다.

  2. turborepo와 같이 편하게 구축할 수 있는 툴이 있기에 부담없이 도전해볼만한 것 같다. 하나하나 설정해가면서 하라고 하면 상당히 곤욕을 많이 치뤘을 것 같다.

  3. workspace의 개념을 잘 이해하지 못했던 것과 모노레포의 패키지 설치 방식이 낯설다는 것 때문에 세팅하느라 좀 햇갈렸으나 워낙 자료가 방대하게 있고 클로드와 같은 생성형 AI툴을 활용하니 생각보다 진입 장벽이 굉장히 낮았다.

  4. 같은 프로덕트/ 여러가지 서비스에 대해 동일한 UI가 필요하지만 디자이너가 없다느니 디자인이 없다느니 등등의 변명으로 알게 모르게 다른 UI를 만들곤 했다. 최소한 turborepo와 같은 모노레포를 활용하면 UI 일관성을 유지하는데 굉장히 큰 도움이 되지 않을까 싶다.

마치며…

신기술이라고 하기에는 오래 됐지만 아무튼 나에겐 새롭기 때문에, 새로운 기술은 언제나 짜릿하다.


Turborepo 설치 방법

터보레포로 모노레포 구축하기

  • 설치방법

    1. 프로젝트 초기 설정

    1.1 Turborepo 프로젝트 생성

      npx create-turbo@latest
    

    프롬프트에 따라 프로젝트 이름과 패키지 매니저(npm, yarn, pnpm)를 선택합니다.

    1.2 프로젝트 구조 확인

    생성된 프로젝트 구조:

      my-monorepo/
      ├── apps/
      │   ├── docs/
      │   └── web/
      ├── packages/
      │   ├── eslint-config-custom/
      │   ├── tsconfig/
      │   └── ui/
      ├── package.json
      └── turbo.json
    

    2. Turborepo 설정

    2.1 turbo.json 설정

    turbo.json 파일을 열고 다음과 같이 설정합니다:

      {
        "$schema": "<https://turbo.build/schema.json>",
        "globalDependencies": ["**/.env.*local"],
        "pipeline": {
          "build": {
            "dependsOn": ["^build"],
            "outputs": ["dist/**", ".next/**"]
          },
          "lint": {
            "outputs": []
          },
          "dev": {
            "cache": false
          }
        }
      }
    

    이 설정은 빌드, 린트, 개발 스크립트의 실행 방식을 정의합니다.

    3. 워크스페이스 추가

    3.1 새 패키지 추가

    예를 들어, 공통 컴포넌트 라이브러리를 추가합니다:

      cd packages
      mkdir components
      cd components
      npm init -y
    

    3.2 패키지 설정

    packages/components/package.json:

      {
        "name": "@my-monorepo/components",
        "version": "0.0.0",
        "main": "./index.tsx",
        "types": "./index.tsx",
        "license": "MIT",
        "scripts": {
          "lint": "eslint .",
          "build": "tsc"
        },
        "devDependencies": {
          "@types/react": "^18.0.0",
          "@types/react-dom": "^18.0.0",
          "eslint": "^7.32.0",
          "eslint-config-custom": "*",
          "react": "^18.2.0",
          "tsconfig": "*",
          "typescript": "^4.5.2"
        }
      }
    

    4. 의존성 관리

    4.1 워크스페이스 간 의존성 추가

    예를 들어, web 앱에 components 패키지를 추가:

      cd apps/web
      npm install @my-monorepo/components
    

    4.2 공통 의존성 관리

    루트 package.json에 공통 devDependencies 추가:

      {
        "devDependencies": {
          "@typescript-eslint/eslint-plugin": "^5.0.0",
          "@typescript-eslint/parser": "^5.0.0",
          "eslint": "^8.0.0",
          "typescript": "^4.5.2"
        }
      }
    

    5. 스크립트 설정

    5.1 루트 package.json 스크립트

      {
        "scripts": {
          "build": "turbo run build",
          "dev": "turbo run dev",
          "lint": "turbo run lint",
          "format": "prettier --write \\\\"**/*.{ts,tsx,md}\\\\""
        }
      }
    

    6. 빌드 최적화

    6.1 캐시 활성화

    turbo.json에 캐시 설정 추가:

      {
        "pipeline": {
          "build": {
            "outputs": ["dist/**", ".next/**"],
            "cache": true
          }
        }
      }
    

    6.2 원격 캐시 설정 (선택사항)

    Vercel 또는 다른 원격 캐시 서비스 사용 시:

      npx turbo login
      npx turbo link
    

    7. CI/CD 설정

    7.1 GitHub Actions 워크플로우 예시

    .github/workflows/ci.yml 파일 생성:

      name: CI
    
      on:
        push:
          branches: [main]
        pull_request:
          branches: [main]
    
      jobs:
        build:
          runs-on: ubuntu-latest
    
          steps:
            - uses: actions/checkout@v2
            - name: Use Node.js
              uses: actions/setup-node@v2
              with:
                node-version: '14'
            - name: Install dependencies
              run: npm ci
            - name: Build
              run: npm run build
            - name: Lint
              run: npm run lint
    

    8. 고급 기능 활용

    8.1 필터링

    특정 패키지만 빌드:

      turbo run build --filter=@my-monorepo/web
    

    8.2 병렬 실행

    turbo.json에 병렬 실행 설정 추가:

      {
        "pipeline": {
          "build": {
            "dependsOn": ["^build"],
            "outputs": ["dist/**", ".next/**"]
          },
          "test": {
            "dependsOn": ["build"],
            "outputs": [],
            "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts", "test/**/*.tsx"]
          }
        }
      }
    

    9. 성능 모니터링

    9.1 빌드 요약 확인

      turbo run build --summarize
    

    9.2 빌드 프로파일링

      turbo run build --profile=build-profile.json
      npx turbo-visualize build-profile.json
    

    10. 문제 해결

    • 캐시 문제 발생 시 캐시 정리:

        turbo clean
      
    • 의존성 그래프 확인:

        turbo run build --graph
      
  • turbo.json 정의

      {
        // $schema: JSON 스키마 정의. 이를 통해 IDE에서 자동완성 및 유효성 검사 지원
        "$schema": "<https://turbo.build/schema.json>",
    
        // globalDependencies: 모든 태스크에 영향을 미치는 전역 의존성 정의
        "globalDependencies": ["**/.env.*local"],
    
        // pipeline: 각 스크립트(태스크)의 실행 방식과 의존성을 정의
        "pipeline": {
          // build 태스크 정의
          "build": {
            // dependsOn: 이 태스크가 실행되기 전에 완료되어야 하는 다른 태스크들
            // "^build"는 현재 패키지의 의존성들의 build 태스크가 먼저 실행되어야 함을 의미
            "dependsOn": ["^build"],
    
            // outputs: 이 태스크가 생성하는 출력물의 위치
            // 캐싱 및 증분 빌드에 사용됨
            "outputs": ["dist/**", ".next/**"],
    
            // cache: 이 태스크의 결과를 캐시할지 여부
            "cache": true,
    
            // inputs: 이 태스크의 입력으로 간주되는 파일들
            // 여기에 명시된 파일들이 변경되면 캐시가 무효화됨
            "inputs": ["src/**/*.tsx", "src/**/*.ts", "package.json"]
          },
    
          // lint 태스크 정의
          "lint": {
            // outputs가 빈 배열인 경우, 이 태스크는 파일을 생성하지 않음을 의미
            // 캐싱은 여전히 수행되지만, 출력물은 저장되지 않음
            "outputs": []
          },
    
          // dev 태스크 정의
          "dev": {
            // cache: false로 설정하여 항상 새로 실행되도록 함
            // 개발 서버와 같이 지속적으로 실행되는 태스크에 적합
            "cache": false,
    
            // persistent: true로 설정하여 이 태스크가 지속적으로 실행됨을 명시
            "persistent": true
          },
    
          // test 태스크 정의
          "test": {
            // dependsOn: test 태스크는 build 태스크가 완료된 후에 실행
            "dependsOn": ["build"],
    
            // inputs: 테스트 파일과 소스 파일을 입력으로 지정
            "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts", "test/**/*.tsx"]
          },
    
          // deploy 태스크 정의
          "deploy": {
            // dependsOn: deploy 전에 build와 test가 성공적으로 완료되어야 함
            "dependsOn": ["build", "test", "lint"],
    
            // outputs: 배포 로그나 아티팩트의 위치
            "outputs": ["deploy-output/**"],
    
            // cache: false로 설정하여 항상 새로 실행되도록 함
            // 배포는 항상 최신 상태를 반영해야 하므로
            "cache": false
          }
        },
    
        // globalEnv: 모든 태스크에서 사용할 수 있는 환경 변수 목록
        "globalEnv": ["NODE_ENV", "API_URL"],
    
        // remoteCache: 원격 캐시 설정 (예: Vercel)
        "remoteCache": {
          "signature": true
        }
      }
    

위 설치 방법을 토대로 설치하면 금방 설치할 수 있다.

0
Subscribe to my newsletter

Read articles from 개똥이 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

개똥이
개똥이

어제보다 더 나은 서비스를 만들어내는 사람이 되고자 노력하며, 내일의 나를 위해 기록합니다.