목차
개발 서버에서는 잘 보이던 페이지가 빌드하면 500 을 내거나, 배포한 뒤 한참 지나서야 깨진다면 파일을 읽는 코드부터 의심해 보세요. 이 글은 Next.js 앱이 로컬 파일을 읽을 때 빠지기 쉬운 두 함정과, 그걸 배포 전에 잡는 순서를 정리해요.
혹시 이런 고민 있으신가요?
- 글꼴 파일을 읽었는데 "Unsupported OpenType signature" 처럼 파일이 잘못됐다는 오류가 나요. 파일은 멀쩡한데요.
- 마크다운 글 폴더를 읽는 페이지가
Module not found로 500 을 내요. 경로는 분명히 맞게 적었어요. - 로컬 빌드에서는 되는데, 도커 이미지로 띄운 서버에서만 파일을 못 찾는다는 오류가 나요.
이 글을 읽으면
new URL('...', import.meta.url)로 파일을 찾는 코드를 번들러가 오해하지 않는 경로 코드로 바꿀 수 있어요.- standalone 출력에 빠진 파일을
outputFileTracingIncludes로 넣고, 그 설정이 실제로 먹었는지 확인할 수 있어요. - 빌드한 standalone 서버를 직접 띄워, 파일 읽기 문제를 배포 전에 드러내는 검사를 만들 수 있어요.
1단계. 오류를 경로별로 모아 원인 후보를 좁히기
파일 읽기 오류는 메시지가 엉뚱해서 헤매기 쉬워요. 그래서 먼저 어느 경로에서, 어떤 메시지로 깨지는지 한 표에 모아요. 공통점이 보이면 원인이 좁혀져요.
처음 보는 말부터 풀어 둘게요. 번들러는 여러 소스 파일을 서버나 브라우저가 실행할 묶음으로 합쳐 주는 도구예요. 이삿짐을 상자에 나눠 싸는 사람이라고 보면 돼요. Turbopack 은 Next.js 에 들어 있는 번들러로, 개발 서버와 빌드에서 코드를 묶어요. import.meta.url 은 지금 실행 중인 파일의 주소이고, file:///.../font.ts 같은 모양이에요.
저희 블로그를 만들 때 이런 오류가 차례로 나왔어요. 셋 다 next dev 에서 해당 페이지를 열자마자 500 이었어요.
| 읽으려던 것 | 코드 | 나온 오류 |
|---|---|---|
| 마크다운 글 폴더 | new URL("../posts", import.meta.url) | Module not found: Can't resolve '../posts' |
| 테마 CSS 파일 | new URL("./styles/theme.css", import.meta.url) | css client asset is not css chunk placeable |
| 글꼴 파일, 이름을 변수로 | new URL(`../fonts/${name}`, import.meta.url) | Unsupported OpenType signature |
세 줄의 공통점은 new URL(..., import.meta.url) 이에요. 메시지는 제각각이지만 모두 파일 시스템이 아니라 번들러 단계에서 생긴 말투예요. Module not found 와 chunk 는 번들러가 쓰는 말이거든요.
마지막 줄이 가장 헷갈렸어요. 글꼴 파서가 파일 형식이 틀렸다고 했지만 글꼴 파일은 멀쩡했어요. 알고 보니 Turbopack 이 글꼴 폴더 전체를 자산으로 묶었고, 코드가 받은 건 글꼴이 아니라 같은 폴더의 라이선스 텍스트 파일이었어요. 파서가 텍스트를 글꼴로 읽으려다 실패한 거예요.
TIP
파일 형식 오류가 났는데 파일이 멀쩡하다면, 읽은 바이트의 앞부분을 로그로 찍어 보세요. 글꼴 대신 텍스트가 들어왔다면 파일이 아니라 경로를 푸는 쪽의 문제예요.
2단계. new URL 대신 파일 위치에서 경로를 만들기
new URL('상대경로', import.meta.url) 은 "이 파일 옆에 있는 파일"을 가리키는 흔한 문법이에요. 그런데 번들러에게는 이 문법이 "이 파일을 결과물에 자산으로 넣어 달라"는 신호로 읽힐 수 있어요. 저희의 경우 Turbopack 이 이렇게 읽었어요.
그러면 세 가지 일이 생겨요.
- 가리킨 곳이 폴더면, 자산으로 가져올 수 없어
Module not found가 나요. - 가리킨 곳이 CSS 면, CSS 가져오기로 읽혀 CSS 를 둘 수 없는 자리에서 오류가 나요.
- 경로에 변수가 섞이면, 어떤 파일이 올지 몰라 폴더 전체를 묶고 엉뚱한 파일을 줄 수 있어요.
고치는 방법은 간단해요. 서버에서 파일을 읽는 코드라면 번들러가 해석할 문법을 쓰지 말고, 지금 파일의 폴더를 구한 뒤 path.join 으로 경로 문자열을 만들어요. 번들러는 이걸 평범한 문자열 계산으로 보고 건드리지 않아요.
import { readFileSync } from "node:fs" // 번들러가 자산 가져오기로 읽을 수 있어요const fontUrl = new URL(`../assets/fonts/${name}`, import.meta.url)const data = readFileSync(fontUrl)import { readFileSync } from "node:fs"import path from "node:path"import { fileURLToPath } from "node:url" // 이 파일이 있는 폴더에서 경로를 만들어요. 번들러가 해석하지 않는 문자열이에요const HERE = path.dirname(fileURLToPath(import.meta.url))const FONT_DIR = path.join(HERE, "..", "assets", "fonts") export const fontFile = (name: string) => readFileSync(path.join(FONT_DIR, name))fileURLToPath 는 file:///... 모양의 주소를 운영체제의 파일 경로로 바꿔 주는 Node.js 함수예요. path.dirname 은 그 경로에서 파일 이름을 떼고 폴더만 남겨요.
저희는 글 폴더, 테마 CSS, 글꼴 세 곳을 모두 이 모양으로 바꿨어요. 그리고 바꾼 자리마다 왜 new URL 을 쓰지 않는지 주석을 달았어요. 나중에 누가 "더 짧은 문법"으로 되돌리면 같은 오류가 돌아오니까요.
TIP
여러분의 코드에서 new URL( 과 import.meta.url 이 같은 줄에 있는 곳을 검색해 보세요. 브라우저가 쓰는 워커 주소처럼 번들러가 자산으로 묶어 줘야 하는 경우는 그대로 두고, 서버에서 readFileSync 로 읽는 경우만 바꾸면 돼요.
3단계. standalone 출력에 빠진 파일을 목록으로 넣기
2단계를 마치면 개발 서버에서는 잘 돌아요. 그런데 함정이 하나 더 있어요. standalone 출력에 그 파일들이 들어가지 않을 수 있어요.
standalone 출력은 Next.js 가 빌드 결과에서 서버 실행에 필요한 파일만 골라 모은 폴더예요. node_modules 를 따로 설치하지 않아도 이 폴더만으로 서버가 떠서, 도커 이미지에는 보통 이 폴더를 넣어요. 무엇을 넣을지 코드를 읽어 정하는 과정을 파일 추적이라고 불러요.
Next.js 는 standalone 폴더를 만들 때 @vercel/nft 로 import, require, fs 사용을 정적으로 분석해서, 페이지가 불러올 파일을 정해요(Next.js output 문서). 이삿짐 싸는 사람이 집 안을 둘러보고 쓰는 물건만 상자에 넣는 셈이에요. 코드가 실행 중에 문자열로 만든 경로로만 읽는 파일은 이 사람 눈에 잘 띄지 않아요.
빌드할 때 한 번 읽고 끝나는 파일이면 문제가 드러나지 않아요. 페이지가 이미 만들어져 있으니까요. 문제는 실행 중에 다시 읽을 때예요. 저희 글 페이지는 1시간마다 재검증해요. 재검증은 이미 만들어 둔 페이지를 정해진 시간마다 서버가 다시 만드는 기능이에요. 그 순간 서버가 글, 테마 CSS, 글꼴을 다시 읽는데, 컨테이너 안에는 그 파일이 없어요. 그러면 재생성이 실패하고 묵은 페이지가 계속 나가요.
그래서 추적기가 놓치는 파일을 설정에 직접 적어요. outputFileTracingIncludes 는 키가 경로 패턴이고, 값이 추적에 더할 파일 패턴인 객체예요. 값은 프로젝트 루트, 즉 next.config 가 있는 폴더를 기준으로 풀려요(같은 문서).
경로로만 읽는 파일은 설정에 적어야 standalone 폴더에 들어가요
import path from "node:path"import type { NextConfig } from "next" const nextConfig: NextConfig = { output: "standalone", // 여러 패키지가 한 저장소에 있다면 추적을 저장소 루트에서 시작해요 outputFileTracingRoot: path.join(import.meta.dirname, "../.."), // 실행 중 경로로만 읽는 파일은 추적기가 못 보니 직접 적어요 outputFileTracingIncludes: { "/*": [ "../../packages/content/posts/**/*", "../../packages/design/styles/theme.css", "../../packages/design/fonts/*.otf", ], },} export default nextConfig바꿀 곳은 세 가지예요. 여러분의 파일 위치에 맞게 값을 고치고, 한 저장소에 여러 패키지를 두는 구조가 아니라면 outputFileTracingRoot 와 ../../ 는 빼도 돼요. 패턴은 넉넉하게 폴더 단위로 잡는 편이 파일을 새로 더할 때 덜 빠뜨려요.
키도 주의하세요. 문서는 키를 경로 패턴이라고 설명하고, 그래서 처음에는 글 페이지 경로를 딴 키를 적었어요. 그런데 저희 빌드에서는 그 키가 먹지 않았고, "/*" 키만 먹었어요. 왜 그런지는 아직 밝히지 못했어요.
참고
"/*" 키는 모든 경로에 같은 파일을 넣어요. 파일이 크거나 많다면 이미지가 커질 수 있으니, 좁은 키가 여러분의 빌드에서 먹는지 4단계 방법으로 먼저 확인해 보세요.
4단계. standalone 서버를 직접 띄워 200 을 확인하기
1단계부터 3단계의 문제는 공통점이 있어요. 개발 서버만 보고는 절반이 안 보여요. 그래서 배포 전에 빌드 결과를 실제로 돌려 봐야 해요.
순서는 이래요.
- 앱을 빌드해요.
- standalone 폴더 안의
server.js로 서버를 띄워요. 도커 이미지가 실행하는 것도 이 파일이에요. - 파일을 읽는 경로를 하나씩 요청해 상태 코드가 200 인지 봐요.
pnpm --filter web buildPORT=3100 node apps/web/.next/standalone/apps/web/server.js & for route in /blog /blog/first-post /blog/first-post/opengraph-image /sitemap.xml; do printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3100$route")" "$route"done여러 패키지가 있는 저장소에서는 standalone 폴더 안에 저장소 구조가 한 번 더 생겨서, 서버 파일이 standalone/apps/web/server.js 처럼 깊은 곳에 있어요. 한 앱짜리 프로젝트라면 .next/standalone/server.js 예요. 이미지를 그리는 경로라면 상태 코드와 함께 응답 형식도 확인해요. curl 로 받은 파일을 file 명령에 넣으면 PNG 인지와 크기가 나와요.
그런데 이 검사만으로는 재검증 문제가 안 보여요. 빌드할 때 이미 만든 페이지를 돌려줄 뿐이니까요. 저희는 여기에 두 가지를 더했어요.
- 재검증 시간을 2초로 줄여서 빌드해요. 몇 초 기다렸다 다시 요청하면 서버가 파일을 다시 읽어요.
- 서버를 띄운 뒤 원본 글 폴더를 잠깐 다른 곳으로 옮겨요. 그래도 200 이면 서버가 standalone 폴더 안의 복사본을 읽고 있다는 뜻이에요.
모든 경로가 200 이 될 때까지 빌드부터 다시 돌려요
outputFileTracingIncludes 를 넣기 전에 이렇게 돌렸을 때는 테마 CSS 를 열지 못해 오류가 났어요. 세 가지 파일을 넣은 뒤에는 원본 폴더를 치운 채로도 글과 이미지 경로가 모두 200 이었어요. 확인이 끝나면 재검증 시간을 원래 값으로 되돌려 다시 빌드해요.
TIP
원본 폴더를 옮겨 보는 검사는 "컨테이너 안에 이 파일이 정말 있나"를 도커 없이 묻는 가장 싼 방법이에요. 이미지를 굽고 띄우는 데 몇 분이 걸린다면 이 검사를 먼저 돌려 보세요.
참고
저희는 이 검사를 standalone 서버로만 했고, 같은 시점에 도커 이미지를 직접 만들어 돌려 보지는 못했어요. 이미지가 standalone 폴더를 그대로 복사해 실행한다는 전제에서 같은 결과를 기대하는 거예요.
자주 묻는 질문
Q1. 파일을 public 폴더에 두면 이런 문제가 없지 않나요?
public 은 브라우저가 주소로 받아 가는 정적 파일을 두는 곳이에요. 서버 코드가 readFileSync 로 읽을 파일이라면 경로 문제는 그대로 남고, 문서에 따르면 standalone 폴더에 public 이 기본으로 복사되지도 않아요. 서버가 읽을 파일은 서버 코드 옆에 두고 이 글의 방법으로 챙기는 편이 나아요.
Q2. 빌드할 때 한 번만 읽으면 3단계는 필요 없나요?
페이지를 모두 빌드 시점에 만들고 재검증도 하지 않는다면 실행 중에 파일을 다시 읽을 일이 없어요. 하지만 재검증을 켜거나, 요청마다 그리는 이미지 경로가 있다면 실행 중에 파일을 읽어요. 4단계의 원본 폴더 치우기 검사로 어느 쪽인지 확인해 보세요.
Q3. 개발 서버에서는 다 되는데 왜 빌드에서만 깨지나요?
개발 서버는 저장소 전체가 있는 곳에서 돌기 때문에, 추적에서 빠진 파일도 원래 자리에서 읽혀요. standalone 서버는 골라 담은 폴더만 가지고 돌아요. 그래서 3단계 문제는 개발 서버에서 절대 보이지 않아요.
정리
- 파일 읽기 오류는 경로와 메시지를 한 표에 모으면 공통 원인이 보여요.
- 서버에서 읽는 파일은
new URL('...', import.meta.url)대신 파일의 폴더에서path.join으로 경로를 만들어요. - 실행 중 경로로만 읽는 파일은
outputFileTracingIncludes에 적고, 저희 빌드처럼"/*"키만 먹는 경우가 있는지 확인해요. - 배포 전에 standalone 서버를 띄워 파일을 읽는 경로가 200 인지 확인해요.
- 재검증이 있다면 재검증 시간을 줄이고 원본 폴더를 치운 채 한 번 더 요청해요.
개발 서버에서 되는 것과 배포한 서버에서 되는 것은 다른 질문이라는 걸 기억하면, 이런 오류는 대부분 배포 전에 잡혀요.