무색
기술블로그
에세이
연구
프로덕트
소개

무색

소프트웨어로 비즈니스의 가능성을 만듭니다. 웹·앱 개발, 음성 AI, 자동화 콘텐츠 제작까지 — 기술이 필요한 곳에 무색이 있습니다.

연락처

[email protected]

사업자 정보

상호: 무색

대표: 배성재

사업자등록번호: 577-58-00836

인천광역시 연수구 인천타워대로 323, 에이동 8층 801-802호 AB-132 (송도동, 송도 센트로드)

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관
INCHEON, KR
Docker 빌드에서 터진 7가지 호환성 지뢰: ESM + React 19 + PayloadCMS
museck 만들기
2026. 1. 7.

Docker 빌드에서 터진 7가지 호환성 지뢰: ESM + React 19 + PayloadCMS

DockerNext.jsReact 19ESMPayloadCMS

"로컬에서 되는데 Docker에서 안 돼요."

개발자라면 한 번쯤 들어봤을, 아니 직접 겪어봤을 문장이다. 무색 홈페이지를 PayloadCMS + Next.js 15 + React 19로 만들고 나서 Docker로 빌드하려고 했더니 에러가 7개나 연달아 터졌다. 로컬에서는 아무 문제 없이 돌아가던 코드가 Docker 빌드 환경에서는 완전히 다른 얼굴을 보여줬다.

양파를 까듯 한 에러를 고치면 그 뒤에 숨어있던 다음 에러가 드러났다. 하루 동안 7개 커밋을 찍으며 전부 잡았는데 같은 스택을 쓰는 분들에게 도움이 될 것 같아 정리해본다.

배경: 어떤 스택이었나

프로젝트 구성은 이렇다.

  • Next.js 15 (App Router, standalone output)
  • PayloadCMS 3.x (MongoDB 기반 Headless CMS)
  • React 19 + TypeScript 5.7
  • package.json에 "type": "module" (ESM 프로젝트)

각각은 최신이고 잘 동작하는 기술이다. 문제는 조합이다. Docker 빌드는 로컬 개발 환경이 암묵적으로 제공하던 것들을 전부 걷어내기 때문에 이런 조합의 호환성 문제가 한꺼번에 드러난다.

7가지 에러, 해결 순서대로

아래 다이어그램이 전체 흐름이다. 빨간색이 에러, 파란색이 수정, 초록색이 최종 성공.

1. postcss.config.js → .cjs (ESM/CJS 충돌)

첫 에러부터 당황스러웠다. 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: {},
  },
}

내용은 그대로, 확장자만 바꾸면 끝. 단순하지만 모르면 한참 헤맨다.

2. JSX → React.JSX (React 19 breaking change)

React 19에서 전역 JSX 네임스페이스가 사라졌다. 타입 정의 파일에서 JSX.IntrinsicElements를 쓰고 있었는데 이걸 React.JSX.IntrinsicElements로 바꿔야 했다.

재미있는 건 이게 TypeScript 레벨에서만 발생하는 문제라서 런타임에서는 전혀 표시가 안 난다. 로컬에서 IDE가 빨간 줄을 안 그어줬다면 Docker 빌드 할 때까지 모를 수도 있다.

3. ESLint ignoreDuringBuilds

ESLint 설정 파일이 Docker 빌드 환경에서 제대로 해석이 안 됐다. 정확히는 eslintrc 의존성 문제인데 빌드 시에는 린팅이 꼭 필요하지 않으니까 next.config.ts에서 빌드 시 ESLint를 건너뛰도록 설정했다.

린팅은 CI에서 별도로 돌리면 되니까 빌드 단계에서까지 중복할 필요가 없다.

4. PAYLOAD_SECRET 더미 주입

여기서부터 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에서 진짜 시크릿을 주입하니까 보안 문제는 없다.

5. generateStaticParams에 try-catch 추가

이건 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가 없으면 빈 배열을 반환하게 했다. 빌드 시에는 정적 페이지를 하나도 생성하지 않고 전부 런타임에 동적 생성하는 방식이다.

6. force-dynamic으로 SSR 전환

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에서도 비슷한 접근이 필요하다.

7. mkdir -p ./public

마지막 에러는 허탈할 정도로 단순했다. Next.js standalone 빌드가 public/ 디렉토리를 복사하려 하는데 PayloadCMS는 미디어를 자체적으로 관리하기 때문에 public/ 디렉토리 자체가 없었다. Dockerfile에서 빌드 전에 mkdir -p ./public을 실행해주는 것으로 해결.

돌아보며

하루 만에 7개 에러를 전부 잡았지만 돌이켜보면 이 경험에서 배운 게 몇 가지 있다.

첫째, Docker 빌드는 리트머스 시험지다. 로컬 환경이 암묵적으로 제공하는 것들(설치된 패키지, 환경변수, 실행 중인 DB)을 전부 걷어내기 때문에 코드가 정말 독립적으로 빌드 가능한지 드러난다.

둘째, 최신 스택의 조합은 예측이 안 된다. React 19, ESM, PayloadCMS 3 각각은 잘 동작한다. 문제는 셋을 한 Dockerfile 안에 넣었을 때 생긴다. 이건 문서를 아무리 읽어도 미리 알기 어렵다.

셋째, 에러 하나당 커밋 하나. 7개 수정을 한 커밋에 몰아넣었으면 나중에 문제가 생겼을 때 뭘 되돌려야 할지 알 수 없었을 거다. 양파 까기 패턴에서는 각 층을 명확히 분리해두는 게 중요하다.

혹시 비슷한 스택으로 프로젝트를 시작하는 분이 있다면 Docker 빌드를 가능한 한 초기에 한 번 돌려보길 권한다. 코드가 쌓이기 전에 이런 호환성 문제를 잡아두는 게 훨씬 편하다.

자주 묻는 질문

Next.js ESM 프로젝트에서 postcss.config.js가 에러나는 이유는?
package.json에 type: module이 있으면 모든 .js 파일을 ESM으로 해석합니다. CJS 문법(module.exports)을 쓰는 설정 파일은 확장자를 .cjs로 바꿔서 CJS임을 명시해야 합니다.
PayloadCMS Docker 빌드 시 DB가 없어서 실패하면 어떻게 하나요?
PAYLOAD_SECRET에 빌드용 더미값 ARG 주입, generateStaticParams를 try-catch로 감싸 빈 배열 반환, force-dynamic으로 SSR 전환 세 가지를 조합합니다. DB 기반 Headless CMS Docker 빌드의 필수 패턴입니다.
React 19에서 JSX 타입 에러가 나는 이유는?
React 19에서 전역 JSX 네임스페이스가 제거되었습니다. JSX.IntrinsicElements를 React.JSX.IntrinsicElements로 변경해야 합니다. TypeScript 레벨 문제라 런타임에서는 안 보이고 Docker 빌드 시에만 드러날 수 있습니다.
museck 만들기(4/10)
Prev

Gitea Actions + DinD로 셀프 호스팅 CI/CD 삽질기

Next

GitOps로 Production 배포 자동화하기