모노레포에 Turborepo를 붙인 뒤 로컬에서는 두 번째 빌드가 확실히 빨라졌다. 같은 코드를 다시 빌드하면 cache hit이 뜨고 금방 끝났다. 그런데 CI에서는 매번 처음부터 빌드했다. 빌드할 코드가 바뀌지 않은 커밋에서도 마찬가지였다.
로컬에 저장된 캐시를 CI에서도 쓰려면 원격 캐시가 필요하다. 빌드 결과를 서버에 올려 팀과 CI가 함께 재사용하는 방식이다. 설정과 함께 확인해야 할 것은 누가 캐시를 읽고 쓸 수 있는지다.
이 글에서는 원격 캐시의 동작 원리부터 연결 방법, 접근 권한, 캐시가 재사용되지 않을 때 확인할 설정까지 살펴본다. 프론트 모노레포 셋업 A→Z — pnpm workspaces + Turborepo + Changesets의 원격 캐시 부분을 확장한 글이다. 모노레포와 Turborepo의 기본 역할은 turbo.json 을 지우면 모노레포가 안 돌아갈까에서 다뤘다.
1. 로컬에만 있는 캐시
먼저 캐시가 뭔지부터 짚고 간다. 대충 알고 넘어가기 쉬운 자리인데, 뒤에 나오는 설정이 전부 여기에 걸려 있다.
빌드 캐시는 한 번 빌드한 결과물을 어딘가에 저장해 뒀다가, 똑같은 걸 또 빌드하라고 하면 실제로 빌드하지 않고 저장해 둔 걸 꺼내 놓는 것이다. turbo build를 두 번 돌리면 두 번째가 몇 초 만에 끝나는 이유가 이거다. 두 번째는 아무 일도 안 한다. 저장해 둔 파일을 제자리에 복사할 뿐이다.
문제는 그 “어딘가”가 기본값으로는 프로젝트 폴더 안의 .turbo/cache 라는 점이다. 빌드를 돌린 그 노트북 디스크에만 있다. 동료 노트북에도 없고, CI 러너에도 없다. CI 러너는 잡이 끝나면 통째로 사라지는 일회용 환경이라 더 그렇다. 매번 빈손으로 시작한다.
원격 캐시는 이 저장 위치를 서버로 옮겨서 팀과 CI가 같이 보게 하는 것이다. 한 번 빌드하면 어디서나 재사용한다는 게 이 뜻이다.
2. 같은 입력이면 같은 결과라는 게 무슨 뜻일까
이걸 모르면 뒤에 나오는 self-hosted도 서명도 왜 그렇게 하는지가 안 보인다.
“저장해 둔 걸 꺼내 쓴다”는 말은 쉬운데, 꺼내 써도 되는지를 누가 어떻게 판단하느냐가 문제다. 코드를 한 줄 고쳤는데도 옛날 빌드 결과를 꺼내 주면 그건 캐시가 아니라 버그다.
Turborepo는 이걸 해시로 판단한다. 태스크를 돌리기 전에 그 태스크에 들어가는 입력을 전부 모아 하나의 값으로 압축한다. 소스 파일 내용, 의존하는 패키지의 상태, 태스크 설정, 그리고 turbo.json에 선언해 둔 환경변수 같은 것들이다. 그 결과가 abc123 같은 문자열 하나로 나오고, 이게 캐시 키가 된다.
입력이 한 글자라도 다르면 이 값이 완전히 달라진다. 반대로 입력이 전부 같으면 몇 번을 계산해도 같은 값이 나온다. 그래서 키가 같으면 결과물도 같다고 봐도 된다는 전제가 성립한다. 원격 캐시가 서 있는 자리가 여기다. 로컬에서 계산한 abc123과 CI에서 계산한 abc123이 같은 값이면, CI가 만든 산출물을 그대로 받아 써도 된다.
입력 목록에 정확히 무엇까지 들어가는지는 버전에 따라 달라질 수 있어서 전부 확인하지는 못했다. 다만 선언되지 않은 것은 해시에 안 들어간다는 점은 확실하다. 이게 나중에 9절에서 문제가 된다.
3. 그래서 turbo build는 이 순서로 움직인다
원격 캐시가 켜지면 실행 순서가 이렇게 된다.
turbo build
→ 입력 해시 계산 (예: abc123)
→ ① 로컬 캐시에 abc123 있나? 없으면
→ ② 원격 캐시에 abc123 있나? 있으면 다운로드해서 복원 (REMOTE cache hit)
→ 둘 다 없으면 실제 빌드 → 로컬 + 원격 양쪽에 업로드
원격 캐시가 하는 일은 이게 전부다. (해시 키 → 아티팩트 tarball) 한 쌍을 HTTP 서버에 넣고 빼는 것. 빌드 로직에는 아무것도 안 끼어든다. 그래서 켜고 끄는 게 간단하고, 잘못 켜면 조용히 잘못된 결과를 받는 것도 이 구조 때문이다.
4. 캐시 서버는 누가 운영하나
붙이는 방법은 크게 둘이다.
| 방식 | 인프라 | 적합 |
|---|---|---|
| A. Vercel Remote Cache (관리형) | 없음(Vercel 계정만) | 대부분의 팀. 무료·즉시 |
| B. Self-hosted | 직접 서버/스토리지 운영 | 사내망 격리·데이터 주권·비Vercel 필요 시 |
표 1. 원격 캐시 서버를 붙이는 두 가지 방식
둘 중 하나를 고르는 게 아니라 같은 규격의 서버를 누가 운영하느냐의 차이다. Turborepo는 원격 캐시가 지켜야 할 HTTP API 규격을 문서로 공개해 뒀고, 그 규격만 구현하면 어떤 서버든 붙는다. self-host가 가능한 이유가 이거다.
5. Vercel에 붙이는 게 제일 빠르다
로컬에서는 명령 두 줄이다.
npx turbo login # 브라우저로 Vercel 계정 인증
npx turbo link # 이 레포를 Vercel 팀(scope)의 원격 캐시에 연결
turbo link가 .turbo/config.json을 만든다. 팀 id와 apiUrl만 들어 있어서 커밋해도 되는 파일이다. 토큰은 여기 안 들어가고 ~/.turbo/config.json이라는 사용자 설정에 따로 저장된다. 이쪽은 커밋하면 안 된다. 되돌리려면 turbo unlink와 turbo logout이다.
잘 붙었는지는 로컬 캐시를 지워 놓고 확인하면 된다.
turbo build # 1차: 빌드 후 원격 업로드
rm -rf .turbo/cache # 로컬 캐시 삭제
turbo build # 2차: "remote cache hit"로 다운로드 복원
2차에서 다시 빌드가 돌면 원격까지 못 간 것이다.
CI는 사정이 다르다. 브라우저를 띄워 로그인할 수가 없으니 환경변수 두 개로 인증한다.
TURBO_TOKEN— vercel.com/account/tokens에서 발급한 액세스 토큰TURBO_TEAM— Vercel 팀 슬러그 (개인 계정이면 사용자명)
# .github/workflows/ci.yml
jobs:
build:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build lint test # env가 있으면 원격 캐시 자동 사용
이 두 개가 있으면 turbo가 알아서 원격 캐시를 쓴다. 명령에 따로 붙일 플래그가 없다.
앱을 Vercel에 배포하고 있다면, Vercel 쪽 빌드는 프로젝트가 그 팀에 있는 한 원격 캐시를 자동으로 쓴다. 이 경우엔 토큰을 따로 안 넣어도 된다.
6. 캐시 서버를 직접 띄우려면
빌드 산출물을 외부 서비스에 올릴 수 없는 상황이면 캐시 서버를 직접 띄운다. 가장 널리 쓰는 오픈소스 구현은 ducktors/turborepo-remote-cache 다. Fastify로 만들어져 있고 저장소로 로컬 파일시스템·AWS S3·GCS·Azure Blob·MinIO·R2를 쓸 수 있다.
docker run -d -p 3000:3000 \
-e TURBO_TOKEN=super-secret-token \ # 클라이언트가 제시할 토큰(콤마로 여러 개 허용)
-e STORAGE_PROVIDER=s3 \
-e STORAGE_PATH=my-turbo-cache-bucket \
-e S3_ACCESS_KEY=xxx -e S3_SECRET_KEY=xxx -e S3_REGION=ap-northeast-2 \
ducktors/turborepo-remote-cache
클라이언트 쪽은 환경변수 세 개로 이 서버를 바라보게 한다.
export TURBO_API=https://cache.mycompany.com # ★ 서버 base URL (self-host의 핵심)
export TURBO_TOKEN=super-secret-token # 서버가 허용한 토큰
export TURBO_TEAM=team_myteam # 네임스페이스(구분자). 보통 team_ 접두
turbo build
플래그로 줘도 된다. turbo build --api="https://cache.mycompany.com" --token="..." --team="team_myteam"이다.
CI도 이 셋을 secret으로 주면 그만이다. Vercel 방식과의 유일한 차이는 TURBO_API로 서버 주소를 바꿔 주는 것뿐이다. 여기서 TURBO_TEAM은 Vercel 팀이 아니라 그냥 네임스페이스 이름이다. 프로젝트가 여러 개면 이걸로 캐시를 나눠 둔다.
7. 무엇을 어디에 적어야 하나
설정이 환경변수·turbo.json·CLI 플래그 세 군데로 흩어져 있어서 한 번에 모아 둔다.
환경변수는 이렇다.
| 변수 | 뜻 |
|---|---|
TURBO_TOKEN |
인증 토큰 (Vercel 발급 or self-host 서버 토큰) |
TURBO_TEAM |
팀/네임스페이스 슬러그 |
TURBO_API |
원격 캐시 서버 base URL (self-host 시 필수, Vercel은 생략) |
TURBO_REMOTE_ONLY |
1이면 로컬 캐시 무시하고 원격만 사용 |
TURBO_REMOTE_CACHE_SIGNATURE_KEY |
아티팩트 서명 검증용 비밀키 (아래 8절) |
TURBO_CACHE_DIR |
로컬 캐시 경로 (기본 .turbo/cache) |
표 2. 원격 캐시와 관련된 환경변수
turbo.json에서 켜고 끄는 건 두 줄이다.
{
"remoteCache": {
"enabled": true, // 원격 캐시 on/off
"signature": true, // 서명 검증 활성화 (8절)
},
}
한 번만 다르게 돌리고 싶을 때 쓰는 플래그도 있다.
| 플래그 | 효과 |
|---|---|
--remote-only |
원격 캐시만 사용 (로컬 무시) |
--cache=remote:rw |
원격 읽기·쓰기 (remote:r 읽기전용, local:rw 등 조합) |
--force |
캐시 무시하고 무조건 재실행 |
--no-cache |
캐시에 쓰지 않음 |
--summarize |
실행 요약을 .turbo/runs/*.json으로 출력(캐시 히트 확인용) |
표 3. 한 번만 다르게 돌릴 때 쓰는 CLI 플래그
이 중에 --cache=remote:r과 remote:rw는 다음 절과 이어진다. PR 빌드는 읽기 전용으로 두고 main 빌드에만 쓰기를 허용하는 식으로 나눠 쓴다.
8. 왜 아무나 캐시에 쓰면 안 될까
원격 캐시는 “빌드를 빠르게 해 주는 것”으로만 보이기 쉬운데, 실제로는 배포물의 출처를 정하는 문제다.
원격 캐시가 켜져 있으면 로컬은 누군가가 올린 빌드 산출물을 받아서 그대로 쓴다. 다시 빌드하지 않으니 그 안에 뭐가 들었는지 확인할 기회도 없다. 그 산출물이 그대로 배포되면 서비스에 올라간다.
그래서 누가 캐시에 쓸 수 있느냐가 곧 누가 배포물을 정할 수 있느냐가 된다. 신뢰할 수 없는 쪽이 캐시에 쓸 수 있으면, 그쪽이 만들어 넣은 산출물을 팀 전체와 CI가 아무 의심 없이 받아 간다. 이런 걸 캐시 오염(cache poisoning)이라고 한다. 캐시에 나쁜 걸 심어 두고 남들이 받아 가게 만드는 것이다.
막는 장치가 셋이다.
첫째, 토큰이다. 원격 캐시에 접근하려면 TURBO_TOKEN이 있어야 한다. 이 토큰이 읽기와 쓰기를 다 여는 열쇠라서, 토큰이 새면 캐시를 읽는 것뿐 아니라 캐시에 쓰는 것까지 열린다. secret 관리가 여기서 그냥 관례가 아니라 실제 방어선이다.
둘째, 쓰기 권한을 나누는 것이다. 7절의 --cache=remote:r이 그 자리에 쓰인다. 아무나 열 수 있는 PR 빌드는 읽기만 하게 두고, 사람이 승인해서 머지된 main 빌드만 쓰기를 허용하면, 캐시에 들어가는 산출물의 출처가 좁아진다.
셋째, 서명이다. 토큰이 이미 샌 뒤라도 걸러 내려면 산출물 자체에 서명을 붙인다.
turbo.json에"remoteCache": { "signature": true }- 환경변수
TURBO_REMOTE_CACHE_SIGNATURE_KEY=<비밀키>설정 (업로드/다운로드 양쪽 동일 키)
이렇게 하면 Turborepo가 업로드할 때 아티팩트에 HMAC 서명을 붙이고, 다운로드할 때 그 서명을 검사한다. 키가 없거나 서명이 안 맞는 아티팩트는 거부한다. 팀과 CI가 전부 같은 키를 secret으로 나눠 가져야 한다.
관련해서 하나 더. fork에서 올라온 PR은 원격 캐시를 못 쓴다. GitHub이 fork PR에는 secret을 안 넣어 주기 때문이다. 설정이 잘못된 것으로 보기 쉬운데, 모르는 사람의 코드가 우리 캐시에 쓰지 못하게 막아 둔 것이라 그게 맞는 동작이다.
9. 실행 환경이 바뀌면 왜 자꾸 miss가 날까
붙이고 나서 제일 자주 겪는 게 이거다. 설정은 다 맞는데 원격 히트가 안 나고 매번 새로 빌드한다.
원인은 2절에서 말한 그 전제에 있다. 원격 캐시가 의미를 가지려면 다른 환경에서 같은 입력으로 같은 해시가 나와야 한다. 환경마다 다른 게 하나라도 해시에 섞이면 키가 매번 달라지고, 그러면 항상 miss 다.
- 락파일을 커밋하고(
pnpm-lock.yaml)--frozen-lockfile로 설치한다. 의존성 트리를 고정하지 않으면 환경마다 다른 버전이 깔린다 - Node·pnpm 버전을 통일한다.
packageManager필드와 CI의setup-node로 못 박는다 - 빌드에 영향을 주는 환경변수를
turbo.json에 선언한다. 이게 제일 놓치기 쉽다
{
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"env": ["NEXT_PUBLIC_API_URL"],
"outputs": [".next/**", "!.next/cache/**"],
},
},
}
세 번째가 왜 중요하냐면, 선언하지 않은 환경변수는 해시에 안 들어가기 때문이다. 그런데 산출물은 바꾼다. 그러면 개발 환경 값으로 빌드한 결과를 운영 빌드가 캐시 히트로 받아 가는 상황이 생긴다. miss보다 이쪽이 훨씬 곤란하다. 아무 에러도 안 나고 그냥 잘못된 게 배포된다.
나머지 둘도 같이 적어 둔다.
- 절대경로·타임스탬프처럼 실행할 때마다 달라지는 값이 산출물에 박히지 않게 한다. 환경별 경로가 들어가면 해시가 흔들린다
outputs를 정확히 지정한다. 빠뜨리면 캐시할 대상이 없어서 캐시 자체가 무의미해진다
10. 잘 되고 있는지는 어떻게 아나
로그에 그대로 찍힌다.
• Remote caching enabled
web:build: cache hit, replaying logs 1a2b3c...
Tasks: 3 successful, 3 total
Cached: 3 cached, 3 total
Time: 280ms >>> FULL TURBO
Remote caching enabled가 없으면 인증부터 안 된 것이다. 안 될 때 확인 순서는 이렇다.
- 원격 miss만 계속 난다 → 9절을 점검한다. 락파일, Node 버전, env 선언 순이다.
--summarize로.turbo/runs/*.json을 열면 해시 입력을 볼 수 있다 - CI에서 인증이 실패한다 →
TURBO_TOKEN·TURBO_TEAMsecret을 확인한다. self-host 면TURBO_API까지 - fork PR에서 캐시가 안 된다 → 8절에서 말한 그거다. 고칠 게 없다
- 서명이 거부된다 → 팀과 CI의
TURBO_REMOTE_CACHE_SIGNATURE_KEY가 서로 다른 것이다
11. 도입 비용과 한계
빨라지는 대신 넘겨받는 것들이 있다. 여기까지 읽고 나면 안 켤 이유가 있나 싶겠지만, 켜기 전에 알아 둘 것이 몇 개 있다.
| 치르는 것 | 무슨 일이 생기나 |
|---|---|
| 네트워크 왕복이 생긴다 | 캐시를 확인하고 받아 오는 시간이 매 태스크마다 붙는다. 산출물이 작고 빌드가 빠른 태스크는 그냥 다시 빌드하는 게 나을 수도 있다. 이 손익 분기는 측정하지 못했다 |
| 캐시가 틀리면 조용히 틀린다 | 9절의 환경변수 문제처럼, 잘못된 히트는 에러를 안 낸다. 빌드는 성공하고 결과물만 다르다. 이상하다 싶으면 --force로 캐시를 무시하고 한 번 돌려 비교하는 게 먼저다 |
| 원격 캐시가 서비스 의존성이 된다 | 캐시 서버가 느리거나 죽으면 빌드도 같이 느려진다. TURBO_REMOTE_ONLY=1로 두고 있었다면 더 그렇다 |
| self-host 면 서버를 운영해야 한다 | 스토리지 용량, 오래된 아티팩트 정리, 인증서, 접근 로그가 전부 우리 일이 된다. 빌드 시간을 아끼고 운영 부담을 얻는 교환이다 |
| 비밀 하나가 늘어난다 | TURBO_TOKEN과 서명 키를 팀·CI 전체가 공유해야 한다. 사람이 나가면 회수해야 하고, 새면 8절의 방어가 통째로 풀린다 |
| 산출물이 밖으로 나간다 | Vercel 방식을 쓰면 빌드 결과물이 외부 서비스에 저장된다. 산출물에 뭐가 들어 있는지에 따라 이게 문제가 되는 조직이 있다 |
표 4. 원격 캐시를 켜면서 치르는 것
그래서 CI의 캐시는 왜 아무 소용이 없었나
붙이는 것 자체는 어렵지 않다. Vercel이면 turbo login과 turbo link, CI에는 TURBO_TOKEN과 TURBO_TEAM이면 끝난다. self-host 라면 서버를 띄우고 TURBO_API로 주소만 바꾸면 나머지는 같다. 정작 오래 붙잡게 되는 곳은 9절이다. 락파일과 Node 버전과 env 선언이 환경마다 어긋나 있으면, 서버는 잘 붙어 있는데 히트가 한 번도 안 난다.
함께 읽기
- 프론트 모노레포 셋업 A→Z — pnpm workspaces + Turborepo + Changesets
- turbo.json 을 지우면 모노레포가 안 돌아갈까

답글 남기기