정적 페이지 몇 장을 public/ 아래 올리고 배포했습니다. 로컬에서는 완벽했습니다.
배포된 주소를 열었더니 스타일이 하나도 먹지 않은 날것의 HTML이 떴습니다.
파란 밑줄 링크, 기본 세리프 폰트, 거대하게 늘어난 아이콘. CSS·JS·이미지가 전부 안 붙은 화면입니다.
그런데 서버는 정상이었습니다. 파일도 전부 배포돼 있었습니다.
확인한 것부터
가장 먼저 한 일은 파일이 실제로 올라갔는지 확인하는 것이었습니다.
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' \
https://example.com/sample/app/css/site.css
# 200 text/css; charset=utf-8
200이 떨어집니다. JS도, 이미지도, HTML도 전부 200에 MIME 타입까지 정확했습니다.
배포된 HTML을 받아 <link> 태그도 확인했습니다. 경로가 멀쩡히 들어 있었습니다.
<link rel="stylesheet" href="./css/site.css">
파일 있고, 경로 있고, 응답 200. 그런데 브라우저에서는 안 붙습니다.
범인은 주소 끝 슬래시였다
Next.js는 기본값이 trailingSlash: false입니다. 주소 끝의 슬래시를 떼어내는 방향으로 리다이렉트합니다.
curl -sI https://example.com/sample/app/
# HTTP/2 308
308이 떨어집니다. /sample/app/ 으로 요청해도 브라우저는 최종적으로 /sample/app 에 있게 됩니다.
여기서 문제가 생깁니다. 상대경로는 "현재 문서가 있는 디렉터리" 기준으로 풀립니다.
| 브라우저 주소 | ./css/site.css 가 풀리는 곳 |
|---|---|
/sample/app/ | /sample/app/css/site.css ✅ |
/sample/app | /sample/css/site.css ❌ |
슬래시가 떨어지는 순간 app은 디렉터리가 아니라 파일 이름으로 취급됩니다.
기준 디렉터리가 /sample/ 로 한 단계 올라가고, 모든 상대경로가 통째로 어긋납니다.
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/sample/css/site.css
# 404
브라우저가 실제로 요청한 주소는 이쪽이었고, 당연히 404였습니다.
왜 curl로는 안 잡혔나
이 버그의 진짜 교훈은 원인이 아니라 내 검증 방법이 이 버그를 구조적으로 못 잡는다는 것이었습니다.
curl .../sample/app/css/site.css # 200 — 내가 만든 절대 주소
curl에 넘긴 것은 내가 손으로 조립한 완성된 주소입니다. 상대경로 해석 단계를 아예 거치지 않습니다. 그래서 파일이 존재하기만 하면 무조건 200이 나옵니다.
브라우저는 다릅니다. 리다이렉트로 바뀐 최종 주소를 기준으로 ./css/를 직접 계산해서 요청합니다.
그 계산 결과가 틀렸다는 사실은 브라우저만 알 수 있습니다.
절대 URL을 찍는 검증은 "파일이 있는가"만 답한다. "브라우저가 그 파일을 찾아가는가"는 답하지 못한다.
배포 후 자동 점검 스크립트가 전부 200을 뱉는 동안, 화면은 계속 깨져 있었습니다.
고친 방법
1. 에셋은 절대경로로
가장 확실한 해법입니다. 상대경로 해석 자체를 없앱니다.
<!-- 리다이렉트에 따라 풀리는 위치가 달라짐 -->
<link rel="stylesheet" href="./css/site.css">
<!-- 항상 같은 곳 -->
<link rel="stylesheet" href="/sample/app/css/site.css">
CSS 안에서 쓰는 url(../images/bg.jpg) 같은 경로는 그대로 둬도 됩니다.
CSS 파일의 위치는 고정이고, 그 파일을 기준으로 풀리기 때문에 페이지 주소에 영향받지 않습니다.
2. 내부 링크에서 끝 슬래시를 뺀다
리다이렉트를 없애면 문제의 원인 자체가 사라집니다.
<a href="/sample/app/detail/">상세</a> <!-- 308 한 번 거침 -->
<a href="/sample/app/detail">상세</a> <!-- 바로 도착 -->
페이지를 옮겨 다닐 때마다 리다이렉트가 한 번씩 끼는 것도 없어집니다.
canonical과 구조화 데이터의 URL도 같이 맞춰야 검색엔진이 최종 주소를 정확히 인식합니다.
3. 로컬 미리보기 방식이 배포와 다르다는 것을 인지한다
파일을 브라우저로 직접 여는 방식(file://)에서는 절대경로가 디스크 루트를 가리킵니다.
/sample/app/css/site.css는 file:///sample/app/css/site.css가 되어 존재하지 않습니다.
로컬 확인은 정적 서버를 띄워서 해야 합니다.
python3 -m http.server 8899 --directory public
# → http://127.0.0.1:8899/sample/app/
여기서 주의할 점이 하나 더 있습니다. 파이썬 기본 서버는 Next.js와 반대로 동작합니다. 슬래시 없는 주소를 슬래시 붙은 쪽으로 301 리다이렉트합니다. 로컬에서 상대경로가 멀쩡히 동작하는 이유가 여기 있습니다. 로컬 통과는 배포 안전을 보장하지 않습니다.
다음부터 쓸 점검 절차
배포 후 이 순서로 확인합니다. 앞의 두 개는 자동화할 수 있고, 세 번째는 사람이 봐야 합니다.
1. 리다이렉트가 붙는지 본다
curl -sI https://example.com/path/ | head -1
# 308이 나오면 상대경로를 쓴 문서는 전부 의심 대상
2. 문서 안의 경로를 뽑아 최종 주소 기준으로 다시 푼다
절대 URL을 그대로 찍지 말고, HTML에서 긁은 상대경로를 리다이렉트 이후 주소 기준으로 join한 뒤 요청합니다.
import urllib.parse
base = 'https://example.com/sample/app' # 슬래시 떨어진 최종 주소
print(urllib.parse.urljoin(base + '/', './css/site.css')) # 잘못된 기준
print(urllib.parse.urljoin(base, './css/site.css')) # 브라우저가 쓰는 기준
두 결과가 다르면 그 페이지는 깨집니다.
3. 브라우저로 실제 화면을 본다
헤드리스 브라우저로 스크린샷 한 장이면 충분합니다.
chrome --headless --screenshot=out.png --window-size=1440,900 \
https://example.com/sample/app
스타일이 없는 화면은 사람 눈에 0.5초면 보입니다. 자동 점검이 초록불인데 화면이 깨져 있는 상황을, 이 한 장이 막습니다.
정리
- Next.js 기본값은 주소 끝 슬래시를 떼는 방향이고, 그 순간 상대경로의 기준 디렉터리가 한 단계 올라갑니다.
- 프레임워크 라우팅 아래에 정적 파일을 둘 때는 에셋 경로를 절대경로로 고정하는 편이 안전합니다.
- 내부 링크의 끝 슬래시를 없애면 리다이렉트 자체가 사라져 원인이 제거됩니다.
- HTTP 200은 "파일이 있다"는 뜻일 뿐입니다. 브라우저가 그 파일에 도달하는지는 브라우저로만 확인됩니다.
배포 파이프라인에 스크린샷 한 장을 추가하는 비용은 몇 초입니다. 그 몇 초가, 링크를 보낸 뒤에 "화면이 이상한데요"를 듣는 상황을 막아줍니다.