Turborepo 모노레포 빌드 캐싱 시스템 구축기 ( with S3 )
소스 해시 기반 S3 캐싱으로 CI/CD 시간을 단축시킨 이야기 ( GitHub Actions, S3, Turborepo, Build Cache )
정보
모노레포 환경에서 CI/CD 시간을 단축시키는 소스 해시 기반 빌드 캐싱 시스템 구축 경험에 대해 작성해볼게요.
이전 포스트: GitHub Actions를 활용한 CI/CD 파이프라인 구축
[01-AWS_아키텍쳐]

🎯 문제 상황
기존 CI/CD 파이프라인에서 몇가지 문제점이 있었어요.
- 빌드부터 배포까지 시간이 20분 소요
- 동일한 코드임에도 불구하고 재빌드하는 경우가 발생
초기에는 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 워크플로우
Pull Request
,수동 트리거
시CI 워크플로우
실행- 소스 해시 계산
S3
에 캐시 확인 및 캐시 저장- 캐시 존재 시
CD 워크플로우
실행 - 캐시 미스 시 빌드 후
S3
에 캐시 저장
- 캐시 존재 시
Pull Request
시CD 워크플로우
미실행,수동 트리거
에서 실행을 선택한 경우CD 워크플로우
실행
2️⃣ CD 워크플로우
- 소스 해시 계산
S3
에 캐시 확인 및 복원- 캐시 존재 시 복원
- 캐시 미스 시 빌드 후
S3
에 캐시 저장
- 배포 시작
Slack
알림 - 캐싱된 파일을 활용하여
Docker
이미지 빌드 - 소스 해시를 태그로 빌드된 이미지
ECR
에Push
- 배포 성공/실패
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 Actions
에Docker
설치 후 그 내부에서 빌드를 했어요.
그때는 빌드 소요 시간이 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 /app/apps/fe/.next/standalone ./ COPY /app/apps/fe/.next/static ./apps/fe/.next/static COPY /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분내로 감소되었어요.
- 캐싱을 활용해서 빌드한 결과물 재사용
Docker
에서 빌드하지 않고 빌드된 결과물을Docker
에 넣어줌
이 두가지가 크게 작용한 것 같아요.
매번 액션을 돌려야해서 테스트하기는 불편하지만 난이도가 높은 기술은 아니라서 Turborepo
를 사용한다면 시도해보시는걸 추천해요.