CI/CD 개선기(feat. 캐시)

들어가며
안녕하세요. 모요 프론트엔드 플랫폼 개발자 윤민상이에요.
저는 모요 프론트엔드 CI/CD를 전반적으로 개선하는 작업을 해왔어요. 결과만 보면 CI 시간을 85%(14분 → 2분), CD 시간을 59%(8분 → 3분 15초) 줄였고, 월 약 600만원의 비용도 감축했어요.
모요는 빠르게 성장해 온 팀이라, 그 속도 위에서 미처 챙기지 못한 것들이 쌓여 있었어요(레거시가 만든 성장, 레거시가 만들 성장에서 팀이 이 레거시를 어떻게 다뤄왔는지 이야기했어요). 모노레포로 옮긴 지도 오래되지 않았고요. CI/CD도 그중 하나였어요.
단적인 예로, 개선을 시작할 때 캐시가 아예 꺼져 있었어요. 매 실행마다 모든 걸 처음부터 빌드·검증하고 있었죠. 그때그때의 최선으로 달려온 흔적이고, 이제 하나씩 제대로 챙겨가는 중이에요.
그래서 이 글은 "얼마나 빨라졌나"보다, 그 과정에서 내린 판단들 — 무엇을 캐싱하고 무엇을 일부러 안 했는지, 어디까지 최적화하고 어디서 멈췄는지 — 을 중심으로 풀어볼게요. 단일 효과로 가장 컸던 캐시는 따로 한 섹션으로 묶어 깊이 있게 이야기하고요.
왜 개선이 필요했나?
모요 프론트엔드는 pnpm workspaces + Turborepo로 관리하는 모노레포예요. 앱이 여러 개이고, 패키지 의존성 계층도 있어서 CI/CD 파이프라인이 복잡한 편이에요.
글에서 앱, 패키지, 태스크라는 말이 자주 나와서 먼저 정리하고 갈게요.
- 앱 / 패키지: 하나의 저장소 안에 여러 프로젝트가 함께 있는 구조를 모노레포라고 해요. 그중 실제 배포되는 서비스가 앱(
apps/), 여러 앱이 공유하는 재사용 코드 묶음이 패키지(packages/)예요. 대략 아래 같은 모양이에요.
- 태스크(task): Turborepo에서
build,test,typecheck처럼 각 패키지가 실행하는 개별 작업 단위예요. 뒤에서 "이 태스크를 캐싱한다"는 말은 이 작업 하나의 결과를 저장했다가 재사용한다는 뜻이에요.
개선 전 상황을 한 줄로 요약하면 이랬어요. 아래 수치는 P50(전체 실행 중 절반이 이 시간 이내로 완료되는 중간값) 기준이에요.
- CI: PR을 올리면 14분을 기다려야 결과를 볼 수 있었어요
- CD: 배포 하나가 완료되기까지 8분, 메인 앱은 16분이 걸렸어요
- 비용: runner 비용이 매달 상당했고, 불필요하게 실행되는 워크플로가 많았어요
이 세 가지를 동시에 개선하는 게 목표였어요.
공통 기반: Composite Actions로 중복 제거
가장 먼저 한 일은 중복 코드 제거예요. CI, CD뿐 아니라 API 자동 업데이트, Storybook 배포, openapi knip 정리 등 다양한 워크플로가 있는데, pnpm install, pnpm build:packages 등 중복된 코드가 많았어요. 이를 GitHub Actions Composite Actions를 활용해 setup-monorepo라는 공통 액션 하나로 통합했어요.
이 공통화 작업이 단순한 DRY(Don't Repeat Yourself)를 넘어 중요했던 이유는, 캐시 전략을 한 곳에서 일관되게 관리할 수 있게 됐기 때문이에요.
runner 최적화: 워크플로마다 적절한 runner 매칭
runner는 성능에 비례해 비용이 들어요. 모든 워크플로를 큰 runner로 돌리면 빠르지만 비싸고, 모두 작은 runner로 돌리면 싸지만 느려요. 결국 워크플로 성격에 맞게 매칭하는 게 답인데, 그동안은 명시적인 기준 없이 그때그때 정해져 있었어요. 그래서 이번에 정비하면서 runner 선택 가이드를 문서화해 두었어요.
기준은 내부망 접근 필요 여부와 작업 무게 두 축이에요. 이 둘을 조합하면 워크플로마다 적합한 runner가 정해져요. 러너 스펙표·주의사항 등과 함께 사내 문서로 정리해뒀는데, 아래는 그중 일부 내용이에요.
이 기준으로 전체 워크플로를 한 번 훑으면서 성능이 중요한 빌드는 더 좋은 runner로 올리고, self-hosted가 필요 없는 가벼운 작업은 GitHub 가벼운 runner로 내렸어요. 빌드 쪽만 봐도 runner를 올린 것만으로 메인 앱 빌드가 11분에서 6분으로(55% 단축) 줄었어요.
흥미로운 건, 이렇게 일부 runner 성능을 올렸는데도 전체 비용은 오히려 줄었다는 거예요. runner 매칭 외에도 뒤에서 다룰 여러 비용 절감 작업이 함께 작용한 덕이에요.
문서화해 둔 덕에 앞으로 새 워크플로를 추가할 때마다 "어떤 runner를 써야 하지?"를 매번 직관으로 결정하지 않고, 같은 기준으로 판단할 수 있게 됐어요. 시간이 지나 최적화한 상태가 흐트러지더라도, 이 기준으로 다시 맞출 수 있어요.
캐시: 가장 큰 레버
CI/CD를 빠르게 만든 요소는 여러 가지예요. 빌드 시간만 놓고 보면 runner 교체가 가장 크게 당겼지만, 이건 사실 돈을 더 써서 산 속도예요. 더 비싼 runner를 쓰면 빨라지는 건 당연하니까요.
그래서 저는 추가 비용 없이 얻은 것 중 가장 컸던 캐시를 가장 의미 있게 봐요. 캐시는 새로 돈을 쓴 게 아니라, GitHub이 기본 제공하는 캐시를 제대로 활용한 것뿐이거든요. 게다가 CI·CD 양쪽에서 매 실행마다 작동해서 효과가 누적돼요. 모노레포는 패키지가 많고 빌드·타입체크·테스트가 줄줄이 엮여 있어서 같은 작업을 매 실행 반복하기 쉬운 구조인데, 한 번 잘 캐싱해두면 그 반복을 통째로 건너뛰니 절약 효과가 매 실행마다 쌓이는 거예요. 다만 캐시는 제대로 관리하지 않으면 오히려 문제의 원인이 되기도 해요.
앞서 말했듯 개선 전엔 turbo.json에서 캐시가 아예 꺼져 있었어요. Turborepo를 쓰면서도 태스크 캐싱을 끄고 있어서, 매 실행마다 모든 패키지를 처음부터 빌드·검증하고 있었던 거예요. 그래서 캐시를 켜고 그 결과물을 GitHub Actions 캐시로 저장·복원하게 만드는 것만으로도 효과가 바로 나타났어요. 다만 켜는 걸로 끝이 아니었어요. 아래에서 다룰 namespace 충돌, thrashing, 누수 같은 문제가 전부 캐시를 켠 다음에 마주친 것들이거든요.
GitHub Actions 캐시의 작동 방식
캐시 전략을 제대로 이해하려면 GitHub Actions 캐시의 두 가지 특성을 먼저 짚어두는 게 중요해요.
- ref 단위로 scope된다: 캐시는 기본적으로 현재 브랜치(ref)에서 찾고, 없으면 default branch(저희는
main)에서 fallback해요. PR이 막 시작될 때는 해당 브랜치에 캐시가 아직 없으니,main에 미리 쌓아둔 캐시를 첫 hit으로 활용해요. 이렇게 미리 채워둔 캐시를 warm cache라고 불러요. - 저장과 복원은 별개의 동작이다: "복원만 하고 저장 안 함", "복원 + 저장", "전혀 안 씀" 같은 식으로 워크플로마다 따로 정할 수 있어요.
- 캐시 키는 계층적으로 매칭된다:
key로 exact match를 먼저 시도하고, 실패하면 prefix가 일치하는 가장 최신 캐시를 fallback으로 가져와요.
이 세 가지가 뒤에서 다룰 거의 모든 캐시 전략의 기반이에요.
캐시 종류와 사용 범위
캐시 전략을 이해하려면 먼저 어떤 캐시가 어디서 쓰이는지를 깔고 가야 해요. 저희 파이프라인의 캐시는 세 가지예요.
| 캐시 | 역할 | 사용 워크플로 |
|---|---|---|
| pnpm 캐시 | pnpm store 캐싱으로 패키지 설치 속도 향상 | 공용 (CI·CD·Storybook·API 업데이트 등) |
| Turbo 캐시 | 패키지의 빌드·typecheck·test 결과를 해시 캐싱 | 공용 (워크플로마다 범위 상이) |
| Next.js 캐시 | .next/cache로 모듈별 컴파일 결과 저장 | CD (앱 빌드 시에만) |
왜 CI에서도 패키지를 빌드하는지 짚고 갈게요. 모노레포에서는 typecheck·test 같은 검증을 돌리려면 다른 패키지의 빌드 산출물(dist/, .d.ts)이 먼저 있어야 해요. 패키지가 서로 의존하는 그래프 구조라서, 한 패키지를 검증하려면 그게 의존하는 패키지들이 먼저 빌드돼 있어야 하거든요. 그래서 CI에서도 패키지 빌드를 깔고 시작해요.
앱 빌드에는 Turbo만으로 부족해요
Turbo와 Next.js 캐시는 작동 단위가 달라요.
- Turbo는 태스크 단위: 입력(소스·의존성·환경변수)을 해시해서 입력이 같으면 태스크 자체를 스킵해요. outputs가 있는 빌드 태스크는 결과 디렉토리까지 복원하고, 패키지 빌드에서는 이 hit이 자주 나서 효과가 커요. test·typecheck처럼 outputs가 없는 태스크는 변경된 패키지만 골라서 실행하는 방식으로 불필요한 재실행을 줄여요.
- Next.js 빌드 캐시는 파일 단위:
next build가 도는 동안.next/cache에서 SWC/webpack 산출물을 모듈별로 가져와 변경된 파일만 재컴파일해요.
문제는 앱 빌드는 Turbo 캐시가 거의 매번 miss라는 거예요. 배포는 보통 앱 파일이 바뀌어서 도는 거고, Turbo는 입력이 한 파일이라도 달라지면 그 태스크를 miss로 잡아요. 그래서 결과적으로 next build는 실제로 돌게 돼요. 이때 .next/cache가 모듈 재컴파일을 줄여서 빌드 시간을 잡아줘요.
물론 패키지도 파일 하나만 바뀌어도 그 패키지의 Turbo 캐시는 miss예요. 다만 패키지 하나의 빌드 시간은 짧아서 miss가 나도 부담이 작아 별도의 모듈 단위 캐시를 도입하지는 않았어요. 반면 next build는 무거워서 같은 miss라도 비용 차이가 커서, 여기에만 .next/cache로 부분 절약을 챙긴 거예요.
캐시를 의도적으로 안 쓰는 곳
태스크라고 다 Turbo 캐싱이 이득은 아니에요. lint는 의도적으로 Turbo 캐싱에서 제외했어요.
저희는 lint를 변경된 파일에만 돌려요. PR에서 바뀐 파일 목록을 뽑아 자체 스크립트로 넘겨서, 그 파일들만 lint하는 방식이에요. 매 실행마다 입력이 사실상 달라지고 실행 자체도 이미 빨라요. 여기에 Turbo를 끼우면 입력 해시 계산, 캐시 저장/복원 오버헤드만 늘고 시간은 크게 달라지지 않아요.
비슷한 판단을 한 곳이 또 있어요. main 머지 후 전체 검증을 한 번 더 돌리는 안전망 워크플로가 있는데, 저희는 이걸 ci-full이라고 불러요(PR에서 변경분만 골라 돌린 걸, 머지 후 전체 범위로 다시 검증하는 용도예요). 이 ci-full은 캐시를 복원하지 않아요. (다음 PR들이 쓸 warm cache를 마지막에 저장은 하지만, 자기 실행에는 캐시를 불러오지 않아요.) ci-full은 PR에서 놓친 걸 잡는 안전망이라, 캐시 오염 같은 잠재 위험을 배제하고 "원본 그대로 한 번 더 검증"하는 게 더 중요하거든요. 어차피 비동기로 도는 안전망이라 속도가 가장 빠를 필요도 없고요.
정리하면 캐시는 디폴트로 켜는 게 아니라, 작업 특성에 맞을 때만 쓴다는 원칙이에요. lint든 ci-full이든, 캐시가 강력하다고 모든 곳에 쓰는 게 답은 아니라는 얘기예요. 캐시 섹션 전체를 관통하는 생각이기도 해요.
pnpm 캐시: setup-node를 쓰지 않은 이유
actions/setup-node에는 cache: 'pnpm' 옵션이 있어요. 당연히 처음엔 이걸 쓰려 했는데, 이 옵션은 restore-keys를 지원하지 않아요. lockfile 한 줄만 바뀌어도 캐시가 exact match에 실패해서 3800개 이상의 패키지를 전량 재다운로드해야 했어요(약 72초).
그래서 actions/cache를 직접 구성해서 restore-keys를 통한 partial hit를 가능하게 했어요. pnpm store는 내용 해시 기반이라 오래된 캐시를 가져와도 lockfile 변경분만 추가로 받으면 돼서, lockfile 일부가 바뀌어도 대부분의 패키지를 재활용할 수 있어요.
cache-mode: 워크플로마다 캐시 전략을 다르게
setup-monorepo를 다양한 워크플로에서 공통으로 쓰다 보니 새로운 문제가 생겼어요. Turbo 캐시를 모든 워크플로에서 똑같이 save하면 캐시 용량이 낭비돼요. Storybook 배포나 API 업데이트처럼 1회성 잡은 캐시를 쌓아봤자 다음에 재사용되지 않거든요.
그래서 cache-mode를 세 가지로 나눴어요.
| cache-mode | 동작 | 사용 예 |
|---|---|---|
restore-and-save | 복원 + 저장 | CI, CD — 반복 실행되며 캐시 누적이 필요한 경우 |
restore-only | 복원만 (저장 안 함) | Storybook 배포, API 업데이트 등 1회성 잡 |
none | 캐시 완전 비활성 | 직접 actions/cache/save로 세밀하게 제어하는 경우 or 캐시를 의도적으로 사용하지 않는 경우 |
restore-only 잡은 warm cache의 혜택은 받되 새 캐시를 생성하지 않아요. 덕분에 한정된 캐시 용량을 꼭 필요한 캐시에 집중시킬 수 있어요.
setup-monorepo 액션에서는 이 cache-mode에 따라 저장/복원 스텝을 분기해요.
restore-and-save는 actions/cache, restore-only는 actions/cache/restore를 쓰는 게 핵심이에요. 같은 키 구조를 공유하되 저장 여부만 스텝 수준에서 갈라요.
여기까지가 캐시를 "어떻게 켜고 어디에 쓸지"의 설계예요. 그다음 곧바로 마주친 문제들은 트러블슈팅에서, 이 상태를 흐트러지지 않게 유지하는 측정 체계는 "운영을 위한 장치들"에서 다룰게요.
트러블슈팅
CI/CD 캐시 namespace 분리
lockfile이 바뀌어도 직전 캐시를 재활용하려고, CI와 CD 모두 restore-keys에 lockfile hash 앞부분까지만 일치하면 가져오도록 설정했어요. 문제는 CI와 CD의 캐시 키가 같은 prefix를 공유하고 있었다는 거예요. 그래서 CI가 자기 캐시 대신 CD가 쌓아둔 ~16GB짜리 캐시를 fallback으로 집어왔고, 디스크 큰 runner를 쓰는 CD와 달리 ubuntu-latest를 쓰는 CI lint 워크플로는 이 16GB에 디스크가 꽉 차 터졌어요(ENOSPC).
핵심은 CD가 캐시를 직접 CI에 넘기는 게 아니라, 둘이 같은 prefix 공간을 공유해서 CI가 CD가 남긴 걸 잘못 꺼내 온다는 데 있어요.
캐시 키는 끝에 …-〈lock〉-〈sha〉가 붙는 구조라, sha(커밋 해시)가 매 커밋 달라져서 exact match는 원래 거의 안 나요. 그래서 항상 restore-keys의 prefix 매칭으로 내려가요. 평소엔 …-〈lock〉-까지만 맞아도 자기 캐시가 걸리는데, lockfile이 바뀌면 그 단계도 어긋나 더 앞 prefix까지 내려가고, 거기서 CD가 저장해 둔 16GB가 "가장 최신"으로 걸려 꺼내 오는 거예요.
해결은 CD 키에만 namespace(cd)를 넣어 prefix 공간 자체를 분리한 거예요. 이제 CI의 prefix 탐색이 CD 저장소에 닿지 않아요.
키는 액션 입력 하나로 갈랐어요. cache-namespace가 비면 CI 공용, 'cd'면 CD 전용 prefix가 돼요.
사실 CI와 CD가 같은 패키지를 빌드하니 캐시를 공유해 더 빨라지지 않을까도 고려해봤어요. 하지만 실측해보니 배포가 자주 나가는 모요는 CD 캐시 hit rate가 이미 충분히 높아 공유해도 유의미한 속도 차이가 없었고, 공유하려면 빌드 산출물을 외부 모듈처럼 참조하게 만드는 등 추가 처리도 따라와서 지금은 이득 대비 부담이 커요. 그래서 도입은 보류하고, 캐시 제어를 더 세밀하게 가져가야 할 시점에 다시 볼 레버로 남겨뒀어요.
thrashing: 캐시를 쌓아도 금방 사라진다
분명히 캐시를 잘 쌓고 있었는데 hit rate가 생각보다 낮았어요. 분석해본 결과 캐시가 빈번하게 생성되고 삭제되는 cache thrashing 현상이 발생하고 있다는 것을 깨달았어요.
이 문제가 왜 생기는지 이해하려면 GitHub Actions가 캐시를 언제 삭제하는지 알아야 해요. GitHub Actions 캐시는 레포 단위로 저장할 수 있는 총 용량이 정해져 있고, 7일 동안 한 번도 사용되지 않은 캐시는 자동으로 삭제돼요. 용량 한도를 넘으면 가장 오래 사용되지 않은 캐시부터 삭제(LRU)돼요. 캐시 개수에는 제한이 없고, 전체 용량을 기준으로 관리되는 방식이에요.
문제는 이 방식이 burst 트래픽에 약하다는 거예요. 여러 워크플로가 한꺼번에 돌면서 새로운 캐시를 많이 만들면, 기존 캐시들이 뒤로 밀려 삭제될 수 있어요. GitHub Actions 캐시는 자주 쓴 것 우선 유지(LFU)같은 정책 없이 단순 LRU라서, 얼마나 자주 썼는지는 고려되지 않아요. 그래서 평소에 자주 쓰던 캐시라도 사라질 수 있어요. 그러면 자주 쓰던 캐시를 매번 새로 만들게 되고, 삭제→재생성→삭제 흐름이 반복되는 게 cache thrashing이에요.
The cache eviction process may cause cache thrashing, where caches are created and deleted at a high frequency. — GitHub Actions docs
해결책은 GitHub의 삭제 정책에 맡기지 않고 직접 정책을 운영하는 거예요. cleanup-caches.yml 워크플로에서 PR·main·그 외 ref(예: 머지큐)를 각각 다른 규칙으로, 캐시 종류·역할별로 "몇 개를 유지할지" 명시적으로 결정해요.
여기서 머지큐라는 말이 처음 나와서 잠깐 짚어둘게요. 저희는 별도 릴리스 브랜치 없이 main
하나에 바로바로 합치는 trunk-based 방식을 사용하고, main에 머지되면 곧장 프로덕션까지
배포돼요. 그러다 보니 "여러 PR이 동시에 머지될 때 서로 충돌 없이 안전한가"를 보장하는 장치가
필요한데, 그게 GitHub 머지큐(Merge
Queue)예요.
머지 직전에 PR을 순서대로 줄 세워, 실제 main에 들어갈 상태 그대로 CI를 한 번 더 돌린 뒤 통과한
것만 머지해요. 이 과정에서 머지큐가 PR마다 임시 브랜치(ref)를 만드는데, 뒤에서 이 브랜치의 캐시가
문제를 일으켜요.
다만 적정 유지 개수는 팀마다 다르다고 생각해요. 저희가 고려한 기준은 다음과 같아요.
- 캐시 키 구조: Turbo 캐시 키는
lockfile-hash + sha조합이라 브랜치별 최신 1개만 유지해도 충분해요. 반면 lockfile 기반 캐시(pnpm)는 여러 브랜치에서 동시에 다른 버전이 생길 수 있어서 여유를 두는 게 부분 재활용에 유리해요. - 브랜치 전략: trunk-based인지 git-flow인지에 따라 캐시가 쌓이는 브랜치(ref) 구조가 달라지고, 어떤 캐시를 몇 개 유지할지도 달라져요. 저희는 trunk-based + GitHub 머지큐를 쓰기 때문에 머지큐 브랜치에서 생기는 캐시를 별도로 관리해야 했어요.
- 팀 규모, PR 빈도, 가용 용량: 동시 PR이 많으면 더 많은 캐시 용량을 유지해야 캐시 삭제를 막을 수 있어요. 하지만 유지 개수가 늘수록 레포 캐시 가용 용량도 그만큼 소모되니, 둘 사이의 균형을 같이 봐야 해요. 캐시 한도는 GitHub 설정에서 늘릴 수 있어서 팀 상황에 맞는 적절한 용량을 찾아야 해요.
참고로 지금 저희 설정은 이래요. "정답"이라기보다 위 기준으로 도출한 현재값이에요. turbo 캐시는 어차피 키에 sha가 붙어 고유하므로 어느 ref든 prefix별 최신 1개만 두고, 개수로 조절하는 건 pnpm store예요.
| ref 종류 | pnpm store 유지 | 근거 |
|---|---|---|
| main | 5개 | 12명이 써서 lockfile 버전이 갈리는 경우가 종종 있고, 데이터 기반으로 5개면 넉넉 |
| 머지큐 | 3개 | main fallback되니 적게, 단 동시 진입한 여러 큐 ref는 살리게 |
| PR | 최신 커밋 1세트 | 새 커밋 push 시 이전 커밋 캐시 삭제, 머지되면 전부 삭제 |
pnpm store는 개당 ~900MB라 main 5개면 약 4.5GB 수준이에요. 처음엔 최근 캐시 데이터를 기반으로 LLM 도움을 받아 초안을 뽑고, 실제 hit rate와 cleanup 로그를 보면서 조정해왔어요. "몇 개가 정답"이 아니라, 우리 팀 상황에 맞게 유연하게 운영한다는 게 핵심이에요.
머지큐 캐시 누수: 나중에 발견한 11GB
캐시 정책을 다 정비했다고 생각했는데, 한참 뒤에 캐시 용량이 이상하게 빠르게 차오르는 게 발견됐어요. 조사해보니 머지큐 브랜치 캐시가 약 11GB나 누수되고 있었어요.
GitHub Merge Queue는 머지 직전에 refs/heads/gh-readonly-queue/main/pr-*-{sha} 같은 임시 브랜치를 만들어서 거기서 CI를 한 번 더 돌려요. 머지가 완료되면 브랜치는 자동 삭제되지만, GitHub은 거기서 생성된 캐시를 7일간 그대로 유지해요. 머지큐는 PR마다 고유 브랜치를 만들기 때문에 캐시가 계속 쌓이는 구조였어요.
그런데 저희 cleanup-caches.yml은 main과 PR ref만 다루고 있어서, 머지큐 ref는 어느 cleanup 로직에도 닿지 않았어요. 머지큐를 도입한 시점에 이 cleanup을 같이 설계했어야 했는데, 캐시 정리는 main과 PR만 고려해서 작업했던 게 원인이었어요. 도입 직후엔 양이 적어서 티가 안 나다가, 시간이 지나 누적되면서 발견됐어요.
해결은 머지큐 ref만 선별해서 별도 정리 로직을 추가하는 거였어요. 머지큐 ref의 캐시는 머지가 끝나면 다른 ref에서 fallback 대상으로 삼지 않으므로 사실상 다시 쓰일 일이 없어요. 그래서 main보다 더 적게 유지하도록 했어요. 그리고 이런 누수가 다시 쌓이지 않도록 캐시 용량 모니터링도 함께 도입했어요. 용량 추이를 주기적으로 볼 수 있게 되니, 다음에 비슷한 문제가 생기면 훨씬 빠르게 감지할 수 있어요.
이 경험에서 배운 건, 아무리 잘 검토해도 놓치는 건 생기고, 그래서 놓쳐도 빨리 감지되는 장치가 있어야 한다는 거예요. 머지큐를 도입할 때도 인프라 영향을 검토하긴 했지만, 캐시 정리는 main과 PR만 떠올렸지 머지큐 ref까지는 미처 생각이 닿지 않았어요. 사람의 검토는 이렇게 빈틈이 생기기 마련이라, 방금처럼 용량이 이상하게 차오르면 곧장 알아챌 수 있는 감지 장치가 그 빈틈을 메워줘요.
Turbo env 거버넌스: 의도치 않은 캐시 미스
Turbo는 태스크를 캐싱할 때 소스 파일뿐 아니라 turbo.json의 globalEnv에 등록된 환경변수도 해시 입력으로 써요. 그래서 그 변수값이 달라지면 캐시 미스가 나요. 문제는 여기에 buildtime 변수(빌드 시점에 번들에 포함되는 값)와 runtime 변수(실행 시 서버에서 주입되는 값)가 뒤섞여 있었다는 거예요. 실제로 APP_ENV처럼 buildtime 해시엔 영향을 줄 필요가 없는 runtime 변수까지 globalEnv에 들어가, CI 환경마다 값이 조금씩 달라지며 불필요한 캐시 미스를 만들고 있었어요.
그래서 buildtime/runtime 분류 기준을 문서로 만들고, validate-turbo-env 스크립트로 process.env 사용처와 turbo.json 등록이 어긋나면 CI에서 바로 잡히게 했어요. 추적이 불필요한 변수는 이유 주석과 함께 ALLOWLIST에 등록해, 판단 근거가 코드에 남도록 했고요.
CI 개선: 14분 → 2분
캐시 외에도 CI에서 개선한 것들을 정리할게요.
워크플로 병렬 처리: lint, typecheck, test가 순차적으로 돌던 걸 병렬로 바꿨어요.
ESLint → oxlint 전환: 같은 팀 동료가 진행한 작업으로, lint 도구를 Rust 기반 oxlint로 바꿨어요. CI에서 약 7분 30초 → 1분으로, 약 7~8배 빨라졌어요.
변경 파일 감지로 조기 중단: PR에서 변경된 파일을 먼저 감지해서, 관련 없는 워크플로는 실행 자체를 건너뛰어요. CSS만 바꿨는데 typecheck가 돌 필요 없잖아요. 불필요한 실행을 줄이는 것 자체가 비용 감축이에요.
CI 전용 검증 스크립트: 빌드 안전성, 패키지 레이어 의존성, openapi 서비스 정합성, 공용 툴 정합성 등 다양한 검증 스크립트를 만들고 CI에 추가했어요. 코드 리뷰에서 놓칠 수 있는 구조적인 문제를 자동으로 잡아줘요.
.github/scripts/ 자체에 대한 CI: CI를 굴리는 스크립트도 결국 코드라 깨질 수 있어요. .github/scripts/에 대한 별도 CI를 만들어서 타입체크·테스트를 돌리고, CI 인프라 자체의 안정성을 확보했어요.
main 브랜치 CI Full 워크플로: PR 머지 후 main에서도 전체 CI를 돌려요. PR 단계에서 변경 파일 감지로 스킵한 워크플로까지 포함해서, 머지 후에 문제가 생겼을 때 빠르게 감지하는 안전망이에요.
테스트: 속도와 안정성을 같이 잡기
CI에서 테스트는 가장 손이 많이 간 부분이었어요. 느리면 매 PR이 기다려야 하고, 간헐적으로 실패하면(flaky) 멀쩡한 PR이 막혀요. 속도와 안정성을 같이 봐야 했어요.
워커 수 제한은 필요한 곳에만
모노레포라 Turbo가 패키지별 test 태스크를 동시에 띄우고, 그 각각이 다시 vitest 워커를 여러 개 띄워요. 이 워커 수를 vitest 기본값(가용 코어를 최대한 활용)으로 두면 가장 빠르지만, 자원 경합이 심해지면서 flaky 테스트가 더 자주 터져요. 그래서 워커 수를 제한하는 설정(maxWorkers)을 뒀는데, 이건 속도를 일부 포기하는 선택이에요. 코어를 다 못 쓰니 그만큼 느려지거든요.
핵심은 이 설정을 모든 CI에 켜지 않고 ci-full에서만 켠다는 거예요. 빠른 피드백이 중요한 PR CI는 기본값 그대로 두어 속도를 챙기고, 안정성이 더 중요한 ci-full에서만 워커 수를 제한해 flaky를 눌러요. 한 가지 더 — 제한을 고정값(예: 항상 2개)으로 두지 않고 러너 vCPU에 비례시켰어요. 고정하면 16코어짜리 큰 러너에서 코어 대부분이 놀아서 불필요하게 느려지거든요. "줄이되, 러너 크기에 맞춰 줄인다"는 거예요.
테스트 범위를 단계마다 다르게
PR에서는 lint와 같은 원리로, main 대비 바뀐 파일과 연결된 테스트만 돌려요(vitest --changed). 빠른 피드백이 중요한 단계라 변경분에 집중하는 거예요. 대신 머지 직전 머지큐(merge group)에서는 전체 테스트를 그대로 돌려요. 머지큐는 실제로 main에 들어가기 직전의 최종 안전망이라, 여기서까지 변경분만 보면 "PR에서는 안 건드린 테스트가 이번 머지로 깨지는" 경우를 놓치거든요.
PR은 변경분으로 빠르게, 머지큐는 전체로 확실하게, 머지 후 ci-full(main)에서 한 번 더 전체로 — 같은 CI라도 단계의 목적에 맞게 범위를 나눈 거예요.
ci-full은 동시 실행 pool도 PR CI와 분리했어요. 머지 후 비동기로 도는 검증이 PR들의 러너를 잠식해서, 정작 빠른 피드백이 필요한 PR CI가 큐잉으로 밀리지 않게 하려는 거예요.
flaky를 다루는 세 단계
flaky는 세 단계로 접근했어요.
- 덮기 — CI에서 테스트 retry를 3회 두어서, 진짜 회귀가 아닌 일시적 흔들림이 PR을 막지 않게 했어요.
- 드러내기 — retry로 가리기만 하면 flaky가 쌓이니까, ci-full에서 flaky를 드러냈어요. ci-full에서 실패한 테스트는 git blame으로 작성자를 자동 태깅해서 빠르게 공유되고, lint처럼 자동 복구 가능한 실패는 스크립트가 바로 PR을 만들어 올려줘요.
- 체계화 — 테스트 실패 진단 절차를 문서·스킬로 정리했어요. 실패가 flaky인지 실제 회귀인지 판단하는 기준을 문서화하고, AI 에이전트가 그 절차대로 원인을 좁혀가도록
debug-failing-test스킬을 만들었어요. "이 테스트 또 깨졌네"에서 멈추지 않고, 누구든(사람이든 AI든) 같은 절차로 원인까지 도달할 수 있게요.
retry로도 안 잡히던 flaky
앞의 방식은 "실패한 테스트"를 다루는 거예요. 그런데 테스트가 "실패"하지도 않는데 CI가 터지는 flaky가 있었어요. 테스트가 다 끝나고 정리되는 과정에서 window is not defined 에러로 프로세스가 죽는 경우예요. 테스트 자체가 실패한 게 아니니 retry로도 안 잡히고, 그냥 프로세스가 비정상 종료돼요.
사실 이건 React + jsdom 환경에서 꽤 흔한 문제예요. vitest에도 비슷한 이슈가 여럿 올라와 있어요. 테스트가 끝나면 vitest가 가짜 브라우저 환경(window 등)을 정리하는데, React 스케줄러가 setImmediate로 잡아둔 렌더 작업이 그 정리 이후에 매크로태스크로 뒤늦게 실행되면서 이미 사라진 window를 건드려 터지는 거예요.
모요는 여기에 antd가 얽혀서 더 까다로웠어요. antd notification처럼 화면 최상단에 붙는 요소는 테스트 라이브러리(React Testing Library)가 자동으로 정리하는 범위 밖이라, 마지막 테스트가 끝난 뒤에도 처리할 작업이 남아 있었거든요.
그래서 남은 매크로태스크 큐를 두 겹으로 비웠어요. 매 테스트 뒤(afterEach)와 마지막 테스트 뒤(afterAll) 두 곳에서, 큐를 비우는 도중 또 새 작업이 예약되기 때문에 남은 게 없을 때까지 반복해서 비워요.
사실 이건 근본 해결이라기보단, 라이브러리(React·vitest·antd) 조합에서 오는 문제라 우리 레이어에서 할 수 있는 방어적 회피에 가까워요. 원인을 없앤 게 아니라, 큐가 비는 타이밍을 우리가 맞춰주는 거죠. 그래서 "몇 번 비우면 충분한지"도 딱 떨어지지 않아 남은 게 없을 때까지 반복하는 형태가 됐고요.
여기에 모노레포 특성이 하나 더 얽혀요. 같은 조건(React 19 + jsdom + 테스트 라이브러리)을 쓰는 앱이 여럿이라, 이 flaky가 앱마다 똑같이 잠재돼 있었거든요. 그래서 해결 로직을 공용 헬퍼 하나로 만들어 해당 앱 전체에 적용했어요. 앱마다 같은 코드를 복붙하고 "여기 바뀌면 저기도 바꿔라" 주석을 다는 대신 기준을 한 곳에 뒀어요. 앞서 설정 공통화에서 했던 얘기와 같은 방식이에요.
이렇게 원인까지 파고들어 하나씩 대응하고는 있지만, flaky를 "완전히 없앴다"고 말하긴 조심스러워요. 방금처럼 회피에 가까운 대응도 있고, 새로운 형태가 언제든 생길 수 있으니까요. 그래서 목표는 "완전 박멸"보다 새 flaky가 나와도 빠르게 인지하고 대응할 수 있는 상태를 유지하는 거예요.
테스트도 결국 캐시 섹션에서 했던 얘기와 같아요. 워커를 늘리는 것도, retry를 거는 것도 디폴트로 좋은 게 아니라 무엇을 위한 장치인지 분명할 때만 의미가 있어요.
설정 공통화: 흩어진 config를 공유 preset으로
앞의 vitest 설정 얘기는 사실 테스트만의 문제가 아니었어요. lint, tsconfig 같은 빌드·검증 설정을 위한 공유 config 패키지(@repo/typescript-config, @repo/lint-config, @repo/vitest-config)는 원래도 있었고, 일부는 이번에 추가했어요. 문제는 정작 이걸 제대로 안 쓰는 앱이 많았다는 거예요. 공유 preset이 있는데도 앱마다 자체 설정을 들고 있거나 기준에서 살짝 벗어나 있었는데, 이런 건 눈에 잘 안 띄어서 방치되기 쉬웠어요.
그래서 기존 코드베이스를 하나씩 보면서 공용으로 둘 것·앱별 예외로 남길 것·이참에 고칠 것을 분류했어요. 앱마다 미묘하게 다른 설정은 그 자체로 "여기서만 깨지는" 문제의 원인이 되거든요. 공용 preset으로 정리하니 기준이 한 곳에서 관리되고, 새 앱·패키지도 extends 한 줄로 같은 기준 위에서 시작해요.
그리고 이렇게 맞춘 기준이 다시 흐트러지지 않도록 CI 검증을 붙였어요. 앞서 "공용 툴 정합성" 검증 스크립트라고 짧게 언급한 게 이거예요. 공유 config를 써야 할 곳이 자체 설정을 들고 있거나 공용 기준에서 벗어난 설정이 들어오면 CI에서 바로 잡혀요. 공통화는 한 번 하고 끝이 아니라, 시간이 지나도 유지되도록 거버넌스까지 같이 둬야 의미가 있어요.
CD 개선: 8분 → 3분 15초
배포 병렬화: 원래 alpha + staging → prod 순서로 순차 배포했어요. 이 순서에는 이유가 있었어요. alpha·staging에 먼저 배포해 이상이 없는지 확인한 뒤 prod로 넘기자는 취지였고, 그래서 앞 단계가 끝나야 다음 단계를 시작할 수 있었어요.
그런데 실제로는 그 중간 단계를 아무도 보고 있지 않았어요. 검증은 누군가 실제로 들여다볼 때만 의미가 있는데, alpha·staging 배포는 확인하는 사람 없이 그냥 순서대로 지나가고 있었거든요(어차피 실질적인 검증은 PR별 임시환경에서 이뤄지고요). 결국 아무도 안 보는 검증을 위해 배포 시간만 늦추고 있던 셈이라, 다른 환경 검증을 기다릴 이유가 없었어요. 그래서 순차를 병렬로 바꿔 대기 시간을 2분 줄였어요.
Next.js 빌드-타입체크 분리: Next.js는 기본적으로 빌드 시 타입체크를 같이 해요. 타입체크만 따로 빼서 ignoreBuildErrors: true를 적용하니 빌드 시간이 11% 줄었어요(3분 37초 → 3분 13초). 빌드와 타입체크 모두 성공해야 배포로 넘어가는 구조는 그대로 유지했어요.
Rollup → Vite 마이그레이션 + ESM 전환: 패키지 빌드 도구를 Rollup에서 Vite로 전환하면서 패키지별 빌드 시간이 최대 2.4배(약 30초) 빨라졌어요.
React 19 + Next.js 16 마이그레이션: 같은 팀 동료가 진행한 작업으로, 메인 앱 기준 CD 빌드 시간이 약 6분에서 3분으로 절반 줄었어요. 앞선 runner·빌드 개선으로 이미 11분에서 6분까지 줄여둔 상태였는데, 이 마이그레이션으로 6분에서 다시 3분으로 한 단계 더 내려간 거예요(11분 → 6분 → 3분). 로컬에서는 훨씬 빠르게 돌지만, 캐시 I/O 병목이 남아 있어서 수치가 더 커지지 않았어요. 이 병목은 남은 과제에서 다룰게요. 임팩트가 커서 이 작업이 마무리될 때까지 다른 자잘한 빌드 개선들은 의도적으로 미뤄뒀어요.
워크플로 통합 및 이관: DevOps 팀이 관리하던 2개의 CD 워크플로를 1개로 통합하고 프론트엔드 팀으로 이관했어요. 배포 피드백 루프가 짧아지고, 수동 배포 옵션 추가와 배포 실패 대응 문서도 함께 정비했어요. 또 원래는 커밋할 때마다 여러 서비스를 미리 빌드해두고 있었는데, 이걸 실제 배포가 트리거될 때만 빌드하도록 바꿔서 불필요한 빌드 실행 비용을 줄였어요. 빠른 배포를 위해 매 커밋마다 미리 빌드해두는 방식이 유용하긴 하지만, CD 속도가 충분히 빨라지면 그 필요성이 줄어요. 필요성이 느껴지면 GitHub 라벨을 달면 자동으로 해당 브랜치 배포가 트리거되는 방식으로 대체하는 것도 고려하고 있어요.
결과
| Before | After | |
|---|---|---|
| CI (P50) | 14분 | 2분 (85% 감소) |
| CD (P50) | 8분 / 메인 앱 16분 | 3분 15초 / 메인 앱 6분 (59% 감소) |
| 빌드 | 메인 앱 11분 | 메인 앱 3분 (73% 감소) |
| 비용 | - | 월 약 600만원 감축 |
| 캐시 | - | 복원 hit 100% / Turbo 태스크 재사용 77% |
위 수치는 빌드 + 배포 시간 기준이에요. 워크플로가 동시에 여러 개 돌 때 runner pool 부족으로 발생하는 큐잉(pending) 대기는 별도 이슈로 따로 보고 있어요. 본인이 한 작업의 효과와 인프라 큐잉 같은 다른 변수가 섞이지 않도록, 의도적으로 분리해서 측정하고 있어요.
비용 절감(월 약 600만원)은 한 가지 작업의 결과가 아니라, 앞에서 다룬 여러 변화가 겹친 합이에요. ① runner 재매칭으로 과한 runner를 걷어낸 것, ② 변경 파일 감지로 불필요한 워크플로 실행을 줄인 것, ③ 커밋마다 매번 돌던 서비스 빌드들의 시점을 배포 트리거 시점으로 옮겨 불필요한 빌드 컴퓨팅 리소스를 줄인 것이 큰 축이에요.
운영을 위한 장치들
이번 작업을 하면서 한 가지 의식한 게 있어요. 혼자 정비해두면, 시간이 지나 그 히스토리를 아는 작업자가 공백이 됐을 때 "이거 왜 이렇게 했지?"를 아무도 답하지 못하는 상태가 된다는 거예요. 캐시 정책이나 runner 선택처럼 결정의 배경이 보이지 않으면, 누군가 의도와 다르게 바꿔도 잡아낼 수가 없어요.
그래서 문서와 모니터링을 함께 갖추는 데 신경을 썼어요.
문서
- CI/CD 운영 문서: 캐시 키 전체 구조, cache-mode 분류, warm cache 워밍 메커니즘, restore-keys 정책 배경, cleanup-caches.yml 수명 관리 로직, lockfile 변경 시 영향 분석, Datadog 메트릭 태그 구조, runner 선택 기준(self-hosted/GitHub-hosted × 작업 무게)까지 사내 docs로 정리했어요.
- Turbo env 분류 기준: buildtime/runtime 분류 기준을 문서로 남겼어요.
모니터링
CI/CD 대시보드
CI를 단계별(typecheck·lint·test)로 나눠 본 P50 소요 시간
빌드 시간을 평균·P50·P95로 나눠, 목표선 대비 추이를 보는 대시보드
빌드 시간, 성공률 등을 대시보드로 모니터링할 수 있게 했어요. 단순히 평균만 보면 놓치는 문제가 있어요. 대부분의 빌드는 빠른데 가끔 몇몇만 유독 오래 걸리는 경우가 있거든요. 그래서 P50과 P95(상위 5%에 해당하는 느린 케이스) 지표를 함께 봐요. "평균은 그대로인데 느린 케이스만 늘어나는" 상황을 조기에 잡을 수 있어요.
또한 전체 실행 시간이 느려졌다는 것만 알아서는 어느 단계가 문제인지 알기 어려워요. 하나의 워크플로는 보통 여러 job으로 구성되어 있어서, 전체 시간만 봐서는 "테스트가 느려진 건지, 빌드가 느려진 건지"를 바로 파악하기 어렵거든요. 그래서 job 단위로 세분화해서 볼 수 있게 했어요.
메트릭에는 job·워크플로·브랜치뿐 아니라 트리거(PR / 머지큐) 태그도 붙여요. 같은 CI라도 PR에서 도는 것과 머지큐에서 도는 건 성격이 달라서, 섞어서 보면 수치가 흐려지거든요. 분리해두니 "내가 개선한 PR CI"의 효과를 머지큐 실행에 희석되지 않고 볼 수 있어요.
setup/work phase 분리 측정
CI 시간을 setup phase(설치·캐시 복원·패키지 빌드)와 work phase(실제 lint·typecheck·test)로 나눠 Datadog에 전송해요. setup 시간 추이에는 캐시 I/O 병목이나 hit rate 저하가 모두 반영돼서, 전체가 느려졌을 때 "캐시가 문제인지 실제 작업이 늘어난 건지"를 구별할 수 있어요.
캐시 hit rate 자동 집계
phase 시간만으로는 캐시 회귀를 간접적으로밖에 못 봐서, 다음 단계로 hit rate 커스텀 메트릭을 Datadog에 직접 전송했어요. 캐시 종류·워크플로·브랜치별 태그가 붙어 있어서 어느 캐시에서 회귀가 생겼는지 드릴다운할 수 있어요. actions/cache hit rate뿐 아니라 Turbo가 각 태스크를 얼마나 캐싱했는지도 같이 찍고요.
캐시 종류별 hit rate — pnpm·turbo는 100%지만 Next.js만 0%로 잡힌 게 한눈에 보인다
여기서 한 가지 짚을 게 있어요. "캐시를 가져왔다"와 "그래서 태스크를 스킵했다"는 다른 얘기예요. pnpm 캐시는 hit rate가 100%인데, 사실 lockfile 기반이라 restore-keys로 partial hit를 받는 구조라 프로젝트를 통째로 갈아엎지 않는 한 hit가 안 날 수가 없어요. Turbo도 캐시 복원은 거의 항상 되지만, 그 안에서 실제로 재사용돼 태스크를 건너뛴 비율(개별 태스크 단위 hit율)은 약 77% 정도예요. 나머지는 해당 패키지가 변경돼서 turbo build 같은 태스크가 다시 도는, 설계 의도대로의 miss고요. 그래서 "복원됐는지"와 "스킵됐는지"를 따로 봐야 해요.
실제로 이렇게 분리해서 측정한 덕에 Next.js 캐시 hit rate가 0%라는 걸 발견하기도 했어요. Next.js 버전 업그레이드 과정에서 캐시 동작이 바뀐 게 원인이었는데, 측정하지 않았다면 그냥 지나쳤을 거예요. 나중에 Next.js가 업데이트되거나 추가 최적화가 가능해질 때 어디를 봐야 하는지도 알 수 있게 됐어요.
캐시 사용량 모니터링
머지큐 캐시 누수처럼 예상치 못한 곳에서 문제가 발생할 수 있다는 걸 경험했어요. 그래서 현재 저장된 캐시의 수·크기·종류별 분포를 매일 Datadog에 전송해요. 임계값을 초과하면 자동으로 Slack 알림이 가요.
캐시 사용량 - 캐시 총 사용량·한도 대비 추이와 타입별 분포를 매일 집계해, 이상 증가를 조기에 잡는다
거버넌스: 결정이 깨지면 CI가 막는다
다만 문서만으로 모든 드리프트를 막을 수는 없어요. 문서는 읽어야 지켜지는데, 읽지 않아도 지켜지게 하려면 결정을 코드로 강제해야 해요. 그래서 결정이 깨지면 CI에서 곧장 잡히도록 자동 검증을 붙였어요.
사실 이건 이 글 곳곳에 이미 흩어져 있었어요. process.env랑 turbo.json 등록이 안 맞으면 validate-turbo-env가 CI에서 에러를 내고, 공용 config를 안 쓰면 "공용 툴 정합성" 검증이 에러를 내요. runner·캐시 정책은 문서에 기준이 있어서 흐트러져도 다시 맞출 수 있고요. 따로 보면 제각각인 장치들이지만, 노리는 건 하나예요 — 사람이 매번 기억하지 않아도, 시스템이 기준을 지켜주게 만드는 것.
이번 CI/CD 작업뿐 아니라, 모요 프론트엔드 팀은 평소에도 사내 지식창고와 문서화에 신경을 쓰고 있어요. 프론트엔드 공용 지식은 AI 에이전트가 바로 찾아 쓸 수 있도록 스킬로 정리해두는 식이에요. 이 부분은 따로 더 풀어볼 만한 주제라, 기회가 되면 별도로 얘기해볼게요.
어디까지 최적화할까
작업을 진행하면서 팀 안에서 계속 했던 이야기가 있어요. "이걸 어디까지 할 건지"예요.
최적화는 로그그래프 같아요. 초반엔 노력 대비 큰 향상이 있지만, 갈수록 같은 노력으로 얻는 단축은 줄어들어요. CI/CD 1분 단축이 매일 모든 PR에 곱해진다는 점에서 가치는 분명하지만, 동시에 그 1분을 위해 며칠을 쓰는 게 맞는지, 기능 개발이나 안정성 같은 다른 영역의 기회비용은 어떤지도 같이 봐야 했어요.
그래서 이번 작업도 처음부터 모든 캐시·워크플로를 갈아엎지 않고, 임팩트가 큰 것부터 단계적으로 적용했어요. Rollup → Vite 같은 큰 변화는 빌드 시간 효과가 명확해서 바로 진행했지만, 캐시 retention 정책처럼 손이 많이 가는 작업은 실측 hit rate로 가치를 검증한 후에 진행했어요. 오버엔지니어링을 피하면서도 의미 있는 곳에 집중하는 게 목표였어요.
"알지만 일부러 안 한" 것도 있어요. 대표적인 게 패키지 레이어 재배치예요. Turbo 캐시는 의존성 그래프를 따라가서, 하위 레이어 패키지가 바뀌면 그걸 의존하는 상위 패키지가 줄줄이 캐시 miss가 나요. 그래서 캐시 관점만 보면 자주 바뀌는 코드일수록 의존 범위가 좁은 상위 레이어에 둬야 miss 전파가 줄어요. 지금 패키지 구성은 이 기준으로 보면 개선 여지가 있어요 — 자주 손대는 코드가 하위 레이어에 있어서 변경 한 번에 캐시가 넓게 깨지는 케이스가 있거든요.
다만 레이어를 옮기는 건 의존 구조 자체를 건드리는 큰 작업인데, 현재 hit rate(Turbo 77%)와 빌드 시간이 이미 충분히 좋아서 그 정도까지 깎을 가치는 아직 없다고 판단했어요. 캐시가 더 큰 병목이 되는 시점이 오면 그때 다시 볼 레버로 남겨뒀어요.
남은 과제
CI/CD 개선은 "한 번 하면 끝"이 아니에요. 캐시 hit rate가 떨어지지는 않는지, 새 워크플로가 추가되면서 불필요한 실행이 생기지는 않는지 계속 모니터링해야 해요. 지금 보고 있는 과제도 몇 가지 있어요.
- 워크플로 큐잉 대기 시간: 결과 표 아래에서 언급한, runner pool 부족으로 워크플로가 펜딩되는 문제예요. 빌드/배포 시간 자체는 충분히 줄였지만, 동시 실행이 많으면 runner가 새로 뜨는 콜드 스타트가 심해져요. 처음엔 배포용 self-hosted runner의 콜드 스타트만 보고 있었는데, 파고들어 보니 GitHub-hosted
ubuntu-latest-large도 동시 수요가 몰리면 콜드 스타트가 생기더라고요. 결국 특정 runner만의 문제가 아니라 양쪽 다 얽혀 있던 거예요. 큰 runner 수요 자체를 앱 규모에 따라 나눠 분산하거나, 그래도 부족하면 self-hosted로 전환하는 방향을 검토하고 있어요. 배포 쪽에도 비슷한 구조가 있어요. 저희는 실제 배포를 ArgoCD가 맡고 GitHub Actions는 트리거·전달만 하는데, 이 진입점 job도 서비스마다 runner를 하나씩 잡아서 동시 배포 때 한꺼번에 모자라요. 여러 서비스 배포를 묶어 단일 runner에서 처리하는 식으로 점유를 줄이는 것도 함께 보고 있어요. - 캐시 I/O 병목: 빌드가 빨라질수록, 워크플로마다 캐시를 저장·복원하는 I/O 시간이 상대적으로 더 큰 비중을 차지해요. 특히 워크플로가 순차로 이어질 때 이 I/O가 누적돼서 두드러져요. 캐시에 담기는 산출물을 줄이는 것부터, read를 제외한 영역을 artifact로 바꾸거나 다음 워크플로를 곧바로 잇는 방식, 그리고 아래 self-hosted runner 도입까지 묶어서 보고 있어요.
- self-hosted runner 캐시 도입: DevOps 팀이 self-hosted runner 캐시 최적화를 진행 중이고, 도입을 같이 준비하고 있어요. 다만 self-hosted runner는 전략을 잘 세우지 못하면 운영·서버 비용이 비싸져서 모든 워크플로에 적용하기는 적절하지 않아요. 그러다 보니 GitHub 기본 runner와 self-hosted runner가 섞이게 되고, runner 환경이 달라서 캐시를 공유하기 어려워지는 새로운 문제가 생겨요. 이 환경 간 캐시 공유를 위한 별도 워크플로도 같이 설계해야 해요. 비용 / 성능 / 캐시 활용도 사이의 트레이드오프를 고려해야 해요.
마치며
CI/CD 개선은 한 번 만들고 끝나는 게 아니에요. 캐시 하나만 봐도 namespace, 용량, 해시 입력이 맞물리고, 한 곳을 건드리면 다른 곳이 터지는 일이 생겨요. 그래서 이 글을 관통한 질문은 "어떻게 더 빠르게"보다 "무엇을 캐싱하고 무엇을 안 할지, 어디까지 하고 어디서 멈출지"였어요. 캐시도 retry도 워커도, 좋은 도구라서가 아니라 목적이 분명할 때만 켰고요.
그리고 이번 작업에서 가장 신경 쓴 부분은 "내가 없어도 유지보수 가능하게 만들기"예요. 문서 없이 몇 년이 지나면 작성자의 의도를 파악할 수 없고, 거버넌스가 없으면 의도와 다른 코드가 들어와도 잡을 수 없어요. 거버넌스와 문서화는 good to have가 아니라 must have예요.
AI 시대일수록 그 가치는 더 커져요. 기준이 명확할수록 AI도 그 기준 안에서 더 잘 작동하니까요. AI에게 작업을 요청하면 "이 캐시 키를 바꾸면 namespace 충돌이 생길 수 있어요"처럼 의도와 파급 범위까지 함께 짚어줘요. 문서가 AI의 판단 근거가 되는 거예요.