Next.js Proxy 패턴으로 JWT 인증 설계하기
“액세스 토큰을 어디에 저장하는가”는 단순한 저장소 선택이 아니에요. 이 결정 하나가 SSR(Server-Side Rendering) 가능 여부, UX(사용자 경험), 보안, 코드 구조까지 결정해요. 이 글에서는 두 프로젝트에서 서버 세션 + Proxy 패턴(모든 API 경로를 하나의 Route Handler로 받아 백엔드로 중계하는 방식)과 SSR Prefetch 아키텍처(서버에서 미리 데이터를 가져와 클라이언트 캐시로 전달하는 방식)로 이 문제를 해결한 과정을 다뤄요.
1부: 브라우저 메모리에서 서버 세션으로
백엔드가 내려주는 응답 구조
첫 번째 프로젝트에서 로그인 API를 연동할 때 한 가지 제약이 있었어요. 백엔드가 액세스 토큰과 사용자 정보는 JSON 본문에, 리프레시 토큰은 HTTP-Only 쿠키에 담아 보내는 구조였어요.
sequenceDiagram
participant Client
participant Backend
Client->>Backend: POST /auth/login (email, password)
Backend-->>Client: Body: accessToken, user
Note over Backend,Client: Set-Cookie: refreshToken=xxx HttpOnly Secure
리프레시 토큰은 HTTP-Only 쿠키이므로 JavaScript에서 접근할 수 없어요. XSS(Cross-Site Scripting) 공격으로부터 보호하기 위한 설계예요. 응답 본문으로 받은 액세스 토큰을 어디에 저장하느냐에 따라 인증 아키텍처 전체가 달라져요.
1차 시도: 브라우저 메모리(Zustand)에 저장
가장 직관적인 선택은 Zustand 같은 상태 관리 라이브러리로 브라우저 메모리에 저장하는 거예요. 브라우저 메모리는 localStorage보다 XSS에 안전해요. localStorage는 JavaScript로 언제든 접근할 수 있지만, 메모리에 있는 값은 탈취 경로가 제한적이에요.
flowchart LR
A["로그인 응답"] --> B["accessToken 추출"]
B --> C["Zustand store에 저장"]
C --> D["API 요청 시 헤더에 첨부"]
하지만 세 가지 문제가 나타났어요.
문제 1: 새로고침 시 토큰 소실
브라우저 메모리는 페이지를 새로고침하면 초기화돼요. Zustand store가 비워지면서 액세스 토큰이 사라져요.
이를 해결하기 위해 앱 초기화 시 리프레시 토큰으로 액세스 토큰을 재발급받는 로직을 추가했어요. 리프레시 토큰은 HTTP-Only 쿠키에 있으므로, 브라우저가 자동으로 쿠키를 첨부하여 갱신 요청을 보내요.
sequenceDiagram
participant Browser
participant Backend
Note over Browser: 페이지 새로고침
Note over Browser: Zustand store 초기화 (토큰 소실)
Browser->>Backend: POST /auth/refresh (쿠키 자동 첨부)
Backend-->>Browser: accessToken: 새 토큰
Note over Browser: Zustand store에 다시 저장
하지만 새로운 문제가 생겼어요.
문제 2: UI 깜빡임
새로고침 후 토큰 재발급이 완료되기까지 수백 ms의 공백이 생겨요. 이 시간 동안 앱은 “로그인되지 않은 상태”로 판단해요.
flowchart LR
A["페이지 로드"] --> B["토큰 없음<br/>→ 비로그인 UI"]
B --> C["토큰 재발급 완료"]
C --> D["토큰 있음<br/>→ 로그인 UI"]
style B fill:#fee2e2
style D fill:#dcfce7
로그인 버튼이 잠깐 보였다가 프로필 메뉴로 바뀌는 깜빡임이 발생해요. 눈에 띄는 UX 결함이었어요.
문제 3: SSR에서 접근 불가
가장 치명적인 문제예요. Server Component에서 액세스 토큰에 접근할 수 없어요.
Next.js App Router의 Server Component는 서버에서 실행돼요. 인증이 필요한 데이터를 서버에서 미리 가져오려면 액세스 토큰이 필요한데, 그 토큰은 브라우저 메모리에만 있어요.
flowchart TD
A["Server Component<br/>(서버에서 실행)"] --> B{"액세스 토큰 접근"}
B -->|"쿠키 → cookies()"| C["접근 가능"]
B -->|"브라우저 메모리"| D["접근 불가"]
style D fill:#fee2e2
style C fill:#dcfce7
서버에서 데이터를 미리 가져올 수 없으니, 모든 데이터 페칭이 클라이언트에서 일어나요. Next.js를 쓰면서 SSR의 이점을 전혀 활용하지 못하는 상황이었어요.
근본 원인과 해결: 서버 세션
세 가지 문제의 근본 원인은 하나예요. 액세스 토큰이 브라우저에만 존재한다는 것. 토큰을 서버에서 접근할 수 있는 곳으로 옮기면 모든 문제가 해결돼요.
Next.js 서버에 서버 세션(인메모리 저장소)을 만들어 액세스 토큰을 저장하는 구조로 전환했어요.
// server-session.ts
const sessionStore = new Map<string, SessionData>();
type SessionData = {
accessToken: string;
refreshToken: string;
userData: { name: string; role: string } | null;
};
export function createSession(data: SessionData): string {
const sid = crypto.randomUUID();
sessionStore.set(sid, data);
return sid;
}
export function getSession(sid: string) {
return sessionStore.get(sid);
}
글로벌 Map에 세션 데이터를 저장하고, UUID로 생성한 세션 ID(sid)를 키로 사용해요. 로그인 Route Handler에서 이 세션을 생성해요.
// app/proxy/auth/login/route.ts
export async function POST(request: Request) {
const body = await request.json();
const res = await fetch(`${BACKEND_URL}/auth/login`, {
method: "POST",
body: JSON.stringify(body),
});
const { accessToken, name, role } = await res.json();
const refreshToken = extractCookieValue(res, "refreshToken");
// 토큰은 서버 세션에만 저장
const sid = createSession({
accessToken,
refreshToken,
userData: { name, role },
});
// 브라우저에는 세션 ID만 전달
(await cookies()).set("sid", sid, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 3600,
});
// 응답에 토큰을 포함하지 않음
return Response.json({ name, role });
}
sequenceDiagram
participant Browser
participant Next as Next.js Server
participant Backend
Browser->>Next: 로그인 요청
Next->>Backend: POST /auth/login
Backend-->>Next: accessToken, user + refreshToken 쿠키
Note over Next: createSession() — 서버 세션에 토큰 저장
Next-->>Browser: Set-Cookie: sid=uuid (HttpOnly)
이제 액세스 토큰은 **Next.js 서버의 Map**에 있어요. 브라우저에는 세션 ID만 HttpOnly 쿠키로 존재하므로, JavaScript에서 토큰에 접근할 수 없어요.
- 새로고침? 세션은 서버 메모리에 있으므로 초기화되지 않아요
- SSR? Server Component에서
cookies()로 세션 ID를 읽고, 세션에서 액세스 토큰을 꺼내 API를 호출할 수 있어요
SSR 하이드레이션으로 깜빡임 해결
UI 깜빡임 문제도 서버 세션으로 해결할 수 있어요. layout.tsx에서 세션을 읽어 Zustand store의 초기값으로 주입해요.
// app/layout.tsx
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const sid = (await cookies()).get("sid")?.value;
const session = sid ? getSession(sid) : null;
const userData = session?.userData ?? null;
return <UserStoreProvider initialState={{ userData }}>{children}</UserStoreProvider>;
}
서버에서 이미 인증 상태를 알고 있으므로, 첫 렌더링부터 올바른 UI를 표시해요. “비로그인 UI → 로그인 UI” 전환이 사라져요.
Catch-all Proxy 패턴
서버 세션으로 전환하면서 SSR 문제는 해결됐어요. 하지만 클라이언트 측 요청에서 새로운 문제가 생겼어요. 버튼 클릭으로 데이터를 수정하거나 추가 조회를 할 때, 액세스 토큰이 서버 세션에 있어서 클라이언트에서 직접 백엔드를 호출할 수 없어요.
이 문제를 catch-all Route Handler [...path]로 해결했어요. 모든 API 요청을 하나의 핸들러에서 처리해요.
// app/proxy/[...path]/route.ts
async function proxyRequest(request: Request) {
const sid = (await cookies()).get("sid")?.value;
const session = sid ? getSession(sid) : undefined;
// /proxy/api/v1/todos → BACKEND_URL/api/v1/todos
const url = new URL(request.url);
const backendPath = url.pathname.replace("/proxy", "");
return fetch(`${BACKEND_URL}${backendPath}${url.search}`, {
method: request.method,
headers: { Authorization: `Bearer ${session?.accessToken}` },
body: ["GET", "HEAD"].includes(request.method) ? undefined : await request.text(),
});
}
export {
proxyRequest as GET,
proxyRequest as POST,
proxyRequest as PUT,
proxyRequest as PATCH,
proxyRequest as DELETE,
};
클라이언트에서는 apiFetch 헬퍼가 URL을 자동으로 리라이트해요.
// lib/api.ts
export async function apiFetch({ url, ...options }: ApiOptions) {
// /api/v1/todos → /proxy/api/v1/todos (프록시 경유)
const proxyUrl = url.startsWith("/api/") ? `/proxy${url}` : url;
return fetch(proxyUrl, { credentials: "include", ...options });
}
클라이언트 코드에서는 /api/v1/todos로 호출하지만, 실제로는 /proxy/api/v1/todos Route Handler를 거쳐 백엔드에 도달해요. 클라이언트가 액세스 토큰을 직접 다루지 않아요.
flowchart TB
subgraph Browser
A["Client Component<br/>apiFetch /api/v1/todos"]
end
subgraph "Next.js Server"
B["Catch-all Route Handler<br/>/proxy/...path"]
C["서버 세션 (Map)"]
end
subgraph Backend
D["실제 API"]
end
A -->|"1. /proxy/api/v1/todos (자동 리라이트)"| B
B -->|"2. getSession(sid)"| C
C -->|"3. accessToken"| B
B -->|"4. Authorization: Bearer xxx"| D
D -->|"5. 응답"| B
B -->|"6. 응답 전달"| A
투명한 토큰 갱신
Proxy 패턴의 또 다른 장점은 토큰 갱신을 서버에서 투명하게 처리할 수 있다는 점이에요. 클라이언트는 토큰 만료를 전혀 인지하지 못해요.
sequenceDiagram
participant Client
participant Proxy as Route Handler (Proxy)
participant Session as 서버 세션
participant Backend
Client->>Proxy: GET /proxy/api/v1/todos
Proxy->>Session: getSession(sid)
Session-->>Proxy: accessToken (만료됨)
Proxy->>Backend: Authorization: Bearer (만료된 토큰)
Backend-->>Proxy: 401 Unauthorized
Note over Proxy: 토큰 갱신 시도
Proxy->>Backend: POST /auth/refresh (refreshToken)
Backend-->>Proxy: 새 accessToken + 새 refreshToken
Note over Proxy: updateSession() — 새 토큰으로 세션 업데이트
Proxy->>Backend: Authorization: Bearer (새 토큰) — 원래 요청 재시도
Backend-->>Proxy: 200 OK + 데이터
Proxy-->>Client: 데이터 (토큰 만료를 모름)
Proxy가 401 응답을 받으면 세션의 리프레시 토큰으로 새 액세스 토큰을 발급받고, 세션을 업데이트한 뒤 원래 요청을 재시도해요. 클라이언트에는 토큰 갱신 로직이 필요 없어요.
1부 정리
| 문제 | 브라우저 메모리 | 서버 세션 + Proxy |
|---|---|---|
| 새로고침 시 토큰 소실 | 매번 재발급 필요 | 서버 메모리에 유지 |
| UI 깜빡임 | 비로그인 → 로그인 전환 | layout.tsx에서 세션 읽어 초기값 주입 |
| SSR 데이터 페칭 | 서버에서 토큰 접근 불가 | cookies() → 세션 조회 → API 호출 |
| 토큰 보안 | 메모리라 비교적 안전 | 브라우저에 토큰 자체가 없어요 |
| 클라이언트 요청 | 직접 호출 가능 | Catch-all Proxy 경유 |
| 토큰 갱신 | 클라이언트에서 처리 | Proxy에서 투명하게 처리 |
2부: SSR + Prefetch로 한 단계 더
1부에서는 서버 세션 + Proxy 패턴으로 SSR을 가능하게 했어요. 2부에서는 이 패턴을 NextAuth 환경에서 어떻게 발전시켰는지 다뤄요.
다른 시작점, 같은 문제
두 번째 프로젝트에서는 인증 구조가 달랐어요. 백엔드가 로그인 시 액세스 토큰과 리프레시 토큰을 둘 다 응답으로 내려주고, 프론트엔드에서 **NextAuth(Auth.js)**로 이를 관리하고 있었어요.
NextAuth JWT의 동작 방식
NextAuth는 세션 전략(DB 저장)과 JWT 전략(암호화 쿠키) 두 가지를 제공해요. 이 프로젝트는 JWT 전략을 사용했고, 세션 정보를 암호화된 JWT 쿠키에 저장해요.
sequenceDiagram
participant Browser
participant NextAuth as Next.js (NextAuth)
participant Backend
Browser->>NextAuth: 로그인 요청
NextAuth->>Backend: POST /auth/login
Backend-->>NextAuth: accessToken, refreshToken
Note over NextAuth: jwt 콜백에서 accessToken을 JWT에 저장
NextAuth-->>Browser: Set-Cookie: authjs.session-token (encrypted, HttpOnly)
NextAuth JWT 전략의 동작은 다음과 같아요.
jwt콜백에서 백엔드가 준accessToken을 NextAuth의 JWT 토큰에 추가해요- 이 JWT 전체가 암호화되어 HTTP-Only 쿠키에 저장돼요
- 서버에서
auth()함수를 호출하면 쿠키를 복호화하여accessToken에 접근할 수 있어요 - 브라우저에서는 암호화된 쿠키이므로 토큰에 직접 접근할 수 없어요
결국 1부의 “서버 세션”과 같은 효과예요. 별도 Redis나 DB 없이, 암호화된 쿠키 자체가 서버 세션 역할을 해요. 액세스 토큰은 서버 측에서만 접근 가능하고, 클라이언트는 암호화된 세션 쿠키만 가지고 있어요.
문제: 모든 페이지가 CSR
액세스 토큰이 서버에만 있으므로, 이 프로젝트에서는 모든 API 호출을 Server Action으로 처리하고 있었어요. 그리고 모든 page.tsx에 "use client"가 붙어 있어 전부 CSR(Client-Side Rendering)이었어요.
flowchart LR
A["빈 HTML 도착"] --> B["JS 번들 다운로드"]
B --> C["React 실행"]
C --> D["useQuery → Server Action → API"]
D --> E["로딩 스피너..."]
E --> F["데이터 표시"]
style E fill:#fee2e2
Next.js App Router를 쓰면서 SSR을 전혀 활용하지 않는 구조예요. 1부에서 겪은 “브라우저 메모리” 문제는 해결됐지만, SSR의 장점을 활용하지 못하는 문제는 여전했어요.
| 문제 | 영향 |
|---|---|
| 매 페이지 로딩 스피너 | 체감 품질 저하 |
| 빈 HTML → JS 로드 후 렌더 | 레이아웃 깨짐 |
| 브라우저 → Next.js → API (2 hop) | 불필요한 네트워크 왕복 |
| 권한 체크가 클라이언트 전용 | JS 조작으로 우회 가능 |
Next.js를 쓰면서 모든 페이지를 "use client"로 만드는 것은 Next.js의 가장 큰 장점(서버 렌더링)을 포기하는 것이에요.
제안: 3가지 데이터 흐름 경로
이 문제를 해결하기 위해 데이터 흐름을 용도별로 분리하는 구조를 설계했어요.
1. 초기 로드 — SSR Prefetch
flowchart LR
A1["브라우저 요청"] --> A2["서버에서 auth()로 토큰 접근"]
A2 --> A3["서버 → API 직접 호출 (내부망)"]
A3 --> A4["데이터 포함 HTML → 즉시 표시"]
페이지 초기 로드 시 서버에서 auth()로 토큰을 꺼내 API를 직접 호출해요. 서버 → 서버 통신이므로 내부망에서 수 ms면 돼요.
2. 리페치 — Route Handler Proxy
flowchart LR
B1["useQuery refetch"] --> B2["fetch /api/proxy/*"]
B2 --> B3["Route Handler에서 auth() → API 호출"]
클라이언트에서 데이터를 다시 조회할 때 Route Handler를 경유해요. 1부의 Proxy 패턴과 동일해요.
3. 뮤테이션 — Server Action
flowchart LR
C1["useMutation"] --> C2["Server Action → API 호출"]
C2 --> C3["onSuccess: invalidateQueries"]
기존에 이미 Server Action으로 처리하고 있으므로 그대로 유지해요.
ProtectedPage 래퍼 패턴
이 구조를 모든 페이지에 일관되게 적용하기 위해 <ProtectedPage> 래퍼 컴포넌트를 설계했어요. <HydrationBoundary>는 서버에서 가져온 데이터를 클라이언트의 TanStack Query 캐시로 전달하는 컴포넌트예요.
flowchart TD
A["ProtectedPage<br/>(Server Component)"] --> B{"permission prop?"}
B -- "있음" --> C["서버 권한 체크<br/>auth() → 권한 API 호출"]
C -- "권한 없음" --> D["NoPermission 반환<br/>데이터 fetch 안 함"]
C -- "권한 있음" --> E{"prefetch prop?"}
B -- "없음" --> E
E -- "있음" --> F["QueryClient 생성 → prefetch 실행"]
F --> G["HydrationBoundary로 감싸서 반환"]
E -- "없음" --> H["children 그대로 반환"]
<ProtectedPage>는 Server Component로, 두 가지 역할을 선택적으로 수행해요.
- 서버 권한 체크: 권한이 없으면 데이터를 아예 가져오지 않고 차단해요. JS 조작으로 우회할 수 없어요
- Prefetch + Hydration: 서버에서 데이터를 미리 가져와 TanStack Query 캐시에 넣어둬요
이렇게 하면 page.tsx가 극도로 단순해져요.
// app/dashboard/products/page.tsx (Server Component)
export default function ProductsPage() {
return (
<ProtectedPage permission="PRODUCT__R" prefetch={prefetchProductList}>
<ProductList />
</ProtectedPage>
);
}
클라이언트의 useQuery는 기존 코드와 완전히 동일해요. 다만 HydrationBoundary가 서버에서 가져온 데이터를 캐시에 넣어주므로, useQuery는 캐시 히트로 로딩 스피너 없이 즉시 화면을 표시해요.
SSR + Prefetch vs All CSR
flowchart LR
subgraph CSR["All CSR (기존)"]
direction LR
C1["빈 HTML"] --> C2["JS 로드"] --> C3["API 호출"] --> C4["화면 표시"]
end
subgraph SSR2["SSR + Prefetch (제안)"]
direction LR
S1["서버에서 API 호출<br/>(내부망, 수 ms)"] --> S2["데이터 포함 HTML"] --> S3["화면 즉시 표시"]
end
| All CSR (기존) | SSR + Prefetch (제안) | |
|---|---|---|
| 로딩 스피너 | 매 페이지 | 없어요 |
| 레이아웃 깨짐 | HTML이 빈 껍데기 | 데이터 포함 HTML |
| 권한 체크 | 클라이언트 (우회 가능) | 서버 (우회 불가) |
| API 경로 | 브라우저 → Next.js → API (2 hop) | 서버 → API (1 hop, 내부망) |
| page.tsx 복잡도 | 200줄+ (UI + 로직 혼합) | 5줄 (ProtectedPage 래핑) |
| UI 컴포넌트 코드 | 동일 | 동일 |
핵심은 UI 컴포넌트 코드를 전혀 바꾸지 않는다는 점이에요. useQuery, useMutation, UI 라이브러리 모두 기존 그대로 사용해요. 변경은 page.tsx의 래핑 방식뿐이에요.
마무리하며
두 프로젝트 경험에서 얻은 핵심 교훈은 세 가지예요.
- JWT 액세스 토큰은 서버에서 관리해야 SSR과 보안을 동시에 잡을 수 있어요. 직접 서버 세션을 만들든 NextAuth JWT를 쓰든, 원칙은 같아요
- 클라이언트 요청은 Proxy 패턴으로 서버 토큰에 접근하게 해요. Route Handler가 세션에서 토큰을 꺼내 백엔드로 전달하는 구조예요
- SSR + Prefetch를 도입하면 같은 인프라 비용으로 로딩 스피너 제거, 레이아웃 안정, 서버 권한 체크를 얻을 수 있어요
“액세스 토큰을 어디에 저장하는가”에서 시작한 고민이 결국 전체 애플리케이션 아키텍처를 결정짓는 핵심 요소라는 것을 두 프로젝트를 거치며 확인했어요.