인증 · 미들웨어
미들웨어가 자기 서버를 호출하다 프로덕션에서만 터진 이슈
권한을 매번 fresh하게 가져오려고 미들웨어가 자기 서버의 BFF 라우트를 호출했다. 로컬에선 멀쩡했는데 프로덕션 배포 직후 로그인한 전 사용자가 에러 페이지로 튕겼다. 로그가 한 줄도 안 남아 단서조차 없던 P0. 유일한 단서가, 역설적이게도 "로그가 없다"는 사실 그 자체였다.
증상 — 로컬은 멀쩡, 프로덕션만 전멸
배포 직후 들어온 보고는 이랬다.
- 로그인은 된다. 그런데 로그인 후 어떤 페이지를 눌러도
/auth-error로 강제 이동 - 새 세션이든 기존 세션이든 동일. 로그인된 사용자 전원이 서비스를 못 씀
- 로컬에선 재현이 안 됨.
pnpm dev로 같은 빌드를 띄워 admin·일반 계정 다 돌려도 멀쩡
"로컬 OK / 프로덕션 전멸 / 재현 불가"는 디버깅 입장에서 최악의 조합이다. 코드를 아무리 노려봐도 로컬에선 잘 도니까, 눈으로 봐선 틀린 데가 안 보인다.
첫 단서 — 있어야 할 로그가 통째로 비어 있다
프로덕션 서버 로그를 뒤지다 이상한 걸 발견했다. 평소 페이지 진입마다 찍히던 BFF 호출 로그가 하나도 안 남아 있었다.
# 평소 (정상)
GET /api/bff/api/profile 200
GET /api/bff/api/profile 200
...
# 사고 당시 — 위 줄이 아예 없음
(profile 호출 흔적 0건)처음엔 "로그 수집이 빠졌나" 싶었다. 그런데 BFF가 실패했으면 BFF가 찍는 에러 로그라도 있어야 하는데 그것조차 없었다. 여기서 가설이 뒤집혔다.
에러 로그가 안 남은 게 아니라, BFF 코드까지 도달조차 못 한 거다.
평소 보이던 로그가 갑자기 사라지면 그건 "그 코드까지 못 갔다"는 신호다. 그러면 BFF에 도달하기 전 단계 — 즉 BFF를 호출하는 쪽인 미들웨어를 봐야 했다.
왜 이런 코드를 짰나 — fresh role 정책
문제의 미들웨어는 직전 PR에서 들어간 정책의 산물이었다. 권한을 매 요청마다 백엔드에서 fresh하게 받아오자는 발상이었다. 배경은 이렇다.
로그인 처리는 NextAuth(JWT)로 한다. 로그인하면 JWT가 발급돼 쿠키에 저장되고, 그 안에 role을 박아두면 페이지 진입 시 그걸 보고 admin/일반을 분기할 수 있다. 빠르고 간단하다.
문제는 JWT가 한 번 발급되면 만료(1년)까지 안 바뀐다는 점이다.
| JWT에 role 박기 | 매 요청 fresh fetch | |
|---|---|---|
| 속도 | 빠름 (네트워크 0) | 느림 (매번 BE 왕복) |
| 권한 회수 반영 | 최대 1년 지연 | 즉시 |
| admin이 권한 박탈 시 | 사용자는 1년간 admin인 척 동작 | 다음 진입에서 바로 차단 |
권한 회수가 1년 지연되는 건 보안상 받아들이기 어려웠다. 그래서 "JWT엔 role을 박지 말고, 페이지
진입마다 백엔드 /api/profile로 fresh role을 받자"로 결정했다. 그 호출을 미들웨어가
BFF 라우트를 통해 하도록 짠 게 흐름이었다.
[브라우저] 페이지 클릭
↓
[미들웨어] 인증됐나 + role이 뭔가 확인
↓
[미들웨어 → BFF] /api/bff/api/profile ← 여기가 문제
↓
[BFF] 쿠키의 JWT를 Bearer 토큰으로 변환해 BE로 전달
↓
[백엔드] 사용자 정보 + role 응답
↓
[미들웨어] role 받았으니 분기 → 페이지 렌더근본 원인 — 자기 자신을 외부 URL로 호출하는 self-fetch
미들웨어 코드를 열어 봤다. 범인은 한 줄이었다.
// src/middleware.ts (사고 당시)
async function fetchProfileRole(origin, cookie) {
try {
const res = await fetch(`${origin}/api/bff/api/profile`, {
// ^^^^^^^ ← 자기 자신의 절대 URL
headers: { Cookie: cookie },
signal: AbortSignal.timeout(3000),
})
if (!res.ok) return null
const profile = await res.json()
return profile.user?.role ?? null
} catch {
return null // ← 에러가 나도 조용히 null만 반환
}
}origin은 현재 사이트의 절대 URL이다. 사용자가 https://<host>/admin/users에 들어왔으면
origin = 'https://<host>', 그래서 fetch가 때리는 주소는 https://<host>/api/bff/api/profile —
사이트가 자기 자신을 외부 URL로 다시 호출하는 꼴이다. 이게 self-fetch다.
왜 환경에 따라 갈리느냐가 핵심이다.
- 로컬(
http://localhost:3000): 서버가 그냥 내 노트북 프로세스다.localhost:3000→localhost:3000은 같은 머신 루프백이라 바로 연결된다. 그래서 self-fetch가 잘 돈다. - 프로덕션(컨테이너): 서버는 격리된 컨테이너 안에서 돈다. 그 안에서 자기 자신의 외부 public URL로 fetch하면, 실제로는 컨테이너 밖 인터넷으로 한 번 나갔다가 로드밸런서를 거쳐 다시 들어와야 한다.
[컨테이너 내부]
↓ fetch("https://<host>/api/bff/...")
↓ 외부 public URL로 나가야 함
↓ DNS lookup → public IP
↓ HTTPS handshake → Load Balancer → 다시 컨테이너로
↓ ❌ 이 경로가 컨테이너 내부에서 라우팅 안 되거나 timeout이 경로가 막히니 fetch가 실패 → role = null → 미들웨어가 전원을 /auth-error로 보낸 것이다.
로컬은 루프백으로 그냥 됐기 때문에 재현이 안 됐던 거고, 프로덕션 컨테이너에서만 터졌다.
여기서 1번 단서가 맞물렸다. fetch가 BFF에 도달하기도 전에 네트워크 단에서 죽으니, BFF 로그가 안 남는 게 당연했다. "로그의 부재 = 도달 실패"라는 가설이 정확히 들어맞은 것이다.
두 번째 함정 — silent catch가 단서를 삼켰다
근본 원인은 self-fetch지만, 진단을 더디게 만든 진범은 따로 있었다.
catch {
return null // 무슨 에러인지 한 줄도 안 남김
}fetch가 timeout이든 DNS 실패든 네트워크 에러든, 이 catch가 전부 삼키고 조용히 null만 돌려줬다. 그래서 프로덕션 로그엔:
- BFF 에러 로그 → 없음 (BFF 실행 자체를 못 함)
- 미들웨어 에러 로그 → 없음 (catch가 삼킴)
- 남은 건 "어쩐지 전부
/auth-error로 가더라"뿐
원인이 어디서 터졌는지 가리키는 화살표가 코드 안에 하나도 없었다. server-side fetch의 catch는
프로덕션에서 무슨 일이 났는지 알려주는 거의 유일한 단서인데, 그걸 return null 한 줄로 날린 셈이다.
해결 — self-fetch를 없애고 BE를 직접 호출
발상은 단순했다. 자기 서버를 한 번 더 거치지 말고, 미들웨어가 백엔드를 직접 때리면 된다.
[변경 전]
미들웨어 → 우리 서버의 BFF (self-fetch, 실패) → 백엔드
[변경 후]
미들웨어 → 백엔드 (직접)self-fetch라는 단계 자체를 지웠다. 기존엔 쿠키를 BFF에 넘기면 BFF가 알아서 쿠키 속 JWT를 Bearer 토큰으로 바꿔 BE에 전달했는데, 이제 미들웨어가 그 일을 직접 한다.
// 변경 후 (hotfix)
async function fetchProfileRole(accessToken) {
try {
const res = await fetch(`${getApiBaseUrl()}/api/profile`, {
// ^^^^^^^^^^^^^^ 백엔드 base URL을 직접
headers: { Authorization: `Bearer ${accessToken}` },
// ^^^^^^^^^^^^^ 쿠키 대신 Bearer 토큰을 직접 박음
signal: AbortSignal.timeout(3000),
})
if (!res.ok) {
console.error('[MIDDLEWARE] BE profile !ok status:', res.status)
// ↑ 더는 silent 안 함 — 실패 status를 남긴다
return null
}
// ...
} catch (e) {
console.error('[MIDDLEWARE] BE profile fetch threw', e instanceof Error ? `${e.name}: ${e.message}` : e)
return null
}
}미들웨어는 getToken()으로 JWT를 풀 수 있으니, 그 안의 accessToken(백엔드가 발급한 진짜 토큰)을
꺼내 Authorization: Bearer ...로 직접 박았다. BFF가 하던 토큰 변환을 미들웨어가 인라인으로
대신한 것이고, 더불어 catch에 console.error를 박아 다음에 또 터지면 어디서 죽었는지 보이게 했다.
왜 axios가 아니라 fetch였나
여기서 한 번 막혔다. 우리 프로젝트엔 인터셉터·base URL이 다 세팅된 axios 인스턴스가 있어서 "그걸 쓰면 깔끔하지 않나" 싶었는데, 미들웨어에선 axios가 아예 안 된다.
Next.js 미들웨어는 일반 Node가 아니라 Edge Runtime에서 돈다. CDN edge 노드에서 빠르게
실행되도록 설계된 제한 환경이고, Node.js API를 못 쓴다. axios는 내부적으로 Node의
http/https 모듈을 쓰는데 Edge엔 그 모듈이 없다.
// middleware.ts (Edge Runtime)
import axios from 'axios'
// ❌ 런타임 에러: Cannot find module 'http'| API | Node Runtime | Edge Runtime |
|---|---|---|
fetch (Web 표준) | ✓ | ✓ |
axios | ✓ | ❌ |
fs, http, https (Node 모듈) | ✓ | ❌ |
export const runtime = 'nodejs'로 미들웨어를 Node로 강제하면 axios도 쓸 수 있긴 하다. 하지만
그러면 Edge의 지연·속도 장점이 통째로 날아간다. 미들웨어는 모든 요청 앞단에서 도는 레이어라 가장
가벼워야 한다. 그래서 선택의 여지 없이 Web 표준 fetch로 갔다.
BFF 우회 — 원칙을 깨도 되나
우리 프로젝트엔 "모든 백엔드 호출은 BFF를 거친다"는 약속이 있다. BFF가 쿠키↔Bearer 변환, path traversal 가드, 일관된 에러 응답을 책임지는 단일 관문이다. 그런데 이번 hotfix는 그 원칙을 미들웨어에서만 깬다. 고민이 됐지만 결론은 "미들웨어만 예외가 맞다"였다.
- 미들웨어는 페이지 렌더 전 server-side 분기를 책임지는 특수 레이어다
- 그 책임을 다하려고 self-fetch에 의존했는데, 그게 프로덕션에서 안 도는 게 이번 사고의 본질이다
- 일반 client → BE 호출은 BFF 경유 원칙을 그대로 둔다. 이 변경은 미들웨어 한 함수에 한정
일반화하면 교훈은 이거다 — server-side 코드에서 자기 자신의 외부 URL을 self-fetch하지 않는다. 같은 프로세스 안이면 그 라우트의 로직을 직접 인라인하거나 함수로 import해서 부르고, 외부 BE를 호출할 일이면 BE를 직접 친다.
배운 점
- "로컬은 되는데 프로덕션만 안 되는" 버그는 환경 경계를 의심한다. 컨테이너 네트워크, 절대 URL 라우팅, Edge vs Node 런타임 — 코드가 아니라 실행 환경이 다른 지점을 먼저 본다. 눈으로 코드를 노려봐야 안 보이는 이유가 거기 있다
- 로그의 부재가 가장 강한 단서일 때가 있다. 평소 찍히던 로그가 통째로 사라지면 "에러가 안 났다"가 아니라 "그 코드까지 도달조차 못 했다"는 신호다. 이번엔 그게 BFF 앞단(미들웨어)으로 범위를 좁혀줬다
- server-side catch의 silent fail은 절대 금지다.
return null한 줄이 진단을 며칠 늘린다.catch (e)로 받아console.error에 어디서·무슨 에러인지 반드시 남긴다 — 프로덕션에선 그게 유일한 단서다 - self-fetch 패턴은 PR 리뷰 체크리스트에 올린다. 미들웨어·API 라우트·서버 컴포넌트가 자기 도메인을 fetch하는지, 로컬 OK가 프로덕션 OK를 보장하지 않는 인프라 의존(fetch·DNS·라우팅)이 없는지 머지 전에 짚는다. 이번 사고의 진짜 손실은 그게 리뷰에서 안 걸린 것이었다