이전 두 글에서는 이 프로젝트에서 어떻게 결정했고(1편) HttpOnly 쿠키만으로 막을 수 없는 영역(2편)을 정리했습니다. 이번 글은 운영하면서 보였던 한계와 “다시 한다면 이 부분은 다르게 갈 것”이라고 적어둔 회고입니다.
크게 여섯 가지입니다. 한 줄로 요약하면:
- 갱신·로그아웃의 쿠키 처리도 백엔드로 통일 — 로그인은 이미 백엔드 Set-Cookie인데 갱신·로그아웃만 Next.js로 떨어져, 토큰 관리가 두 레이어에 흩어진 상태를 한쪽으로 모으기
- CSP를 1일차부터 — XSS의 진짜 1차 방어선
- Refresh Token Rotation 기본 탑재 — 무한 갱신 시나리오 자동 차단
- 쿠키 Path/Prefix 보수화 —
Path=/api/auth/refresh,__Host-prefix - 인증 활동 로깅을 도메인에 맞게 — 최소 로깅은 기본, 상세 로깅은 사고 났을 때 피해가 큰 서비스에서만
- JWT + Middleware 조합을 한 번은 진지하게 재검토 — 지금 Middleware의 한계를 정직하게 보고 결정하기
여기에 회고 항목으로 따로 빼진 않았지만, 다시 한다면 같이 챙길 운영 베이스라인이 하나 더 있습니다: 갱신을 reactive에서 proactive로 바꾸고 인증 엔드포인트에 rate limit을 같이 거는 것 — 이유는 본문 5번 401 카운트 콜아웃에서 짧게 짚습니다.
1. 갱신·로그아웃의 쿠키 처리도 백엔드로 통일
1편에서 본 것처럼 지금 구조는 토큰 관리가 두 레이어에 나뉘어 있습니다:
- 로그인: 백엔드가 응답에
Set-Cookie: refresh_token을 직접 내려보냄 (Next.js를 거치지 않음) - 갱신·로그아웃: Next.js API Route(
/api/auth/refresh,/api/auth/logout)가 쿠키를 읽고 백엔드 호출 / 쿠키 만료를 담당
원래는 모든 인증 엔드포인트가 백엔드 쿠키 처리였는데, 갱신·로그아웃만 점진적 리팩토링 과정에서 Next.js 쪽으로 옮겨졌고 로그인은 그대로 남았습니다. 결과적으로 같은 refresh_token 쿠키의 라이프사이클이 두 레이어에 걸쳐 관리되는 셈입니다.
운영하면서 비용이 보였습니다:
- 디버깅 양면: 쿠키가 안 들어오면 “로그인은 백엔드, 갱신은 Next.js” 중 어느 쪽 응답을 봐야 할지 매번 헷갈림
- 속성 일치 책임이 두 곳에: 로그인의 백엔드
Set-Cookie와 갱신의response.cookies.set옵션(SameSite, Path, Max-Age 등)이 정확히 같아야 브라우저가 같은 쿠키로 인식. 한쪽만 바뀌면 사일런트 버그 - Next.js 변화에 휘둘림: Next.js의 API Route는 두 가지 환경(Node 런타임 vs Edge 런타임)에서 돌 수 있는데 환경마다 쓸 수 있는 기능이 달라서, 한쪽에서 짠 코드가 다른 쪽으로 옮기면 안 도는 일이 생깁니다. 거기다 Next.js 메이저 업데이트마다 쿠키 다루는 함수 모양이 바뀝니다(예: 동기였던
cookies()가await cookies()비동기로 바뀜). 갱신·로그아웃이 Next.js에 있으면 이런 변화가 일어날 때마다 코드를 다시 손봐야 합니다 — 백엔드에 있으면 안 겪을 일 - 모바일/CLI 클라이언트 호환성: 같은 백엔드를 다른 클라이언트가 쓰면 Next.js를 거치지 않으니 refresh를 또 백엔드에 새로 노출해야 함
Note (잠깐 — Next.js는 왜 환경이 두 개나 있나)
비유로 보면 Node는 본사 사무실, Edge는 전 세계 곳곳에 둔 작은 지점입니다.
- Node 환경: 우리가 평소에 쓰는 그 Node.js 그대로 — npm에서 받은 어떤 라이브러리(DB 연결, 파일 처리 등)도 다 돕니다. 대신 한 서버에서만 도니까 사용자가 멀리 있으면 그 서버까지 다녀와야 하고, 한동안 안 쓰이다가 깨어날 때 시작이 좀 느려요(수백 ms)
- Edge 환경: 매우 가볍게 만든 미니 실행 환경. 전 세계 데이터센터에 동시에 깔려서 한국 사용자는 한국 지점에서, 미국 사용자는 미국 지점에서 응답합니다(시작 수 ms). 대신 작게 만든 환경이라 쓸 수 있는 기능이 좁아요 — 파일 다루기 안 됨, 대부분의 DB 라이브러리 안 됨
그래서 Next.js는 각 API·페이지마다 둘 중 어디서 돌릴지 고르라는 설계입니다 — 인증 체크처럼 가볍고 빠르면 좋은 건 Edge로, DB 조회처럼 무거운 작업은 Node로. 파일 맨 위에 export const runtime = 'nodejs' | 'edge' 한 줄로 직접 지정하고, 안 적으면 라우트·페이지는 Node, 미들웨어는 Edge가 기본입니다 — 라우트는 DB·파일 같은 무거운 작업이 많아 npm 생태계와의 호환성이 중요하고, 미들웨어는 모든 요청에 한 번씩 끼는 가벼운 작업이라 콜드스타트가 짧은 Edge가 자연스럽기 때문이에요. 다만 두 환경의 호환성이 자주 바뀌어서, 갱신·로그아웃 코드가 어느 쪽에 있든 매번 손봐야 하는 골치가 생깁니다.
다시 한다면 갱신·로그아웃도 백엔드가 직접 처리해서 토큰 관리를 한 곳으로 모으겠습니다. 그러면:
refresh_token쿠키의 발급·회전·만료가 백엔드 한 곳에 모임 → 양쪽 쿠키 옵션(SameSite·Path·Max-Age 등)이 어긋나 조용히 깨지는 버그가 안 생김- 프론트는
withCredentials: true만 켜면 끝 - CORS 설정 정도가 추가 작업이지만 한 번 잡으면 됨
Note (언제 Next.js 변환 레이어가 정당화되는가)
백엔드를 절대 못 건드리는 환경(레거시·외부 협력사 API)에서 프론트만으로 보안 한 단계를 올리고 싶을 때는 여전히 합리적입니다. 다만 그 경우에도 로그인·갱신·로그아웃을 한 레이어로 통일하는 게 우선이고, “임시 보강”이라는 점을 문서화해두지 않으면 영구 설계로 굳어집니다.
2. CSP를 1일차부터
한 줄 요약
CSP는 브라우저한테 “이 페이지에선 이 출처의 스크립트만 실행해. 그 외는 무시”라고 미리 약속하는 허용 목록입니다. 공격자가 페이지에 악성 스크립트를 몰래 심어도, 출처가 허용 목록에 없으면 브라우저가 그냥 실행을 거부합니다.
XSS와의 관계
2편에서 본 시나리오의 공통 원인은 결국 XSS(공격자가 우리 사이트에 악성 JavaScript를 몰래 심는 공격)가 실제로 실행되는 것이었습니다. HttpOnly·SameSite 같은 방어책은 “XSS가 일단 뚫린 뒤” 피해를 줄여주는 것뿐이고, XSS 자체를 막아주진 않습니다. CSP는 그 한 단계 앞에서, 악성 스크립트의 실행 자체를 거부합니다. React 같은 프레임워크도 부분적으로 도와주지만 외부 라이브러리나 우회 패턴까지는 못 막아서, 브라우저가 끝에서 한 번 더 걸러주는 CSP가 따로 필요합니다.
어떻게 생겼나
HTML 페이지를 만들어서 브라우저로 내려보내는 서버(이 프로젝트라면 Next.js, 전통적인 구조라면 Express·Spring 같은 백엔드, 정적 사이트라면 nginx 같은 웹 서버)가 그 HTML 응답을 보낼 때 응답 헤더 자리에 한 줄을 같이 박아 보냅니다:
Content-Security-Policy: script-src 'self' https://my-cdn.com왜 “1일차부터”인가
나중에 도입하려면 그동안 쌓인 코드 중 규칙 위반이 너무 많아 정리만 한 달이 갈 수 있습니다. 처음부터 켜두면 새 코드가 자연스럽게 규칙을 따르니까 정리할 게 없어요.
도입 3단계
운영에 영향 주지 않으면서 안전하게 켜는 표준 순서가 있습니다:
- 신고만 받는 모드부터 시작 — 위반이 발생해도 차단은 안 하고 “이런 위반이 있었다”는 신고만 서버에 보냄. 운영 영향 0인 채로 “지금 켜면 뭐가 걸릴지” 파악
- 신고 0건 만들기 — 신고 들어오는 부분(주로 페이지에 직접 박힌
<script>...</script>같은 인라인 코드)을 하나씩 손봐서 허용 목록에 등록. 등록 방법은 nonce(요청마다 일회용 허가증)와 hash(스크립트 지문값) 두 가지가 있고, 콘솔의 위반 메시지가 “어떤 값을 허용해야 하는지” 정답을 그대로 알려주므로 그걸 보고 채워가면 됩니다 - 실제 차단으로 전환 — 다음에 볼 코드처럼, 헤더 이름만 살짝 바꾸면 끝
세 단계 모두 결국 응답 헤더의 이름과 값만 바꾸는 일입니다. Next.js라면 next.config.js의 headers() 한 곳에서 정리돼요:
async headers() { return [{ // 이 헤더 규칙을 적용할 URL 패턴 (정규식). // /(.*)는 "사이트 전체". 페이지별로 다른 정책을 두려면 source 블록을 여러 개로 분리 // (예: { source: '/admin/(.*)', headers: [...엄격...] }, { source: '/(.*)', headers: [...일반...] }) source: '/(.*)', headers: [ // 1단계 — 보고만 받기 (운영 영향 0) { key: 'Content-Security-Policy-Report-Only', value: "script-src 'self'; report-uri /api/csp-report" },
// 3단계 — 헤더 이름에서 -Report-Only만 떼면 실제 차단 (1단계와 둘 중 하나만) { key: 'Content-Security-Policy', value: "script-src 'self' 'nonce-XYZ'" }, ] }]}여기서 source: '/(.*)'는 이 헤더를 붙일 URL 범위(정규식 — (.*)는 모든 경로, 즉 사이트 전체)이고, 실제 CSP 허용 출처 목록은 value 안의 script-src 뒤 부분('self', https://my-cdn.com 등)에 들어갑니다. 외부 CDN을 추가하고 싶으면 손대는 곳은 value이고 source는 그대로 둡니다.
자세한 코드 패턴은 Next.js 공식 CSP 가이드에 거의 그대로 나와 있습니다.
처음부터 1단계라도 켜두지 않으면 “정리하기 부담스럽다”는 이유로 도입이 무기한 미뤄지기 쉽습니다. 시작 시점에 켜는 비용이 가장 쌉니다.
3. Refresh Token Rotation 기본 탑재
이건 사실 2편 마지막의 ‘완화책’ 섹션에서 언급만 하고 도입하지 않았던 항목입니다. 단순성을 위해 미뤘지만, 비용 대비 효과가 너무 큽니다.
핵심 아이디어:
- refresh할 때마다 새 refreshToken을 발급하고 이전 토큰은 무효화
- 같은 refreshToken이 두 번 사용되면 = “탈취된 토큰이 동시에 쓰이는 중”으로 간주
- 해당 사용자의 모든 토큰을 강제 폐기 → 정상 사용자에게도 재로그인 요구
이게 있으면 2편에서 본 “XSS 활성 상태에서 refresh를 무한 갱신” 시나리오를 자동 감지할 수 있습니다. 공격자가 갱신해서 받은 새 토큰이 정상 사용자의 다음 갱신과 충돌하는 순간 폐기 트리거가 작동합니다.
다시 한다면 백엔드 인증 모델 1차 구현에 같이 넣을 항목입니다. 나중에 끼워 넣으려면 토큰 발급/검증 로직 전반을 손대야 해서 비용이 커집니다.
4. 쿠키 Path / Prefix 보수화
1편의 채택 옵션에서는 별 고민 없이 path: '/', 도메인 별칭(Domain) 없음으로 설정했습니다. 동작에는 문제가 없지만, 운영해 보니 쿠키가 굳이 갈 필요 없는 곳까지 따라다닌다는 점이 걸렸습니다. Path 제한과 __Host- prefix를 통해 “노출 면적”을 더 줄일 수 있었습니다.
4-1. Path 제한 — “이 쿠키, 어디까지 따라가게 할 거야?”
쿠키의 Path 속성은 이 쿠키가 어떤 URL 경로의 요청에 자동 첨부될지를 정합니다. Path=/로 두면 같은 도메인의 모든 경로에서 자동 전송됩니다.
어디서 설정하나
Path는 쿠키를 발급하는 쪽이 응답에 Set-Cookie 헤더를 박을 때 같이 지정하는 속성입니다. 이 프로젝트는 1편에서 정리한 대로 로그인 시점에 백엔드가 직접 Set-Cookie를 내려보내는 구조라, 이 설정도 백엔드 코드에서 만집니다.
가장 본질은 결국 HTTP 응답 헤더 한 줄입니다:
Set-Cookie: refresh_token=eyJxxx...; Path=/api/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=604800어떤 백엔드 프레임워크든 결국 이 헤더 모양으로 나가고, 프레임워크가 제공하는 API는 그 위에 얇은 래퍼일 뿐입니다:
// Spring BootResponseCookie.from("refresh_token", token) .path("/api/auth") // ← 이게 핵심 .httpOnly(true).secure(true).sameSite("Lax") .maxAge(Duration.ofDays(7)) .build();// Express / Noderes.cookie('refresh_token', token, { path: '/api/auth', // ← 이게 핵심 httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000,})# FastAPI (Flask·Django도 set_cookie 시그니처가 거의 동일)response.set_cookie( key="refresh_token", value=token, path="/api/auth", # ← 이게 핵심 httponly=True, secure=True, samesite="lax", max_age=60 * 60 * 24 * 7,)브라우저는 받은 Path 값을 그대로 저장해두고, 이후 그 경로에 매칭되는 요청에만 쿠키를 자동 첨부합니다. 프론트가 따로 만질 일은 없습니다 — 발급된 그대로 동작합니다.
지금 설정(Path=/)에서 실제로 어떻게 동작하는지 보면:
브라우저 쿠키 저장소: refresh_token (Path=/, HttpOnly, Secure, SameSite=Lax)
→ GET /api/users : 쿠키 자동 첨부 ✗ 사실은 필요 없음→ POST /api/orders : 쿠키 자동 첨부 ✗ 사실은 필요 없음→ GET /dashboard : 쿠키 자동 첨부 ✗ 사실은 필요 없음→ POST /api/auth/refresh : 쿠키 자동 첨부 ✓ 진짜로 필요→ POST /api/auth/logout : 쿠키 자동 첨부 ✓ 진짜로 필요전송된다고 해서 백엔드가 그 값을 쓰는 건 아니지만, HTTP 요청 헤더에 토큰이 매번 같이 흐른다는 것 자체가 노출 면적입니다. 실제로 토큰이 새는 경로는 이런 것들입니다:
- 백엔드 로그 — 모든 요청을 기록하는 미들웨어가 잘못 설정돼서 헤더까지 통째로 로그 파일에 찍어버리는 경우. 로그는 보통 보관 기간이 길고(수개월~수년) 접근 권한이 헐거워서, 한 번 새면 영구 노출이 됩니다
- 외부 모니터링 서비스 — Sentry, Datadog 같은 SaaS가 에러·성능 데이터를 자기네 서버로 보낼 때 요청 헤더까지 같이 전송하는 경우. 토큰이 우리 통제 밖, 제3자 회사의 DB로 빠져나가 버립니다
한 번 새면 그건 공격자가 손에 넣는 즉시 7일짜리 사용자 자격증명이 됩니다 — refresh_token은 그것만 있으면 새 accessToken을 계속 발급받을 수 있는 마스터 키이기 때문입니다. 그리고 위 시스템들이 항상 올바르게 설정되어 있다는 보장은 어디에도 없습니다.
Path=/api/auth로 좁히면 이렇게 바뀝니다:
브라우저 쿠키 저장소: refresh_token (Path=/api/auth, HttpOnly, Secure, SameSite=Lax)
→ GET /api/users : 쿠키 미전송 (Path 매치 안 함)→ POST /api/orders : 쿠키 미전송→ GET /dashboard : 쿠키 미전송→ POST /api/auth/refresh : 쿠키 전송 ✓→ POST /api/auth/logout : 쿠키 전송 ✓부가 효과:
- 일반 API 호출 30여 개에 쿠키가 아예 첨부되지 않음 → 사고로 새는 면적 축소
- 2편의 SameSite Lax 빈틈 중 “GET 통과” 시나리오의 공격 표면도 같이 줄어듦 (외부 사이트가
<a href="https://example.com/api/something">을 만들어도 그 경로엔 쿠키가 안 가니까) - 백엔드의 일반 API 미들웨어가 실수로
req.cookies.refresh_token을 참조해도 그냥undefined라 사고 차단
Note (Path는 보안 경계가 아니다)
주의: Path 제한은 어디까지나 “쿠키가 자동 첨부되는 범위”를 줄이는 장치일 뿐, 공격자가 의도적으로 우회하는 걸 막진 않습니다. 같은 출처에서 fetch('/api/auth/refresh')를 직접 호출하면 어차피 쿠키가 첨부됩니다. Path는 “실수로 노출될 면적”을 줄이는 위생 조치로 이해해야 합니다.
4-2. __Host- prefix — 이름만 바꿔도 브라우저가 안전장치를 강제한다
Set-Cookie 헤더에서 쿠키 이름이 __Host-로 시작하면, 브라우저는 그 쿠키를 받기 전에 다음 세 가지가 모두 만족되는지 검사합니다. 하나라도 어긋나면 쿠키 자체를 거부합니다.
| 강제 조건 | 안 지키면 | 왜 중요한가 |
|---|---|---|
Secure 속성 필수 | 쿠키 거부 | HTTP 평문으로는 절대 전송되지 않음 (MITM 차단) |
Path=/ 필수 | 쿠키 거부 | 위의 Path 제한과는 양자택일 |
Domain 속성 금지 | 쿠키 거부 | 현재 호스트에만 묶임 → 서브도메인 간 공유 불가 |
이름만 refresh_token → __Host-refresh_token으로 바꾸는 거라 코드 변경량은 작지만, 보호 효과는 큽니다. 특히 세 번째 조건이 핵심입니다.
왜 “Domain 속성 금지”가 중요한가
Domain=.example.com처럼 도메인 별칭을 두면 그 쿠키는 app.example.com, admin.example.com, blog.example.com 등 모든 서브도메인에 자동 전송됩니다. 2편에서 본 두 번째 SameSite 빈틈 — “서브도메인은 같은 사이트로 본다” — 이 공격 시나리오가 여기서 시작됩니다:
admin.example.com : 사용자가 로그인된 관리자 도구blog.example.com : 마케팅 팀이 운영하는 워드프레스 (XSS 사고 빈번)
[기존: Domain=.example.com인 쿠키]blog 측 XSS 스크립트가 fetch('https://admin.example.com/api/auth/refresh') 호출→ 브라우저: "같은 example.com이니까" 쿠키 자동 첨부→ admin 측 토큰이 갱신되어 공격자가 새 accessToken 획득
[__Host-refresh_token으로 바꾸면]Domain 속성 자체가 금지되어 쿠키는 admin.example.com에만 묶임→ blog 측에서 같은 fetch를 호출해도 쿠키 미첨부→ 공격 차단서브도메인이 여러 개거나, 그중 일부의 보안 수준이 낮을 가능성이 있다면 __Host- prefix는 거의 무조건 도입할 가치가 있습니다.
4-3. 그래서 다시 한다면 무엇을 고를까
위 두 가지는 양자택일입니다 (__Host-는 Path=/를 강제하니까). 무엇을 막을지에 따라 선택이 갈립니다:
| 환경 | 선택 | 이유 |
|---|---|---|
| 서브도메인이 여러 개, 일부는 통제 밖 | __Host-refresh_token + Path=/ | 서브도메인 간 공격을 원천 차단하는 게 더 중요한 위협 |
| 단일 도메인 (서브도메인 거의 없음) | refresh_token + Path=/api/auth | 서브도메인 위험이 없으니, 노출 경로 최소화가 더 실용적 |
| 둘 다 신경 쓰임 | __Host-refresh_token + 일반 API에서 쿠키 사용 절대 금지(코드 컨벤션) | Path를 못 좁히는 대신, 백엔드 미들웨어가 인증 외 경로에서 쿠키를 읽지 않도록 컨벤션으로 보강 |
이 프로젝트는 단일 도메인이라 다시 한다면 후자(Path=/api/auth)로 갔을 겁니다. 다만 향후 admin.*, analytics.* 같은 서브도메인 확장 가능성이 보이면 그 시점에 __Host-로 전환을 검토하는 식으로요.
Tip (구현 시 주의)
__Host-prefix는 로컬 개발 환경(http://localhost)에서도Secure속성을 요구합니다. 일부 브라우저는 localhost는 예외로 허용하지만, 모든 브라우저가 그렇진 않습니다. 개발 환경에선 prefix 없이 동작하다가 운영 배포 시 prefix를 붙이는 분기 처리가 필요할 수 있습니다.- Path 제한을 적용할 땐 로그아웃 시 쿠키 만료(
response.cookies.delete)도 같은 Path로 호출해야 브라우저가 같은 쿠키로 인식해서 삭제합니다. Path가 다르면 다른 쿠키로 봐서 만료가 안 됩니다.
5. 인증 활동 로깅 — 도메인에 맞는 깊이로
이상 행동은 보고 있어야 보입니다. 지금 구조에는 토큰/세션 관련 로깅이 거의 없어서, 가령 같은 사용자의 refreshToken이 10분 안에 5번 갱신되거나 IP가 점프해도 알 수 없습니다. (업계에서는 이런 “시스템 안에서 무슨 일이 벌어지는지 들여다볼 수 있는 능력”을 옵저버빌리티(observability) 라고 부릅니다.)
그런데 “다 남기자”는 답은 아닙니다. 로깅 깊이는 사고가 났을 때 그 서비스가 실제로 입는 피해 크기에 따라 결정해야 합니다. 토큰이 한 번 탈취됐을 때 돈이 빠져나가거나 민감 정보가 노출되는 서비스라면 깊게, 내부 직원만 쓰는 도구라면 얕게. 두 단계로 나눠서 생각합니다.
5-1. 최소 로깅 — 어떤 프로젝트든 기본
- 로그인 성공/실패 이벤트
- 401 응답 카운트
- 자동 refresh 시도·실패율 (프론트 측 Sentry 등)
이 정도는 프레임워크 기본 로그 + Sentry 같은 도구를 깔면 거의 따라오는 비용입니다. 이 프로젝트도 1일차부터 잡았어야 했고, “도입”이라 부르기도 애매한 수준입니다.
Note (401 카운트는 왜 따로 보나)
이 프로젝트의 갱신 방식은 reactive refresh — accessToken이 만료된 채로 요청을 보내고, 401을 받은 다음에야 refreshToken으로 갱신해서 재시도하는 구조였습니다. 그래서 정상 흐름에서도 사용자당 토큰 수명마다 401이 1번씩 찍히고, baseline 자체가 0이 아닙니다. 게다가 rate limit도 걸려 있지 않아서, 누군가 무효 토큰을 마구 들이밀어도 차단되지 않습니다. 이 조건에서 401 카운트는 사실상 유일한 감지 수단이 됩니다:
- 인증 실패의 비정상 폭증 — 특정 IP·사용자에서 401이 평소 분포 대비 급격히 늘어남 = 무효 토큰을 무작위로 들이밀고 있다는 신호. rate limit이 없는 구조에선 이게 무차별 대입·토큰 추측을 잡는 거의 유일한 지표
- 갱신 무한 루프 같은 프론트 버그 —
401 → refresh → 또 401 → refresh루프가 돌면 사용자 1명당 분당 수십 건의 401이 찍힘. baseline의 수십 배라 단순 카운트만으로도 잡힘 - 배포 회귀 즉시 감지 — 백엔드 토큰 검증 로직 배포 직후 401이 평소 분포 대비 N배 튀면 그 배포가 사고. 알람만 걸어두면 됨
비용은 응답 코드 카운팅이라 거의 공짜인데, baseline 분포 대비 임계값 알람 하나로 위 세 종류 사고를 다 잡습니다. 더 근본적으론 다시 한다면 만료 직전 proactive refresh(프론트 영역) + 인증 엔드포인트 rate limit(백엔드·인프라 영역) 을 같이 넣는 게 맞고, 그 전까진 401 카운팅이 그 빈자리를 메워주는 역할입니다. rate limit은 프론트가 직접 거는 게 아니라 nginx·Cloudflare 같은 앞단이나 백엔드 미들웨어에서 거는 것이라, 다시 한다면 백엔드·인프라 팀에 1일차에 함께 발의할 항목입니다.
5-2. 상세 로깅 — 사고 났을 때 피해가 큰 서비스에서만
- 사용자별 refresh 호출 빈도 — 사용자마다 최근 짧은 시간(예: 5분·1시간) 동안 refresh를 몇 번 호출했는지 집계. 평소보다 갑자기 훨씬 자주 호출되면 토큰 탈취 의심 신호
- 같은 사용자 토큰의 위치·기기 변화 — 같은 사용자의 토큰이 갑자기 다른 IP(접속 위치, 예: 한국 → 미국)나 다른 User-Agent(브라우저·OS 정보, 예: Chrome/Windows → Safari/iOS)에서 들어오는지 추적. 토큰이 탈취돼 공격자의 다른 기기에서 쓰이는 시나리오 감지
- 로그인 실패 패턴 — 같은 계정에 짧은 시간 안에 비밀번호 시도가 수십~수백 번 들어오는지. 비밀번호를 무차별 대입해서 맞을 때까지 시도하는 브루트포스(brute force) 공격 탐지의 기반
이건 다릅니다. 로그를 어디에 쌓을지, 어떻게 모아 볼지, 어느 수치를 넘으면 알람을 띄울지까지 다 정해야 하는 작업이고, 실제로 공격당할 가능성이 있는 서비스에서만 들인 만큼의 효과가 나옵니다 — 금융·헬스케어·결제, 또는 외부에 공개된 사용자 많은 서비스. 수십 명짜리 B2B 내부 도구에는 과투자입니다.
이런 항목들은 대부분 백엔드에서 남겨야 하는 데이터라 프론트 혼자 결정할 수 있는 영역도 아닙니다. 다시 한다면 도메인이 “상세 로깅이 필요한 쪽”이라고 판단된 시점에 백엔드 팀에 이 스키마를 함께 잡자고 발의하겠습니다. 그래야 “비정상 패턴은 무엇인지”를 정의할 수 있고, 그 위에 rate limit이나 강제 로그아웃 같은 차단 정책을 얹을 수 있습니다. 데이터 없이 감으로 임계값을 잡고 차단하면 정상 사용자만 막히는 함정에 빠집니다.
6. JWT + Middleware 조합을 한 번은 진지하게 재검토
1편의 “왜 JWT를 안 썼는가?”에서 적었듯, JWT를 안 고른 건 백엔드가 이미 랜덤 문자열 토큰을 DB 조회로 검증하는 구조였고, 즉시 무효화가 더 실용적이었기 때문입니다. 그때 환경에서는 맞는 결정이었다고 지금도 생각합니다. 다만 운영하면서 “Middleware에서 더 많이 했으면 좋았을 텐데”라는 아쉬움이 반복됐고, 이 지점에서 JWT 카드를 한 번 다시 펴볼 가치가 생깁니다.
지금 Middleware가 못 하는 것
현재 Next.js Middleware는 보호 라우트 진입 시 쿠키 존재 여부만 확인합니다.
// 지금 구조export function middleware(req) { const hasRefresh = req.cookies.get('refresh_token') if (!hasRefresh) return NextResponse.redirect('/login') return NextResponse.next()}이게 못 하는 일들:
- 유효성 검증 — 쿠키가 있긴 한데 만료된 거면? 일단 통과시킨 뒤 클라이언트가 refresh를 호출해서야 알 수 있음
- 사용자 식별 — 라우트별 redirect, A/B 분기, 로깅에 userId가 필요해도 Middleware는 모름
해결하려면 Middleware에서 백엔드에 매번 검증 요청을 보내야 하는데, 그러면 모든 페이지 진입에 백엔드 round-trip이 추가됩니다. 그래서 결국 안 했고, 그 만큼 책임이 페이지/컴포넌트 단으로 흘러 들어갔습니다.
JWT를 쓰면 무엇이 가능해지는가
accessToken을 JWT로 발급하면 Middleware가 백엔드 없이 토큰을 검증할 수 있습니다:
// JWT 도입 시 가능해지는 구조 (개략)export async function middleware(req) { const token = req.cookies.get('access_token')?.value if (!token) return redirectTo('/login')
const payload = await verifyJWT(token, PUBLIC_KEY) // 서명 검증 + 만료 확인 if (!payload) return redirectTo('/login')
// payload.role, payload.userId 등을 즉시 활용 if (req.nextUrl.pathname.startsWith('/admin') && payload.role !== 'admin') { return redirectTo('/forbidden') } return NextResponse.next()}얻는 것:
- 페이지 진입 단계에서 권한·만료 검증이 끝남 → 컴포넌트가 깨끗해짐
- 백엔드 round-trip 0회 → edge에서 처리되어 응답이 빠름
payload.userId를 헤더로 백엔드에 전달하면 백엔드도 토큰 디코딩을 한 번 덜 할 수 있음
이게 정말 매력적이라, 만약 라우트별 권한 분기·SSR 개인화·B2B의 멀티 테넌시 같은 요구가 1일차부터 있는 프로젝트였다면 처음부터 JWT를 진지하게 검토했을 겁니다.
그런데 1편의 결정 근거는 아직 유효한가
JWT의 가장 큰 약점, 즉시 무효화 어려움은 그대로입니다. accessToken을 짧게(예: 15분) 두고 Refresh Token Rotation(위 3번)으로 보완하는 게 정석이지만, 이건 다음 두 가지를 받아들이는 결정이기도 합니다:
| 항목 | 영향 | 받아들일 수 있는가 |
|---|---|---|
| 최대 15분의 폐기 지연 | ”지금 당장 차단”이 불가능, 다음 갱신 시점까지는 살아있음 | 내부 도구는 보통 OK, 금융·헬스케어는 X |
| 키 관리 비용 | JWT를 만들고 검증할 때 쓰는 비밀 키를 안전하게 보관하고, 주기적으로 바꾸고, 검증하는 모든 서버에 새로 배포해야 함 | AWS KMS 같은 키 관리 서비스가 이미 회사에 있으면 OK, 없으면 그 인프라부터 깔아야 해서 큰 작업 |
| payload 크기 | 토큰 자체가 커짐 (수 KB) → 매 요청 헤더에 같이 실려 나가니 네트워크 부담 | 일반 웹 서비스에선 보통 문제 없음. 통신 속도가 느리거나 데이터를 조금만 보낼 수 있는 환경(IoT 기기, 모바일 약전계 지역 등)에선 따져봐야 함 |
| 즉시 무효화하려면 결국 서버 조회 필요 | ”로그아웃하면 즉시 차단”을 진짜로 보장하려면 “무효화된 토큰 목록(=블랙리스트)“을 DB나 Redis에 두고 매 요청마다 확인해야 함 → JWT 본래 장점인 “서버에서 매번 조회하지 않고 토큰 자체만으로 검증”이 사라짐 | ”그럴 거면 처음부터 세션 토큰 쓰면 되지, 왜 JWT?”라는 의문이 다시 듦 |
그래서 다시 한다면
“JWT로 바꾼다”가 아니라 “JWT + Middleware 조합을 1주일 정도 시간 들여 진지하게 평가한다” 가 결론입니다. 평가 기준은:
-
/admin,/user/profile같은 페이지마다 인가(누가 이 페이지를 볼 수 있는지 권한 체크) 코드가 각 페이지 안에 흩어져 있는가 (그렇다면 Middleware 한 곳에서 처리하는 게 훨씬 깔끔) - 서버에서 HTML을 미리 만들어 보낼 때 “이 사용자가 누구인지”를 그 시점에 알아야 하는 화면이 있는가 (예: “안녕하세요 ○○님” 같은 개인화, 사용자 그룹별로 다른 UI 보여주기, 누가 이 페이지에 들어왔는지 서버에서 기록)
- 사용자가 로그아웃을 눌렀거나 관리자가 계정을 정지시켰을 때, 그 즉시 차단되지 않고 최대 15분까지 그대로 살아있는 상황을 받아들일 수 있는 서비스인가 (JWT는 토큰 자체가 만료될 때까지 유효해서, accessToken을 15분짜리로 두면 그 동안은 무효화가 안 됨. 금융·헬스케어처럼 1초라도 늦으면 안 되는 도메인은 부적합)
- 비밀 키를 안전하게 보관·교체·배포할 인프라(AWS KMS 등)가 이미 있거나, 새로 도입할 의향이 있는가
체크박스가 두 개 이상 켜지면 JWT를 도입하느라 드는 비용(키 관리·15분 지연 감수 등) 대비 얻는 이득이 보입니다. 모두 꺼지면 지금 구조(세션 토큰 + HttpOnly 쿠키)가 여전히 정답입니다.
Note (중간 안: 하이브리드)
“refresh_token은 그대로 세션 토큰(즉시 무효화 가능), accessToken만 JWT로” 같은 하이브리드도 가능합니다. accessToken JWT의 15분 폐기 지연을 받아들이는 대신, refresh가 들어올 때 백엔드가 DB에서 사용자 상태(정지·삭제·권한 변경)를 한 번 확인하니까 최악의 무효화 지연도 15분으로 묶입니다. 1편의 즉시 무효화 가치와 Middleware의 자체 검증 가치를 양쪽 어느 정도 챙기는 구조입니다.
묶어서: “다음에도 분명히 할 3가지”
위 여섯 개를 모두 도입할 수도 없고 도입하는 게 항상 옳지도 않습니다. 무엇을 막을지·팀 규모·서비스 성격에 따라 우선순위가 갈립니다. 다만 이 프로젝트와 비슷한 B2B 내부 도구를 다시 만든다면 무조건 들고 가는 네 가지는 분명합니다:
- 백엔드가 직접
Set-Cookie— 구조 단순성 - CSP Report-Only를 처음부터 — XSS 1차 방어의 도입 비용을 가장 싸게
- 최소한의 인증 활동 로깅 — 로그인 성공/실패·401·자동 refresh 실패 정도. Sentry 깔면 거의 따라오는 비용
- 인증 엔드포인트 rate limit — reactive refresh 구조에선 무효 토큰 폭격을 막을 다른 안전장치가 없음. nginx·Cloudflare 같은 앞단 서버(reverse proxy)에 “IP당 분당 N회” 규칙을 한 줄 추가하거나, 백엔드에
express-rate-limit같은 미들웨어 한 줄을 끼우면 끝나는 작업이라 도입 비용이 거의 0. 다만 프론트가 직접 손댈 수 없는 백엔드·인프라 영역이라, 다시 한다면 1일차에 백엔드·인프라 팀에 같이 발의할 항목
나머지(Refresh Rotation, 쿠키 Path/Prefix 보수화, 상세 인증 로깅, JWT + Middleware 평가 등)는 무엇을 막을지·요구사항이 명확해진 뒤 조건부로 추가하는 게 낫다고 봅니다. “지금은 안전한가”보다 “지금 어떻게 돌아가는지 보이는가”가 결국 더 중요한 질문이었습니다.
이전 글:
ari Space