Next.js는 public 폴더를 어떻게 처리하는가
TL;DR
public/는 "빌드/시작 시점에 디스크를 스캔해 만든 경로 Set에 들어있으면, 라우팅 파이프라인에서 동적 페이지보다 먼저 매칭되어 send 모듈로 그대로 흘려보내는" 정적 파일 폴더다. 5가지 핵심:
- 위치 고정 —
publicDir = <root>/public(CLIENT_PUBLIC_FILES_PATH). URL은/public접두사 없이 루트(/) 에 매핑된다.public/favicon.ico→/favicon.ico. - Set 기반 매칭 — 시작 시
recursiveReadDir로 파일 목록을publicFolderItemsSet에 적재. 요청 경로가 Set에 있으면 매칭. - 우선순위: 페이지보다 먼저 — 매칭 순서가
nextStatic → public → appFile → pageFile. 그래서 public 파일이 동적 라우트를 이긴다. - 약한 캐싱 —
/_next/static만immutable 1년을 받는다. public 파일은 Next가 Cache-Control을 안 붙여서send기본값(max-age=0+ etag 재검증)으로 나간다. ← 가장 흔한 함정. - 빌드 시 충돌 검사 —
public/_next금지,public파일과 같은 경로의 page 금지. dev/prod/export/standalone마다 처리 방식이 조금씩 다르다.
1. public은 어디에 정의되나
서버는 publicDir를 멤버로 들고 있고, Node 서버는 이를 <root>/public으로 계산한다.
protected readonly publicDir: string
protected getPublicDir(): string {
return join(/* turbopackIgnore: true */ this.dir, CLIENT_PUBLIC_FILES_PATH)
}
CLIENT_PUBLIC_FILES_PATH는 'public' 문자열. 즉 폴더 이름은 하드코딩되어 있고 설정으로 바꿀 수 없다.
2. 런타임 매칭 — filesystem.ts의 getItem
실제 "이 요청이 정적 파일인가?"를 판정하는 곳은 라우터의 파일시스템 체커다.
2.1 시작 시 파일 목록 적재
const publicFolderPath = path.join(opts.dir, 'public')
try {
for (const file of await recursiveReadDir(publicFolderPath)) {
// Ensure filename is encoded and normalized.
publicFolderItems.add(encodeURIPath(normalizePathSep(file)))
}
} catch (err: any) {
→ public/ 전체를 재귀 순회해 각 파일 경로를 URL 인코딩 + 경로 구분자 정규화해서 publicFolderItems Set에 넣는다. 이 Set이 "서빙 가능한 정적 파일 화이트리스트" 역할을 한다.
함정: 이 목록은 시작 시점에 한 번 만들어진다. 프로덕션에서 서버 기동 후
public/에 파일을 추가해도 (그 경로가 Set에 없으므로) 서빙되지 않는다. dev에서는 §2.4처럼 매 요청 디스크를 확인해 해결한다.
2.2 매칭 순서 — public이 페이지보다 먼저 ★
const itemsToCheck: Array<[Set<string>, FsOutput['type']]> = [
[this.devVirtualFsItems, 'devVirtualFsItem'],
[nextStaticFolderItems, 'nextStaticFolder'],
[legacyStaticFolderItems, 'legacyStaticFolder'],
[publicFolderItems, 'publicFolder'],
[appFiles, 'appFile'],
[pageFiles, 'pageFile'],
]
이 배열을 위에서 아래로 순회하며 첫 매칭을 반환한다. publicFolder가 appFile/pageFile보다 앞에 있으므로:
public/foo.txt가 있고 동시에app/[...slug]/page.tsx같은 catch-all이 있어도 →/foo.txt는 public 파일로 서빙된다 (catch-all로 안 넘어감).- 즉 public 파일은 동적 라우트를 가린다.
2.3 매칭되면 디스크 경로 계산
case 'publicFolder': {
itemsRoot = publicFolderPath
break
}
...
if (itemsRoot && curItemPath) {
fsPath = path.posix.join(itemsRoot, curItemPath)
}
nextStaticFolder는 /_next/static 접두사를 떼지만, publicFolder는 접두사 제거가 없다 — URL 경로가 곧 public/ 아래 상대 경로이기 때문(루트 매핑).
2.4 dev에서는 Set 대신 디스크를 즉석 확인
if (!matchedItem && opts.dev) {
const isStaticAsset = (
[
'nextStaticFolder',
'publicFolder',
'legacyStaticFolder',
] as (typeof type)[]
).includes(type)
if (isStaticAsset && itemsRoot) {
let found = fsPath && (await fileExists(fsPath, FileType.File))
if (!found) {
try {
// In dev, we ensure encoded paths match
// decoded paths on the filesystem so check
const tempItemPath = decodeURIComponent(curItemPath)
fsPath = path.posix.join(itemsRoot, tempItemPath)
found = await fileExists(fsPath, FileType.File)
} catch {}
if (!found) {
continue
}
}
}
→ dev에서는 Set에 없어도 실제 파일이 디스크에 있으면 서빙한다. 파일 워처를 기다리지 않아도 되도록, 인코딩/디코딩 두 변형 경로를 모두 확인한다. 그래서 dev 중 public에 파일을 추가하면 즉시 접근 가능하다.
2.5 i18n — 정적 자산은 기본 로케일에서만
if (i18n) {
const localeResult = handleLocale(
itemPath,
// legacy behavior allows visiting static assets under
// default locale but no other locale
isDynamicOutput
? undefined
: [
i18n?.defaultLocale,
...(i18n.domains?.map((item) => item.defaultLocale) || []),
]
)
→ /en/foo.png 같은 비-기본 로케일 접두사로는 정적 자산을 못 가져온다(레거시 동작). 기본 로케일 접두사만 허용.
3. 서빙 — router-server.ts → serveStatic
매칭된 결과(matchedOutput)는 라우팅 파이프라인을 거친 뒤 처리된다.
3.1 미들웨어가 먼저 돈다 ★
const {
finished,
parsedUrl,
statusCode,
resHeaders,
bodyStream,
matchedOutput,
} = await resolveRoutes({
req,
res,
isUpgradeReq: false,
signal: signalFromNodeResponse(res),
invokedOutputs,
})
resolveRoutes는 headers/redirects/rewrites/미들웨어를 먼저 적용하고 matchedOutput을 함께 돌려준다. 미들웨어 응답(bodyStream)이나 리다이렉트(statusCode 3xx)가 있으면 정적 파일 서빙 전에 그쪽으로 끝난다.
함정:
/_next/static과 달리 public 자산 요청에도 미들웨어가 실행된다.matcher로 제외하지 않으면 이미지/폰트 요청마다 미들웨어가 돈다.
3.2 정적 파일 서빙 본체
if (matchedOutput?.fsPath && matchedOutput.itemPath) {
if (
opts.dev &&
(fsChecker.appFiles.has(matchedOutput.itemPath) ||
fsChecker.pageFiles.has(matchedOutput.itemPath))
) {
res.statusCode = 500
const message = `A conflicting public file and page file was found for path ${matchedOutput.itemPath} https://nextjs.org/docs/messages/conflicting-public-file-page`
...
}
if (
!res.getHeader('cache-control') &&
matchedOutput.type === 'nextStaticFolder'
) {
if (opts.dev && !isNextFont(parsedUrl.pathname)) {
res.setHeader('Cache-Control', 'no-cache, must-revalidate')
} else {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
}
}
if (!(req.method === 'GET' || req.method === 'HEAD')) {
res.setHeader('Allow', ['GET', 'HEAD'])
res.statusCode = 405
...
}
try {
return await serveStatic(req, res, matchedOutput.itemPath, {
root: matchedOutput.itemsRoot,
etag: config.generateEtags,
})
핵심 3가지:
- dev 충돌: 매칭된 경로가 동시에 page/app 파일이면 500 +
conflicting-public-file-page. - Cache-Control은
nextStaticFolder에만immutable 1년부여.publicFolder는 이 분기에 들어가지 않는다 → Next가 헤더를 안 붙임. - GET/HEAD만 허용, 그 외 405.
3.3 약한 캐싱의 정체 ★
public 파일에 Next가 Cache-Control을 설정하지 않으므로, 실제 헤더는 serveStatic이 쓰는 send 모듈의 기본값으로 결정된다. serveStatic 호출은 root와 etag만 넘기고 maxAge를 주지 않는다:
export function serveStatic(
req: IncomingMessage,
res: ServerResponse,
path: string,
opts?: Parameters<typeof send>[2]
): Promise<void> {
return new Promise((resolve, reject) => {
send(req, path, opts)
.on('directory', () => {
// We don't allow directories to be read.
const err: any = new Error('No directory access')
err.code = 'ENOENT'
reject(err)
})
.on('error', reject)
.pipe(res)
.on('finish', resolve)
})
}
send는 maxAge 미지정 시 Cache-Control: public, max-age=0을 보내고, etag/last-modified로 304 재검증을 한다. 결론:
| 자산 | Cache-Control |
|---|---|
/_next/static/... (import한 자산) | public, max-age=31536000, immutable |
public/... 파일 | public, max-age=0 + etag (매번 재검증) |
→ 로고/폰트 등을 public에 두면 매 방문마다 304 왕복이 생긴다. 장기 캐시가 필요하면 (a) 코드에서 import해 /_next/static으로 보내거나, (b) next.config의 headers()로 해당 경로에 Cache-Control을 직접 지정해야 한다.
3.4 serve-static.ts의 안전장치
send.mime.define({
'image/avif': ['avif'],
'image/x-icns': ['icns'],
'image/jxl': ['jxl'],
'image/heic': ['heic'],
})
send의 mime 테이블에 최신 포맷(avif/icns/jxl/heic)을 보강.- 디렉터리 접근 차단: 디렉터리 요청은
ENOENT로 바꿔 404 처리 (디렉터리 리스팅 없음).
4. 빌드 시점 처리 — build/index.ts
4.1 public/_next 금지
if (hasPublicDir) {
const hasPublicUnderScoreNextDir = existsSync(
path.join(publicDir, '_next')
)
if (hasPublicUnderScoreNextDir) {
throw new Error(PUBLIC_DIR_MIDDLEWARE_CONFLICT)
}
}
export const PUBLIC_DIR_MIDDLEWARE_CONFLICT = `You can not have a '_next' folder inside of your public folder. This conflicts with the internal '/_next' route. https://nextjs.org/docs/messages/public-next-folder-conflict`
→ /_next는 Next 내부 전용 경로라, public/_next가 있으면 빌드가 실패한다(dev에서도 동일하게 체크).
4.2 페이지와 경로 충돌 금지
// Check if pages conflict with files in `public`
// Only a page of public file can be served, not both.
for (const page in mappedPages) {
const hasPublicPageFile = await fileExists(
path.join(publicDir, page === '/' ? '/index' : page),
FileType.File
)
if (hasPublicPageFile) {
conflictingPublicFiles.push(page)
}
}
const numConflicting = conflictingPublicFiles.length
if (numConflicting) {
throw new Error(
`Conflicting public and page file${
numConflicting === 1 ? ' was' : 's were'
} found. https://nextjs.org/docs/messages/conflicting-public-file-page\n${...}`
→ pages/foo.js(또는 동등 경로)와 public/foo가 동시에 있으면 빌드 실패. 같은 URL을 둘이 못 가진다(런타임에선 public이 이기지만, 빌드는 아예 막는다).
5. 출력 모드별 차이
5.1 output: 'export' (정적 내보내기)
public을 outDir로 복사하되, 페이지가 쓰는 경로는 제외한다:
const publicDir = join(dir, CLIENT_PUBLIC_FILES_PATH)
// Copy public directory
if (!options.buildExport && existsSync(publicDir)) {
...
await span.traceChild('copy-public-directory').traceAsyncFn(() =>
recursiveCopy(publicDir, outDir, {
filter(path) {
// Exclude paths used by pages
return !exportPathMap[path]
},
})
)
}
또한 out/public/static 디렉터리는 export 대상으로 쓸 수 없다:
if (outDir === join(dir, 'public')) {
throw new ExportError(
`The 'public' directory is reserved in Next.js and can not be used as the export out directory. ...`
)
}
if (outDir === join(dir, 'static')) {
throw new ExportError(
`The 'static' directory is reserved in Next.js and can not be used as the export out directory. ...`
)
}
5.2 output: 'standalone'
copyTracedFiles(standalone 번들러)는 서버 실행에 필요한 추적 파일과 package.json만 복사하고 public은 복사하지 않는다. 그래서 standalone 배포 시 사용자가 public/와 .next/static/을 수동으로 복사해 넣어야 한다(공식 문서의 그 안내가 여기서 비롯).
6. 전체 흐름 한 장
[빌드] public/ 존재?
├─ public/_next 있음 → 빌드 실패 (PUBLIC_DIR_MIDDLEWARE_CONFLICT)
├─ public/foo + pages/foo 둘 다 → 빌드 실패 (conflicting-public-file-page)
├─ export 모드 → public을 out/으로 복사 (페이지 경로 제외)
└─ standalone → public 복사 안 함 (수동 필요)
[서버 시작] recursiveReadDir(public) → publicFolderItems Set (인코딩/정규화)
[요청] GET /favicon.ico
│ resolveRoutes: headers → redirects → rewrites → 미들웨어 ← public에도 적용됨
│ getItem 매칭 순서: _next/static → public → app → pages ← public이 페이지보다 먼저
▼ publicFolder 매칭
│ dev면 디스크 즉석 확인 / 동시에 page면 500 충돌
│ method GET·HEAD만 (else 405)
│ Cache-Control: public은 미설정 → send 기본값(max-age=0 + etag)
▼ serveStatic → send(req, fsPath).pipe(res) (디렉터리 접근 차단)
7. 실무 함정 모음
| 함정 | 설명 | 대응 |
|---|---|---|
| 약한 캐싱 | public 파일은 max-age=0 + etag로 매번 304 재검증 | 장기 캐시는 import(→/_next/static) 또는 headers()로 직접 지정 |
| 미들웨어 실행 | /_next/static과 달리 public 자산에도 미들웨어가 돈다 | 미들웨어 matcher로 정적 자산 제외 |
| 빌드 후 추가 무효 | 프로덕션은 시작 시 Set을 한 번 만든다 | 빌드/재시작 필요 (dev는 즉시 반영) |
| 루트 매핑 혼동 | public/img/a.png은 /img/a.png (/public 접두사 없음) | URL에 public 넣지 말 것 |
| 페이지/public 경로 충돌 | 같은 경로 동시 존재 시 빌드 실패 | 경로 분리 |
public/_next 금지 | 내부 /_next와 충돌 | 다른 폴더명 사용 |
| standalone 누락 | public이 standalone에 자동 포함 안 됨 | 배포 스크립트에서 수동 복사 |
| i18n 비기본 로케일 | /fr/a.png로는 정적 자산 접근 불가 | 기본 로케일 경로 사용 |
8. 핵심 라인 인덱스
| 개념 | 위치 |
|---|---|
publicDir 멤버 | server/base-server.ts:329, 497 |
getPublicDir (CLIENT_PUBLIC_FILES_PATH) | server/next-server.ts:457-459 |
| public 파일 목록 적재 | server/lib/router-utils/filesystem.ts:153, 180-184 |
| 매칭 순서 (public > page) | filesystem.ts:527-534 |
| public 디스크 경로 계산 | filesystem.ts:648-665 |
| dev 즉석 fs 확인 | filesystem.ts:669-694 |
| i18n 정적 자산 기본 로케일 한정 | filesystem.ts:543-555 |
| 미들웨어 포함 라우팅 | server/lib/router-server.ts:417-430 |
| 정적 서빙 + 충돌/캐시/405 | router-server.ts:510-552 |
serveStatic (send) | server/serve-static.ts:13-31 |
| mime 보강 / 디렉터리 차단 | serve-static.ts:6-11, 21-26 |
public/_next 금지 | build/index.ts:1467-1474 + lib/constants.ts:75 |
| 페이지/public 충돌 검사 | build/index.ts:1479-1500 |
| export 복사 + 예약 디렉터리 | export/index.ts:336-346, 659-672 |