Deallo

인증 · 미들웨어

미들웨어가 자기 서버를 호출하다 프로덕션에서만 터진 이슈

권한을 매번 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:3000localhost: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'
APINode RuntimeEdge 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·라우팅)이 없는지 머지 전에 짚는다. 이번 사고의 진짜 손실은 그게 리뷰에서 안 걸린 것이었다