
"로컬에서 되는데 Docker에서 안 돼요."
개발자라면 한 번쯤 들어봤을, 아니 직접 겪어봤을 문장이다. 무색 홈페이지를 PayloadCMS + Next.js 15 + React 19로 만들고 나서 Docker로 빌드하려고 했더니 에러가 7개나 연달아 터졌다. 로컬에서는 아무 문제 없이 돌아가던 코드가 Docker 빌드 환경에서는 완전히 다른 얼굴을 보여줬다.
양파를 까듯 한 에러를 고치면 그 뒤에 숨어있던 다음 에러가 드러났다. 하루 동안 7개 커밋을 찍으며 전부 잡았는데 같은 스택을 쓰는 분들에게 도움이 될 것 같아 정리해본다.
프로젝트 구성은 이렇다.
각각은 최신이고 잘 동작하는 기술이다. 문제는 조합이다. Docker 빌드는 로컬 개발 환경이 암묵적으로 제공하던 것들을 전부 걷어내기 때문에 이런 조합의 호환성 문제가 한꺼번에 드러난다.
아래 다이어그램이 전체 흐름이다. 빨간색이 에러, 파란색이 수정, 초록색이 최종 성공.
첫 에러부터 당황스러웠다. postcss.config.js에서 module.exports를 쓰고 있는데 ESM 프로젝트라서 CJS 문법을 이해하지 못한 거다.
Node.js의 ESM/CJS 이중 생활은 아직 진행 중이다. package.json에 "type": "module"이 있으면 모든 .js 파일을 ESM으로 해석한다. 그런데 PostCSS 같은 도구는 아직 CJS로 설정을 작성해야 하니까 확장자를 .cjs로 바꿔서 "이 파일은 CJS야"라고 명시해줘야 한다.
// postcss.config.cjs (확장자만 .js -> .cjs로 변경)
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}내용은 그대로, 확장자만 바꾸면 끝. 단순하지만 모르면 한참 헤맨다.
React 19에서 전역 JSX 네임스페이스가 사라졌다. 타입 정의 파일에서 JSX.IntrinsicElements를 쓰고 있었는데 이걸 React.JSX.IntrinsicElements로 바꿔야 했다.
재미있는 건 이게 TypeScript 레벨에서만 발생하는 문제라서 런타임에서는 전혀 표시가 안 난다. 로컬에서 IDE가 빨간 줄을 안 그어줬다면 Docker 빌드 할 때까지 모를 수도 있다.
ESLint 설정 파일이 Docker 빌드 환경에서 제대로 해석이 안 됐다. 정확히는 eslintrc 의존성 문제인데 빌드 시에는 린팅이 꼭 필요하지 않으니까 next.config.ts에서 빌드 시 ESLint를 건너뛰도록 설정했다.
린팅은 CI에서 별도로 돌리면 되니까 빌드 단계에서까지 중복할 필요가 없다.
여기서부터 PayloadCMS 특유의 문제가 시작된다. PayloadCMS는 빌드 시점에도 초기화를 시도하는데 이때 PAYLOAD_SECRET 환경변수가 반드시 있어야 한다. 프로덕션 시크릿을 Dockerfile에 넣을 수는 없으니 빌드용 더미값을 ARG로 주입했다.
# 빌드 시에만 사용되는 더미 시크릿
ARG PAYLOAD_SECRET=build-time-dummy-secret-do-not-use-in-prod
ENV PAYLOAD_SECRET=${PAYLOAD_SECRET}런타임에는 K8s Secret에서 진짜 시크릿을 주입하니까 보안 문제는 없다.
이건 CMS + Docker 조합에서 피할 수 없는 닭-달걀 문제다. Next.js의 generateStaticParams는 빌드 시점에 실행되면서 PayloadCMS API로 페이지 목록을 가져오려 한다. 그런데 Docker 빌드 환경에는 MongoDB가 없다.
export async function generateStaticParams() {
try {
const payload = await getPayload({ config })
const pages = await payload.find({ collection: 'pages', limit: 100 })
return pages.docs.map((page) => ({ slug: page.slug }))
} catch {
return [] // DB 없으면 빈 배열 -> 런타임에 동적 생성
}
}try-catch로 감싸서 DB가 없으면 빈 배열을 반환하게 했다. 빌드 시에는 정적 페이지를 하나도 생성하지 않고 전부 런타임에 동적 생성하는 방식이다.
5번만으로는 부족했다. Next.js가 여전히 일부 페이지를 정적으로 생성하려 시도했기 때문에 프론트엔드 레이아웃에 export const dynamic = 'force-dynamic'을 선언해서 전체를 SSR로 돌렸다.
5번과 6번은 세트다. generateStaticParams의 try-catch가 1차 방어선이고 force-dynamic이 2차 방어선. DB 기반 Headless CMS를 Docker로 빌드할 때 이 이중 방어 패턴은 거의 필수라고 보면 된다. PayloadCMS뿐 아니라 Strapi나 Directus에서도 비슷한 접근이 필요하다.
마지막 에러는 허탈할 정도로 단순했다. Next.js standalone 빌드가 public/ 디렉토리를 복사하려 하는데 PayloadCMS는 미디어를 자체적으로 관리하기 때문에 public/ 디렉토리 자체가 없었다. Dockerfile에서 빌드 전에 mkdir -p ./public을 실행해주는 것으로 해결.
하루 만에 7개 에러를 전부 잡았지만 돌이켜보면 이 경험에서 배운 게 몇 가지 있다.
첫째, Docker 빌드는 리트머스 시험지다. 로컬 환경이 암묵적으로 제공하는 것들(설치된 패키지, 환경변수, 실행 중인 DB)을 전부 걷어내기 때문에 코드가 정말 독립적으로 빌드 가능한지 드러난다.
둘째, 최신 스택의 조합은 예측이 안 된다. React 19, ESM, PayloadCMS 3 각각은 잘 동작한다. 문제는 셋을 한 Dockerfile 안에 넣었을 때 생긴다. 이건 문서를 아무리 읽어도 미리 알기 어렵다.
셋째, 에러 하나당 커밋 하나. 7개 수정을 한 커밋에 몰아넣었으면 나중에 문제가 생겼을 때 뭘 되돌려야 할지 알 수 없었을 거다. 양파 까기 패턴에서는 각 층을 명확히 분리해두는 게 중요하다.
혹시 비슷한 스택으로 프로젝트를 시작하는 분이 있다면 Docker 빌드를 가능한 한 초기에 한 번 돌려보길 권한다. 코드가 쌓이기 전에 이런 호환성 문제를 잡아두는 게 훨씬 편하다.