현대 웹 애플리케이션에서 사용자 인증을 구현할 때 가장 널리 사용되는 방식 중 하나가 바로 JWT 기반의 Access Token과 Refresh Token을 활용한 세션 관리입니다. 이 글에서는 이 방식의 동작 원리와 함께, CSR과 SSR 환경에서 토큰을 어떻게 저장해야 하는지 살펴보겠습니다.
Access Token과 Refresh Token이란?
Access Token
Access Token은 사용자가 인증된 후 API 요청 시 사용자의 신원을 증명하는 짧은 수명의 토큰입니다. 일반적으로 15분에서 1시간 정도의 짧은 유효기간을 가지며, 이 토큰만으로 보호된 리소스에 접근할 수 있습니다.
특징:
- 짧은 유효기간 (보통 15분~1시간)
- 매 API 요청마다 전송
- 탈취되더라도 피해를 최소화할 수 있는 구조
Refresh Token
Refresh Token은 Access Token이 만료되었을 때 새로운 Access Token을 발급받기 위한 토큰입니다. 상대적으로 긴 유효기간을 가지며(보통 2주~1개월), 오직 토큰 갱신 용도로만 사용됩니다.
특징:
- 긴 유효기간 (보통 2주~1개월)
- 새로운 Access Token 발급 시에만 사용
- 한 번 사용되면 새로운 Refresh Token으로 갱신 (Refresh Token Rotation)
토큰 기반 인증 흐름
1. 사용자 로그인
↓
2. 서버가 Access Token + Refresh Token 발급
↓
3. 클라이언트가 토큰 저장
↓
4. API 요청 시 Access Token 사용
↓
5. Access Token 만료 시
↓
6. Refresh Token으로 새 Access Token 발급
↓
7. 새 토큰으로 API 요청 재시도
CSR vs SSR: 토큰 저장 전략의 차이
토큰을 어디에 저장할지는 애플리케이션의 렌더링 방식에 따라 달라져야 합니다. CSR과 SSR 환경은 근본적으로 다른 보안 고려사항을 가지고 있기 때문입니다.
CSR (Client-Side Rendering) 환경
React, Vue, Angular 등의 순수 CSR 애플리케이션에서는 모든 렌더링이 브라우저에서 일어납니다.
권장 저장 방식:
Access Token: 메모리 (JavaScript 변수)
// 전역 상태 관리 (예: React Context, Zustand, Redux)
let accessToken = null;
function setAccessToken(token) {
accessToken = token;
}
function getAccessToken() {
return accessToken;
}
Refresh Token: HttpOnly 쿠키
// 로그인 API 응답 시 서버에서 설정
Set-Cookie: refreshToken=abc123; HttpOnly; Secure; SameSite=Strict; Path=/api/refresh
이유:
- Access Token을 메모리에 저장하면 XSS 공격으로 탈취되더라도 페이지를 새로고침하면 사라집니다
- Refresh Token은 HttpOnly 쿠키에 저장하여 JavaScript에서 접근 불가능하게 만듭니다
- 이는 XSS 공격으로부터 Refresh Token을 보호합니다
단점:
- 페이지 새로고침 시 Access Token이 사라지므로, 새로고침 시 Refresh Token으로 재발급 받는 로직 필요
- 탭을 닫으면 로그인 상태가 사라집니다
구현 예시:
// API 인터셉터 설정
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Access Token 만료 시
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Refresh Token은 쿠키에 자동으로 포함됨
const { data } = await axios.post('/api/refresh');
setAccessToken(data.accessToken);
// 원래 요청 재시도
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return axios(originalRequest);
} catch (refreshError) {
// Refresh Token도 만료된 경우 로그아웃
redirectToLogin();
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
SSR (Server-Side Rendering) 환경
Next.js, Nuxt.js 등의 SSR 애플리케이션에서는 서버에서도 인증 정보가 필요합니다.
권장 저장 방식:
Access Token: HttpOnly 쿠키
// 서버에서 설정
Set-Cookie: accessToken=xyz789; HttpOnly; Secure; SameSite=Strict; Path=/
Refresh Token: HttpOnly 쿠키 (별도 경로)
Set-Cookie: refreshToken=abc123; HttpOnly; Secure; SameSite=Strict; Path=/api/refresh
이유:
- SSR 환경에서는 서버 측에서도 토큰에 접근해야 하므로 쿠키가 필수입니다
- 서버 컴포넌트에서 데이터를 가져올 때 쿠키의 토큰을 사용합니다
- HttpOnly 설정으로 XSS 공격으로부터 보호합니다
- SameSite 설정으로 CSRF 공격을 방어합니다
구현 예시 (Next.js):
// middleware.js - 서버 측 토큰 갱신
export async function middleware(request) {
const accessToken = request.cookies.get('accessToken');
const refreshToken = request.cookies.get('refreshToken');
// Access Token이 없거나 만료된 경우
if (!accessToken && refreshToken) {
try {
const response = await fetch(`${process.env.API_URL}/api/refresh`, {
method: 'POST',
headers: {
'Cookie': `refreshToken=${refreshToken.value}`
}
});
const data = await response.json();
// 새 Access Token 설정
const responseWithToken = NextResponse.next();
responseWithToken.cookies.set('accessToken', data.accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
return responseWithToken;
} catch (error) {
// Refresh 실패 시 로그인 페이지로 리다이렉트
return NextResponse.redirect(new URL('/login', request.url));
}
}
return NextResponse.next();
}
// 서버 컴포넌트에서 사용
import { cookies } from 'next/headers';
async function UserProfile() {
const cookieStore = cookies();
const accessToken = cookieStore.get('accessToken')?.value;
const response = await fetch(`${process.env.API_URL}/api/user`, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
const user = await response.json();
return <div>{user.name}</div>;
}
토큰 저장소 비교표
저장소 XSS 취약 CSRF 취약 서버 접근 페이지 새로고침 적합한 환경
| LocalStorage | ⚠️ 높음 | ✅ 없음 | ❌ 불가능 | ✅ 유지 | 사용 비권장 |
| SessionStorage | ⚠️ 높음 | ✅ 없음 | ❌ 불가능 | ❌ 소실 | 사용 비권장 |
| 메모리 | ✅ 낮음 | ✅ 없음 | ❌ 불가능 | ❌ 소실 | CSR (Access) |
| HttpOnly 쿠키 | ✅ 없음 | ⚠️ 있음* | ✅ 가능 | ✅ 유지 | SSR, CSR (Refresh) |
*SameSite 속성으로 완화 가능
보안 모범 사례
1. Refresh Token Rotation
Refresh Token을 사용할 때마다 새로운 Refresh Token을 발급하여 이전 토큰을 무효화합니다.
// 서버 측 구현 예시
app.post('/api/refresh', async (req, res) => {
const oldRefreshToken = req.cookies.refreshToken;
// 토큰 검증
const decoded = verifyToken(oldRefreshToken);
// 새 토큰 쌍 발급
const newAccessToken = generateAccessToken(decoded.userId);
const newRefreshToken = generateRefreshToken(decoded.userId);
// 이전 Refresh Token 무효화 (DB에서 제거)
await revokeRefreshToken(oldRefreshToken);
// 새 Refresh Token 저장
await saveRefreshToken(newRefreshToken, decoded.userId);
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/refresh'
});
res.json({ accessToken: newAccessToken });
});
2. 짧은 Access Token 유효기간
Access Token의 유효기간을 15~30분으로 짧게 설정하여 탈취 시 피해를 최소화합니다.
3. HTTPS 사용 필수
모든 토큰 전송은 반드시 HTTPS를 통해 이루어져야 합니다.
4. CSRF 보호
쿠키를 사용하는 경우 SameSite 속성과 함께 CSRF 토큰을 추가로 사용할 수 있습니다.
5. 토큰 저장소 격리
Access Token과 Refresh Token을 서로 다른 Path에 저장하여 노출 범위를 최소화합니다.
결론
토큰 기반 인증은 현대 웹 애플리케이션에서 강력하고 확장 가능한 인증 방식입니다. 하지만 올바른 저장 전략 없이는 보안 취약점이 될 수 있습니다.
핵심 정리:
- CSR 환경: Access Token은 메모리에, Refresh Token은 HttpOnly 쿠키에 저장
- SSR 환경: 두 토큰 모두 HttpOnly 쿠키에 저장하되, Path를 분리
- 공통: Refresh Token Rotation, 짧은 Access Token 유효기간, HTTPS 사용 필수
각 환경의 특성을 이해하고 적절한 저장 전략을 선택하는 것이 안전한 인증 시스템을 구축하는 첫걸음입니다.
'WebCommon' 카테고리의 다른 글
| Passkey 이해하기 2 - 코드로 배우는 실전 구현 (0) | 2025.12.24 |
|---|---|
| Passkey 이해하기 1 - 개념과 동작 원리 (1) | 2025.12.24 |
| GET, POST 뭐가 더 안전한 요청일까? (0) | 2025.12.05 |
| CLOUDFLARE로 이미지 업로드 (4) | 2024.09.13 |
| CSP 'upgrade-insecure-requests' 이슈 (1) | 2024.07.05 |