Access Token과 Refresh Token을 활용한 세션 관리
2025. 12. 8. 14:37

현대 웹 애플리케이션에서 사용자 인증을 구현할 때 가장 널리 사용되는 방식 중 하나가 바로 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 사용 필수

각 환경의 특성을 이해하고 적절한 저장 전략을 선택하는 것이 안전한 인증 시스템을 구축하는 첫걸음입니다.