Next.js는 public 폴더를 어떻게 처리하는가

TL;DR

public/는 "빌드/시작 시점에 디스크를 스캔해 만든 경로 Set에 들어있으면, 라우팅 파이프라인에서 동적 페이지보다 먼저 매칭되어 send 모듈로 그대로 흘려보내는" 정적 파일 폴더다. 5가지 핵심:

  1. 위치 고정publicDir = <root>/public (CLIENT_PUBLIC_FILES_PATH). URL은 /public 접두사 없이 루트(/) 에 매핑된다. public/favicon.ico/favicon.ico.
  2. Set 기반 매칭 — 시작 시 recursiveReadDir로 파일 목록을 publicFolderItems Set에 적재. 요청 경로가 Set에 있으면 매칭.
  3. 우선순위: 페이지보다 먼저 — 매칭 순서가 nextStatic → public → appFile → pageFile. 그래서 public 파일이 동적 라우트를 이긴다.
  4. 약한 캐싱/_next/staticimmutable 1년을 받는다. public 파일은 Next가 Cache-Control을 안 붙여서 send 기본값(max-age=0 + etag 재검증)으로 나간다. ← 가장 흔한 함정.
  5. 빌드 시 충돌 검사public/_next 금지, public 파일과 같은 경로의 page 금지. dev/prod/export/standalone마다 처리 방식이 조금씩 다르다.

1. public은 어디에 정의되나

서버는 publicDir를 멤버로 들고 있고, Node 서버는 이를 <root>/public으로 계산한다.

329:next.js-canary/packages/next/src/server/base-server.ts
  protected readonly publicDir: string
459:next.js-canary/packages/next/src/server/next-server.ts
  protected getPublicDir(): string {
    return join(/* turbopackIgnore: true */ this.dir, CLIENT_PUBLIC_FILES_PATH)
  }

CLIENT_PUBLIC_FILES_PATH'public' 문자열. 즉 폴더 이름은 하드코딩되어 있고 설정으로 바꿀 수 없다.


2. 런타임 매칭 — filesystem.tsgetItem

실제 "이 요청이 정적 파일인가?"를 판정하는 곳은 라우터의 파일시스템 체커다.

2.1 시작 시 파일 목록 적재

153:next.js-canary/packages/next/src/server/lib/router-utils/filesystem.ts
  const publicFolderPath = path.join(opts.dir, 'public')
184:next.js-canary/packages/next/src/server/lib/router-utils/filesystem.ts
    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이 페이지보다 먼저 ★

534:next.js-canary/packages/next/src/server/lib/router-utils/filesystem.ts
      const itemsToCheck: Array<[Set<string>, FsOutput['type']]> = [
        [this.devVirtualFsItems, 'devVirtualFsItem'],
        [nextStaticFolderItems, 'nextStaticFolder'],
        [legacyStaticFolderItems, 'legacyStaticFolder'],
        [publicFolderItems, 'publicFolder'],
        [appFiles, 'appFile'],
        [pageFiles, 'pageFile'],
      ]

이 배열을 위에서 아래로 순회하며 첫 매칭을 반환한다. publicFolderappFile/pageFile보다 에 있으므로:

  • public/foo.txt가 있고 동시에 app/[...slug]/page.tsx 같은 catch-all이 있어도 → /foo.txt는 public 파일로 서빙된다 (catch-all로 안 넘어감).
  • 즉 public 파일은 동적 라우트를 가린다.

2.3 매칭되면 디스크 경로 계산

665:next.js-canary/packages/next/src/server/lib/router-utils/filesystem.ts
            case 'publicFolder': {
              itemsRoot = publicFolderPath
              break
            }
            ...
          if (itemsRoot && curItemPath) {
            fsPath = path.posix.join(itemsRoot, curItemPath)
          }

nextStaticFolder/_next/static 접두사를 떼지만, publicFolder는 접두사 제거가 없다 — URL 경로가 곧 public/ 아래 상대 경로이기 때문(루트 매핑).

2.4 dev에서는 Set 대신 디스크를 즉석 확인

694:next.js-canary/packages/next/src/server/lib/router-utils/filesystem.ts
          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 — 정적 자산은 기본 로케일에서만

555:next.js-canary/packages/next/src/server/lib/router-utils/filesystem.ts
        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.tsserveStatic

매칭된 결과(matchedOutput)는 라우팅 파이프라인을 거친 뒤 처리된다.

3.1 미들웨어가 먼저 돈다 ★

430:next.js-canary/packages/next/src/server/lib/router-server.ts
      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 정적 파일 서빙 본체

552:next.js-canary/packages/next/src/server/lib/router-server.ts
      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 호출은 rootetag만 넘기고 maxAge를 주지 않는다:

31:next.js-canary/packages/next/src/server/serve-static.ts
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)
  })
}

sendmaxAge 미지정 시 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.configheaders()로 해당 경로에 Cache-Control을 직접 지정해야 한다.

3.4 serve-static.ts의 안전장치

11:next.js-canary/packages/next/src/server/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 금지

1474:next.js-canary/packages/next/src/build/index.ts
      if (hasPublicDir) {
        const hasPublicUnderScoreNextDir = existsSync(
          path.join(publicDir, '_next')
        )
        if (hasPublicUnderScoreNextDir) {
          throw new Error(PUBLIC_DIR_MIDDLEWARE_CONFLICT)
        }
      }
75:next.js-canary/packages/next/src/lib/constants.ts
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 페이지와 경로 충돌 금지

1500:next.js-canary/packages/next/src/build/index.ts
          // 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로 복사하되, 페이지가 쓰는 경로는 제외한다:

672:next.js-canary/packages/next/src/export/index.ts
  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 대상으로 쓸 수 없다:

346:next.js-canary/packages/next/src/export/index.ts
  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
정적 서빙 + 충돌/캐시/405router-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