Turborepo를 쓰는 모노레포에서 빌드 캐시를 점검하다가 두 가지를 찾았습니다. 하나는 71일 동안 루트 빌드 명령이 아예 실행된 적이 없었다는 것이고, 다른 하나는 그걸 고친 뒤에도 캐시가 성공을 알리면서 파일을 하나도 만들지 않았다는 것입니다.
둘 다 에러를 내지 않았습니다. 그래서 오래갔습니다.
실패는 조용했다
Turborepo를 붙인 모노레포의 루트 package.json에는 보통 이런 스크립트가 들어갑니다.
이 저장소도 같았습니다.
{ "scripts": { "build": "turbo run build" } }
돌려보니 이렇게 나왔습니다.
$ npx turbo build --filter=blog
x Failed to add workspace "dreamstore" from "apps/dreamstore/package.json",
| it already exists at "apps/dreamstore-app-master/package.json"
앱 두 개가 package.json의 name을 똑같이 dreamstore로 쓰고 있었습니다.
하나는 현재 앱이고 하나는 마이그레이션 참고용으로 남겨둔 옛 코드였습니다.
옛 코드를 워크스페이스로 들여온 커밋은 2026년 6월 1일자였습니다.
측정한 날이 8월 11일이니 71일입니다.
그동안 이걸 아무도 못 챈 이유가 있습니다.
pnpm은 워크스페이스를 경로로 식별합니다.
이름이 겹쳐도 pnpm install은 멀쩡히 됩니다.
그리고 앱을 빌드할 때는 pnpm --filter blog build처럼 개별로 돌리고 있었습니다.
그 경로는 turbo를 거치지 않습니다.
즉 매일 쓰는 명령은 다 잘 됐고, 아무도 안 쓰는 명령 하나만 죽어 있었습니다.
.turbo 캐시 디렉토리가 아예 존재하지 않았던 게 그 방증입니다.
한 번이라도 성공했다면 만들어졌을 폴더입니다.
Turborepo는 outputs에 적힌 것만 캐시한다
이름을 고치니 turbo가 돌았습니다. 그런데 끝에 경고가 붙었습니다.
WARNING no output files found for task blog#build.
Please check your `outputs` key in `turbo.json`
turbo.json을 열어보니 이랬습니다.
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**"]
}
}
}
create-turbo가 만들어주는 Next.js 기본값입니다.
그런데 이 저장소에서 빌드 산출물을 만드는 워크스페이스는 이렇습니다.
| 워크스페이스 | 빌드 | 산출물 |
|---|---|---|
| 블로그 | astro build | dist/ |
| 랜딩 | vite build | dist/ |
| 공용 패키지 | tsc | dist/ |
| 개인 프로젝트 하나 | next build | .next/ |
.next/**는 네 개 중 하나만 맞았습니다.
그것도 곁다리로 붙여둔 개인 프로젝트였습니다.
이게 이 함정의 고약한 점입니다. 설정이 완전히 엉터리였다면 눈에 띄었을 겁니다. 하나는 맞아서, 훑어보면 그럴듯해 보입니다.
62밀리초 만에 성공했다고 말하는 빈 빌드
여기서 실제로 무슨 일이 벌어지는지 확인해봤습니다. 빌드 결과 폴더를 지우고 다시 돌렸습니다.
$ rm -rf apps/blog/dist
$ npx turbo build --filter=blog
blog:build: cache hit, replaying logs 7ee581b375db050c
Cached: 1 cached, 1 total
Time: 62ms >>> FULL TURBO
$ ls apps/blog/dist
(아무것도 없음)
캐시 적중이라고 했고, FULL TURBO라고 했고, 62밀리초 만에 끝났습니다. 그리고 만들어진 파일은 0개입니다.
Turborepo가 캐시하는 것은 두 가지입니다.
작업의 로그와, outputs에 적어준 파일들입니다.
로그는 저장돼 있으니 그대로 재생됩니다.
파일은 애초에 저장 대상이 아니었으니 복원할 게 없습니다.
그러니까 캐시는 정상 동작했습니다. “아무것도 저장하지 말라”고 시킨 대로 아무것도 저장하지 않았고, 복원할 때도 아무것도 복원하지 않았습니다.
문제는 이게 성공한 빌드와 로그가 똑같다는 것입니다.
cache hit과 FULL TURBO는 잘 됐을 때 나오는 바로 그 문구입니다.
고친 뒤에 다시 재봤다
outputs를 실제 산출물에 맞췄습니다.
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
네 워크스페이스의 결과 폴더를 전부 지우고 다시 돌렸습니다.
| 캐시 적중 시간 | 복원된 파일 | |
|---|---|---|
| 고치기 전 | 62ms FULL TURBO | 0개 |
| 고친 뒤 | 132ms FULL TURBO | 412개 |
412개는 블로그 79, 랜딩 122, 공용 패키지 26, 개인 프로젝트 185을 더한 수입니다. 경고도 사라졌습니다.
70밀리초 차이가 그 412개를 디스크에 쓰는 값입니다. 바꿔 말하면, 고치기 전의 62밀리초는 아무 일도 안 해서 빨랐던 것입니다.
빠른 것과 되는 것을 구분하는 법
캐시 도구를 볼 때 로그만 보면 안 된다는 게 결론입니다. 캐시의 목적은 빨라지는 게 아니라 결과를 다시 얻는 것인데, 로그는 앞쪽만 보여줍니다.
확인은 한 줄로 됩니다.
rm -rf <산출물 폴더> && <빌드 명령> && ls <산출물 폴더>
캐시 적중이라고 하면서 폴더가 비어 있으면 그 캐시는 껍데기입니다.
공식 문서의 Configuring tasks 문서도 outputs에 적히지 않은 파일은 캐시되지 않는다고 명시하고 있습니다.
한 가지 더 있습니다.
create-turbo로 시작했다면 turbo.json이 Next.js를 전제로 깔려 있습니다.
Astro, Vite, tsc, Expo 어느 쪽이든 산출물 경로가 다르므로, 첫날에 한 번은 열어봐야 합니다.
남는 질문
정직하게 적어두면, 이 작업으로 CI가 얼마나 빨라졌는지는 모릅니다. 이 저장소의 배포는 turbo를 거치지 않고 호스팅 쪽이 프로젝트별로 빌드합니다. 71일 동안 손해 본 시간도 모릅니다. 아무도 그 명령을 안 썼기 때문에 실제 손해는 0이었을 수도 있습니다.
확인한 것은 이것뿐입니다. 빌드 오케스트레이터가 71일 동안 안 돌고 있었고, 돌게 만든 뒤에도 캐시가 빈 껍데기였고, 둘 다 로그로는 구분되지 않았습니다.
도구를 붙였다는 것과 도구가 일하고 있다는 것은 다른 이야기입니다. 전자는 설정 파일이 증명하고, 후자는 산출물이 증명합니다.
2026년 8월 11일 기준으로 turbo 2.9.14에서 확인했습니다.