ywc.life

모노레포 Dockerfile에서 yarn install 캐시가 매번 깨지는 이유

· docker, monorepo, ci, build-cache

CI에서 빌드 캐시를 분명히 켜놨는데, 버튼 색 하나 고친 커밋에서도 yarn install이 처음부터 다시 돈다면 어디를 의심해야 할까요? 우리 팀의 Docker 빌드가 정확히 그랬어요. 매번 6분씩 걸리던 이 문제가 COPY 몇 줄을 바꾸는 PR 하나로 해결됐는데, diff만 봐서는 이게 왜 빨라지는지 바로 이해가 안 됐어요. 이 글은 그 PR을 분석하고 직접 재현해보면서 배운 Docker 레이어 캐시 원리의 정리예요.

문제: 캐시가 있는데 매번 재설치

Yarn workspaces 모노레포를 단일 Dockerfile로 빌드하고 있었어요. 의존성 설치를 담당하는 deps 스테이지는 이런 모양이었죠.

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn
COPY packages/ ./packages/     # 전체 소스 복사
RUN yarn install

BuildKit 레이어 캐시도 켜져 있었어요. 그런데 의존성을 하나도 안 건드린 커밋에서도 매번 yarn install이 통째로 다시 실행됐어요.

diff는 단순한데, 왜 3배가 빨라질까?

이 문제를 해결한 PR은 허무할 만큼 단순했어요. COPY packages/ 한 줄을 package.json만 골라 복사하는 여러 줄로 쪼갠 게 사실상 전부였거든요. 처음엔 캐시 자체가 고장 나 있었던 건가 싶었는데, 기존 빌드의 로그를 다시 보니 이야기가 달랐어요.

#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY packages/ ./packages/
#8 [4/4] RUN yarn install          ← CACHED가 없다

WORKDIR까지는 분명히 CACHED가 찍혀요. 캐시는 정상 동작 중이었어요. **미스가 시작되는 지점이 정확히 COPY packages/**였던 거죠. 캐시가 고장 난 게 아니라, 이 레이어부터는 "캐시를 쓰면 안 된다"고 Docker가 판단하고 있었어요. 왜일까요?

원리: Docker의 캐시 판정 규칙

Dockerfile의 COPY/RUN 한 줄이 각각 레이어가 되고, 캐시 판정 규칙은 두 가지예요.

  • COPY: 복사되는 파일 내용의 체크섬이 이전 빌드와 같으면 캐시 히트
  • RUN: 명령 문자열이 같고 + 위의 모든 레이어가 캐시 히트면 캐시 히트

그리고 한 레이어가 미스나면 그 아래 레이어는 전부 무조건 다시 실행돼요.

여기서 중요한 건, Docker는 yarn install이 실제로 어떤 파일을 읽는지 전혀 모른다는 점이에요. "위에서 뭐라도 바뀌었으면 이 명령의 결과도 달라질 수 있다"고 보수적으로 가정할 뿐이죠. yarn install의 진짜 입력은 package.jsonyarn.lock뿐인데, COPY packages/전체 소스를 install 앞에 놓는 바람에 소스 변경이 install 레이어의 캐시 키에 섞여 들어간 거예요.

정리하면 — 캐시는 켜져 있었지만, 깨질 수밖에 없는 순서로 레이어를 쌓아둔 것이었어요.

해결: install 앞에는 manifest만

이 원리를 알고 나면 PR의 수정이 정확히 급소를 찌른 거였다는 게 보여요. install 앞에는 워크스페이스별 package.json만 복사하는 거예요.

COPY package.json yarn.lock .yarnrc.yml ./
COPY .yarn ./.yarn
COPY packages/web/package.json ./packages/web/
COPY packages/admin/package.json ./packages/admin/
COPY packages/core/package.json ./packages/core/
# ...워크스페이스마다 한 줄씩
RUN yarn install

정말 달라질까요? 원리를 이해했는지 확인할 겸 직접 최소 재현을 만들어 돌려봤어요. 소스 파일(src.js) 한 줄만 바꾸고 재빌드한 로그를 나란히 놓으면 이래요.

── 전체 COPY (before) ──────────────────────
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY packages/ ./packages/
#8 [4/4] RUN yarn install            ← 재실행

── manifest만 COPY (after) ─────────────────
#6 [2/6] WORKDIR /app
#6 CACHED
#7 [3/6] COPY packages/web/package.json ./packages/web/
#7 CACHED
   ...(admin, core도 CACHED)
#10 [6/6] RUN yarn install
#10 CACHED                           ← 스킵!

install 레이어의 캐시 키에 영향을 주는 입력이 install이 실제로 읽는 파일들과 정확히 일치하게 된 거예요. 커밋 유형별로 정리하면 이렇게 돼요.

커밋 유형전체 COPY (before)manifest만 COPY (after)
소스만 변경install 재실행install 캐시 히트
의존성 변경install 재실행install 재실행 (당연히 해야죠)

깨져야 할 때만 깨지는 캐시가 된 거죠.

그럼 소스는 언제 들어올까요? multi-stage가 이 구조를 받쳐줘요.

FROM node:20-alpine AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .                          # 소스는 여기서
RUN yarn build

소스 변경은 builder 스테이지의 COPY . . 이후만 무효화하고, deps 스테이지는 건드리지 못해요. 스테이지 경계가 캐시 무효화의 방화벽 역할을 하는 셈이에요.

그런데 왜 글롭 한 줄이 아니라 패키지마다 한 줄씩일까?

여기서 자연스러운 의문이 하나 생겨요. 이렇게 쓰면 안 되나요?

COPY packages/*/package.json ./packages/

안 돼요. Docker의 COPY는 와일드카드에 매치된 파일들을 복사할 때 디렉터리 구조를 보존하지 않아요. 실제로 web, admin, core 세 워크스페이스를 만들어놓고 위 한 줄로 빌드해보면 결과가 이래요.

=== files ===
/app/packages/package.json     ← 파일이 하나뿐이다
=== content ===
{"name":"web"}                 ← 나머지 둘은 덮어써져서 사라졌다

세 파일이 전부 ./packages/ 바로 밑으로 평탄화되면서 서로 덮어쓰고, 마지막 하나만 남아요. 구조를 유지할 방법이 글롭에는 없어서 패키지별로 한 줄씩 명시하는 거예요.

물론 이 수동 열거에는 유지비용이 있어요. 새 워크스페이스를 추가할 때 COPY 한 줄을 잊으면 install이 그 패키지의 존재를 모른 채 돌아가요. PR에도 이걸 대비한 경고 주석이 함께 들어가 있더라고요.

# :warning: Add a line here whenever a new workspace package is added under packages/*.

대안도 있어요. 최신 Dockerfile 문법(labs 채널)의 COPY --parents는 글롭의 디렉터리 구조를 보존해주고, Turborepo의 turbo prune --docker는 아예 이 manifest 열거를 자동 생성해줘요. 이 PR은 프레임워크 도입 없이 최소 변경으로 같은 효과를 얻는 쪽을 택한 거였고요.

결과

dev 환경 실측 기준으로 빌드가 약 6분에서 약 2분으로 줄었어요. (여기에는 next build에 BuildKit 캐시 마운트를 적용한 최적화도 함께 포함돼 있지만, 가장 큰 축은 매 빌드 반복되던 재설치가 사라진 거예요.)

마무리: 세 가지 교훈

첫째, "캐시를 켰는가"보다 "캐시가 무효화되지 않는 순서인가"가 먼저예요. 캐시 인프라가 멀쩡해도 레이어 순서가 틀리면 캐시는 장식이 돼요. 빌드 로그에서 CACHED가 끊기는 지점을 찾는 것부터 시작하세요.

둘째, 각 레이어의 입력을 그 명령의 실제 입력과 일치시키세요. RUN보다 앞의 COPY는 전부 그 RUN의 캐시 키예요. install 앞에는 manifest만, 소스는 빌드 직전에 — 이 원칙 하나로 대부분 정리돼요.

셋째, 모노레포 Dockerfile에서 COPY <전체소스>가 install보다 앞에 있다면 일단 의심하세요. 코드 한 줄에 의존성 재설치가 따라온다면 십중팔구 이 패턴이에요.

참고 자료

댓글