Next.js Proxy 패턴으로 JWT 인증 설계하기
“액세스 토큰을 어디에 저장하는가”는 단순한 저장소 선택이 아닙니다. 이 결정 하나가 SSR(Server-Side Rendering) 가능 여부, UX(사용자 경험), 보안, 코드 구조까지 결정합니다. 이 글에서는 두 프로젝트에서 서버 세션 + Proxy 패턴과 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로 한 단계 더
다른 시작점, 같은 문제
두 번째 프로젝트에서는 인증 구조가 달랐습니다. 백엔드가 로그인 시 액세스 토큰과 리프레시 토큰을 둘 다 응답으로 내려주고, 프론트엔드에서 NextAuth(Auth.js) 로 이를 관리하고 있었습니다.
NextAuth JWT의 동작 방식
NextAuth를 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> 래퍼 컴포넌트를 설계했습니다.
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를 도입하면 같은 인프라 비용으로 로딩 스피너 제거, 레이아웃 안정, 서버 권한 체크를 얻을 수 있습니다
“액세스 토큰을 어디에 저장하는가”에서 시작한 고민이 결국 전체 애플리케이션 아키텍처를 결정짓는 핵심 요소라는 것을 두 프로젝트를 거치며 확인했습니다.