Turborepo 모노레포 빌드 캐싱 시스템 구축기 ( with S3 )

소스 해시 기반 S3 캐싱으로 CI/CD 시간을 단축시킨 이야기 ( GitHub Actions, S3, Turborepo, Build Cache )

18
단어: 2,006
게시글 썸네일
정보

모노레포 환경에서 CI/CD 시간을 단축시키는 소스 해시 기반 빌드 캐싱 시스템 구축 경험에 대해 작성해볼게요.
이전 포스트: GitHub Actions를 활용한 CI/CD 파이프라인 구축

[01-AWS_아키텍쳐]

01-AWS_아키텍쳐

🎯 문제 상황

기존 CI/CD 파이프라인에서 몇가지 문제점이 있었어요.

  1. 빌드부터 배포까지 시간이 20분 소요
  2. 동일한 코드임에도 불구하고 재빌드하는 경우가 발생

초기에는 2번을 해결하기 위해서 파일 변경 감지 후 선택적 빌드를 적용했어요.
파일 변경 감지 후 빌드하면 PR으로 인한 액션에서 빌드, 배포에서의 빌드 등 같은 빌드를 여러번 동작하고 워크플로우 작동시간이 너무 길어서 다른 방법을 찾아봤어요.
그러다가 회사 코드에서 보고 잊고 있었던 터보레포 캐싱이 떠올라서 S3로 적용하기로 했어요.

정보

터보레포 캐싱은 동일한 작업을 두번하지 않도록 한번 작업한걸 저장해두고 재사용하는 기능이에요.
일반적으로 Vercel Remote Cache를 사용하긴 하지만 최대한 AWS만으로 처리하고 싶어서 S3를 사용했어요.

🏗️ 터보레포

해당 포스트를 이해하기 위해 현재 사용중인 터보레포 구조 및 설정을 소개할게요.

1️⃣ 터보레포 구조

현재 프로젝트는 모노레포 구조이고 /apps 폴더에는 프론트엔드와 백엔드가 있고, /packages 폴더에는 공통 패키지가 있어요.

story-dict/ ├── apps/ │ ├── fe/ # Next.js 프론트엔드 │ └── be/ # NestJS 백엔드 └── packages/ ├── database/ # Prisma DB 패키지 (@sd/db) ├── ui/ # Shadcn/ui 기반 UI 컴포넌트 (@sd/ui) ├── utils/ # 공유 유틸리티 (@sd/utils) ├── eslint-config/ # 공유 ESLint 설정 ├── tailwind-config/ # 공유 Tailwind 설정 └── typescript-config/ # 공유 TypeScript 설정

2️⃣ turbo.json

터보레포 설정을 관리하는 파일이에요.

dependsOn에 정의한 명령어가 끝나고 난 뒤 현재 태스크를 실행해요.
inputs에 정의한 파일이 변경되면 캐싱된 결과물을 사용하지 않아요.
outputs에 정의한 파일들을 캐싱해요.

{ "$schema": "https://turbo.build/schema.json", "ui": "tui", "remoteCache": { "signature": true }, "tasks": { "dev": { "dependsOn": [ "@sd/utils#build", "@sd/db#db:generate", "@sd/db#build" ], "cache": false, "persistent": true }, "build": { "dependsOn": ["^build", "^db:generate"], "inputs": ["$TURBO_DEFAULT$", ".env*", "src/**", "prisma/**"], "outputs": [".next/**", "!.next/cache/**", "dist/**", "build/**"] }, "start": { "cache": false, "persistent": true }, "start:prod": { "cache": false, "persistent": true }, "lint": { "dependsOn": ["^build"], "inputs": ["src/**", "*.{js,ts,tsx}", ".eslintrc*", "eslint.config.*"], "outputs": [] }, "type-check": { "dependsOn": ["^build"], "inputs": ["src/**", "tsconfig.json", "*.ts", "*.tsx"], "outputs": [] }, "db:generate": { "inputs": ["prisma/schema.prisma", "prisma/migrations/**"], "outputs": ["node_modules/.prisma/**", "prisma/generated/**"], "cache": true }, "db:push": { "cache": false }, "clean": { "cache": false } }, "globalEnv": [ "NODE_ENV", "DATABASE_URL", "NEXTAUTH_SECRET", "NEXTAUTH_URL" ] }

🏛️ 아키텍처 설계

정보

소스 해시 계산은 아래와 같이 처리하는데 빌드 결과물 즉, 캐싱할 파일(.next, dist 등)들을 제외한 파일들로 계산해요.

# 소스코드 해시 계산 (빌드 캐시 키로 사용) - name: Calculate source hash if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request' id: source-hash run: | SOURCE_HASH=$(find apps packages -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.json" -o -name "*.prisma" | grep -v "/dist/" | grep -v "/.next/" | sort | xargs cat | sha256sum | cut -d' ' -f1) echo "SOURCE_HASH=$SOURCE_HASH" >> $GITHUB_OUTPUT echo "계산된 소스코드 해시: $SOURCE_HASH"

아키텍처 설계는 다음과 같아요.
CI, CD 워크플로우로 나누어서 .yml 파일로 관리해요.

1️⃣ CI 워크플로우

  1. Pull Request, 수동 트리거CI 워크플로우 실행
  2. 소스 해시 계산
  3. S3에 캐시 확인 및 캐시 저장
    1. 캐시 존재 시 CD 워크플로우 실행
    2. 캐시 미스 시 빌드 후 S3에 캐시 저장
  4. Pull RequestCD 워크플로우 미실행, 수동 트리거에서 실행을 선택한 경우 CD 워크플로우 실행

2️⃣ CD 워크플로우

  1. 소스 해시 계산
  2. S3에 캐시 확인 및 복원
    1. 캐시 존재 시 복원
    2. 캐시 미스 시 빌드 후 S3에 캐시 저장
  3. 배포 시작 Slack 알림
  4. 캐싱된 파일을 활용하여 Docker 이미지 빌드
  5. 소스 해시를 태그로 빌드된 이미지 ECRPush
  6. 배포 성공/실패 Slack 알림

🔧 CI 워크플로우

Pull Request와 수동 트리거 시 실행되는 CI 워크플로우에요.
추가로 빌드만 확인하고 캐싱처리해두고 싶을 수 있기 때문에 CD 워크플로우 실행은 선택으로 만들어두었어요.

1️⃣ 기본 설정 및 트리거

name: CI on: pull_request: branches: [master, development] workflow_dispatch: inputs: run_deployment: description: "배포(CD)도 함께 실행하시겠습니까?" required: false default: true type: boolean

2️⃣ 소스 해시 계산

빌드 결과물(.next, dist)을 제외한 모든 소스 파일의 해시를 계산해요.
이 해시가 S3 캐시의 키와 ECR 이미지 태그로 사용돼요.

# 소스코드 해시 계산 (빌드 캐시 키로 사용) - name: Calculate source hash if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request' id: source-hash run: | SOURCE_HASH=$(find apps packages -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.json" -o -name "*.prisma" | grep -v "/dist/" | grep -v "/.next/" | sort | xargs cat | sha256sum | cut -d' ' -f1) echo "SOURCE_HASH=$SOURCE_HASH" >> $GITHUB_OUTPUT echo "계산된 소스코드 해시: $SOURCE_HASH"

3️⃣ S3 캐시 확인 및 저장

소스 해시로 S3 캐시가 있는지 확인해요.

# S3에서 기존 빌드 캐시 확인 - name: Check existing build cache if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request' id: cache-check run: | SOURCE_HASH=${{ steps.source-hash.outputs.SOURCE_HASH }} if aws s3 ls s3://${{ secrets.AWS_S3_BUCKET }}/builds/${SOURCE_HASH}.tar.gz; then echo "✅ 빌드 캐시 존재 (해시: $SOURCE_HASH)" echo "CACHE_EXISTS=true" >> $GITHUB_OUTPUT else echo "❌ 빌드 캐시 없음. 새로 빌드합니다 (해시: $SOURCE_HASH)" echo "CACHE_EXISTS=false" >> $GITHUB_OUTPUT fi

캐시가 없을 때만 빌드 후 S3에 저장해요.

- name: Build and cache to S3 if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request' && steps.cache-check.outputs.CACHE_EXISTS == 'false' run: | echo "🔨 애플리케이션 빌드 중..." pnpm build SOURCE_HASH=${{ steps.source-hash.outputs.SOURCE_HASH }} echo "💾 S3에 빌드 캐시 저장 중..." tar -czf ${SOURCE_HASH}.tar.gz \ --ignore-failed-read \ apps/fe/.next \ apps/fe/public \ apps/be/dist \ packages/database/dist aws s3 cp ${SOURCE_HASH}.tar.gz \ s3://${{ secrets.AWS_S3_BUCKET }}/builds/

4️⃣ 배포 트리거 결정

CD 워크플로우를 실행할지 여부를 결정하는 코드에요.

- name: Check if deployment needed id: check-deploy run: | # 수동 실행 시 체크박스 값 확인 if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then if [[ "${{ inputs.run_deployment }}" == "true" ]]; then echo "should_deploy=true" >> $GITHUB_OUTPUT echo "🚀 수동 실행: 배포도 함께 실행합니다" else echo "should_deploy=false" >> $GITHUB_OUTPUT echo "⏹️ 수동 실행: 배포는 건너뜁니다" fi # PR인 경우 배포 제외 elif [[ "${{ github.event_name }}" == "pull_request" ]]; then echo "should_deploy=false" >> $GITHUB_OUTPUT echo "⏹️ PR 실행: 배포는 제외하고 CI만 실행합니다" fi trigger-cd: name: Trigger Deployment needs: ci if: needs.ci.outputs.should_deploy == 'true' && success() uses: ./.github/workflows/cd.yml with: environment: "production" triggered_by: "ci" secrets: inherit

🚢 CD 워크플로우

실제 배포를 담당하는 CD 워크플로우에요.

1️⃣ 기본 설정

CI 워크플로우에서 호출되거나 직접 수동 실행이 가능해요.

name: CD on: workflow_call: inputs: environment: description: "배포 환경" required: true type: string triggered_by: description: "트리거 소스" required: false type: string default: "manual" workflow_dispatch: inputs: environment: description: "배포할 환경을 선택하세요" required: true default: "production" type: choice options: - production - staging

2️⃣ 빌드 캐시 복원

CI에서 생성한 캐시를 동일한 해시로 복원해요.

# 소스코드 해시 계산 (CI와 동일한 방식) - name: Calculate source hash id: source-hash run: | SOURCE_HASH=$(find apps packages -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.json" -o -name "*.prisma" | grep -v "/dist/" | grep -v "/.next/" | sort | xargs cat | sha256sum | cut -d' ' -f1) echo "SOURCE_HASH=$SOURCE_HASH" >> $GITHUB_OUTPUT echo "계산된 소스코드 해시: $SOURCE_HASH" # 소스코드 해시로 S3 캐시 복원 - name: Restore build cache from S3 run: | SOURCE_HASH=${{ steps.source-hash.outputs.SOURCE_HASH }} if aws s3 ls s3://${{ secrets.AWS_S3_BUCKET }}/builds/${SOURCE_HASH}.tar.gz; then echo "✅ 빌드 캐시 발견. 복원 중... (해시: $SOURCE_HASH)" aws s3 cp s3://${{ secrets.AWS_S3_BUCKET }}/builds/${SOURCE_HASH}.tar.gz ./build-cache.tar.gz tar -xzf build-cache.tar.gz echo "BUILD_CACHE_HIT=true" >> $GITHUB_ENV echo "캐시 복원 완료" else echo "❌ 빌드 캐시 없음. 새로 빌드합니다 (해시: $SOURCE_HASH)" echo "BUILD_CACHE_HIT=false" >> $GITHUB_ENV fi

대부분 S3에 캐시가 있겠지만, 특수한 상황으로 인해 S3에 캐시가 없을수도 있기 때문에 캐시가 없을 경우 CI와 동일하게 빌드 및 S3에 저장을 해요.

# cache miss 시 빌드 실행 - name: Build applications (cache miss) if: env.BUILD_CACHE_HIT == 'false' run: | echo "🔨 애플리케이션 빌드 중..." pnpm build SOURCE_HASH=${{ steps.source-hash.outputs.SOURCE_HASH }} echo "💾 S3에 빌드 캐시 저장 중..." tar -czf ${SOURCE_HASH}.tar.gz \ --ignore-failed-read \ apps/fe/.next \ apps/fe/public \ apps/be/dist \ packages/database/dist aws s3 cp ${SOURCE_HASH}.tar.gz \ s3://${{ secrets.AWS_S3_BUCKET }}/builds/ echo "✅ 빌드 캐시 저장 완료 (해시: $SOURCE_HASH)" env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_ACCESS_SECRET_KEY }} AWS_REGION: ${{ secrets.AWS_REGION }}

3️⃣ Docker 이미지 빌드 및 ECR 이미지 푸시

도커 이미지를 빌드하고 이미지를 푸시할때 추후에 구분하기 쉽기 위해서 latest, SOURCE_HASH(해시값)을 태그로 달아서 빌드해요.

# 전체 이미지 빌드 (빌드 캐시 상태 전달) - name: Build and tag images run: | export ECR_REGISTRY=${{ steps.login-ecr.outputs.registry }} SOURCE_HASH=${{ steps.source-hash.outputs.SOURCE_HASH }} DOCKER_DEFAULT_PLATFORM=${{ env.DOCKER_PLATFORM }} docker-compose build # FE 이미지 태그 설정 (소스 해시 사용) docker tag ${{ env.ECR_FE_REPO }}:latest $ECR_REGISTRY/${{ env.ECR_FE_REPO }}:latest docker tag ${{ env.ECR_FE_REPO }}:latest $ECR_REGISTRY/${{ env.ECR_FE_REPO }}:${SOURCE_HASH} # BE 이미지 태그 설정 (소스 해시 사용) docker tag ${{ env.ECR_BE_REPO }}:latest $ECR_REGISTRY/${{ env.ECR_BE_REPO }}:latest docker tag ${{ env.ECR_BE_REPO }}:latest $ECR_REGISTRY/${{ env.ECR_BE_REPO }}:${SOURCE_HASH} # ECR에 이미지 푸시 - name: Push images to ECR run: | export ECR_REGISTRY=${{ steps.login-ecr.outputs.registry }} SOURCE_HASH=${{ steps.source-hash.outputs.SOURCE_HASH }} # FE 이미지 푸시 docker push $ECR_REGISTRY/${{ env.ECR_FE_REPO }}:latest docker push $ECR_REGISTRY/${{ env.ECR_FE_REPO }}:${SOURCE_HASH} # BE 이미지 푸시 docker push $ECR_REGISTRY/${{ env.ECR_BE_REPO }}:latest docker push $ECR_REGISTRY/${{ env.ECR_BE_REPO }}:${SOURCE_HASH}

4️⃣ Slack 알림

배포 시작 시 시간, 해시값, 커밋, 액션 등등을 보내고 배포의 성공/실패 여부에 상관없이 결과 알림을 보내요.

# 배포 시작 알림 - name: Notify Slack on Start id: slack-start uses: slackapi/slack-github-action@v1.26.0 with: payload: | { "attachments": [ { "color": "${{ env.SLACK_START_COLOR }}", "title": "🚀 배포 시작", "text": "배포 환경: ${{ inputs.environment }}\n커밋: ${{ env.COMMIT_SUBJECT }}", "fields": [ { "title": "시작시간", "value": "${{ env.START_TIME_FORMATTED }}", "short": true }, { "title": "이미지 태그 (해시)", "value": "${{ steps.source-hash.outputs.SOURCE_HASH }}", "short": true }, ${{ env.SLACK_COMMON_FIELDS }} ] } ] } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK # ... 생략 # 배포 결과 알림 ( 성공/실패 통합 ) - name: Notify Slack on Completion if: always() uses: slackapi/slack-github-action@v1.26.0 with: payload: | { "attachments": [ { "color": "${{ job.status == 'success' && env.SLACK_SUCCESS_COLOR || env.SLACK_FAILURE_COLOR }}", "title": "${{ job.status == 'success' && '✅ 배포 성공' || '❌ 배포 실패' }}", "text": "배포 환경: ${{ inputs.environment }}\n커밋: ${{ env.COMMIT_SUBJECT }}\n상태: ${{ job.status }}", "fields": [ { "title": "소요시간", "value": "${{ env.DURATION }}", "short": true }, { "title": "이미지 태그 (해시)", "value": "${{ steps.source-hash.outputs.SOURCE_HASH }}", "short": true }, ${{ env.SLACK_COMMON_FIELDS }} ] } ] } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

🐳 Dockerfile 최적화

정보

과거에는 GitHub ActionsDocker 설치 후 그 내부에서 빌드를 했어요.
그때는 빌드 소요 시간이 12분정도 걸렸는데 GitHub Actions에서 빌드를 하면 4분정도 걸리더라고요.
S3 캐싱을 사용하지 않더라도 외부에서 빌드하고 그 파일을 Docker에 복사하는 방식이 빌드 시간을 줄이는데 도움돼요.

Docker 빌드도 캐시를 활용하도록 최적화했어요.

1️⃣ Frontend/Backend Dockerfile

COPY . .에서 캐싱된 .next/dist/를 가져와요.
그리고 캐시한 파일을 가져왔다면 빌드를 하지 않고 바로 이미지로 만들고, 없다면 빌드 후 이미지로 만들어요.

# 소스 코드 복사 COPY . . # ... 생략 # 빌드 캐시 처리를 위한 ARG 선언 ARG USE_BUILD_CACHE=false # 빌드 처리 (빌드 파일 존재 여부 확인) RUN if [ -d "apps/fe/.next" ] && [ -d "apps/fe/public" ]; then \ echo "✅ 빌드 파일이 존재합니다. 캐시를 사용합니다."; \ else \ echo "🔨 빌드 파일이 없습니다. 새로 빌드합니다."; \ pnpm turbo build; \ fi # ... 생략 # 실행 파일 복사 COPY --from=builder /app/apps/fe/.next/standalone ./ COPY --from=builder /app/apps/fe/.next/static ./apps/fe/.next/static COPY --from=builder /app/apps/fe/public ./apps/fe/public

⚡ Turborepo 설정

  • dependsOn: 선행 명령어
  • inputs: 정의된 파일들이 변경되면 캐시 무효화
  • outputs: 정의된 파일들 캐싱
{ "remoteCache": { "signature": true }, "tasks": { "build": { "dependsOn": ["^build", "^db:generate"], "inputs": ["$TURBO_DEFAULT$", ".env*", "src/**", "prisma/**"], "outputs": [".next/**", "!.next/cache/**", "dist/**", "build/**"] } } }

🎉 마무리

터보레포를 사용하면서 캐싱 기능을 제대로 활용해보지 못했었는데 이번 기회에 어느정도 알게 되었어요.
가장 정석적인 Vercel Remote Cache을 이용하지는 않았지만 AWS 서비스를 사용하는김에 가능하다면 모두 AWS에서 처리하고 싶은 마음에 S3를 사용했어요.

기존에는 GitHub Actions가 돌아가는 시간이 20분정도 걸렸었는데 개선 후 7분내로 감소되었어요.

  1. 캐싱을 활용해서 빌드한 결과물 재사용
  2. Docker에서 빌드하지 않고 빌드된 결과물을 Docker에 넣어줌

이 두가지가 크게 작용한 것 같아요.
매번 액션을 돌려야해서 테스트하기는 불편하지만 난이도가 높은 기술은 아니라서 Turborepo를 사용한다면 시도해보시는걸 추천해요.

📮 참고

  1. GitHub ( ci.yml, cd.yml, fe/be의 Dockerfile, turbo.json, package.json 등을 참고하시면 돼요 )
  2. 터보레포 캐싱
연관된 포스트