배포하니 CSS가 통째로 사라졌다 — 트레일링 슬래시와 상대경로가 만나는 지점

Next.js배포트러블슈팅정적파일Vercel

정적 페이지 몇 장을 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.cssfile:///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은 "파일이 있다"는 뜻일 뿐입니다. 브라우저가 그 파일에 도달하는지는 브라우저로만 확인됩니다.

배포 파이프라인에 스크린샷 한 장을 추가하는 비용은 몇 초입니다. 그 몇 초가, 링크를 보낸 뒤에 "화면이 이상한데요"를 듣는 상황을 막아줍니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.

비용이 궁금하면 외주 계산기로 먼저 확인해보세요.