JWT 인증, 프론트엔드에서 어떻게 관리하고 계신가요?
웹 애플리케이션에서 JWT(JSON Web Token)를 사용한 인증 방식은 이제 표준처럼 자리 잡았습니다. 하지만 개발자에게 Access Token과 Refresh Token 관리는 종종 까다로운 숙제가 되곤 합니다. Access Token을 어디에 저장할지 (메모리? 세션 스토리지? 로컬 스토리지? 쿠키?), 만료된 토큰은 어떻게 처리하고 사용자는 모르게 재발급할지, 여러 API 요청이 동시에 발생했을 때 토큰 재발급은 어떻게 효율적으로 처리할지 등 고민거리가 많습니다.
저 또한 이러한 고민들을 안고 프로젝트를 진행하던 중, Access Token은 메모리에 싱글톤 방식으로 관리하고, API 요청 시 401 Unauthorized 에러가 발생하면 자동으로 Refresh Token을 사용해 Access Token을 재발급받아 원래 요청을 재시도하는 로직을 프론트엔드에 구현했습니다. Refresh Token은 보안을 위해 HTTPOnly, secure 쿠키에 저장하여 관리하고요.
이 글에서는 제가 구현한 TypeScript 기반의 api 호출 로직과 더불어, JWT 디버깅의 불편함을 해소하고자 직접 개발한 크롬 익스텐션 ‘JWT Badge’를 소개하고자 합니다.
Access Token 관리 전략: 메모리 저장과 싱글톤 패턴
Access Token은 상대적으로 만료 시간이 짧고, 탈취 시 바로 사용될 수 있어 보안에 민감합니다. 저는 다음과 같은 이유로 Access Token을 클라이언트 측 메모리에 싱글톤 인스턴스를 통해 관리하는 방식을 선택했습니다.
- 보안: 브라우저 스토리지(Local Storage, Session Storage)에 저장할 경우 XSS 공격에 취약할 수 있습니다. 쿠키에 저장하는 방법도 있지만, CSRF 공격에 대한 고려가 필요하고 모든 요청에 자동으로 토큰이 포함되는 것이 때로는 불필요할 수 있습니다. 메모리 저장은 브라우저를 새로고침하면 사라지지만, SPA(Single Page Application) 환경에서는 상태 유지가 가능하고 XSS로부터 비교적 안전합니다.
- 관리 용이성: 싱글톤 인스턴스를 사용하면 애플리케이션 전역에서 단일 지점을 통해 토큰에 접근하고 관리할 수 있습니다.
- 유연성: 현재는 단일 인증 서버를 사용하지만, 추후 여러 인증 서버를 사용하거나 모바일 앱 등 다양한 클라이언트를 고려했을 때, 필요시에만 명시적으로
Authorization: Bearer <token>
헤더를 구성하여 요청하는 방식이 표준적이고 유연하다고 판단했습니다.
Refresh Token의 경우, Access Token보다 만료 기간이 길고 탈취 시 더 큰 보안 위협이 될 수 있으므로, 서버에서 발급 시 HttpOnly
및 Secure
플래그를 설정한 쿠키로 전달받아 브라우저가 자동으로 관리하도록 했습니다. 이렇게 하면 자바스크립트 코드로 Refresh Token에 직접 접근할 수 없어 XSS 공격으로부터 안전합니다.
다음은 이러한 전략을 구현한 AuthService
의 핵심 구조입니다.
// AuthService.ts
import {AuthResponseDto} from '@flow/auth'; // 실제 프로젝트의 DTO 경로로 수정하세요.
class AuthService {
private accessToken: string | null = null;
private isRefreshing = false;
private refreshSubscribers: ((token: string) => void)[] = [];
private baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || '';
constructor() {
// 초기화 시 특별한 로직이 필요하면 여기에 작성
}
setAccessToken(token: string) {
this.accessToken = token;
}
getCookie(name: string): string | null {
if (typeof document === 'undefined') return null;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null;
return null;
}
async getAccessToken(): Promise<string | null> {
if (this.accessToken) {
return this.accessToken;
}
// 메모리에 토큰이 없으면 (예: 페이지 새로고침 후 첫 API 요청 시)
// refreshAccessToken을 호출하여 쿠키의 refreshToken으로 accessToken을 가져옵니다.
// 만약 refreshToken도 없다면 로그인 페이지로 리다이렉트 등의 처리가 필요합니다.
try {
return await this.refreshAccessToken();
} catch (error) {
console.error('초기 AccessToken 로드 실패 (Refresh 시도):', error);
// 여기서 로그인 페이지로 리다이렉트하거나, 에러를 전파하여
// 로그인 유도가 필요한 UI를 표시할 수 있습니다.
// window.location.href = '/login'; // 예시
return null; // 또는 throw error;
}
}
// ... (refreshAccessToken, fetchWithAuth 등 나머지 메서드는 아래에서 설명)
}
const authService = new AuthService();
export default authService;
getAccessToken
메서드는 먼저 메모리에 accessToken
이 있는지 확인하고, 있다면 즉시 반환합니다. 만약 없다면 (예: 사용자가 페이지를 새로고침한 직후) refreshAccessToken
을 호출하여 새로운 Access Token을 발급받으려고 시도합니다.
401 에러? 문제없어! 자동 토큰 재발급 및 요청 재시도
API 요청 결과로 401 Unauthorized 에러를 받으면, 이는 Access Token이 만료되었거나 유효하지 않다는 신호입니다. 이때 사용자 경험을 해치지 않고 자동으로 새 Access Token을 발급받아 원래 요청을 재시도하는 것이 중요합니다.
refreshAccessToken
: 중복 재발급 방지 로직
여러 API 요청이 거의 동시에 발생하고 모두 401 에러를 반환하는 경우, 각 요청마다 토큰 재발급을 시도하면 서버에 불필요한 부하를 줄 수 있습니다. 이를 방지하기 위해 isRefreshing
플래그와 refreshSubscribers
큐를 사용합니다.
// AuthService.ts (계속)
async refreshAccessToken(): Promise<string> {
// 이미 토큰 갱신 요청이 진행 중이면, 해당 요청이 완료될 때까지 대기
if (this.isRefreshing) {
return new Promise<string>((resolve) => {
this.refreshSubscribers.push(resolve);
});
}
this.isRefreshing = true;
try {
// 실제 토큰 재발급 API 호출
// (Refresh Token은 HttpOnly 쿠키에 담겨 자동으로 전송됨)
const response = await fetch(`${this.baseUrl}/auth/refresh/token`, {
method: 'POST',
credentials: 'include', // Refresh Token 쿠키 전송을 위해 필수
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json() as AuthResponseDto; // AuthResponseDto는 { accessToken: string, ... } 형태라고 가정
if (!data.accessToken || response.status !== 200) { // 성공 응답 상태 코드 확인 (예: 200 OK)
throw new Error(`토큰 갱신 실패 (상태: ${response.status})`);
}
const newAccessToken = data.accessToken;
this.setAccessToken(newAccessToken);
// 대기 중이던 모든 요청에 새로운 토큰을 전달하고 큐를 비움
this.refreshSubscribers.forEach(callback => callback(newAccessToken));
this.refreshSubscribers = [];
return newAccessToken;
} catch (error) {
console.error('토큰 갱신 오류:', error);
// 중요: 토큰 갱신 실패 시 로그인 페이지로 리다이렉트 또는 전역 에러 처리
// 예: window.location.href = '/login';
this.refreshSubscribers.forEach(callback => callback(null as any)); // 실패 알림 (선택적)
this.refreshSubscribers = [];
throw error; // 에러를 다시 던져서 호출한 쪽에서 처리할 수 있도록 함
} finally {
this.isRefreshing = false;
}
}
최초의 401 응답을 받은 요청만이 실제 토큰 재발급 API를 호출하고(this.isRefreshing = true
), 그사이 다른 요청들은 refreshSubscribers
배열에 콜백 함수를 등록하고 대기합니다. 토큰 재발급이 성공하면, 저장된 콜백들을 실행하여 모든 대기 중인 요청들이 새로운 토큰으로 작업을 재개할 수 있도록 합니다.
WorkspaceWithAuth
: 인증 헤더 추가 및 자동 재시도 래퍼 함수
모든 인증이 필요한 API 요청은 WorkspaceWithAuth
함수를 통해 이루어지도록 래핑했습니다. 이 함수는 자동으로 Authorization: Bearer <token>
헤더를 추가하고, 401 에러 발생 시 위에서 설명한 refreshAccessToken
을 호출하여 토큰을 재발급받은 후 원래 요청을 재시도합니다.
// AuthService.ts (계속)
async getAuthHeaders(headers: Record<string, string> = {}): Promise<Record<string, string>> {
let token = await this.getAccessToken(); // 내부적으로 필요시 refresh 시도
// getAccessToken에서 null이 반환될 경우 (초기 로드 실패 및 로그인 필요 상황)
if (!token) {
console.warn('인증 토큰이 없습니다. 로그인이 필요할 수 있습니다.');
// 여기서 에러를 던지거나, 로그인 페이지로 리다이렉트 등의 처리를 할 수 있습니다.
// throw new Error('No access token available');
// 혹은 특정 상황에서는 헤더 없이 요청을 보내도록 할 수도 있습니다.
// 이 경우 호출하는 쪽에서 응답을 적절히 처리해야 합니다.
return headers; // 일단은 기존 헤더만 반환
}
const defaultHeaders: Record<string, string> = {
'Accept': 'application/json',
};
// Content-Type은 요청의 body 유형에 따라 fetch가 자동으로 설정하도록 하거나,
// 명시적으로 제공된 경우에만 사용합니다.
// FormData의 경우 Content-Type을 직접 설정하면 boundary 문제가 발생할 수 있습니다.
const contentTypeKey = Object.keys(headers).find(key => key.toLowerCase() === 'content-type');
if (!contentTypeKey && !(options?.body instanceof FormData)) { // options는 fetchWithAuth의 파라미터
defaultHeaders['Content-Type'] = 'application/json';
}
return {
...defaultHeaders,
...headers,
'Authorization': `Bearer ${token}`,
};
}
async fetchWithAuth<T=Response>(url: URL | RequestInfo, options?: RequestInit): Promise<T> {
const initialHeaders: Record<string, string> = options?.headers as Record<string, string> || {};
let authHeaders = await this.getAuthHeaders(initialHeaders); // 첫 시도 시 헤더 가져오기
// options.body가 FormData인 경우 Content-Type 헤더 제거 (브라우저가 자동 설정하도록)
if (options?.body instanceof FormData) {
if (authHeaders instanceof Headers) { // Headers 객체인 경우
authHeaders.delete('Content-Type');
} else { // 일반 객체인 경우
delete authHeaders['Content-Type'];
delete authHeaders['content-type']; // 대소문자 구분 없이 제거
}
}
const requestOptions: RequestInit = {
...options, // method, body 등 나머지 옵션들
headers: authHeaders,
};
try {
let response = await fetch(this.baseUrl + url.toString(), requestOptions);
if (response.status === 401) {
try {
console.log('Access Token 만료 감지. 토큰 재발급 시도...');
const newToken = await this.refreshAccessToken(); // 토큰 재발급
if (!newToken) { // newToken이 null (재발급 실패)
console.error('토큰 재발급에 실패했습니다. 로그인 페이지로 이동합니다.');
// window.location.href = '/login'; // 로그인 페이지로 리다이렉트
throw new Error('Failed to refresh token'); // 재시도 없이 에러 발생
}
// 새 토큰으로 Authorization 헤더 업데이트
// requestOptions.headers는 Headers 객체일 수도, 일반 객체일 수도 있음
const newAuthHeaders = { ...(requestOptions.headers as Record<string, string>) };
newAuthHeaders['Authorization'] = `Bearer ${newToken}`;
console.log('새로운 토큰으로 원래 요청 재시도...');
response = await fetch(this.baseUrl + url.toString(), { // 전체 URL 사용
...requestOptions,
headers: newAuthHeaders,
});
// 재시도 후에도 401이면, refresh token도 만료되었거나 다른 문제. 로그인 필요.
if (response.status === 401) {
console.error('토큰 재발급 후에도 401. 로그인 필요.');
// window.location.href = '/login';
throw new Error('Authentication failed after token refresh.');
}
} catch (refreshError) {
console.error('토큰 갱신 또는 요청 재시도 중 심각한 오류:', refreshError);
// 여기서 로그인 페이지로 리다이렉트 또는 전역 에러 처리
// window.location.href = '/login';
throw refreshError; // 에러 전파
}
}
// JSON 반환을 기본으로 하지만, 실제 응답 타입에 따라 다를 수 있음
// 호출하는 쪽에서 T를 지정하여 타입을 명시해야 함
if (!response.ok && response.status !== 200 && response.status !== 201) { // 204 No Content 등도 고려
// 401이 아닌 다른 HTTP 에러 처리
const errorData = await response.clone().json().catch(() => response.text()); // 에러 응답 파싱 시도
console.error(`API Error ${response.status}:`, errorData);
throw new Error(`API request failed with status ${response.status}`);
}
// 응답 본문이 없을 수 있는 경우 (e.g., 204 No Content)
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
} catch (error) {
console.error(`'${url}' 요청 중 인증 또는 네트워크 오류:`, error);
throw error; // 최종적으로 에러 전파
}
}
}
WorkspaceWithAuth
는 FormData
를 body로 사용할 때 Content-Type
헤더를 자동으로 제거하여 브라우저가 올바른 multipart/form-data
헤더와 boundary
를 설정하도록 합니다. 또한, 401 에러가 아닌 다른 HTTP 에러에 대한 기본적인 처리도 포함할 수 있습니다.
개발 편의성 UP! JWT 디버깅 크롬 익스텐션 ‘JWT Badge’
JWT를 사용하다 보면 개발자 도구를 열어 쿠키나 요청 헤더에서 토큰을 찾아 복사하고, jwt.io 같은 사이트에서 디코딩하여 만료 시간(exp) 등의 정보를 확인하는 과정이 상당히 번거롭습니다. 특히 Access Token이 쿠키가 아닌 메모리에 있거나, Refresh Token이 HttpOnly 쿠키에 있는 경우 확인이 더 까다롭죠.
이런 불편함을 해결하고자 ‘JWT Badge (JWT 뱃지 확장 프로그램)’ 라는 크롬 익스텐션을 직접 만들었습니다!
이 익스텐션의 주요 기능은 다음과 같습니다:
- 자동 토큰 트래킹: 현재 페이지에서 나가는 요청의
Authorization: Bearer <token>
헤더를 감지하여 Access Token을 자동으로 가져옵니다. (현재는 가장 마지막 요청의 토큰을 보여줍니다.) - 손쉬운 디코딩 정보 확인: 익스텐션 아이콘을 클릭하면 팝업 창에서 현재 트래킹된 Access Token의 Payload(iat, exp, 사용자 정보 등)를 바로 확인할 수 있습니다. 만료까지 남은 시간도 표시해줍니다.
- Refresh Token 확인 (선택적): 특정 이름의 쿠키(예:
refreshToken
)에 Refresh Token이 저장되어 있다면, 해당 토큰 정보도 함께 보여줄 수 있도록 설정 가능합니다. (단, HttpOnly 쿠키는 직접 읽을 수 없으므로, 이 부분은 백그라운드 스크립트와 메시징을 통해 가져오거나, 서버 응답에서 간접적으로 확인하는 방식을 고민해야 합니다. 현재 버전은 주로 Access Token에 집중되어 있습니다.) - 간편한 복사: 토큰 값이나 디코딩된 Payload를 쉽게 복사할 수 있습니다.
- 개발자 도구 탭: 개발자 도구 탭을 통해 현재 통신에 사용되고 있는 토큰에 대해 좀 더 자세한 정보를 알 수 있습니다.
초반에 Access Token도 쿠키에 저장하여 사용할 때(현재는 AuthService
를 통해 메모리 관리로 변경)는 개발자 도구의 Application 탭에서 쿠키를 찾아 디코딩하는 과정이 반복되었는데, 이 익스텐션을 함께 사용하니 모니터 공간 활용과 디버깅 효율이 정말 크게 향상되었습니다.
마무리
프론트엔드에서 JWT 토큰을 효과적으로 관리하는 것은 안전하고 원활한 사용자 경험을 제공하는 데 매우 중요합니다. 제가 소개한 메모리 기반 싱글톤 AuthService
, 자동 토큰 재발급 및 요청 재시도 로직, 그리고 개발 편의성을 위한 ‘JWT Badge’ 익스텐션이 여러분의 JWT 기반 애플리케이션 개발에 도움이 되기를 바랍니다.
물론 이 방법만이 정답은 아니며, 애플리케이션의 특성과 보안 요구사항에 따라 최적의 전략은 달라질 수 있습니다. 하지만 이러한 접근 방식은 많은 SPA 환경에서 유용하게 사용될 수 있을 것이라 생각합니다.
읽어주셔서 감사합니다.