RSS
Next.js

Next.js Docker 빌드 캐시: 배포 CSS에서 패키지 스타일이 빠진 문제

작성자
듀오랩스 대표·8분 읽기

테마 메뉴에서 디자인 시스템을 바꾸는 기능을 배포했습니다. 메뉴에는 「Betelgeuse 라임 다크」라고 정확히 찍히는데, 화면은 기본 시스템 그대로였습니다. 바로 전 배포에서는 같은 기능이 잘 동작했습니다. 그 사이에 바뀐 것은 디자인 시스템 패키지의 버전 하나였습니다.

메뉴는 바뀌고 화면은 그대로인 상태

먼저 브라우저에서 <html> 태그를 봤습니다. data-system="betelgeuse"와 data-variant="lime"이 제대로 붙어 있었습니다. 선택과 저장, 첫 화면 스크립트까지는 정상이라는 뜻입니다. 그런데 계산된 CSS 변수를 찍어 보니 --background가 #000, --primary가 #8fb2ff였습니다. Betelgeuse 다크라면 #111과 #b6f400이 나와야 합니다. 속성은 맞는데 그 속성을 받는 CSS가 없었습니다.

처음에는 다크 모드 선택자 문제라고 생각했습니다. 지난번 배포 직후에는 라이트 모드만 확인했기 때문입니다. 라이트로 다시 열어 보니 라이트도 똑같이 기본 시스템이었습니다. 명암 문제가 아니었습니다.

운영 CSS를 받아서 세어 본 숫자

운영 페이지가 링크한 CSS 파일을 내려받아 data-system="betelgeuse"를 검색했더니 0건이었습니다. 여기서 한 번 헛걸음을 했습니다. 압축된 CSS는 속성 선택자의 따옴표를 지워 [data-system=betelgeuse]로 바꿉니다. 따옴표를 넣고 검색하면 규칙이 있어도 0건이 나옵니다. 압축에 영향받지 않는 색상 값 #fff7d6으로 다시 셌는데, 이것도 0건이었습니다. 정말로 빠져 있었습니다.

같은 커밋을 제 컴퓨터에서 빌드한 결과와 나란히 놓으니 차이가 분명했습니다.

운영 빌드 로컬 빌드
주 CSS 파일 31nsto1l6vkts.css 3v0z32bwtyn7o.css
크기 138,935 B 152,771 B
#fff7d6(Betelgeuse 바탕) 0 1
min-w-52(이번 배포에서 처음 쓴 클래스) 1 1

마지막 줄이 이상했습니다. 이번 배포에서 처음 쓴 Tailwind 클래스는 운영 CSS에도 들어 있었습니다. 낡은 CSS가 통째로 다시 나간 것이 아니라, 새로 만든 CSS에서 패키지가 들고 오는 스타일시트 한 덩어리만 빠진 것입니다. 앱의 globals.css는 디자인 시스템 토큰 다음 줄에서 Betelgeuse의 scope.css를 @import하고 있었고, 그 내용이 결과물에서 사라져 있었습니다.

빈 캐시로 다시 빌드하니 들어간 스타일

로컬 빌드에는 들어가고 운영 빌드에는 빠지니, 차이는 빌드 환경에 있었습니다. 운영은 Docker 안에서 빌드하고, Dockerfile의 빌드 단계는 이렇게 되어 있었습니다.

RUN --mount=type=cache,target=/app/.next/cache npx prisma generate && npm run build

--mount=type=cache는 빌드가 끝나도 이 디렉터리를 버리지 않고 다음 빌드에 다시 붙여 줍니다. 확인하려고 같은 커밋을 Docker로, 이 캐시 없이 빌드해 봤습니다. 처음에는 no space left on device로 실패했습니다. 작업 폴더에 수십 GB짜리 디렉터리가 있었는데 .dockerignore에 빠져 있어서 빌드 컨텍스트로 통째로 올라간 것입니다. git archive HEAD | docker build -로 커밋 내용만 넘기자 빌드가 끝났습니다. 결과물의 CSS 파일 이름은 로컬 빌드와 똑같은 3v0z32bwtyn7o.css였고, #fff7d6도 들어 있었습니다. 같은 코드, 같은 패키지에서 공유 캐시가 있을 때만 스타일이 빠진 것입니다.

공식 가이드와 달랐던 캐시 키

처음에는 캐시를 빌드끼리 공유한 것 자체가 잘못이라고 생각했습니다. 찾아보니 그렇지 않았습니다. Next.js의 CI 빌드 캐싱 가이드는 13 버전 때부터 지금까지 .next/cache를 빌드 사이에 보존하라고 권합니다. 차이는 키에 있었습니다. 가이드의 GitHub Actions 예시는 캐시 키를 잠금 파일(package-lock.json) 해시로 만들어서, 패키지가 바뀌면 새 캐시로 시작합니다. Docker의 --mount=type=cache에는 그런 키가 없습니다. 패키지 버전을 올려도 같은 캐시 디렉터리가 그대로 붙습니다.

여기에 Next.js 16.3에서 바뀐 것이 겹쳤습니다. 16.3부터 Turbopack이 next build에서도 파일 시스템 캐시를 기본으로 씁니다. 이 앱의 16.3.8에서도 설정 기본값이 turbopackFileSystemCacheForBuild: true인 것을 확인했습니다. 공식 문서는 이 기능이 아직 베타이고 안정성 문제를 예상하라고 적어 두었습니다. 같은 Dockerfile을 예전 버전에서 쓸 때는 이런 증상을 본 적이 없으니, 이번 일의 계기는 이 새 캐시였다고 봅니다.

첫 배포는 되고 두 번째 배포는 안 된 차이

돌아보면 두 배포의 차이는 하나였습니다. 첫 배포는 globals.css에 import 줄을 새로 넣은 커밋이었습니다. 앱 소스가 바뀌었으니 그 CSS는 처음부터 다시 만들어졌습니다. 두 번째 배포에서는 globals.css를 건드리지 않고 node_modules 안 패키지의 버전만 올렸습니다.

여기서부터는 확인하지 못한 부분입니다. 캐시가 무엇을 근거로 패키지 스타일시트를 빠뜨렸는지는 모릅니다. 의심 가는 곳은 있습니다. npm은 패키지 tarball 안 파일의 수정 시각을 1985년 10월 26일 하나로 고정해서, 버전이 바뀐 파일도 수정 시각만 보면 같습니다. 캐시가 수정 시각으로 변경을 판단한다면 이 경우를 놓칠 수 있습니다. 공유 캐시는 그대로 둔 채 turbopackFileSystemCacheForBuild만 끄고 빌드해 보면 두 원인 중 어느 쪽인지 가를 수 있는데, 그 실험은 하지 않았습니다. 제가 확인한 것은 「같은 커밋을 빈 캐시로 빌드하면 스타일이 들어간다」까지입니다.

캐시 마운트를 뺀 결정

고친 것은 Dockerfile 한 줄입니다. 빌드 단계에서 --mount=type=cache,target=/app/.next/cache를 빼고, 왜 뺐는지를 주석으로 남겼습니다. 다음 배포부터 운영에서 Betelgeuse의 바탕 #fff7d6, 라임 다크 주색 #b6f400, 테두리 굵기 2px이 모두 정상으로 나왔습니다.

다른 길도 있었습니다. 캐시를 쓰되 가이드처럼 잠금 파일 해시로 키를 만들거나, Turbopack 빌드 캐시만 끄는 방법입니다. 저는 운영 이미지를 만드는 빌드에서는 캐시를 빼는 쪽을 골랐습니다. 운영 빌드의 결과는 커밋 하나로 정해져야 한다고 봅니다. 공유 캐시는 그 사이에 「전에 무엇을 빌드했는가」라는 보이지 않는 입력을 끼워 넣습니다. 이번에는 화면에 바로 드러났지만, 같은 식으로 JS 한 덩어리가 낡은 채 나갔다면 한참 뒤에야 알았을 것입니다. 캐시가 있던 빌드의 컴파일 단계는 36.3초였습니다. 매번 처음부터 만들면 그보다 길어지겠지만, 하루 몇 번 배포하는 규모에서는 감수할 만한 비용이라고 판단했습니다.

다른 앱에 남은 같은 캐시 마운트

같은 Dockerfile 템플릿으로 시작한 다른 앱들도 이 캐시 마운트를 그대로 갖고 있습니다. 아직 증상이 드러나지 않았을 뿐이라 하나씩 정리할 생각입니다. 압축된 CSS를 검색할 때는 따옴표 없는 형태나 색상 값처럼 압축에 영향받지 않는 문자열로 찾는 것이 안전합니다. 첫 검색 결과만 믿었다면 원인을 엉뚱한 곳에서 찾았을 것입니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.