토큰 재발급을 위한 미들웨어
2026년 7월 18일
모바일 토큰 재발급 미들웨어 (KAN-256)
로그인·로그아웃 다음은 /api/v1/auth/refresh, 토큰 재발급이었다. 스웨거를 열어 보니 엔드포인트 자체는 간단했다 — body에 { refreshToken }을 넣으면 { accessToken, refreshToken }을 새로 준다. access만이 아니라 refresh까지 함께rotate하는 방식이다.
curl로 때려 보니 유효한 토큰엔 mock-access-refreshed/mock-refresh-refreshed를 200으로 돌려주고, body가 비면 400(COMMON_INVALID_PARAM)이었다.
진짜 고민은 엔드포인트가 아니라 "이걸 언제, 어디서 부르나" 였다. 티켓엔 설명이 한 줄도 없었고, 로그인·로그아웃과 달리 재발급엔 누를 버튼이 없다. 사용자가 일으키는 일이 아니라 토큰이 만료돼 갈 때 시스템이 조용히 하는 일이다.
게다가 이 프로젝트엔 아직 보호 API가 없다 — 지금 붙은 건 로그인·로그아웃뿐이고 access 토큰은 "로그인했다"는 신호로 쿠키에만 있지, 어디에도 Bearer로 실려 나가지 않는다. 그러니 "보호 API가 401을 뱉으면 재발급하고 재시도한다"는 흔한 트리거는 걸 데가 없었다. 미들웨어에서 네비게이션마다 만료 여부를 보고 재발급하기로 정했다.
왜 미들웨어여야 했나
평범한 페이지 이동은 GET이다. 그런데 그 GET 도중에 응답 쿠키를 심을 수 있는 자리가 Next App Router엔 미들웨어뿐이다.
서버 액션(login·logout)은 버튼 클릭이 보내는 POST에 붙어서 Set-Cookie를 실을 수 있었지만, 재발급엔 그 POST가 없다.
서버 컴포넌트는 렌더 중 요청 쿠키를 읽을 수만 있고 응답 쿠키를 못 심는다(cookies().set()을 서버 컴포넌트에서 부르면 에러가 뜬다 — 이미 스트리밍이 시작된 응답에 헤더를 못 얹으니까).
미들웨어는 요청이 라우트에 닿기 전에 가로채 NextResponse에 쿠키를 얹고 그대로 흘려보낼 수 있다. "페이지를 여는 순간 토큰을 갈아끼운다"는 건 그래서 구조상 여기서 할 수밖에 없다.
불투명 토큰이라 만료를 못 읽는다 — 쿠키 수명을 만료 신호로 삼았다
여기서 한 번 막혔다. "만료됐으면 재발급"이라는데, 만료를 어떻게 아나?
보통은 access 토큰이 JWT라 payload의 exp 클레임을 디코드해 지금 시각과 비교한다. 그런데 로그인으로 실제 토큰을 받아 뜯어보니 mock-access-kakao 같은 불투명 문자열이었다 — 점(.)으로 나뉜 JWT가 아니라 그냥 짧은 문자열. 안에 만료 정보가 없으니 edge에서 디코드할 게 없다.
그래서 방향을 틀었다. 토큰 안에서 못 읽으면, 쿠키 바깥에서 신호를 만든다.
access 쿠키에 짧은 Max-Age(지금은 자리값 15분), refresh 쿠키에 긴 Max-Age(14일)를 준다. 그러면 access 쿠키는 15분 뒤 브라우저가 스스로 버리고, 요청엔 refresh만 남는다. 이 "access는 없고 refresh만 있는 상태"가 곧 "access 만료, 재발급해야 함" 이다. 쿠키의 소멸을 만료 시계로 쓰는 셈인데, 실제 JWT를 쓰더라도 access 쿠키 Max-Age를 토큰 exp에 맞추면 똑같이 도는 흔한 패턴이다.
로그인이 쿠키를 심을 때부터 이 두 TTL로 심게 고쳤다(전엔 Max-Age 없는 세션 쿠키라 브라우저를 닫기 전엔 안 사라졌다 — 그러면 이 트리거가 영영 안 걸린다). TTL 값 자체는 실제 토큰 수명이 정해지면 맞출 자리값이고, 중요한 건 "쿠키 소멸 = 만료"라는 메커니즘이다.
미들웨어가 손대지 않는 경우는 셋이다. access가 살아 있으면 아직 미만료라 통과. refresh조차 없으면 비로그인 탐색(둘러보기)이라 통과 — 여기서 로그인을 강제하면 홈·피드를 비로그인으로 볼 수 있게 해 둔 게 무너진다(ADR 0020 후속의 "둘러보기"). 그리고 /login 화면 자체도 통과시킨다(뒤의 루프 이야기).
재발급 fetcher를 "use server"에 두지 않은 이유
BE 호출 자체는 apiFetch 위에 refreshTokens(refreshToken)라는 순수 함수로 뺐는데, 이 파일엔 로그인·로그아웃이 있는 auth.ts와 달리 "use server"를 일부러 안 붙였다. 유일한 소비자가 edge 미들웨어라서다.
ADR 0020에서 갈랐듯 "use server"는 "이 export는 서버 액션(=클라가 네트워크로 부를 수 있는 RPC)"이라는 표시지 "서버에서 실행"이라는 뜻이 아니다.
재발급 fetcher는 클라가 부르는 게 아니라 미들웨어가 서버에서 직접 부른다. 게다가 서버 액션은 next/headers·next/navigation에 기대는데 그건 edge 미들웨어 런타임에선 안 돈다 — 미들웨어에선 쿠키를 next/headers의 cookies()가 아니라 request.cookies/response.cookies로, 리다이렉트를 redirect()가 아니라 NextResponse.redirect()로 한다.
그래서 쿠키 읽기·심기·리다이렉트는 전부 미들웨어가 자기 API로 하고, fetcher는 BE 호출과 봉투 해제만 하는 순수 함수로 남겨 edge든 서버든 어디서 부르든 문제없게 했다. apiFetch는 전역 fetch와 process.env.API_BASE_URL만 쓰므로 edge에서 그대로 돈다 — 실제로 미들웨어에서 localhost:8080을 때려 200을 받는 걸 검증에서 확인했다(서버→서버라 CORS도 없다).
요청 쿠키와 응답 쿠키에 둘 다 심는 이유
재발급에 성공하면 회전된 쌍을 두 군데에 심는다. 언뜻 중복 같지만 역할이 다르다.
request.cookies.set(...)+NextResponse.next({ request })— 이 쿠키는 브라우저로 안 나간다. 지금 처리 중인 이 요청의 다운스트림 렌더(서버 컴포넌트)가 새 access 토큰을 보게 하는 용도다. 미들웨어가 요청을 고쳐 라우트에 넘기는 것이라, 이렇게 안 하면 방금 재발급했는데도 그 페이지의isLoggedIn()은 여전히 "access 없음"으로 읽는다.response.cookies.set(..., { maxAge })— 이게Set-Cookie헤더로 브라우저에 나가, 브라우저가 회전된 쌍을 각자의 TTL로 저장하게 한다. 다음 요청부터 새 토큰이 실린다.
즉 요청 쿠키는 "이번 렌더용", 응답 쿠키는 "다음 요청용"이다. 둘 다 심어야 방금 연 페이지도 로그인 상태로 보이고, 이후 이동도 이어진다.
실패하면 세션을 끊고 로그인으로 + 루프를 어떻게 피했나
refresh 토큰마저 만료·무효면(진짜 BE라면 그때 non-2xx가 온다) refreshTokens가 ApiError를 던지고, catch에서 두 쿠키를 지우고 /login으로 리다이렉트한다. 로그아웃과 같은 결이다 — 되살릴 수 없는 세션이면 붙들지 말고 끊는다.
여기서 무한 리다이렉트가 날 뻔했다. 실패 → /login으로 리다이렉트하는데, /login에도 아직 refresh 쿠키가 남아 있으면 미들웨어가 또 재발급을 시도하고 또 실패해 또 /login으로... 이걸 두 겹으로 막았다. 첫째, 실패 응답에서 쿠키를 지우니 리다이렉트된 다음 요청엔 refresh가 없어 위의 "refresh 없으면 통과" 가드에 걸린다(브라우저는 리다이렉트를 따라가기 전에 같은 origin의 Set-Cookie 삭제 지시를 먼저 반영한다). 둘째, 아예 /login 경로면 미들웨어가 맨 앞에서 통과시켜, 로그인 화면에선 재발급·리다이렉트를 시도조차 안 한다. 어느 쪽이든 루프가 안 생긴다.
후속 — 로그인 상태를 화면에 뿌리는 문제, 그리고 전략 셋
재발급을 붙이고 나니 바로 다음 질문이 나왔다. "특정 페이지에서 로그인 여부가 필요한데, 이것도 미들웨어로 하나?" 처음엔 그럴듯하게 들리는데, 파고들면 서로 다른 문제 세 개가 섞여 있었다. 하나씩 갈라 보니 각각 맞는 자리가 달랐고, 그중 마지막 것만 이 티켓에서 깔았다.
전략 1 — 미들웨어: "이 경로엔 아예 못 들어온다"일 때만
미들웨어는 요청이 라우트에 닿기 전에 가로채는 자리다. 잘하는 건 리다이렉트와 쿠키 조작이고, 그래서 재발급(위)이나 "로그인 없인 /settings/* 전체 진입 금지" 같은 넓은 패턴의 라우트 차단에 맞는다. 반대로 못 하는 게 명확하다 — 미들웨어는 렌더에 관여하지 않아서 "이 페이지 안의 이 컴포넌트를 보일까 말까"를 정할 수 없다. 그리고 보호 경로를 matcher에 나열하는 순간 라우트 트리와 두 곳 관리(드리프트)가 시작된다. 페이지를 추가하며 matcher 갱신을 잊으면 구멍이 난다.
PLick은 지금 "로그인 안 하면 튕겨야 할 페이지"가 하나도 없다 — 홈·피드·릴스는 비로그인 탐색이 되고, MY도 튕기는 게 아니라 다르게 보일 뿐이다. 그래서 이 전략은 지금은 채택 안 함. 나중에 진짜 차단 페이지가 생기면 미들웨어 matcher가 아니라 (protected) route group의 레이아웃에서 isLoggedIn() → redirect("/login")로 막는 쪽을 먼저 볼 것이다 — 폴더에 넣는 것 자체가 보호라 드리프트가 없다.
전략 2 — 서버 분기: "보이는 것 자체가 다르다"일 때
마이페이지처럼 로그인/비로그인의 화면 구성 자체가 다른 경우는 서버 컴포넌트에서 isLoggedIn()을 읽어 렌더를 가른다. 이미 /me가 하는 방식이다(ProfileCard vs LoginPromptCard, ADR 0020). 이게 좋은 이유는 메커니즘에 있다 — 분기가 서버에서 끝나므로 탈락한 쪽 마크업·JS는 브라우저로 내려가지도 않는다. 클라에서 분기하면 두 벌이 다 내려가서 하나를 숨기는 거고, 로그인 여부를 늦게 알면 깜빡임(비로그인 UI가 잠깐 보였다가 바뀜)도 생긴다. 서버 분기엔 그게 없다. 페이지 단위 큰 분기는 앞으로도 전부 이 방식이다.
전략 3 — 서버가 읽어 클라 컨텍스트로 주입: "클라 상호작용의 행동이 다르다"일 때 (이번에 깔았다)
문제는 좋아요·댓글이다. 이건 페이지가 아니라 클라 아일랜드(서버 렌더 페이지 속 "use client" 조각)이고, 눌렀을 때의 행동이 로그인 여부로 갈린다(비로그인이면 로그인 유도). 그런데 클라 컴포넌트는 스스로 로그인 여부를 알 방법이 없다. 두 겹으로 막혀 있다 — 토큰 쿠키는 HttpOnly라 document.cookie에 안 보이고(HttpOnly는 정확히 "JS 접근 차단"이라는 뜻이다. 우리가 ADR 0019에서 XSS로 토큰이 새는 걸 막으려고 직접 붙인 속성이니, 클라가 못 읽는 건 버그가 아니라 설계다), isLoggedIn()은 next/headers의 cookies()를 쓰는 서버 전용 함수라 클라에서 import하면 에러다. 결론은 하나로 좁혀진다: 서버가 판단해서 클라로 내려주는 수밖에 없다.
내려주는 방법이 또 둘로 갈린다. prop으로 릴레이하거나, 컨텍스트로 뿌리거나. 좋아요·댓글 버튼은 피드·릴스·상세 등 여러 화면에 반복해서 흩어질 예정이라, 페이지마다 isLoggedIn을 읽어 중간 컴포넌트들을 통과시켜 내리는 prop 릴레이는 금방 지저분해진다(중간 컴포넌트들은 그 값을 쓰지도 않으면서 나르기만 한다). 그래서 루트 레이아웃에서 한 번 읽어 컨텍스트로 시드했다:
AuthProvider("use client") —createContext<boolean | null>하나를 감싸는 얇은 provider. 루트 레이아웃(서버)이await isLoggedIn()으로 읽은 boolean을 prop으로 받아 트리에 공급한다. 서버 컴포넌트가 클라 컴포넌트에 직렬화 가능한 값을 prop으로 주는 건 서버/클라 경계에서 허용된 통로다 — 경계를 넘는 건 함수가 아니라 boolean 하나다.useAuth()— 클라 아일랜드가 꺼내 쓰는 훅. 컨텍스트 기본값을false가 아니라null로 두고,null이면(=provider 밖) throw 하게 했다. "로그인 안 함"과 "provider 바깥에서 잘못 씀"을 구분해 실수를 조기에 잡기 위해서다. 반환은 boolean이 아니라{ isLoggedIn }객체다 — 나중에 유저 정보가 붙으면 이 객체만 넓히면 되고, 호출부 시그니처가 안 바뀐다.- 훅과 provider를 한 파일에 뒀다. 컨벤션은 "훅 파일 하나 = 훅 하나(
useXxx.ts)"지만, 이 훅은 provider의 private 컨텍스트 객체와 한 몸이라 떼면 컨텍스트를 export해야 해서 캡슐화가 샌다. 예외로 두고 이유를 주석에 남겼다.
갱신은 어떻게 되나 — 컨텍스트 값은 서버 렌더 시점에 박히는 스냅샷인데, 스냅샷이라 낡을 걱정은 안 해도 된다. 로그인 상태를 바꾸는 행위는 로그인·로그아웃 서버 액션뿐이고, 둘 다 redirect()로 끝난다. ADR 0020에서 봤듯 이 redirect는 소프트 내비게이션으로 루트 레이아웃부터 새 RSC 렌더를 받아오므로, 그 렌더가 새 쿠키 상태를 읽어 시드를 다시 뿌린다. 클라에서 폴링하거나 이벤트로 동기화할 게 없다. 미들웨어와의 합도 맞는다 — refresh 미들웨어가 모든 페이지 요청에서 레이아웃 렌더보다 먼저 돌아 만료 토큰을 갈아 끼우니(위 "요청 쿠키에도 심는 이유"), 레이아웃의 isLoggedIn()은 항상 재발급 반영 후의 쿠키를 본다. access TTL이 15분으로 짧아도 시드가 어긋나지 않는 이유다.
치른 비용도 있다. 루트 레이아웃에서 cookies()를 읽으면 Next는 앱의 모든 페이지를 동적 렌더로 취급한다(요청마다 쿠키가 다르니 빌드 타임 정적 프리렌더가 성립 안 한다). 원래는 비로그인 홈 같은 걸 정적으로 구울 여지가 있었는데 그 문을 닫은 셈이다. 알고 받아들였다 — 이 앱은 어차피 refresh 미들웨어가 전 페이지에 걸려 있고, 앞으로 피드·릴스가 개인화되면 정적 프리렌더가 성립하는 화면이 거의 없다. 만약 나중에 특정 화면을 정적으로 살려야 하면 시드 위치를 루트에서 해당 구간 레이아웃으로 내리면 된다.
마지막으로 선을 그어 둘 것 — 이 컨텍스트는 UX 신호지 인가(authorization)가 아니다. 클라가 들고 있는 boolean은 조작 가능한 힌트일 뿐이고, 비로그인 사용자의 좋아요·댓글을 실제로 막는 책임은 나중에 붙을 보호 API(BE)가 진다. 클라 게이트의 역할은 "로그인하러 보내기"까지다.
"셋이 전부 다 필요한 거야? 꼭?"
전략을 셋으로 나열해 놓고 보니 당연한 반문이 왔다. 전략 2(서버 분기)를 전략 3(컨텍스트)으로 통일하면 안 되나? 그리고 미들웨어는 애초에 꼭 필요했나? 파고들어 보니 답이 선명해서, 그 근거를 여기 남긴다.
전략 2를 전략 3으로 통일할 수 없다 — 취향이 아니라 물리 문제다. React context는 클라 런타임의 개념이다. provider가 트리에 값을 공급하고 useContext가 그걸 구독해 값이 바뀌면 리렌더하는 것 — 이건 브라우저에서 살아 있는 React 트리가 하는 일이다. 반면 서버 컴포넌트는 요청마다 한 번 실행돼 결과(RSC payload)를 내보내면 끝나는 일회성 함수라 "구독"이 성립할 자리가 없고, 그래서 훅 자체를 못 쓴다(useContext 포함 — 부르면 에러다). 즉 /me 같은 서버 컴포넌트가 useAuth()를 쓸 유일한 방법은 페이지를 통째로 "use client"로 바꾸는 것이다.
그렇게 바꾸면 뭘 잃는지 따져 봤다. 첫째, 서버 분기의 핵심 장점이 사라진다 — 서버에서 분기하면 탈락한 쪽(ProfileCard 또는 LoginPromptCard)의 코드가 브라우저로 아예 안 내려가지만, 클라 분기는 두 벌이 다 번들에 실리고 하나를 안 그릴 뿐이다. (참고로 깜빡임은 안 생긴다 — 우리 컨텍스트는 서버 렌더 시점에 시드되므로 SSR 첫 페인트부터 맞는 분기가 그려진다. 잃는 건 번들이지 첫 화면이 아니다.) 둘째가 결정적이다. 곧 /users/me를 붙여 프로필을 진짜 데이터로 갈아끼울 텐데, 우리 페칭 원칙(ADR 0017)은 "단발 읽기 = 서버 컴포넌트 fetch"다. 페이지가 클라 컴포넌트가 되는 순간 렌더 중 await로 서버 fetch를 못 하니, 프로필 하나 때문에 RQ 클라 페칭을 조기 도입해야 한다. 로그인 분기라는 꼬리가 데이터 전략이라는 몸통을 흔드는 꼴이라 기각했다.
반대 방향 통일(클라도 isLoggedIn()을 쓰기)은 아예 불가능하다 — 위에서 봤듯 HttpOnly와 서버 전용 cookies() 두 겹으로 막혀 있다. 서버 쪽 소비자(페이지·레이아웃)는 isLoggedIn()으로 직접 읽고, 클라 쪽 소비자(좋아요·댓글 버튼)는 서버가 읽어 내려준 값을 useAuth()로 받는다. 같은 신호의 양면이고, 각자 상대편 통로를 런타임 경계 때문에 물리적으로 못 쓴다. 어느 한쪽으로 합칠 수 있는 게 아님.
미들웨어를 고른 게 임시방편이 아닌 이유는 따로 있다. 흔한 대안인 "보호 API가 401을 주면 그때 재발급(reactive refresh)"은 App Router에서 반쪽만 성립한다. 서버 컴포넌트 렌더 중엔 쿠키를 못 심기 때문이다. Set-Cookie는 응답 헤더고, 헤더는 body 스트리밍이 시작되기 전에만 쓸 수 있는데, 서버 컴포넌트 렌더는 이미 스트리밍 중인 응답의 일부로 돈다 — 그래서 렌더 컨텍스트에서 cookies().set()을 부르면 에러다. 결과적으로 서버 컴포넌트의 fetch가 401을 받아 재발급에 성공해도 새 토큰을 브라우저에 저장할 길이 없다. 다음 요청은 또 만료 토큰으로 나가고 또 재발급하는 헛바퀴가 된다. Next에서 쿠키를 심을 수 있는 자리는 서버 액션·라우트 핸들러·미들웨어 셋뿐이고, 그중 평범한 GET 네비게이션에 걸리는 건 미들웨어뿐이다. 보호 API가 붙는 순간 이 제약은 실제 문제가 되므로, 미들웨어는 그때 가서도 결국 다시 만들었을 자리다. 지금의 체감 효과가 작다고 헛돈 게 아니다.
(반대로 서버 액션과 RQ 뮤테이션은 쿠키를 심을 수 있다 — 액션은 자기 POST 응답에, 클라 뮤테이션은 라우트 핸들러를 거쳐서. 그래서 나중에 "액션 실행 중 만료" 케이스엔 401-reactive refresh를 보완으로 붙일 수 있다. 미들웨어가 네비게이션을, reactive가 액션 중 만료를 커버하는 상호보완 구도지 둘 중 하나를 고르는 문제가 아니다.)
정리하면 셋은 겹치는 전략 세 개가 아니라 각자 자기만 할 수 있는 일이 있다: 서버 분기는 서버 소비자의 유일한 통로, 컨텍스트는 클라 소비자의 유일한 통로, 미들웨어는 그 둘이 읽는 쿠키를 신선하게 유지하는 배관이다.