배포했더니 블로그 글이 다 404 — 한글 slug 삽질기
2026-06-13 · 5분 읽기
서버 하나에 사이트 두 개를 같은 도메인으로 욱여넣는 작업을 하다가, 멀쩡하던 블로그 글이 전부 404가 뜨는 걸 발견했다. "방금 건드린 것 때문이겠지" 하고 덤볐다가 한참을 헛다리 짚었다. 결론부터 말하면 배포랑은 아무 상관 없었고, 범인은 한글 파일명의 유니코드 정규화였다. 디버깅 과정을 거의 그대로 남겨둔다. 헛다리 짚은 것까지 다 적는 게 나중에 도움이 되더라.
상황
구성은 이렇다. 오라클 클라우드 ARM 서버 한 대에 포트폴리오(Next.js)가 이미 돌고 있었고, 여기에 바둑 앱을 foxibu.is-a.dev/onlineGo 경로로 추가했다. nginx 하나가 앞단에서 경로 보고 갈라주는 구조.
portfolio-nginx (80/443)
├── / → 포트폴리오
├── /onlineGo/* → 바둑 앱
└── /rest|auth|realtime/v1 → Supabase라우팅 작업을 마치고 사이트를 둘러보는데, 블로그 글을 누르니 죄다 404가 떴다. 타이밍이 딱 배포 직후였으니 당연히 nginx 라우팅을 의심했다.
헛다리 1 — nginx 라우팅?
가장 먼저 한 일은 nginx를 거친 응답과 컨테이너에 직접 때린 응답을 비교한 거다.
# nginx 거쳐서
curl -s -D - https://foxibu.is-a.dev/blog/어떤글 | grep -E "^HTTP|^location"
# → 404
# 포트폴리오 컨테이너에 직접
docker exec portfolio-nginx wget -qSO /dev/null http://app:3000/blog/어떤글
# → 404둘 다 404. 즉 nginx를 빼도 컨테이너가 그냥 404를 뱉고 있었다. 라우팅 문제가 아니라 앱 내부 문제라는 뜻. 첫 번째 가설 폐기.
헛다리 2 — devMode와 coming-soon
포트폴리오는 devMode가 켜져 있으면 비로그인 방문자를 /coming-soon으로 보내는 설정이 있었다. 그래서 루트(/)는 307로 coming-soon에 가고 있었고. "아 이거구나" 싶었는데, 이상한 게 있었다.
# /about → 307 redirect (정상적인 coming-soon 동작)
# /blog/글 → 404 (???)같은 (site) 레이아웃을 쓰는데 /about은 얌전히 리다이렉트되고 /blog/글만 404였다. devMode 리다이렉트가 원인이면 둘 다 307이어야 한다. 뭔가 블로그 라우트만의 문제였다.
여기서 결정적인 정보 하나를 놓치고 있었는데, 글이 전부 status: draft였다는 거다. 블로그 글 페이지 로직이 이렇게 생겼다.
const post = getPostBySlug(slug)
if (!post) notFound() // ① 글을 못 찾으면 404
const session = await getServerSession(authOptions)
const isDraft = post.status !== 'published'
if (isDraft && !session) notFound() // ② 미발행 글 + 비로그인 = 404비로그인은 레이아웃에서 coming-soon으로 먼저 튕겨나가니 이 페이지까지 오지도 못한다. 로그인한 관리자만 이 코드까지 도달한다. 그러니까 "관리자로 봐도 404"라는 증상은 ①이나 ②에서 터지고 있다는 얘기였다.
헛다리 3 — MDX 렌더링이 터지나?
컨테이너 로그를 뒤졌더니 진짜 에러가 하나 박혀 있었다.
TypeError: Cannot read properties of null (reading 'digest')
at .../next-server/app-page.runtime.prod.js이 digest null 에러는 보통 서버 컴포넌트 렌더 도중 뭔가 throw될 때 Next가 에러 객체를 처리하다 나는 거다. 블로그 글은 next-mdx-remote로 MDX를 렌더하면서 remark-math, rehype-katex까지 물려 있었다. 글들이 죄다 2020년에 다른 데서 긁어온 오래된 MDX라, "수식이나 문법이 MDX 컴파일러에 안 맞아서 렌더가 터지는구나" 하고 거의 확신했다.
그래서 standalone 컨테이너 안에 의존성이 제대로 들어갔는지 확인했더니:
docker exec portfolio sh -c '
ls -d /app/node_modules/remark-math /app/node_modules/katex /app/node_modules/gray-matter
'
# → 전부 MISSINGgray-matter, remark-math, rehype-katex, katex가 전부 node_modules에 없었다. "찾았다! standalone 빌드가 의존성 추적을 못 했구나" 싶었는데… 이것도 헛다리였다. 정적 import로 들어간 모듈은 webpack이 서버 청크에 통째로 번들해버려서 node_modules에 따로 없는 게 정상이다. 실제로 청크를 뒤지니 gray-matter도 katex도 번들 안에 멀쩡히 있었다.
docker exec portfolio sh -c 'grep -rl "gray-matter" /app/.next/server/chunks'
# → /app/.next/server/chunks/673.js (번들됨, 정상)의존성 누락설도 폐기. 로그의 digest 에러는 진짜 에러이긴 한데, 단 한 번만 찍혀 있어서 내가 테스트하다 만든 일회성일 가능성이 컸다.
결정적 실험 — 영문 글 vs 한글 글
여기서부터는 추측을 멈추고 변수를 갈랐다. 비로그인으로는 레이아웃에서 막혀서 페이지 렌더를 볼 수가 없으니, 로그인한 상태에서 직접 두 종류를 열어보기로 했다.
한글 제목 글(
질문-transaction) → 404영문 제목 글(
brute-force) → 정상 렌더
이거다. 세션도 MDX도 아니었다. 둘 다 같은 draft 글이고 같은 렌더 경로인데 slug에 한글이 들어가느냐 마느냐가 갈랐다. 그러니까 ① getPostBySlug가 한글 slug에서만 null을 뱉고 있었던 거다.
진짜 원인 — 유니코드 정규화 (NFC vs NFD)
getPostBySlug는 단순했다.
const filePath = path.join(POSTS_DIR, `${slug}.mdx`)
if (!fs.existsSync(filePath)) return nullURL의 한글 slug를 그대로 파일명에 박아서 존재 확인을 한다. 그런데 컨테이너에서 직접 돌려보니 정작 파일은 멀쩡히 존재했다.
docker exec portfolio node -e '
const fs=require("fs");
const slug="질문-transaction";
console.log(fs.existsSync("/app/content/posts/"+slug+".mdx")); // → true
'NFC로 친 문자열로는 파일이 찾아진다. 그런데 실제 요청은 404. 결론은 하나다. 페이지가 받는 slug param이 디스크의 파일명과 다른 형태로 도착하고 있었다.
여기서 유니코드의 함정이 등장한다. 같은 "질"이라는 글자도 바이트로는 두 가지로 표현될 수 있다.
NFC (조합형):
질= 한 코드포인트 (U+C9C8)NFD (분해형):
질=ㅈ+ㅣ+ㄹ세 코드포인트
눈에는 똑같아 보이지만 === 비교도, fs.existsSync도 다른 문자열로 취급한다. 디스크의 파일명은 NFC인데, URL을 거쳐 들어온 param이 NFD(또는 percent-encoding이 덜 풀린 형태)로 도착하면, ${slug}.mdx로 만든 경로가 실제 파일과 한 글자도 안 맞는 거다. 영문 slug는 NFC/NFD 구분이 없으니 멀쩡했고.
이게 배포 직후에 "발견"된 것뿐이지, 배포랑은 아무 인과관계가 없었다. 마침 그때 사이트를 둘러봤을 뿐이다. 타이밍에 속아서 한참 엉뚱한 데를 팠다.
해결
들어오는 형태가 뭐가 됐든 다 받아내도록 getPostBySlug를 견고하게 고쳤다. 원본 그대로 → percent-decode → NFC → NFD 순서로 시도하고, 그래도 안 되면 디렉토리를 스캔해서 NFC 정규화 기준으로 비교한다.
function resolvePostFile(slug: string): string | null {
const raws = [slug]
try {
const decoded = decodeURIComponent(slug)
if (decoded !== slug) raws.push(decoded)
} catch {
/* 인코딩 안 된 경우 무시 */
}
// 빠른 경로: 두 정규화 형태로 직접 존재 확인
for (let i = 0; i < raws.length; i++) {
const forms = [raws[i], raws[i].normalize('NFC'), raws[i].normalize('NFD')]
for (let j = 0; j < forms.length; j++) {
if (fs.existsSync(path.join(POSTS_DIR, `${forms[j]}.mdx`))) {
return `${forms[j]}.mdx`
}
}
}
// 폴백: 디렉토리를 훑어 NFC 정규화 기준으로 매칭
const wanted = raws.map((r) => r.normalize('NFC'))
return (
fs
.readdirSync(POSTS_DIR)
.filter((f) => f.endsWith('.mdx'))
.find((f) => wanted.indexOf(f.replace(/\.mdx$/, '').normalize('NFC')) !== -1) ?? null
)
}컨테이너에서 네 가지 입력 형태로 검증.
NFC 입력 : 질문-transaction.mdx
NFD 입력 : 질문-transaction.mdx
인코딩 입력 : 질문-transaction.mdx
인코딩(NFD) : 질문-transaction.mdx전부 같은 파일로 수렴한다. 재빌드하고 로그인한 채로 한글 글을 다시 여니 정상으로 떴다.
남는 이야기
빌드에서 한 번 더 걸렸는데, Set을 for...of로 돌리고 [...set]로 펴는 코드가 이 프로젝트 tsconfig의 target(ES2015 미만)에서 --downlevelIteration 에러를 냈다. 평소 신경 안 쓰던 부분인데, 남의(과거의 내) 설정 위에서 코드를 짤 때는 이런 게 튀어나온다. 그냥 인덱스 루프와 배열로 바꿔서 우회했다.
교훈 몇 가지
"방금 건드린 것"이 항상 범인은 아니다. 타이밍 상관관계에 속아서 nginx → devMode → MDX → 의존성 순으로 한참 헤맸다. 정작 원인은 그 전부터 있던 한글 slug 버그였다.
변수를 가르는 실험 하나가 추측 열 개를 이긴다. 영문 글과 한글 글을 나눠서 열어본 순간 범위가 단번에 좁혀졌다. 더 일찍 했어야 했다.
node_modules에 모듈이 없다고 다 문제는 아니다. 정적 import는 번들로 들어간다. 청크를 직접 확인하기 전까진 "누락"이라고 단정하면 안 된다.한글 다루는 웹앱은 NFC/NFD를 의심하자. 파일명, URL, DB 키 어디서든 정규화가 어긋나면 "분명히 있는데 못 찾는" 현상이 난다. 경계에서 한 번 정규화해 주는 게 안전하다.
배포 한 번에 사이트 두 개를 엮으면서 별의별 걸 다 만졌는데, 정작 제일 오래 잡은 버그는 배포와 무관한 유니코드였다는 게 좀 허무하면서도 재밌었다.