안녕하세요, 인프랩 DevOps 엔지니어 제이크입니다.

여러분은 혹시 CI 파이프라인이 너무 오래 동작해서 PR 리뷰에 영향을 받은 적 있으신가요?

프로젝트 규모가 커질수록 늘어나는 CI/CD 파이프라인의 소요시간으로 고민한 적이 있으신가요?

이번 글에서 소요시간을 최대 4.6배 까지 개선할 수 있었던 노하우를 공유해 드리겠습니다.

의존성 및 빌드 캐시 적중시 기준

최적화 결과

최적화 결과

주요 내용 요약

  • CI(지속적 통합) 소요시간 최대 79% 감소

    • Jenkins Pipeline 최적화:

      • 빌드, 테스트를 실행하는 도커 컨테이너의 의존성 캐시 경로 설정
    • 모노레포 CI에 증분 빌드 적용: turborepo remote cache

간단하지만 매우 효과적이며 쉽게 적용할 수 있는 CI/CD 파이프라인 최적화 노하우를 공유합니다.

최적화 배경

인프랩 개발파트에서는 모노레포를 도입하여 패키지 단위로 코드를 관리하고 있는데요. 프로젝트 규모가 커질수록 코드 베이스와 패키지가 늘어나면서 CI 소요시간이 급증했습니다.

프로젝트 성장에 따른 패키지 수, 소요시간 변화 그래프

  • 프로젝트 초기: 패키지 1개, 4분 14초
  • 프로젝트 후기: 패키지 9개, 12분 4초

프로젝트가 성장하면서 코드에서 사용하는 라이브러리가 점점 많아졌고, 의존성(dependency) 설치 소요시간이 증가했습니다.

또한 빌드와 테스트를 순차 실행하니 소요시간이 연달아 증가해, 빌드 파이프라인에서 직접 병렬로 실행하게 하는 등의 시도를 해 보았으나 - 의존하는 패키지 간의 복잡한 관계와 동시성 문제로 - 다른 해결 방법을 찾아야 하는 상황이었습니다.

이에 pnpmturborepo 를 도입해 모노레포 프로젝트의 생산성을 개선하게 되었습니다.

yarn 과 pnpm 비교

다음은 npm, pnpm, yarn, yarn PnP의 벤치마크 그래프입니다.

이미지 출처: pnpm.io

pnpm vs yarn

그래프에 보이는 것처럼 의존성 설치시 특정 조건에서 약 2배 가량 빠른 모습을 보여주는데요, 저희가 정리한 각각의 특징은 아래와 같습니다.

  • yarn

    • 의존성 설치 소요시간: 최대 120초
    • 복잡한 node_modules 구조로 인해 디스크 I/O 및 CPU 자원 소모
  • pnpm

    • 의존성 설치 소요시간: 최대 63초
    • symlink 로 전역 캐시 경로(.pnpm-store)에 링크 -> 불필요한 파일 복사 감소 -> 디스크 I/O 감소
node_modules
└── .pnpm
    ├── bar@1.0.0
    │   └── node_modules
    │       └── bar -> <store>/bar
    │           ├── index.js
    │           └── package.json
    └── foo@1.0.0
        └── node_modules
            └── foo -> <store>/foo
                ├── index.js
                └── package.json

기존에 사용하던 yarn 은 의존성을 설치하면 복잡한 node_modules 디렉터리 구조로 인해 불필요한 디스크 I/O 와 CPU 자원이 소모되어 더 큰 빌드 서버 사양이 필요하고 소요시간이 증가하는 단점이 있었습니다.

pnpm 을 도입하고 나서 .pnpm-store 에 모든 의존 패키지를 설치하고, 각 패키지 내 node_modules 디렉터리는 symlinks 만 설정하는 식으로 개선되어 디스크 쓰기가 감소했으며 의존성 설치 소요시간을 약 60초 가량 단축할 수 있었습니다.

turborepo

turborepo 를 설명하기 전 모노레포에 대해 잠깐 짚어보고 가겠습니다.

Monorepo란?

이미지 출처: monorepo.tools

모노레포(Monorepo) 란?

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

모노레포는 관계가 잘 정의된 여러 개의 개별 프로젝트를 포함한 저장소를 일컫는다.

모노레포를 도입하면 매번 수많은 저장소를 만들고 관리하는 노력을 줄일 수 있습니다. 자세한 내용은 링크를 참고해주시길 바랍니다.

관계가 잘 정의되지 않고 캡슐화가 되어 있지 않은 단순 코드 모음은 모노레포가 아니며, Monolith 와 다르다고 설명하고 있습니다.

이처럼 모노레포는 여러 앱, 그리고 라이브러리를 하나의 저장소에서 관리하므로 동일 작업을 수많은 저장소에 반복적으로 적용하지 않아도 됩니다.

그렇지만 모노레포의 코드 베이스 규모가 커지면 어떻게 될까요?

  • 순차적인 빌드와 테스트 실행으로 인한 소요시간 증가

  • 변경되지 않은 부분도 불필요한 빌드 & 테스트 반복 실행

다음은 모노레포의 CI 파이프라인에서 변경된 패키지만 병렬로 동시에 여러 빌드를 실행하도록 구성한 예시입니다.

stage('Docker Build') {
    parallel {
        stage('Package A') {
            when {
                anyOf {
                    changeset "cmd/package-alpha/**/*"
                    changeset "docker/package-alpha/**/*"
                }
            }
            steps {
                sh 'docker build -t package-alpha -f docker/package-alpha/Dockerfile .'
            }
        }

        stage('Package B') {
            when {
                anyOf {
                    changeset "cmd/package-beta/**/*"
                    changeset "docker/package-beta/**/*"
                }
            }
            steps {
                sh "docker build -t package-beta -f docker/package-beta/Dockerfile ."
            }
        }
...(중략)...

제품이나 조직의 규모에 따라 적절히 저장소를 분리하는 방법도 있지만, 결국 모노레포를 별도의 도구 없이 위의 예시처럼 직접 CI 파이프라인에서 관리하려면 꽤나 큰 공수가 들어갈 것입니다.

  • 패키지가 추가될 때마다 사람이 직접 CI 파이프라인을 수정하는 것은 정말 어려운 일입니다. (정말 정말 많은 노력이 필요합니다)

  • 만약 패키지가 서로 복잡한 의존 관계로 얽혀 있다면 이를 모두 CI 파이프라인에 구현하기 어렵습니다.

이럴 때 필요한 게 바로 turborepo, nx 와 같은 모노레포 도구입니다.
다양한 모노레포 도구들이 있지만 본문에서는 저희가 도입하게 된 turborepo 위주로 설명드리겠습니다.

핵심 기능

turborepo 는 JavaScript 및 TypeScript 코드를 위한 고성능 빌드 시스템입니다. 공식 페이지에서 설명하는 특징은 아래와 같습니다.

  • Incremental Builds
  • Content-aware hashing
  • Parallel execution
  • Remote Caching
  • Zero runtime overhead
  • Pruned subsets
  • Task pipelines
  • Meets you where you’re at
  • Profile in your browser

여기서 강조할 부분은 Incremental Builds, Content-aware hashing, Parallel execution, Remote Caching 입니다. 순서대로 알아보겠습니다.


Incremental Builds

  • 우리 말로 직역하면 “증분 빌드” 입니다.

  • 늘어난 부분만, 변경한 부분만 테스트 & 빌드해 규모가 큰 모노레포의 CI 소요시간을 크게 줄여줍니다.

  • Detecting affected projects/packages


Content-aware hashing

  • 코드 내용을 구분•식별해서 해시값으로 코드의 변경을 감지합니다.

  • Git에 의존하는 changeset 과는 대조적입니다.


Parallel execution

  • 기존에는 빌드를 순차적으로 실행해 낭비되는 CPU 자원이 있었습니다.

  • 빌드를 병렬로 실행해 CPU 코어를 효율적으로 사용하고 빌드를 더 빨리 끝낼 수 있습니다.

  • Local Task Orchestration

    • Local Task Orchestration Explained

Remote Caching

  • Local Computation Caching

    • task의 처리 결과를 로컬에 보관하고 재사용하는 기능입니다. 같은 머신에서는 같은 형상의 빌드와 테스트를 두 번 이상 하지 않습니다.
  • Distributed Computation Caching

    • Distributed Computation Caching

    • Local Computation Caching 을 확장한 개념으로, task 처리 결과를 한 머신이 아닌 여러 환경에 걸쳐 공유합니다.

    • CI 에이전트 중 하나가 이미 해당 코드 형상을 빌드하고 테스트했다면, 다른 에이전트는 이를 절대 재실행하지 않습니다.

Remote CachingDistributed Computation Caching 과 유사한 개념입니다.

  • 같은 코드 베이스를 수정하는 작업자가 여러 명 있다고 가정하겠습니다.

    • 작업자 A가 먼저 빌드와 테스트를 수행한 이력이 있는 경우 해당 결과는 공용 캐시 서버에 저장됩니다.

    • 이어서 작업자 B가 빌드와 테스트를 실행할 때에는 공용 캐시 서버에 캐시된 작업자 A의 연산 결과를 불러오고, 불필요한 빌드 실행을 건너뛰게 됩니다.

  • 공용 캐시 서버에는 빌드 결과 파일 또한 같이 저장되므로 이를 즉시 배포에 활용할 수 있습니다.
    반복적인 작업이 여러 번 수행되는 CI 파이프라인을 효과적으로 개선할 수 있습니다.
    뒤에서 자세한 수치와 함께 공유드리겠습니다.


위의 4가지 기능을 적절히 활용하면 규모가 큰 프로젝트도 정말 쉽게 관리할 수 있습니다.

근데 왜 이번 글 제목이 turborepo 도입이 아니고 CI 개선하기 일까요?

몇 가지 놓친 부분이 있어 이러한 장점을 모두 활용할 수 없었기 때문인데, 이어서 설명드리겠습니다.

CI 소요시간 최적화

Jenkins Agent

인프랩에서는 젠킨스를 이용해 CI/CD 파이프라인을 구축했습니다.

CI 잡이 트리거 되면, 젠킨스 EC2 에이전트에서 도커 이미지를 실행해 다음과 같은 순서로 잡을 실행합니다.

Jenkins CI Steps

  1. 의존성 설치(Install Dependencies)

  2. 테스트(Test)

  3. 빌드(Build)

  4. 정적 분석(Static Analysis)

의존성 설치

의존성 설치 과정을 요약하면 다음과 같습니다.

pnpm i --frozen-lockfile
  • 필요한 의존성 라이브러리 버전 확인

    • --frozen-lockfile: 필요한 의존성 매니페스트 파일(pnpm-lock.yaml)의 존재 여부를 확인하고, 명시된 버전에 따라 의존성을 설치
  • npmjs.com, Github Registry 같은 패키지 저장소에서 의존성 다운로드 및 설치

  • post-install 스크립트 실행


위에서 pnpm 도입 만으로도 의존성 설치 소요시간을 60초 가량 단축할 수 있었다고 말씀드렸습니다.

실행환경이 매번 달라지는 CI 환경에서는 의존성 캐시를 도입하면 더 많은 시간을 절약할 수 있습니다.

환경별 의존성 설치 결과

로컬 환경에서는 pnpm i 를 실행하면 전역 의존성 캐시 디렉터리에서 캐시를 로드하여 불필요한 설치를 건너뛰는 반면,

CI 환경은 실행 서버가 매번 달라질 수 있어 의존성 캐시 설정을 별도로 진행해야 합니다.

  • 로컬 환경: 설치한 모든 의존성은 전역 캐시 디렉터리(~/Library/pnpm/store/*)에 누적

    • 한 번 설치한 의존성을 다른 프로젝트에서도 참조하여 불필요한 설치 과정을 단축 (캐시 적중시 3초 내외)
  • CI 환경: 실행하는 서버가 매번 달라질 수 있고, 별도의 의존성 캐시를 설정하지 않으면 매번 설치 (약 60초 소요)

CI 환경에서 의존성 캐싱

CI 환경에서 의존성 캐시 방법은 주로 다음과 같습니다.

  1. 압축: node_modules 경로를 tar & gzip 으로 압축

  2. CI용 캐시 이미지 만들기: 의존성을 미리 설치한 도커 이미지를 만들고 이를 빌드에 활용하는 방법

  3. 캐시 볼륨 마운트: build agent 에 남아 있는 global cache 를 활용하는 방법

압축

Github Actions actions/cache@v3 예시

...
- id: cache
      uses: actions/cache@v3
      with:
        path: '**/node_modules'
        key: ${{ runner.os }}-node-${{ hashFiles(format('**/{0}', steps.hash-file.outputs.hash-file)) }}
...

Jenkins Job Cacher 플러그인 활용 예시

cache(maxCacheSize: 250, defaultBranch: 'develop', caches: [
        arbitraryFileCache(path: 'node_modules', cacheValidityDecidingFile: 'package-lock.json')
]) {
    // ...
}

의존성이 담긴 node_modules 디렉터리를 tar ball 로 묶고, gzip, xz 등으로 압축하는 방법입니다.

캐시는 pnpm-lock.yaml 같은 매니페스트 파일 해시로 구분되므로 필요 의존성이 변경될 때마다 새로 설치를 진행해야 한다는 단점이 있습니다.

node_modules 디렉터리를 압축하는 과정은 기본적으로는 싱글 스레드 기반으로 동작하여 시간이 많이 소요되는 작업입니다.


프로젝트 규모가 커질 경우 node_modules 디렉터리 또한 크고 복잡해지므로 캐시 파일 다운로드 및 압축 해제에만 2분 넘게 소요되는 경우가 있습니다.

배 보다 배꼽이 더 큰 셈이므로 차라리 압축하지 않는 편이 더 나을 수 있습니다.


프로젝트 규모가 클 경우

  • pnpm install 소요시간: 60초

  • 캐시파일 다운로드 및 압축 해제 소요시간: 120초 이상 소요

  • 경우에 따라 직접 소요시간 측정 후 비교 & 합리적 판단 필요


Github Actions 를 활용하면 pnpm에서 제공하는 공식 문서 가이드에 따라 cache: 'pnpm' 설정으로 간단하게 적용할 수 있습니다.


pnpm 공식 문서에서 권장하는 Github Actions 파이프라인 예시

name: pnpm Example Workflow
on:
  push:
jobs:
  build:
    runs-on: ubuntu-20.04
    strategy:
      matrix:
        node-version: [15]
    steps:
    - uses: actions/checkout@v3
    - uses: pnpm/action-setup@v2
      with:
        version: 8
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v3
      with:
        node-version: ${{ matrix.node-version }}
        cache: 'pnpm'
    - name: Install dependencies
      run: pnpm install

Jenkins 파이프라인을 활용하는 경우에는 다음과 같이 설정할 것을 권장하고 있는데요.

pipeline {
    agent {
        docker {
            image 'node:lts-bullseye-slim' 
            args '-p 3000:3000' 
        }
    }
    stages {
        stage('Build') { 
            steps {
                sh 'corepack enable'
                sh 'corepack prepare pnpm@latest-8 --activate'
                sh 'pnpm install'
            }
        }
    }
}

위 예시는 별도의 pnpm store path 를 지정하지 않았기 때문에 JENKINS_WORKSPACE 하위에 .pnpm-store 경로를 만들어 의존성을 캐싱합니다.

하지만 Jenkins Multibranch Pipeline 의 경우 각각의 브랜치와 Pull Request 마다 별도의 Workspace 경로를 갖습니다.

이는 즉 같은 프로젝트라도 서로 다른 브랜치와 PR 간에 의존성 캐시를 공유할 수 없다는 것을 의미하며 새로운 PR이 만들어질 때마다 의존성을 처음부터 설치해야 하는 한계가 있습니다.

이를 해결할 수 있는 방법은 캐시 볼륨 마운트에서 이어서 설명 드리겠습니다.

CI용 캐시 이미지 만들기

이어서 소개드릴 방법은 CI용 캐시 이미지를 만드는 것입니다.

다음은 pnpm fetch 를 이용해 의존성 캐시 이미지를 만드는 Dockerfile 예제입니다.

FROM node:18-slim
# pnpm 설치과정 생략

# pnpm fetch does require only lockfile
COPY pnpm-lock.yaml ./

RUN pnpm fetch --prod

의존성 설치 전에 위의 이미지를 pull 받은 후 의존성을 설치하면 캐시를 활용하여 바로 빌드할 수 있습니다.

이미지 태그로 매니페스트 파일(pnpm-lock.yaml)의 해시값을 지정하며 이 또한 앞선 방법과 마찬가지로 해당 파일 변경 시 다시 빌드해야 합니다.


# when cache miss, build and push into image registry like ECR
docker build -t rallit-frontend-dep:02d93ab24 -f dep.Dockerfile .
docker push rallit-frontend-dep:02d93ab24

# when build
docker run -i --rm -v $WORKSPACE:/workspace rallit-frontend-dep:02d93ab24 'pnpm i --frozen-lockfile'

pnpm fetch --prod 명령로 설치한 의존성을 이미지로 빌드하고, pull 받는 과정에서 디스크 오버헤드가 발생합니다.

내부적인 소요시간 측정 결과에 따르면 실제 CI 환경에서 이미지를 pull 받고 실행하는 데 약 168초가 소요되는 것을 확인했습니다.

캐시 이미지를 만드는 방법 또한 프로젝트 규모가 클 수록 소요시간이 길어져 실제 pnpm install 소요시간보다 약 108초 가량의 시간이 더 소요됐습니다.

이러한 캐시 이미지를 pnpm-lock.yaml 파일이 변경될 때 마다 빌드 후 컨테이너 이미지 레지스트리에 업로드하는 것은 비효율적이라고 판단했습니다.

도커 멀티 스테이지 빌드를 활용해 이미지를 빌드하시는 분들은 pnpm 공식 문서를 참고해 cache type mounting 을 적용하는 걸 권장드립니다.


FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile

FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build

도커 RUN dedicated 캐시 활용하기 패키지를 설치할 때 매번 인터넷에서 모든 패키지를 항상 가져올 필요는 없습니다.
--mount 플래그와 함께 명시적 캐시를 사용하면 빌드 간에 대상 디렉터리의 내용이 보존됩니다.
이 레이어를 다시 빌드해야 하는 경우 해당 경로에 있는 캐시를 활용합니다.

저희는 도커 멀티 스테이지 빌드를 활용하지 않고, 빌드 단계별로 별도의 최적화된 이미지를 사용하고 있어 적용하기 어려웠습니다.

이어서 의존성 설치 과정을 효과적으로 단축할 수 있었던 캐시 볼륨 마운트에 대해 설명드리겠습니다.

캐시 볼륨 마운트

앞선 내용에서 Jenkins 파이프라인에서 pnpm 으로 의존성 설치시 별도의 캐시 설정의 필요성을 언급했습니다.

같은 프로젝트라도 서로 다른 브랜치와 PR 간에 의존성 캐시를 공유할 수 없다는 것을 의미하며 새로운 PR이 만들어질 때마다 의존성을 처음부터 설치해야 하는 한계가 있습니다.


실제 .pnpm-store 경로를 비교해 보며 이해해 보겠습니다.

  • 기존(AS-IS)

    • CACHE_PATH 경로가 JENKINS_WORKSPACE 에 위치

    • 서로 다른 프로젝트, 브랜치, PR 사이에 의존성 캐시 공유 불가


# 경로 비교
JENKINS_WORKSPACE=/workspace/rallit_frontend_PR_15242
CACHE_PATH=/workspace/rallit_frontend_PR_15242/.pnpm-store

예를 들어, rallit_frontend의 PR#15242 CI 잡이 완료되었다고 가정하겠습니다.

/workspace/rallit_frontend_PR_15242/.pnpm-store 에는 방금 전 설치한 의존성이 남아 있습니다.

이어서 PR#15243 이 게시되면 어떻게 될까요?

PR#15243은 /workspace/rallit_frontend_PR_15243/.pnpm-store 을 바라보게 되는데요.

실제로는 없는 폴더이므로 처음부터 의존성을 설치하게 되어 소요시간이 증가합니다.

앞서 설치한 PR#15242의 의존성 캐시를 전혀 사용할 수 없습니다.

단순히 코드 한 줄 짜리 변경 건이여도 약 60초 가량을 더 기다려야 해서 상당히 불편합니다.


  • 변경(TO-BE)

    • 에이전트 내 Global Dependency Cache 경로 설정

    • 같은 에이전트에서 수행하는 빌드는 Dependency Cache 활용


# 경로 비교
JENKINS_WORKSPACE=/workspace/rallit_frontend_PR_15242
CACHE_PATH=/tmp/.pnpm-store	

모든 CI 작업을 실행하는 컨테이너의 pnpm store-path 옵션을 동일한 경로로 지정했습니다.

이로 인해 rallit_frontend 의 PR#15242, PR#15243 모두 같은 경로(/tmp/.pnpm-store)를 바라보게 됩니다.

PR#15243 CI 작업에서는 PR#15242에서 이미 설치한 의존성을 재활용하게 되어 약 60초의 의존성 설치 소요시간을 단 3초로 단축할 수 있었습니다.

pnpm store-path 옵션은 빌드용 베이스 이미지를 만드는 Dockerfile에서 아래와 같이 명시해 주었습니다.


# set global node cache directory
ARG CACHE_PATH=/cache/node
RUN yarn config set cache-folder ${CACHE_PATH}/.yarn
RUN pnpm config set store-dir ${CACHE_PATH}/.pnpm-store

패키지 매니저로 yarn 을 사용하는 프로젝트도 의존성 캐시 경로를 공유하도록 해당 설정을 적용해 주었습니다.

이어서 위의 도커 이미지를 실행하는 Jenkinsfile 에서도 CACHE_PATH 환경변수를 설정합니다.


env.DOCKER_OPTION = '-v /tmp:/cache/node -e CI=1'

# (중략)

stage('Install') {
  steps {
    script {
      sh "docker run --rm -i -v $WORKSPACE:/workspace $DOCKER_OPTION $NODE_IMAGE 'pnpm i --frozen-lockfile'"
    }
  }
}

Jenkinsfile Docker 확장 기능을 이용하지 않고 직접 커맨드로 도커 컨테이너를 실행하는 경우
pnpm 에서 CI 환경임을 인식하는 환경변수 중 하나인 BUILD_NUMBER 가 전달되지 않으므로 CI=1 기본값이 적용되지 않습니다.
-e BUILD_NUMBER=$BUILD_NUMBER 로 전달하거나, 혹은 직접 -e CI=1 환경변수를 설정해 주는 것이 좋습니다.
자세한 내용은 공식 문서를 참고해주세요.
CI=true인 경우 pnpm은 잠금 파일(pnpm-lock.yaml)을 생성하지 않으며 잠금 파일이 매니페스트와 동기화되지 않거나 업데이트가 필요하거나 없는 경우 설치에 실패합니다.

해당 조치를 통해서 직전 빌드에서 쓰인 의존성 캐시를 다른 저장소의 CI 에서 재사용 가능하게 되었습니다.


적용 전

+ pnpm i --frozen-lockfile
Scope: all 7 workspace projects
Lockfile is up to date, resolution step is skipped
Progress: resolved 1, reused 0, downloaded 0, added 0
Packages: +3357
(중략)
Progress: resolved 3357, reused 1253, downloaded 2025, added 3357, done
Done in 1m 28.6s

적용 후

+ pnpm i --frozen-lockfile
Scope: all 7 workspace projects
Lockfile is up to date, resolution step is skipped
Packages: -11
-----------
Done in 2.6s

의존성 설치 과정 최적화 결과

의존성 설치 과정 최적화 결과

  • 의존성 캐시 Miss: 1분 28초
  • 의존성 캐시 Hit: 2.6 초
  • 의존성 설치 소요시간 최대 97% 단축

의존성 설치 과정을 기존 88초에서 2.6초로, 85.4초가 감소해 약 97% 단축할 수 있었습니다.

물론 빌드 에이전트의 교체로 인해 의존성 캐시 경로가 초기화 되면 최초 1회는 1분 28초에 해당하는 의존성 설치 과정이 필요합니다.

하지만 매번 빌드를 실행할 때마다 의존성을 설치하던 기존과 달리, 에이전트마다 의존성 캐시를 공유할 수 있어 후속 빌드는 의존성 설치 소요시간을 97% 이상 단축할 수 있는 것입니다.


여러 저장소가 동일한 라이브러리를 사용하더라도 버전이 각각 다른 경우가 있습니다.

이 경우 사용하는 버전을 하나로 통합하면 캐시 적중률(Cache Hit Rate)를 향상시켜, 위와 동일하게 의존성 설치 소요시간을 상당히 단축할 수 있었습니다.

예: inflearn-frontendrallit-frontend 에서 사용하는 Datadog RUM(Real User Monitoring) 에이전트의 버전이 일치할 경우
NPM registry 에서 의존성 다운로드 및 설치 과정을 건너뛰고, 빌드 에이전트에 있는 전역 의존성 캐시를 활용해 시간을 단축할 수 있음.
(rallit-frontend 에서 먼저 설치했다면 inflearn-frontend 에서 이를 이용할 수 있고, 혹은 그 반대도 가능)

테스트 및 빌드 소요시간 단축

앞서 말씀드렸던 모노레포 특성상 일부 패키지만 수정해도 모든 패키지를 전부 테스트 및 빌드해야 합니다.

Turborepo 를 이용하면 모노레포 환경에서 변경된 패키지와 이에 의존하는 패키지만 테스트 및 빌드를 실행해 CI 소요시간을 크게 단축할 수 있습니다. 이를 Task Cache 라고 합니다.

인프랩 Front-end 파트에서는 일찍이 turborepo를 도입하여 로컬 개발 환경에서 이용한 덕분에 로컬 환경에서 실제 변경된 코드만 테스트 및 빌드하여 빠르게 작업물을 검증할 수 있었습니다.

이는 컨텍스트 스위칭 빈도를 줄여 생산성 증대로 이어졌습니다. (실험: 느린 빌드 시간의 숨겨진 비용 in GeekNews)


Turborepo Task Caching

  • package.json 에 있는 build, test, lint 등을 task 라고 합니다.

  • Turborepo 는 task 의 실행 결과물과 로그를 캐싱하여 느린 task를 빠르게 만들어 줍니다.


Content-aware hashing을 이용해 소스코드의 변경을 감지하며, 변경한 패키지만 build, test, lint 등의 task 를 실행합니다.

  • 테스트 및 빌드 결과는 Local Filesystem Cache 라는 이름으로 1차적으로 ./node_modules/.cache/turbo/78awdk123.tar.zst 같은 경로에 보관합니다.

  • Task 캐시가 적중(hit)하면 해당 패키지의 빌드를 건너뛰고 변경된 패키지만 새로 테스트 및 빌드를 실행합니다.

  • 실행 결과를 기반으로 Task 캐시를 갱신하여 최신 상태를 유지할 수 있도록 합니다.


Task 캐시 Miss인 경우, 아래처럼 task 를 실행하고 Local Filesystem cache 에 이를 갱신합니다.


/lib:build: cache miss, executing 86fb10d5583f361c
/app:build: cache miss, executing 7804de963ab93a0e
(중략)
 Tasks:    5 successful, 5 total
Cached:    0 cached, 5 total
  Time:    4m36.208s 

...writing to cache...

Task 캐시를 적중(hit)할 경우, 캐시 갱신 당시에 출력된 로그를 재출력하며 빌드 결과를 캐시 서버에서 다운로드합니다.


/lib:build: cache hit, replaying logs 86fb10d5583f361c
/ds:build: cache hit, replaying logs 7804de963ab93a0e
/a:build: cache hit, replaying logs 8be7f0056074cbc3
/b:build: cache hit, replaying logs d01733e33b71a814

 Tasks:    5 successful, 5 total
Cached:    5 cached, 5 total
  Time:    4.114s >>> FULL TURBO

Task 캐시 적용 결과

Task 캐시 적용 결과

  • Task 캐시 Miss: 4분 36초
  • Task 캐시 Hit: 4.114초
  • Task 캐시 도입으로 소요시간 약 98.5% 개선

Task 캐시도 의존성 캐시와 마찬가지로 캐시가 없는 경우 소요시간이 증가하지만, 그 이후 빌드에서 변경된 패키지만 Task를 수행하므로 상당히 많은 시간을 절약할 수 있었습니다.


문제는 별도로 설정을 하지 않으면 Local Filesystem Cache만 활용할 수 있다는 것입니다.

젠킨스 에이전트의 경우 workspace 를 브랜치 • PR 단위로 분리하기 때문에, 검증을 완료한 PR을 develop 브랜치에 병합하면 처음부터 다시 테스트 및 빌드를 수행해야 했습니다.

CI 파이프라인에서 한 번 빌드한 내용은 다시 빌드하지 않으면 좋을텐데, 이를 어떻게 개선할 수 있을까요?

Turborepo Remote Caching

Turborepo Remote Caching 기능을 이용하면 이 문제를 해결할 수 있습니다.

Task 캐시를 여러 빌드 에이전트와 공유증분 빌드를 적극적으로 수행할 수 있습니다.

Remote Caching을 위해서는 추가 비용이 발생하는 Vercel 클라우드를 사용하거나, Vercel 에서 공개한 API 스펙에 따라 직접 Turborepo custom remote cache server 서버를 구현해야 합니다.

저희는 오픈 소스로 공개되어 있는 ducktors/turborepo-remote-cache 를 이용했습니다.

ducktors/turborepo-remote-cache

Turborepo custom remote cache server의 오픈 소스 구현체로, self-hosted 환경에서 사용하는 데에 합리적입니다.

간단한 웹 서버 애플리케이션이며 아래와 같은 실행 방식을 지원합니다.

  • Docker 컨테이너로 실행

  • Amazon Lambda 등의 서버리스 함수로 실행

다음과 같이 다양한 스토리지 옵션을 제공합니다.

  • Amazon S3 (혹은 호환 스토리지)

  • 파일 시스템

이를 이용해 Lambda + S3 조합으로 완전히 서버리스를 이용해 구축하는 방법도 있고, 각각의 장단점을 따져 잘 맞는 설정을 구성하면 됩니다.

도커 + 파일 시스템을 이용해 간단하게 커스텀 캐시 서버를 구축하는 과정을 간략히 소개드리겠습니다.

다음과 같이 compose.yaml 파일을 작성합니다. ${} 안에 들어가는 변수는 .env 파일을 이용해 작성합니다.


services:
  turborepo-remote-cache:
    image: ducktors/turborepo-remote-cache:1.14.2
    ports:
      - 3000:3000
    volumes:
      - ./tmp:/tmp
    environment:
      - NODE_ENV=production
      - PORT=3000
      - TURBO_TOKEN=${TURBO_TOKEN}
      - LOG_LEVEL=info
      - STORAGE_PROVIDER=local
      # - AWS_REGION=${AWS_REGION}

이어서 가이드에 따라 Turborepo 커스텀 캐시 서버 주소와 몇 가지 파라미터를 도커 환경변수로 전달해줍니다.

  • TURBO_API: 캐시 서버 주소

  • TURBO_TOKEN: 인증 토큰

  • TURBO_TEAM: 캐시를 사용하는 team (주로 프로젝트 단위로 표기함)


stage('Build') {
  steps {
    script {
      env.DOCKER_OPTION = "$DOCKER_OPTION -e TURBO_API=$TURBO_API -e TURBO_TOKEN=$TURBO_TOKEN -e TURBO_TEAM=$TURBO_TEAM"
      sh "docker run --rm -i -v $WORKSPACE:/workspace $DOCKER_OPTION $NODE_IMAGE 'pnpm build'"
    }
  }
}

도커 환경변수 대신 직접 turbo.json 등의 설정 파일에 기재하는 방법도 있지만, 우선 CI에서만 사용할 캐시 서버를 연동하는 것이므로 위와 같이 파이프라인에 명시해 주었습니다.


적용 이후 빌드를 여러 번 실행하면 Remote caching enabled 로그가 출력되면서, Task 캐시가 누적되어 이를 적극적으로 재활용하는 모습을 확인할 수 있습니다.


+ pnpm build:development
• Packages in scope: ......
• Running build:development in 6 packages
• Remote caching enabled
a:build: cache hit, replaying logs 83da8d8fb24dd1a9
b:build: cache hit, replaying logs 249bb66c4e03301b
c:build: cache hit, replaying logs 8be7f0056074cbc3
(중략)

 Tasks:    5 successful, 5 total
Cached:    5 cached, 5 total
  Time:    4.114s >>> FULL TURBO

코드 형상을 기준으로 콘텐츠 해싱을 하므로, 브랜치, PR이 달라도 코드를 수정하지 않았다면 Task도 불필요하게 재실행하지 않습니다.

만약 Continuous Deployment 파이프라인에서는 빌드 캐시를 이용하면 어떻게 빌드 결과물을 배포할 수 있을까요?

turbo.json 에서는 각 command 별 outputs 경로를 설정하면 해당 경로를 빌드 결과물로 인식해 Turborepo 리모트 캐시 서버에 보관하고, 캐시 적중시 이를 다운로드합니다.

따라서 배포 과정에서의 혼선을 막기 위해서는 outputs 옵션을 잘 설정해 주어야 합니다. (관련 공식문서)


turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "app#build": {
      "outputs": ["dist/**"]
    },
    "util#build": {
      "outputs": ["lib/**"]
    }
  }
}

해당 옵션을 제대로 설정하지 않으면 배포 파이프라인을 거치지 않은 긴급 롤백 이후, 파이프라인을 재실행해도 실제 배포 환경에 적용되지 않을 수 있으므로 유의하시길 바랍니다.

편리함에는 신중함정확성뒷받침 되어야 한다는 것을 명심해야 합니다.

소스 코드 정적 분석

의존성 설치, 테스트 및 빌드. 이렇게 두 가지 최적화 과정을 거쳐 모든 캐시를 적중하면 10초 내외로 해당 작업을 마무리할 수 있습니다.

정적 분석을 통해 코드의 보안 취약점부터, 중복 혹은 성능상 이슈가 발생할 수 있는 부분까지 발견할 수 있어 SonarQube를 적극 도입해 활용중입니다.

하지만 매번 CI 과정 중 정적 분석을 위해 분석을 실행해 코드 규모에 따라 40초에서 최대 2분 넘는 시간이 소요되곤 했습니다.

이를 단축하기 위해 분석이 필요 없는 파일은 sonar-project.properties 파일에 명시해 주었습니다.


sonar.exclusions=packages/**/public/mockServiceWorker.js, packages/**/src/@types/**/*, packages/**/src/mocks/**/*, packages/**/vite.config.ts,

sonar.analysis.mode=incremental 사용 불가

기존에는 -Dsonar.analysis.mode=incremental 파라미터를 추가해 변경된 부분만 분석하는 기능이 있었지만 현재는 지원이 중단되었습니다.

직접 파이프라인에서 변경된 디렉터리만 분석하도록 구현하거나, CI 정책을 수정해 develop 브랜치에 병합시에만 분석하는 방법도 있습니다.

언제나 은탄환(silver-bullet)은 없으므로 여러분의 환경에 맞게 적절한 방안으로 개선해야 합니다.

자세한 내용은 SonarQube 문서를 참고하시길 바랍니다.


parallel run sonarqube


저희는 Continuous Deployment 파이프라인에서 컨테이너 배포가 완료되기까지 대기하는 로직이 있었는데, 이와 동시에 SonarQube 분석을 실행해 약 40초에서 최대 2분에 해당하는 정적 분석 소요시간을 단축할 수 있었습니다.

CI 최적화 결과

캐시 Hit 상황 가정 (install, build 모두 적중 시)

optimizing results x


pnpm 의존성 캐싱, turborepo Task 캐싱 적용으로 CI 소요시간을 최대 79%까지 단축할 수 있었습니다.


optimizing results


모노레포 중 패키지 수가 많고 코드가 더 많은 프로젝트일 수록 최적화 결과가 더 돋보였습니다. 패키지가 많을 수록 수정한 부분의 영향 범위가 줄어들기 때문인데요.

심지어 turborepo 및 모노레포가 적용되지 않은 프로젝트 E, F의 경우 의존성 설치 최적화만으로도 CI 소요시간을 약 1.6배에서 2배까지 개선할 수 있었습니다.

조사 대상의 평균 CI 소요시간을 6분 3초에서 2분 4초까지, 최대 66%까지 개선할 수 있었습니다. 정적 분석까지 최적화하면 여기서 더 많은 시간을 절약할 수 있을 것으로 기대됩니다.

마무리하며

위와 같이 간단한 수정을 통해 CI 소요시간을 획기적으로 개선한 경험을 공유드렸는데요! 여러분도 적용해 보시고, 달성한 성과 혹은 더 좋은 방법을 댓글로 공유해주시면 감사하겠습니다.

최적화 과정에서 저를 신뢰해주시고, 여러 복잡한 질문에도 잘 답변해 주신 인프랩 개발파트 구성원분들 덕분에 최적화를 잘 마칠 수 있었습니다. 감사합니다.

다음 글에서는 젠킨스 EC2 에이전트의 컴퓨팅 비용을 최대 70%까지 절약할 수 있었던 스팟 인스턴스 도입 관련 주제로 찾아 뵙겠습니다.

인프랩 DevOps 파트의 최적화 작업은 멈추지 않습니다. 앞으로도 많은 기대 부탁드립니다!

성과 공유 스레드

install이 3초!!!!!!

격렬한 응원 문구

참고 자료