source에는 파일이 없다 — rewrites·assetPrefix, 그리고 절대 발동하지 않는 폴백
팀에서 이런 질문을 받았습니다.
async rewrites() {
return [
{
source: '/public/config/notice.json',
destination: 'https://static-dev.internal/public/config/notice.json',
},
{
source: '/public/images/banner/:path*',
destination: 'https://static-dev.internal/public/images/banner/:path*',
},
];
}
source의 경로는localhost:3000/public이렇게 되는 건가?
맞습니다. 그런데 이 질문에 "네"라고만 답하면 정작 헷갈리는 지점이 안 풀립니다. Next.js 프로젝트에는 public/ 폴더가 실제로 있고, 그래서 /public/...이라는 URL을 보면 자연스럽게 그 폴더를 떠올리게 됩니다. 둘은 아무 관계가 없습니다.
답을 쓰다가 "그럼 왜 하필 URL에 public이 들어가 있지?"가 궁금해졌고, 거기서부터 나온 것들을 정리합니다. 마지막 절의 버그가 이 글의 본론입니다.
1. source는 파일이 아니라 매칭 규칙이다
먼저 확인부터 했습니다.
$ ls apps/web/public/config
ls: apps/web/public/config: No such file or directory
없습니다. 그런데도 /public/config/notice.json 요청은 200을 받습니다.
source는 디스크상의 위치가 아니라 "이 URL 패턴으로 요청이 들어오면 가로채라"는 규칙입니다. 파일이 없어도 되고, 오히려 없는 게 정상입니다.
브라우저 → https://local.app.test:3000/public/config/notice.json
↓ Next 서버가 rewrites에서 매칭
↓ 서버가 대신 요청 (브라우저는 모름)
https://static-dev.internal/public/config/notice.json
redirect가 아니라는 점이 중요합니다. 302를 주고 브라우저를 다른 곳으로 보내는 게 아니라, 서버가 대신 받아서 그대로 돌려줍니다. 그래서 주소창은 안 바뀌고, 응답이 내 오리진에서 온 것으로 취급되니 CORS도 안 걸립니다.
그리고 Next의 public/ 폴더는 루트로 서빙됩니다.
| 디스크 | 서빙되는 URL |
|---|---|
public/images/logo.png |
/images/logo.png |
public/favicon.ico |
/favicon.ico |
public/foo.png가 /public/foo.png로 열리는 일은 없습니다. 그러니 URL의 /public은 그냥 문자열이고, 폴더와 충돌하지도 않습니다.
2. 배열을 반환하면 afterFiles다
여기서 한 번 걸릴 수 있는 게 매칭 순서입니다. rewrites()가 배열을 반환하면 그건 afterFiles 단계에 등록됩니다 — 즉 파일시스템(정적 파일·페이지 라우트)을 먼저 확인하고, 거기서 못 찾았을 때만 rewrite가 걸립니다.
객체로 반환하면 단계를 직접 고를 수 있습니다.
async rewrites() {
return {
// 파일시스템보다 먼저 — 페이지·정적 파일을 덮어쓸 수 있음
beforeFiles: [...],
// 배열 반환과 동일 (기본값)
afterFiles: [...],
// 페이지도 afterFiles도 다 못 잡았을 때 마지막
fallback: [...],
};
}
실무에서 이 차이가 드러나는 순간은 대개 하나입니다. 로컬에 우연히 같은 경로의 파일을 만들어 두면 rewrite가 조용히 무시됩니다. afterFiles니까요. "왜 나만 옛날 데이터가 뜨지?"의 흔한 정체입니다.
반대로 원격 응답이 항상 이겨야 한다면 beforeFiles로 올려야 합니다. 다만 그건 페이지 라우트까지 가릴 수 있으니, 기본값을 벗어날 이유가 없으면 배열로 두는 편이 안전합니다.
:path*는 와일드카드입니다. /public/images/banner/a/b/c.png처럼 하위 경로 전체가 destination의 :path* 자리로 그대로 전달됩니다.
3. 왜 URL에 public이 들어가 있나 — rewrite는 사실상 dev 전용 shim이다
여기가 처음 질문의 진짜 답이었습니다.
호출부는 이렇게 생겼습니다.
const base = process.env.NEXT_PUBLIC_ASSET_BASE_URL || '';
const res = await fetch(`${base}/public/config/notice.json`);
/public이 코드에 리터럴로 박혀 있습니다. 그리고 NEXT_PUBLIC_ASSET_BASE_URL은 환경마다 다릅니다.
| 환경 | NEXT_PUBLIC_ASSET_BASE_URL |
최종 URL | 누가 응답하나 |
|---|---|---|---|
| 로컬 | https://local.app.test:3000 (= 내 dev 서버) |
local.app.test:3000/public/config/notice.json |
Next가 rewrite로 프록시 |
| 배포 | https://cdn.example.net |
cdn.example.net/public/config/notice.json |
CDN이 직접 |
배포 환경에서는 URL이 아예 다른 호스트를 가리키는 절대 경로라, 요청이 Next 서버에 도달조차 하지 않습니다. rewrites 블록은 그 환경에서 죽은 설정입니다.
즉 이 rewrite의 존재 이유는 하나입니다 — CDN의 경로 레이아웃(/public/...)을 로컬에서도 성립시키는 것. 호출부 문자열을 환경별로 분기하지 않으려고, 대신 dev 서버가 그 경로를 흉내 내 줍니다.
이걸 모르면 rewrites를 "CORS 우회 장치"로만 읽게 됩니다. 실제 역할은 로컬을 배포 환경의 URL 모양에 맞추는 어댑터이고, 그래서 배포에서는 아무 일도 하지 않는 게 정상입니다.
정리하면 CDN에는 앱의 public/ 폴더 내용물이 /public/ 아래에 올라가 있고, 로컬에서는 같은 내용물이 /에 서빙됩니다. 같은 파일이 환경마다 다른 경로에 있습니다. 이 비대칭이 다음 절의 원인입니다.
4. 같은 값에 관례가 두 개 생겼다
/public 리터럴을 호출부마다 붙이는 게 번거로우니, 그걸 상수로 감싸려는 시도가 자연스럽게 나옵니다. 그런데 3절의 비대칭 때문에 상수에 분기가 들어갑니다.
// lib/env.ts
// 로컬: public/ 이 루트로 서빙되므로 그대로
// 배포: CDN 은 같은 내용물을 /public 아래에 둔다
export const assetBase = isDev
? process.env.NEXT_PUBLIC_ASSET_BASE_URL
: process.env.NEXT_PUBLIC_ASSET_BASE_URL + '/public';
<img src={`${assetBase}/images/placeholder.png`} />
로컬에서는 local.app.test:3000/images/placeholder.png(public/images/placeholder.png 히트), 배포에서는 cdn.example.net/public/images/placeholder.png. 양쪽 다 맞습니다.
문제는 이제 같은 베이스 URL을 쓰는 방법이 두 개가 됐다는 것입니다.
| 대상 | 관례 | /public을 붙이는 주체 |
|---|---|---|
| 원격 JSON (rewrite 경유) | env + '/public/...' |
호출부 리터럴 |
| 앱 정적 에셋 | assetBase + '/...' |
상수 내부 분기 |
두 관례를 섞으면 로컬에서만 404가 납니다. assetBase는 dev에서 /public을 안 붙이니까요. 실제로 코드베이스에 이런 주석이 남아 있었습니다.
// 이 상수는 dev 분기에서 `/public` 을 붙이지 않으므로
// 이 케이스엔 부적합 → 사용하지 않는다.
const BASE = process.env.NEXT_PUBLIC_ASSET_BASE_URL || '';
const SAMPLE_URL = `${BASE}/public/audio/${file}`;
누군가 이미 한 번 밟고, 다음 사람을 위해 남겨둔 흔적입니다. 주석으로 막는 건 임시방편이고, 근본 해법은 베이스가 두 종류라면 이름도 둘이어야 한다는 것입니다. cdnBase(항상 /public 포함)와 appAssetBase(환경별 해석)처럼 나누면 호출부에서 잘못 고를 여지가 사라집니다. 하나의 이름이 두 의미를 겸하는 한, 주석은 계속 필요합니다.
5. 본론 — || '' 폴백이 절대 발동하지 않는다
4절 코드를 다시 봅시다. 원본은 정확히 이렇게 생겼습니다.
export const assetBase = isDev
? process.env.NEXT_PUBLIC_ASSET_BASE_URL || ''
: process.env.NEXT_PUBLIC_ASSET_BASE_URL + '/public' || '';
두 갈래 다 || ''가 붙어 있으니 "env가 없으면 빈 문자열로 떨어져 상대 경로가 되겠지"로 읽힙니다. 아래쪽 갈래는 그렇게 동작하지 않습니다.
+가 ||보다 우선순위가 높습니다. 그래서 실제 평가 순서는 이렇습니다.
(process.env.NEXT_PUBLIC_ASSET_BASE_URL + '/public') || ''
env가 없으면 undefined + '/public'이 먼저 계산되고, 그 결과는 문자열 "undefined/public" 입니다. 빈 문자열이 아니라 truthy한 문자열이니 || ''는 영원히 도달하지 않습니다.
> const u = undefined;
> u + '/public' || ''
'undefined/public'
그래서 이미지 URL이 이렇게 나갑니다.
https://cdn.example.net/public/images/x.png ← 기대
undefined/public/images/x.png ← 실제
undefined가 호스트가 아니라 상대 경로의 첫 세그먼트로 해석되니, 요청은 현재 오리진의 /undefined/public/images/x.png로 나가고 404가 됩니다. 콘솔에는 깨진 이미지만 남고, undefined라는 단어가 URL에 찍혀 있는 걸 눈으로 발견하기 전까지는 원인을 못 짚습니다.
같은 클래스가 설정 파일에도 있었습니다.
images: {
path: process.env.NEXT_PUBLIC_ASSET_BASE_URL + '/_next/image',
},
여기는 ||조차 없어서 더 직접적입니다. env가 비면 이미지 최적화 엔드포인트가 통째로 "undefined/_next/image"가 됩니다.
왜 조용히 지나가나
세 가지가 겹칩니다.
undefined가 문자열 연결에서 예외를 안 낸다."undefined"라는 6글자로 조용히 흡수됩니다.- 결과가 truthy다. 그래서 뒤에 붙은 방어 코드가 무력화됩니다.
.env.development에는 값이 있다. 로컬에서는 절대 재현되지 않습니다.
3번이 특히 고약합니다. 값이 정의된 env 파일은 개발용뿐이고, 배포용 값은 빌드 파이프라인이 주입합니다. 그 주입이 빠진 환경이 하나 생기는 순간에만 드러납니다. 로컬에서 재현 불가능한 클래스입니다.
고치는 법
폴백을 고치는 게 아니라 폴백을 지우는 게 맞습니다. 정적 에셋 베이스 URL이 없는 채로 뜨는 앱은 어차피 정상이 아닙니다.
// lib/env.ts — 부팅 시 한 번, 없으면 즉시 실패
const RAW = process.env.NEXT_PUBLIC_ASSET_BASE_URL;
if (!RAW) throw new Error('NEXT_PUBLIC_ASSET_BASE_URL is not configured');
export const assetBase = isDev ? RAW : `${RAW}/public`;
굳이 폴백을 남겨야 한다면 최소한 연결 전에 걸어야 합니다.
const RAW = process.env.NEXT_PUBLIC_ASSET_BASE_URL || '';
export const assetBase = isDev ? RAW : `${RAW}/public`;
이러면 env가 비었을 때 /public/images/x.png라는 상대 경로가 나옵니다. 여전히 404지만, undefined가 URL에 섞이지 않으니 원인 추적이 훨씬 빠릅니다.
환경변수 폴백은 "값이 없어도 앱이 뜨게" 만드는 장치가 아닙니다. 없으면 안 되는 값이라면 폴백이 아니라 부팅 시 검증이 답이고, 폴백을 쓸 거라면 그게 실제로 도달 가능한 자리에 있는지부터 확인해야 합니다.
정리
질문은 "source가 어디 있는 파일이냐" 하나였는데, 답을 쓰는 과정에서 나온 건 이렇습니다.
source는 파일이 아니라 URL 매칭 규칙이다. 그 경로에 파일이 없는 게 정상이다.public/폴더는/로 서빙된다. URL의/public은 폴더와 무관한 문자열이다.- 배열 반환은
afterFiles다. 로컬에 같은 경로의 파일이 있으면 rewrite가 조용히 무시된다. - rewrite는 배포 환경의 URL 모양을 로컬에서 성립시키는 어댑터다. 배포에서는 절대 경로라 Next에 도달하지도 않는다. 그래서 "이 rewrite가 프로덕션에서도 도는가"를 먼저 확인해야 한다.
- 같은 베이스 URL에 두 관례가 생기면 이름을 나눠라. 주석으로 막는 건 다음 사람이 다시 밟는다는 뜻이다.
a + b || c에서c는 죽은 코드다.+가 먼저 계산되고 그 결과는 대개 truthy다.
마지막 항목이 이 중 유일하게 프레임워크와 무관한 얘기인데, 하필 제일 오래 살아남는 종류이기도 합니다. 폴백은 그게 필요해지는 환경에서만 실행되고, 그런 환경은 대체로 아무도 안 보고 있을 때 처음 만들어집니다.