앱 포트를 localhost로 옮겼더니 사이트가 500을 뱉었다: Next.js 미들웨어와 -H 127.0.0.1의 함정

서버 보안 점검 중 아주 평범한 조치를 했습니다. 리버스 프록시 뒤에 있는 웹앱들이 외부 인터페이스(0.0.0.0)에 직접 바인딩돼 있길래, 하나씩 127.0.0.1로만 듣도록 바꾸는 작업이었죠. 첫 앱은 깔끔하게 성공했습니다. 두 번째 앱에서, 로컬은 200인데 도메인으로 들어오면 500이 떨어졌습니다. "재기동 타이밍 문제겠지" 하고 넘길 뻔했지만, 그건 결정론적으로 재현되는 버그였습니다. 이 글은 그 500의 정체 — Next.js i18n 미들웨어와 -H 127.0.0.1이 만나는 지점의 함정 — 을 추적한 기록입니다.

1. 배경: "그냥 localhost로 묶으면 되는 일" 아니었나

구조는 흔한 형태입니다. nginx가 443에서 TLS를 종료하고, 경로/호스트별로 뒤쪽 앱들(각각 다른 포트에서 도는 Next.js·Flask 등)로 프록시합니다. 앱 자체가 0.0.0.0에 열려 있으면 방화벽이 허술할 때 포트로 직접 접근당할 수 있으니, "프록시만 통하게" 하려고 앱을 루프백에만 바인딩하는 건 교과서적인 조치입니다.

Next.js에서는 실행 인자에 -H(host)를 붙이면 됩니다.

# Before
next start -p 3002

# After
next start -p 3002 -H 127.0.0.1

첫 앱은 이렇게 바꾸고 재시작하니 리스너가 127.0.0.1로 바뀌었고, 도메인 접속도 200. 끝. 그런데 두 번째 앱에서 균열이 생겼습니다.

2. 증상: 로컬은 200, 프록시 경유는 500

같은 방식으로 바꿨는데 결과가 갈렸습니다.

curl http://127.0.0.1:3002/            → 200   (로컬 직접)
curl https://example.com/             → 500   (nginx 경유)

처음엔 "재시작 직후 워밍업 타이밍" 정도로 의심했습니다. 흔한 오진이죠. 그래서 로컬에서 프록시가 붙이는 헤더를 그대로 흉내 내 재현을 시도했습니다.

curl -H "Host: example.com" \
     -H "X-Forwarded-Proto: https" \
     http://127.0.0.1:3002/            → 500   (재현!)

타이밍이 아니었습니다. Host와 X-Forwarded-Proto가 프록시처럼 들어오면 100% 500. 재현되는 버그는 이미 절반은 잡은 것과 같습니다.

3. 원인: 미들웨어가 보는 "호스트"가 바뀌어 있었다

문제의 앱은 다국어(i18n) 라우팅을 미들웨어로 처리하고 있었습니다. 로케일이 없는 경로로 들어오면 기본 로케일을 붙여 내부 리라이트(rewrite)하는 흔한 패턴입니다.

export function middleware(request) {
  const url = request.nextUrl.clone();
  url.pathname = `/${locale}${pathname}`;
  return NextResponse.rewrite(url);   // 내부 리라이트
}

핵심은 request.nextUrl의 호스트가 무엇이냐입니다. 서버를 -H 127.0.0.1로 기동하면, 미들웨어가 보는 nextUrl의 호스트가 요청의 진짜 Host(example.com)가 아니라 서버가 바인딩된 localhost:포트로 정규화됩니다. 그래서 url.clone()으로 만든 리라이트 대상의 호스트가 요청 호스트와 달라집니다.

Next.js 입장에서 리라이트 목적지의 호스트가 현재 요청 호스트와 다르면, 그건 "내부 경로 리라이트"가 아니라 "외부 URL로의 리라이트"로 해석됩니다. 결과적으로 서버가 https://localhost:3002/... 같은 주소로 스스로 fetch를 시도하고(그쪽엔 TLS도, 그런 서버도 없죠), 그 fetch가 깨지면서 500이 떨어진 겁니다.

왜 로컬 curl은 200이었나? 로컬에서 Host 없이 때리면 nextUrl 호스트와 요청 호스트가 어차피 같은 localhost라 불일치가 안 생깁니다. 불일치는 프록시가 진짜 도메인 Host를 실어 보낼 때만 발생합니다. 그래서 "로컬 검증 통과"가 안심의 근거가 못 됐습니다.

4. 조용한 쌍둥이 버그: rewrite가 아니라 redirect였다면

더 무서운 건 같은 코드의 사촌이었습니다. 옆에 있던 다른 앱은 동일한 i18n 미들웨어를 쓰되, 리라이트가 아니라 NextResponse.redirect(url)를 썼습니다.

리라이트는 서버 내부에서 깨져 500이라도 뱉지만, 리다이렉트는 그 잘못된 호스트가 브라우저로 그대로 나갑니다. 즉 이 앱을 그냥 -H 127.0.0.1로 바꿔 배포했다면, 방문자가 https://localhost:3002/...로 튕겨나가는 — 500보다 더 조용하고 더 나쁜 — 장애가 확정적이었습니다. 500은 최소한 시끄럽기라도 하죠.

5. 해결: 원 요청의 호스트를 되살려주기

고칠 지점은 명확했습니다. 리라이트/리다이렉트 URL을 만들 때, 서버가 정규화해버린 localhost 호스트 대신 원 요청의 호스트와 프로토콜을 복원하는 것. nginx는 이미 HostX-Forwarded-Proto를 넘겨주고 있었으니 그걸 쓰면 됩니다.

const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname}`;

// 프록시 뒤에서 정규화된 localhost 호스트를 원 요청 값으로 되돌린다
const host  = request.headers.get('x-forwarded-host')
           ?? request.headers.get('host');
const proto = request.headers.get('x-forwarded-proto');
if (proto) url.protocol = proto;
if (host) {
  url.host = host;
  // 함정: host 세터는 기존 포트를 지우지 않는다.
  // Host 헤더에 포트가 없으면 명시적으로 비워줘야 :3002가 안 붙는다.
  if (!host.includes(':')) url.port = '';
}
return NextResponse.rewrite(url);

마지막 세 줄이 두 번째 함정입니다. URL.host 세터에 "example.com"을 넣어도, 객체에 이미 박혀 있던 :3002 포트는 자동으로 사라지지 않습니다. 포트를 명시적으로 비우지 않으면 https://example.com:3002/가 만들어져 같은 증상이 반복됩니다.

미들웨어를 고치고 재빌드한 뒤, 두 앱 모두 -H 127.0.0.1 바인딩 + 도메인 경유 200을 확인했습니다.

6. 교훈

  • "일시적"은 진단이 아니다. 재기동 타이밍 탓으로 돌리고 원복했다면 원인은 영영 안 잡혔을 겁니다. 500이 났으면 헤더를 흉내 내서라도 결정론적 재현부터 시도하세요.
  • 로컬 200은 프록시 경유를 보장하지 않는다. 프록시가 실어 보내는 Host·X-Forwarded-*가 앱 동작을 바꾸는 경우, 검증은 반드시 프록시를 통과하는 실제 경로로 해야 합니다.
  • rewrite와 redirect는 실패 방식이 다르다. 같은 버그라도 rewrite는 500(서버 내부), redirect는 사용자를 잘못된 호스트로 내보냅니다. 후자가 더 조용하고 더 위험합니다.
  • 바인딩 호스트는 앱이 "자기 주소"를 인식하는 방식을 바꾼다. -H 127.0.0.1은 네트워크만 좁히는 게 아니라, 프레임워크가 절대 URL을 만들 때 쓰는 기준 호스트에도 영향을 줍니다.
  • URL 객체의 host 세터는 포트를 안 지운다. 작지만 반복되는 함정. 호스트를 바꿀 땐 포트도 함께 정리하세요.

FAQ

Q. 그냥 방화벽으로 포트를 막으면 되지, 왜 앱을 localhost로 바인딩하나요?

방화벽(클라우드 시큐리티 그룹 등)과 앱 바인딩은 서로 다른 층의 방어입니다. 방화벽 규칙이 실수로 열리거나, 같은 호스트 내 다른 프로세스에서의 접근까지 고려하면, 앱 자체를 루프백에만 묶어두는 게 심층 방어(defense in depth)에 맞습니다. 둘 다 하는 게 정석입니다.

Q. 미들웨어를 안 고치고 우회할 방법은 없었나요?

nginx에서 proxy_set_header Host를 조정하거나 앱에 basePath/absolute URL 설정을 손대는 우회도 이론상 가능하지만, 근본 원인은 "미들웨어가 절대 URL을 만들 때 요청 호스트를 무시한다"는 점입니다. 원인 지점에서 고치는 게 가장 부작용이 적었습니다.

Q. 모든 Next.js 앱이 이 문제를 겪나요?

아닙니다. 미들웨어에서 nextUrl.clone()으로 절대 URL을 만들어 rewrite/redirect하는 앱이, 동시에 프록시 뒤에서 -H 127.0.0.1로 도는 조합에서 드러납니다. 상대 경로만 다루거나 미들웨어가 없으면 이 증상은 안 납니다.

이 글은 실제 서버 유지보수 작업에서 겪은 문제를 일반화해 정리한 기록입니다. 특정 호스트명·경로·자격증명 등 구체적 인프라 정보는 의도적으로 제외했습니다.