DevInsight

개발하며 겪은 판단을 적어요

DevOps
조회 0약 4분 읽기

이미지를 얇게 만들수록 빌드가 비대해지는 역설

Next.js/Node.js 컨테이너를 multi-stage 빌드와 standalone 출력으로 최적화하다 보면 용량은 80% 줄었는데 빌드 시간은 되레 늘고, 캐시 무효화로 매 배포마다 의존성을 새로 내려받는 경우가 생긴다. 이 글은 무엇을 줄여야 하고 무엇은 절대 건드리면 안 되는지를 가르는 기준을 결정 메모 형식으로 정리하고, 최적화가 역효과를 내는 지점을 짚는다.

DevInsight에서 발행해요

#Docker#Next.js#Node.js#multi-stage 빌드#레이어 캐시#standalone#이미지 최적화#빌드 시간#DevOps

Next.js를 multi-stage 빌드로 옮기고 standalone 출력을 켜면 이미지 크기는 80%쯤 줄어든다. 그런데 배포 파이프라인의 벽시계 시간을 재보면 빌드가 되레 느려져 있는 경우가 많다. 의존성을 매번 새로 내려받고, 캐시가 자주 무효화되기 때문이다. 이미지가 얇아지는데 시간은 늘어난다. 이 역설은 이미지 최적화의 표면과 이면이 다른 곳에서 생긴다.

얇아진 건 이미지뿐이다, 빌드는 통째로다

multi-stage 빌드의 구도는 세 개의 stage로 갈린다. 의존성만 설치하는 stage, next build를 실행하는 stage, 산출물만 담는 runtime stage. 마지막 stage는 standalone 디렉토리와 .next/static, public만 복사하면 끝난다. devDependencies와 소스 전체는 거기에 없다. 그래서 이미지가 가볍다. 1GB를 넘던 이미지가 150MB 근처까지 내려가는 것도 드물지 않다. 이 숫자만 보면 최적화는 성공적으로 보인다.

모순은 그다음 단계에서 드러난다. standalone은 next build가 소스와 의존성을 트레이싱해서 만들어낸다. 그러려면 빌드 stage에 TypeScript, ESLint, tailwind 같은 devDependencies가 전부 갖춰져 있어야 한다. 배포되는 이미지는 얇지만, 배포 때마다 실행되는 빌드는 여전히 무겁다. 차체만 가볍게 만든 것이지 엔진이 작아진 게 아니다.

그 무거운 빌드는 레이어 캐시를 자주 깨고 다닌다. Docker는 이전 레이어가 조금이라도 바뀌면 이후 레이어를 전부 새로 만든다. 그래서 관례상 package.json과 lockfile을 먼저 복사하고 npm ci를 돌린 뒤 소스를 복사한다. 소스 한 줄이 바뀌어도 node_modules 레이어의 캐시는 유지되게 하려는 의도다.

이 관례가 통하는 조건은 하나다. lockfile이 자주 바뀌면 안 된다. npm install을 손으로 돌려 lockfile이 출렁이는 저장소, 의존성 갱신 PR이 매일 들어오는 저장소는 npm ci 레이어가 계속 깨진다. 그때마다 전체 의존성을 다시 내려받는다. 이미지는 얇은데 빌드 시간은 2분이던 게 10분이 되는 식이다. 레이어 순서 배열이 아니라 lockfile의 안정성이 캐시의 전제라는 뜻이다.

더 흔한 실수는 순서가 아니라 복사 방식에 있다. COPY . . 다음에 npm ci를 두면 소스 변경이 곧 의존성 레이어 무효화다. .dockerignore가 없으면 node_modules와 .next, .git이 빌드 컨텍스트에 통째로 실려 매번 데몬으로 전송된다. 캐시 정책을 아무리 잘 세워도 이 전송 비용은 남는다. 컨텍스트가 300MB라면 소스 한 줄 바꿨을 때 매번 300MB가 왔다 갔다 한다. 이 지점을 고치는 파일은 Dockerfile이 아니라 .dockerignore 한 장이다.

결정 메모: 줄여야 할 것, 건드리면 안 되는 것

최적화를 시작하기 전에 메모로 남겨둘 만한 기준을 세 개로 정리했다.

runtime stage는 진짜로 줄여도 된다. standalone을 쓰고 node_modules를 통째로 복사하지 않는다. .next/standalone.next/static, public을 각각 다른 COPY 문으로 나누면 레이어 분리도 된다. 여기에 함정이 하나 있다. standalone은 static 자산을 포함하지 않는다. .next/static 복사를 빼먹으면 이미지가 404를 뱉고, 그건 빌드가 성공한 뒤 런타임에서야 발견된다. 정적 파일이 통째로 누락된 채 첫 배포가 무사히 올라가는 이유가 이거다.

빌드 stage는 크기가 아니라 시간을 줄이는 대상이다. 이 이미지는 어차피 배포되지 않는다. 대신 매 빌드의 의존성 재다운로드를 없애는 게 목표다. BuildKit의 캐시 마운트가 그 역할을 한다.

FROM node:20-bookworm-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci COPY . . RUN --mount=type=cache,target=/root/.npm npm run build

--mount=type=cache,target=/root/.npm을 달면 타르볼 캐시는 빌더 머신에 남고, 레이어를 새로 만들지 않으면서 재다운로드만 피한다. npm ci는 node_modules를 여전히 재구성하지만 네트워크를 지나지 않는다. 사설 레지스트리 인증을 쓰는 팀이라면 이 캐시가 인증 토큰을 담지 않는지도 함께 확인해야 한다. pnpm을 쓰는 팀은 대신 pnpm fetch로 전역 스토어를 미리 채우는 방식이 같은 목적을 더 빠르게 이룬다.

캐시 마운트에도 맹점이 있다. CI 러너가 매번 새 머신이라면 로컬 캐시는 그 빌드에서 끝난다. buildx의 원격 캐시나 레지스트리 캐시를 걸지 않는 한, 이런 환경에서는 마운트 캐시가 거의 효력을 잃는다. 캐시가 어디에 살아남는지는 환경에 따라 다르다. 로컬 빌드냐, 상시 유지되는 러너냐, 매번 새로 뜨는 러너냐를 먼저 확인하는 것이 순서다.

순서와 lockfile은 건드리면 안 된다. package 파일을 먼저 복사하는 순서, npm ci를 쓰는 규율, lockfile의 안정성. 이 셋은 하나라도 무너지면 의존성 레이어가 하루에 한 번꼴로 깨진다고 보면 된다. 여기에 빼먹기 쉬운 조건이 하나 더 있다. npm ci는 NODE_ENV=production이 아니면 devDependencies까지 설치한다. runtime stage에서 node_modules를 다시 만들지 않는다면야 상관없지만, base 이미지에 npm이 남아 있어 실수로 npm ci를 한 번 더 돌리는 순간 이미지 크기는 조용히 원상복구된다. 그때 devDependencies가 담긴 레이어가 생겼다는 로그도, 에러도 없다.

얇아진 이미지가 무너지는 시점

역효과는 대개 최적화 대상이 아닌 데서 온다. 알파인으로 바꿔가며 몇십 MB를 더 깎는 시도가 대표적이다. sharp 같은 네이티브 모듈은 glibc가 아니라 musl용 바이너리가 필요하다. 사전 빌드된 바이너리가 없으면 소스 컴파일로 넘어가고, 그 순간 python3와 gcc가 필요해진다. 빌드 stage에서 컴파일이 끝났어도, 런타임 stage의 Node 버전이 빌드 stage와 달라지면 이진 호환성이 깨진다. 20MB를 아꼈다가 배포를 두 번 실패한 비용은 메우기 힘들다. node:20-bookworm-slim 하나로 타협하는 팀이 많은 이유다.

Node 버전 고정도 같은 축에 있다. 빌드 stage가 Node 20이고 runtime stage가 Node 18이라면, standalone이 운반한 산출물은 런타임에서 이상 동작을 일으킬 수 있다. 두 stage의 베이스 버전을 하나의 ARG로 빼두면 그 불일치 자체가 사라진다. Next.js가 요구하는 최소 Node 버전을 어기는 배포는 대부분 여기서 비롯된다.

세 번째 실패 지점은 검증을 이미지 크기로만 하는 경우다. docker image ls의 SIZE 열은 저장 공간이지 배포 비용이 아니다. 레지스트리에서 이미지를 내려받는 시간은 압축된 전송 크기에 좌우된다. docker save | gzip -c | wc -c로 전송 바이트를 재면 크기 최적화가 헛일이었는지 단번에 드러난다. 레이어별 점유는 docker history로, 캐시 히트 여부는 빌드 로그의 CACHED 표시로 확인한다. 최적화 전후의 벽시계 시간을 기록해 두지 않았다면, 지금 하는 작업이 뭘 위해 하는 것인지부터 다시 물어봐야 한다.

남길 판단 기준 하나

이 최적화를 크기와 시간의 트레이드오프로 읽으면 늘 헤맨다. 무엇을 줄였는지가 아니라 어떤 레이어가 매번 다시 만들어지는지를 먼저 물어야 한다. 기준은 하나다. 소스 파일 한 줄을 바꿨을 때 node_modules 레이어의 캐시가 깨지는가. 깨진다면 원인이 lockfile인지, COPY 순서인지, 컨텍스트 전송인지 추적하는 것이 시작점이다. 1GB를 100MB로 줄인 팀보다, 배포마다 10분 걸리던 의존성 설치를 0분으로 만든 팀이 더 큰 이득을 봤다. 다음 배포에서 이미지 크기 보고부터 여는 대신 빌드 로그의 CACHED 줄부터 열어 보자.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

DevOps 전체 보기
DevOps

삽질 없이 CI를 줄이는 캐시 3종 세트

GitHub Actions에서 의존성 캐시와 빌드 캐시를 도입했는데도 정작 빌드가 느리거나, 캐시가 오히려 잘못된 결과를 재사용하며 깨지는 경험을 해봤다면 이 글이 답이다. cache와 setup-*의 동작 차이, 매트릭스 분할 전략, 캐시 무효화 판단 기준을 함정과 함께 정리해 실패 없이 CI 시간을 단축하는 법을 다룬다.

#GitHub Actions#CI#캐시#빌드 최적화
DevOps

INFO, WARN, ERROR만으로는 부족하다

console.log에서 JSON 로그로 가는 건 시작일 뿐이다. 로그 레벨을 모호하게 정의하면 알람이 무의미해지고, 스키마 없이 쌓은 로그는 검색조차 불가능하다. 마스킹을 미루면 개인정보가 로그 플랫폼에 그대로 노출된다. 급증하는 로그 비용도 간과할 수 없다. 이 글은 로그 레벨 기준, 공통 필드, 마스킹, 비용 거버넌스 등 구조화 로깅 도입 전에 반드시 정해야 할 결정들을 기록한다.

#구조화로깅#로그레벨#observability#DevOps
DevOps

프론트엔드 개발자가 배포에서 벗어나는 순간

Vercel이 'Develop. Preview. Ship.'으로 압축한 것은 단순한 마케팅 문구가 아니다. 로컬 개발부터 프로덕션 배포까지 원클릭으로 연결하는 경험은 프론트엔드 개발 문화를 재정의하고 있다. 이 글에서는 Vercel이 만들어낸 배포의 투명화와 그 이면에 있는 기술적 트레이드오프, 그리고 팀이 겪는 현실적인 도전을 짚어본다.

#Vercel#프론트엔드#배포#CI/CD

이전 글

console.log를 놓아주는 밤, 미리 답해야 할 다섯 가지 질문

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독