Next.js 빌드 워커를 줄여 메모리와 빌드 시간을 개선한 기록

두 웹/앱을 Yarn Workspaces 모노레포로 분리한 뒤, Docker 빌드가 메모리 부족으로 실패하기 시작했습니다. 전환 전에도 같은 Docker 환경을 사용했기 때문에 처음에는 새로 바꾼 구조를 의심했습니다.

빌드 중 프로세스를 살펴보니 Next.js가 워커 13개를 실행하고 있었고, 워커 수를 바꿔 비교한 결과, 앱 A의 Docker VM 피크 메모리는 8,034MB에서 3,506MB로 줄었고, 전체 빌드 시간도 57초에서 43초로 짧아졌습니다. 병렬도를 낮췄는데 시간까지 줄어든 결과였습니다.

이후에는 이전 커밋들을 다시 빌드 해봤습니다. 모노레포 전환으로 워커가 늘어난 것인지, 전환 전부터 같은 비용이 있었던 것인지 구분하기 위해서였습니다.

빌드 로그에서 발견한 워커 13개

당시 측정은 2026년 8월, Next.js 16.1.6에서 진행했습니다. 맥의 물리 메모리는 24GB였고, Docker Desktop VM에는 7,936MB를 할당해 두었습니다. VM에서 인식한 CPU 수는 14개였습니다.

빌드 중 VM 메모리를 3초 간격으로 샘플링했을 때 기본 설정의 피크는 앱 A 8,034MB, 앱 B 7,493MB였습니다. VM 할당량과 가까운 수치여서 프로세스별 사용량을 확인했습니다.

앱 A의 빌드 로그에는 다음과 같이 표시됐습니다.

Collecting page data using 13 workers ...
Generating static pages using 13 workers (26/26)

부모 next build 프로세스는 약 519MB, 자식 프로세스 13개는 각각 212~266MB를 사용하고 있었고, 자식 프로세스들의 합계만 약 2.9GB였습니다.

여기서 VM 피크와 프로세스별 메모리는 구분해야 합니다. VM 피크는 Docker VM 전체의 used 값으로, Next.js뿐 아니라 BuildKit과 이미지 export 등의 사용량도 포함되고 워커 합계와 측정 범위가 다르므로 두 값을 빼서 나머지 메모리의 원인을 계산할 수는 없습니다. 할당량과 피크의 비교만으로 스왑 사용이나 빌드 실패 원인을 확정할 수도 없습니다.

CPU 수로 정해지는 기본 병렬도

설치된 Next.js 코드를 확인하니 experimental.cpus의 기본값은 다음 기준으로 계산됐습니다.

max(1, CPU 개수 - 1)

CIRCLE_NODE_TOTAL이 있으면 그 값을 우선 사용하고, 없으면 os.cpus().length를 사용합니다. 당시 VM에서는 CPU 14개를 인식했으므로 기본 워커 수가 13개였습니다. 메모리 기반 워커 수 설정은 기본적으로 꺼져 있어, VM의 메모리 여유가 이 값에 반영되는 구조는 아니었습니다.

이 프로젝트의 정적 워커는 별도 자식 프로세스로 실행됐습니다. 페이지의 정적 생성 여부를 확인하거나 HTML을 생성할 때 페이지 모듈과 React, Next.js 런타임, 페이지가 참조하는 공용 코드를 로딩합니다. 프로세스마다 JavaScript 힙과 모듈 상태를 갖기 때문에 일을 나누는 이득과 별개로 메모리와 준비 비용이 듭니다.

워커들만 약 2.9GB를 사용하고 있었으므로, 먼저 워커 수를 줄여 빌드 시간과 메모리가 어떻게 달라지는지 비교했습니다.

워커 수에 따른 빌드 시간과 메모리

두 앱 각각 별도 Git worktree에서 워커 수를 기본값·4개·1개로 바꿔 Docker 빌드를 실행했습니다. 각 설정은 1회씩 측정했고, VM 메모리는 3초 간격으로 샘플링했습니다.

앱 A, 프리렌더 26페이지

워커 수Docker 전체 빌드페이지 단계VM 피크 메모리
기본값 13개57초17.3초8,034MB
4개45초10.1초4,769MB
1개43초9.0초3,506MB

앱 B, 프리렌더 27페이지

워커 수Docker 전체 빌드페이지 단계VM 피크 메모리
기본값 13개44초10.5초7,493MB
4개38초7.2초4,428MB
1개36초7.4초3,466MB

워커 1개에서 앱 A의 피크 메모리는 기본값 대비 약 56%, 앱 B는 약 54% 줄었습니다.

Docker 전체 빌드 시간도 각각 14초, 8초 짧아졌고, 두 앱 모두 전체 시간과 피크 메모리가 가장 작았던 설정은 워커 1개였습니다.

페이지 단계만 보면 앱 B는 워커 4개가 1개보다 0.2초 빨랐습니다. 설정당 한 번의 측정이므로 이 차이만으로 일반적인 우열을 판단하기는 어려웠습니다.

앱 A의 정적 생성 로그에 기록된 렌더 시간은 워커 13개에서 281.3ms, 1개에서 139.9ms였습니다.

기본 설정의 페이지 단계 전체가 17.3초였던 것에 비하면 렌더 작업은 짧았고 이런 작업에서는 병렬 처리로 아끼는 시간보다 여러 프로세스를 시작하고 모듈을 로딩하는 비용이 클 수 있습니다.

측정 결과는 이 해석과 맞지만, 페이지 단계에서 줄어든 8.3초를 전부 워커 기동 비용이라고 볼 수는 없습니다. 기동, 모듈 로딩, 메모리 압박의 영향을 따로 측정하지는 않았습니다.

전환 전부터 있었던 비용

워커 수 조정의 효과는 확인했지만, 그것만으로 모노레포 전환 직후 문제가 생긴 이유가 설명되지는 않았습니다.

처음에는 전환 전 워커 수와 메모리를 측정한 기록이 없었기 때문에 이전 커밋들을 임시 Git worktree에서 다시 빌드했습니다.

이번 비교는 14코어 맥에서 next build --webpack을 직접 실행한 결과입니다. 앱 A와 앱 B는 각각 같은 앱의 전환 전후를 비교했고, 사이트 분리 전 단일 앱도 기준으로 함께 확인했습니다.

사이트 분리 전과 앱 A 비교

시점커밋최대 메모리프로세스 수
사이트 분리 전, 단일 앱 49페이지40aebb1^4.18GB16
pageExtensions, 앱 A40aebb14.24GB16
모노레포, 앱 A8bd8a314.38GB16
모노레포, 앱 A + cpus: 1설정 변경 적용2.46GB4

앱 B도 pageExtensions 시점의 4.19GB에서 모노레포 이후 4.75GB로 늘었습니다. 모노레포 전환 전후 차이는 앱 A 0.14GB, 앱 B 0.56GB였습니다.

세 시점 모두 로그에는 using 13 workers가 표시됐습니다. 표의 프로세스 수 16개는 워커 외의 프로세스도 포함한 값이였습니다.

워커 13개를 실행하는 비용은 모노레포에서 새로 생긴 것이 아니라 사이트 분리 전부터 있었습니다.

모노레포 앱 A에서 cpus: 1만 적용하면 최대 메모리는 4.38GB에서 2.46GB로 줄었습니다. 전환 전후의 차이보다 워커 수를 바꿨을 때의 차이가 컸고, Docker 측정과도 감소 방향이 같았습니다.

이 비교에서는 pageExtensions 도입이나 모노레포 전환에 따른 메모리 급증이 재현되지 않았었고, 다만 전환 과정에서 추가한 transpilePackages와 outputFileTracingRoot 등의 영향을 각각 분리한 실험은 아닙니다.

또한 맥 직접 빌드의 최대 메모리와 Docker VM 전체의 used는 환경과 측정 범위가 다릅니다. 두 수치를 직접 비교해 Linux에서 메모리가 두 배 필요하다고 해석할 수는 없습니다.

워커 1개를 적용한 판단

Docker Desktop에 메모리를 더 할당하는 것도 가능한 선택이었습니다. 다만 로컬 VM 할당량을 늘리는 대신 저장소의 빌드 설정을 바꾸면 다른 개발 환경에서도 같은 워커 수를 사용하도록 할 수 있었습니다.

비교한 설정 중 워커 1개는 메모리를 가장 적게 사용하면서 전체 빌드도 가장 빨랐습니다. 두 앱에 다음 설정을 적용했습니다.

experimental: { cpus: 1 },

워커 4개도 또한 선택할 수 있었습니다. 앱 A에서는 1개보다 전체 빌드가 2초 느리고 피크 메모리는 약 1.3GB 높았습니다.

당시에는 메모리 여유를 더 크게 확보하는 쪽을 선택했습니다. 다른 머신이나 CI에서도 같은 시간과 메모리 감소를 보장한다는 의미는 아니구요.

이 설정은 당시 버전에서 정적 페이지 관련 워커 풀의 수를 조정합니다. Docker의 CPU를 하나로 제한하거나 Webpack을 포함한 빌드 전체를 단일 프로세스로 만드는 설정 또한 아니였습니다.

남은 불확실성과 적용 범위

추가 비교로 워커 비용이 전환 전부터 있었다는 점은 확인했습니다. 당시 Docker 빌드가 모노레포 전환 직후에 실패한 계기는 확정하지 못했습니다. 맥에서 이전 커밋을 다시 빌드한 결과만으로 당시 Docker 환경의 실패 원인까지 배제할 수는 없습니다.

당시 VM 전체 사용량은 약 8GB로 할당량에 가까웠습니다. 전환 과정에서 늘어난 메모리나 VM 안의 다른 작업이 남은 여유를 줄였을 가능성은 있지만, 당시 Docker Desktop 설정과 다른 컨테이너의 사용량을 재현한 기록은 없습니다. 중첩된 .next가 Docker 제외 규칙에서 빠져 빌드 컨텍스트에 약 500MB가 실리던 문제도 따로 수정했으나, 이 전송량을 그대로 메모리 증가량으로 볼 수는 없습니다.

워커 1개를 선택한 결과는 프리렌더 페이지가 26~27개였던 작업량에 대한 것입니다. 빌드할 페이지가 2,000개로 늘거나 페이지별 데이터 조회가 무거워지면 병렬 처리의 이득도 달라집니다. 그때는 빌드 시간과 피크 메모리를 다시 비교해 워커 수와 VM 할당량을 함께 조정해야 합니다. 워커가 적어도 개별 작업이나 다른 빌드 단계에서 메모리를 많이 사용하면 실패할 수 있습니다.

상품 수와 프리렌더 페이지 수 역시 구분해야 합니다.

당시 상품 상세는 getServerSideProps로 요청 시 렌더링했으므로, 상품이 2,000개가 된다고 상세 HTML 2,000개를 빌드에서 생성하는 구조 또한 아니였구요.

이번에는 CPU 수에 따라 정해진 기본 병렬도를 실제 작업량에 맞춰 낮추면서 시간과 메모리를 함께 줄일 수 있었습니다. 빌드 작업이 커지면 같은 설정을 유지하기보다, 그때의 작업량과 측정 결과를 기준으로 다시 한번 선택해볼려고 합니다.

검색어를 입력해 보세요.