ERR_REQUIRE_ESM: 같은 커밋인데 새로 만든 배포만 500이 난 이유
블로그 목록에는 글 297편이 그대로 나왔습니다. 그중 하나를 누르면 500이었습니다. 문서 사이트도 똑같이 목록은 정상이고 문서 본문만 죽었습니다. 두 서비스 모두 마지막 커밋이 일주일 전이었고, 그 사이 코드를 건드린 사람은 없었습니다.
처음 두 번의 짐작은 둘 다 틀렸습니다
제일 먼저 의심한 것은 전날 제가 했던 작업이었습니다. 문서 편집 도구의 리포지터리 이름과 폴더 위치를 옮겼고, 문서 정본이 어디냐를 바꾼 참이었습니다. 시점이 가까우면 원인처럼 보입니다.
관계가 없었습니다. 두 사이트는 발행된 문서를 데이터베이스에서 직접 읽고, 그 경로는 이름을 바꾼 쪽과 닿아 있지 않습니다.
두 번째 짐작은 의존성 버전이 올라갔다는 것이었습니다. 이쪽은 더 그럴듯했고, 그래서 한동안 그렇게 믿었습니다. 이것도 틀렸는데, 틀렸다는 것을 확인하는 데 lockfile 히스토리 한 번이면 됐습니다. 나중에 다시 이야기하겠습니다.
목록은 되고 상세만 죽는다는 사실이 범위를 좁혀 줬습니다
같은 데이터베이스, 같은 커넥션, 같은 배포입니다. 목록이 297편을 다 뿌린다면 DB도 네트워크도 문제가 아닙니다.
두 경로의 차이는 하나뿐이었습니다. 목록 쿼리는 본문 앞 600자만 가져와서 요약을 만들고, 마크다운을 렌더하지 않습니다. 상세 페이지만 marked로 HTML을 만들고 isomorphic-dompurify로 정화합니다. 그러니까 렌더 경로 안의 무언가입니다.
런타임 로그가 답을 들고 있었습니다
500 응답 본문에는 아무것도 없습니다. Next가 내는 기본 오류 화면이라 "A server error occurred"가 전부입니다. 실제 예외는 함수 로그에 있습니다.
Failed to load external module jsdom-4cccfac9827ebcfe:
Error [ERR_REQUIRE_ESM]: require() of ES Module
/var/task/node_modules/@exodus/bytes/encoding-lite.js
from /var/task/node_modules/html-encoding-sniffer/lib/html-encoding-sniffer.js
not supported.렌더가 실패한 게 아니라 모듈이 로드되지 않았습니다. 우리가 부르는 것은 맨 위 하나인데, 로드는 의존성을 타고 네 칸 아래까지 내려갑니다.
isomorphic-dompurify (CJS)
└─ jsdom 29 (CJS)
└─ html-encoding-sniffer (CJS) ← 여기서 require(...)
└─ @exodus/bytes (ESM 전용) ← 상대가 ESM. 여기서 끊깁니다Node에는 모듈 방식이 둘 있습니다. CJS는 require()로 파일을 동기로 읽고, ESM은 import로 비동기 로딩을 전제합니다. CJS 코드가 ESM 전용 패키지를 require()하는 것은 원래 금지였습니다. Node 22.12(20.19)부터 조건부로 열렸는데, 이 앱이 올라가 있는 Vercel 함수는 그 기능을 안 받아 줬습니다.
왜 하필 jsdom 이었는지는 번들 안과 밖의 문제였습니다
같은 앱이 marked도 쓰고 pg도 씁니다. 그것들은 멀쩡했습니다.
Next는 서버 번들을 만들 때 일부 패키지를 일부러 빼놓습니다. 네이티브 바이너리를 들고 있거나 파일을 동적으로 읽는 패키지들이고, 목록은 프레임워크 안에 server-external-packages.jsonc로 박혀 있습니다. 지금 80여 개이고 pg, sharp, prisma, shiki, 그리고 jsdom이 들어 있습니다.
번들에 들어간 모듈은 번들러가 CJS와 ESM 차이를 흡수합니다. 변환된 결과물에는 require가 아예 없습니다. 반대로 번들 밖에 남은 모듈은 실행 시점에 진짜 require를 타고, Node가 그 사슬을 그대로 밟습니다.
뒤집으면 이게 위험 표면입니다. 이 오류가 날 수 있는 패키지는 그 목록 안에만 있습니다. 나중에 다른 프로젝트를 훑을 때 이 성질을 그대로 썼습니다.
lockfile은 버전을 고정할 뿐 로딩 방식을 고정하지 않습니다
이제 두 번째 짐작으로 돌아갑니다. lockfile을 열어 봤습니다.
package-lock.json은 첫 커밋인 7월 10일부터 jsdom 29.1.1과 @exodus/bytes 1.15.1을 가리키고 있었습니다. lock을 건드린 커밋이 그 뒤로 두 개 더 있는데 셋 다 같은 값입니다. package.json의 isomorphic-dompurify 범위도 그날 이후 한 글자도 안 바뀌었습니다.
올라간 버전이 없습니다. 그러면 무엇이 달라졌는가를 확인하려고 전날 배포와 그날 배포를 나란히 놓고 비교했습니다.
| 8월 19일 배포 | 8월 27일 배포 | |
|---|---|---|
| 커밋 | 98b380c | 98b380c (동일) |
| lockfile | 동일 | 동일 |
| 람다 크기 | 19.19MB | 19.19MB |
| 런타임 | nodejs24.x | nodejs24.x |
| 글 상세 | 정상 | 500 |
전날 배포 URL로 직접 요청해 보니 글이 그대로 렌더됐습니다. 여기서 한 번 더 속을 뻔했습니다. CDN이 담아 둔 응답이면 그 함수가 지금 살아 있다는 증거가 못 됩니다. x-vercel-cache가 MISS였다가 두 번째 요청에서 HIT로 바뀌는 것을 보고서야 방금 렌더된 것이 맞다고 판단했습니다. 장애를 조사할 때 200이라는 숫자만 보면 캐시가 시체를 가려 줍니다.
입력이 전부 같은데 결과가 갈렸습니다. 남는 변수는 우리 쪽에 없다는 뜻입니다.
로컬에서는 재현되지 않았고, 그래서 조건을 만들었습니다
로컬 Node는 24.7입니다. require(ESM)이 열려 있는 버전이라 같은 코드가 그냥 돌아갑니다. 타입 체크도 빌드도 통과합니다. 운영에서만 죽는 종류의 실패입니다.
이럴 때 붙잡을 것이 그 기능을 끄는 플래그입니다.
node --no-experimental-require-module \
node_modules/next/dist/bin/next start -p 2099프로덕션 빌드를 만들고 이 플래그로 띄운 다음 글 하나를 요청하니 로그에 같은 문장이 찍혔습니다. ERR_REQUIRE_ESM, 같은 파일, 같은 사슬.
고치기 전에 실패를 먼저 재현한 것이 이 작업에서 제일 중요했습니다. 이게 없으면 핀을 걸고 배포한 뒤 "이제 되는 것 같다"로 끝납니다. 그건 고쳤다는 뜻이 아니라 증상이 사라졌다는 뜻입니다.
jsdom 26이 마지막으로 안전한 버전이었습니다
처음에는 27로 내리면 되겠다고 생각했습니다. jsdom@27.0.0의 의존성을 보니 html-encoding-sniffer가 ^4라서 문제의 고리가 없습니다.
설치하고 플래그를 켜니 다른 데서 걸렸습니다.
require() of ES Module .../@csstools/css-calc/dist/index.mjs
from .../@asamuzakjp/css-color/dist/cjs/index.cjscssstyle이 끌고 오는 다른 사슬이었습니다. 27.0부터 27.3까지 전부 같고, 27.4부터는 html-encoding-sniffer도 ^6으로 올라가 원래 고리까지 돌아옵니다. 한 칸씩 내리면서 확인한 결과 26.1.0이 사슬 전체가 CJS인 마지막 버전이었습니다.
그래서 package.json에 overrides로 26에 묶었습니다. 핀은 부채라서 언제 풀 수 있는지를 같이 적어 둬야 합니다. 그 역할을 테스트가 합니다.
execFileSync(process.execPath, [
"--no-experimental-require-module",
"-e",
"process.stdout.write(require('isomorphic-dompurify').sanitize('<b>ok</b><script>x()</script>'))",
]);CI에서 도니까, jsdom이 의존성을 CJS로 되돌리는 날 이 테스트가 먼저 알려 줍니다. 반대로 다른 외부 패키지가 ESM으로 넘어가는 날에도 배포 전에 걸립니다.
우연히 살아 있던 앱이 하나 있었습니다
같은 팀의 Vercel 프로젝트 일곱 개를 전부 훑었습니다. 방법은 위에서 얻은 성질 그대로입니다. 프레임워크의 외부 패키지 목록을 읽고, 각 프로젝트에 설치된 것만 골라, 플래그를 끈 Node로 하나씩 require해 봤습니다.
셋이 걸렸습니다. 블로그, 문서 사이트, 그리고 회사 본진입니다.
본진은 그 시점에 멀쩡히 돌고 있었습니다. 같은 sanitizer를 쓰는 화면이 넷인데, 랜딩에 있는 둘이 dynamic(..., { ssr: false })로 불려서 서버에서는 그 모듈이 아예 로드되지 않았기 때문입니다. 설계가 아니라 우연입니다. 남은 둘은 로그인 뒤에 있는 관리자 화면이라 보호가 없었고, 그 경로를 지나는 순간 같은 500이 났을 겁니다.
지금 안 났다는 것과 안전하다는 것은 다릅니다. 세 곳 모두 같은 핀을 걸었습니다.
왜 그날 만든 배포부터인지는 아직 모릅니다
여기까지가 확실한 부분이고, 여기서부터는 확신이 없습니다.
우리 쪽 입력은 전부 같았습니다. 빌드 로그에서 눈에 띄는 차이는 둘입니다. 그날 배포는 Skipping build cache, deployment was triggered without cache로 시작해 의존성을 처음부터 설치했고(1.1초에서 13초로 늘었습니다), 빌더 CLI 버전이 59.1.4에서 59.3.0으로 바뀌어 있었습니다. 둘 중 무엇이 방아쇠인지, 아니면 함수 실행 환경 쪽이 조용히 바뀐 것인지는 플랫폼 밖에서 가릴 수 없습니다.
가릴 수 없다는 사실이 핀을 정당화합니다. 플랫폼이 require(ESM)을 다시 열어 주기를 기다리는 대신, 그 전제를 아예 쓰지 않는 쪽으로 옮긴 것입니다. 열리든 안 열리든 우리 코드는 같은 방식으로 로드됩니다.
한 가지 더 남았습니다. 이 장애를 감시망이 전혀 못 잡았습니다. 워치독이 보는 것은 헬스 엔드포인트이고, 그건 데이터베이스 연결만 확인합니다. 목록 페이지도 멀쩡했으니 사람 눈에도 안 걸렸습니다. 사용자가 실제로 밟는 경로와 프로브가 보는 경로가 다르면 이런 장애는 통째로 지나갑니다. 그건 따로 정리해서 다음 글로 쓰겠습니다.
함께 읽기
- Vercel Web Analytics 요금과 설치: Enable 버튼만으로는 안 되는 이유회사 홈페이지의 방문자 수를 Vercel 대시보드에서 보려고 Web Analytics를 켰습니다. Enable을 누르고 잠시 기다렸는데 지표가 0이었습니다. 스크립트를 넣어야 한다는 걸 그때 알았고, 넣고 배포한 뒤에도 한 번 더 헷갈렸습니다. 그 과정을 정리했습니다.
- Cloudflare Workers로 옮긴 Next.js: 바인딩과 요청당 CPU 10ms단축 URL 서비스를 하나 만들면서 Cloudflare 배포를 처음 써봤습니다. 평소 쓰던 구성은 Next.js 16 App Router에 Prisma와 PostgreSQL을 붙이고, 오리진 서버에서 Docker Compose로 컨테이너를 띄워 nginx 뒤에 두는 형태입니다. 같은 프레임워크로 비슷한 앱을 Worker…
- 배포하면 CSS가 404: stale-while-revalidate 함정"모바일에서 사이트가 이상해요."
- 캐시 퍼지 순서: 오리진을 먼저 비워야 하는 이유엣지 캐시를 켜고 나니 대가가 하나 생겼습니다. 글을 고쳐 발행해도 최대 5분 동안 옛 내용이 나갔습니다. 캐시가 없던 때는 즉시 반영되던 일이라 후퇴처럼 느껴졌습니다.
- Cloudflare가 캐시하지 않는 3가지 이유: Vary, RSC, 언어 감지사이트를 Cloudflare 뒤에 두고 캐시 헤더를 제대로 붙였는데도 CF-Cache-Status가 계속 DYNAMIC으로 나오는 일을 겪었습니다. 한 번이 아니라 세 번이었고, 매번 다른 문제처럼 보였습니다.