JavaScript 애플리케이션에서 Refresh Token을 사용한 안전한 "Remember Me" 구현
Wenhao Wang
Dev Intern · Leapcell

소개
빠르게 변화하는 웹 애플리케이션 세계에서 사용자 편의성은 매우 중요합니다. 사용자 편의성을 크게 향상시키는 기능 중 하나는 "Remember Me" 기능으로, 사용자가 자격 증명을 반복해서 입력하지 않고도 브라우저 세션 간에 로그인 상태를 유지할 수 있도록 합니다. 간단해 보이지만, 안전하고 오래 지속되는 "Remember Me" 기능을 구현하는 것은 특히 사용자 데이터 보호 및 인증 지속 시간과 관련하여 흥미로운 과제를 제시합니다. 전통적인 접근 방식은 보안과 지속성의 균형을 맞추는 데 종종 부족합니다. 이 글은 JavaScript 애플리케이션을 위한 강력한 솔루션, 즉 장기적이고 안전한 "Remember Me" 전략으로 Refresh Token을 활용하는 방법에 대해 자세히 알아봅니다. 기본 개념을 살펴보고, 구현 세부 정보를 논의하며, 효과적이고 안전한 시스템을 구축하는 방법을 보여줄 것입니다.
핵심 개념 설명
구현에 들어가기 전에, 논의의 기반이 될 몇 가지 주요 용어를 정의해 보겠습니다.
- Access Token: 보호된 리소스에 액세스하는 데 사용되는 자격 증명입니다. Access Token은 일반적으로 수명이 짧으며(몇 분에서 한 시간) 안전한 API에 대한 모든 요청에 포함됩니다. 짧은 수명은 토큰 도용으로 인한 위험을 최소화합니다.
- Refresh Token: 현재 Access Token이 만료된 후 새 Access Token을 얻는 데 사용되는 수명이 긴 자격 증명입니다. Access Token과 달리 Refresh Token은 모든 API 요청과 함께 전송되지 않습니다. 더 안전하게 저장되며 Access Token을 갱신해야 할 때만 일반적으로 사용됩니다.
- JWT (JSON Web Token): 두 당사자 간에 전송될 클레임을 나타내는 간결하고 URL 안전한 수단입니다. JWT는 사용자 ID, 역할, 만료 시간과 같은 정보를 포함하며 무결성을 보장하기 위해 디지털 서명되는 Access Token에 자주 사용됩니다.
- 세션 vs. 지속성: 세션은 일반적으로 두 개 이상의 통신 장치 또는 프로그램 간의 일시적인 대화식 정보 교환을 의미합니다. 이 맥락에서 지속성은 사용자의 로그인 상태가 브라우저 닫힘 또는 장기간의 비활성 상태를 견딜 수 있는 능력을 의미합니다.
- HTTP-only Cookie: 클라이언트 측 JavaScript에서 액세스할 수 없는 특수한 유형의 쿠키입니다. 이는 공격자가 쿠키 값을 단순히 읽을 수 없으므로 크로스 사이트 스크립팅(XSS) 공격에 면역되게 합니다. 이는 Refresh Token과 같이 민감한 토큰을 저장하는 중요한 보안 조치입니다.
- CSRF (Cross-Site Request Forgery) Token: 서버에서 생성되어 클라이언트로 전송되는 비밀의 고유하고 예측 불가능한 값입니다. 클라이언트는 이후 요청(일반적으로 헤더 또는 폼 필드)에 이 토큰을 포함해야 합니다. 서버는 이 토큰을 검증하여 다른 도메인에서 시작된 무단 요청을 방지합니다.
Refresh Token을 사용한 "Remember Me" 구현
핵심 아이디어는 즉각적인 API 요청에는 수명이 짧은 Access Token을 사용하고, 필요할 때 새 Access Token을 재발급하는 데는 안전하게 저장된 수명이 긴 Refresh Token을 사용하는 것입니다. 이 접근 방식은 보안과 사용자 편의성 사이에서 강력한 균형을 제공합니다.
흐름
-
사용자 로그인:
- 사용자가 자격 증명(사용자 이름/암호)을 제공합니다.
- 백엔드가 사용자를 인증합니다.
- 성공하면 백엔드에서 Access Token과 Refresh Token을 발급합니다.
- Access Token은 일반적으로 응답 본문 또는 HTTP-only가 아닌 쿠키에 전송됩니다.
- Refresh Token은 서버에서 HTTP-only, secure 쿠키로 설정됩니다. 이는 JavaScript가 액세스하는 것을 방지하여 XSS 위험을 완화합니다.
- "Remember Me" 체크박스가 선택된 경우 Refresh Token의 만료 시간은 훨씬 더 길게(예: 30일에서 여러 달) 설정됩니다. 그렇지 않으면 표준 세션 쿠키 또는 더 짧은 기간이 될 수 있습니다.
-
이후 API 요청:
- JavaScript 클라이언트는 모든 보호된 API 요청에 Access Token(예:
Authorization: Bearer <token>
헤더)을 포함합니다.
- JavaScript 클라이언트는 모든 보호된 API 요청에 Access Token(예:
-
Access Token 만료:
- Access Token이 만료되면 이전 토큰으로 API 요청이 실패합니다(예: 401 Unauthorized 상태 코드).
- 클라이언트 측 JavaScript가 이 401 오류를 감지합니다.
-
토큰 새로고침:
- 만료된 Access Token을 감지하면 클라이언트가 서버의 전용 "토큰 새로고침" 엔드포인트로 요청을 보냅니다.
- 이 요청은 HTTP-only Refresh Token 쿠키를 서버로 자동으로 전송합니다.
- 서버가 Refresh Token을 검증합니다:
- 유효성과 만료를 확인합니다.
- 취소되지 않았는지 확인합니다.
- (선택 사항이지만 권장됨) 연관된 CSRF 토큰을 검증합니다.
- 유효한 경우 서버에서 새 Access Token과 잠재적으로 새 Refresh Token(보안 강화를 위한 토큰 로테이션)을 발급합니다.
- 새 Access Token이 클라이언트로 다시 전송됩니다. 새 Refresh Token이 발급되면 HTTP-only 쿠키에서 이전 토큰을 덮어씁니다.
-
원래 요청 다시 시도:
- 새 Access Token으로 클라이언트가 원래 실패한 API 요청을 다시 시도합니다.
코드 예시 (개념적, 프론트엔드 & 백엔드)
간단한 JavaScript(프론트엔드) 및 Node.js(백엔드) 예시를 통해 설명해 보겠습니다.
프론트엔드 (JavaScript - axios
활용)
// Access Token을 위한 간단한 저장소 (실제 앱에서는 더 강력한 상태 관리를 사용) let accessToken = null; // Access Token을 저장하는 함수 const setAccessToken = (token) => { accessToken = token; // 실제 앱에서는 페이지 새로고침 시 즉각적인 액세스를 위해 localStorage에도 저장할 수 있습니다. // localStorage.setItem('accessToken', token); }; // Access Token을 가져오는 함수 const getAccessToken = () => { // return accessToken || localStorage.getItem('accessToken'); return accessToken; }; // 토큰 처리를 위한 인터셉터가 있는 Axios 인스턴스 const api = axios.create({ baseURL: '/api', }); api.interceptors.request.use( (config) => { const token = getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => Promise.reject(error) ); api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; // 오류가 401이고 아직 재시도 중이 아닌 경우 if (error.response.status === 401 && !originalRequest._retry) { originalRequest._retry = true; // 재시도 표시 try { // Refresh Token (HTTP-only 쿠키에 있음)을 사용하여 새 Access Token 요청 const refreshResponse = await axios.post('/api/auth/refresh-token'); setAccessToken(refreshResponse.data.accessToken); // 원래 요청 헤더를 새 토큰으로 업데이트 originalRequest.headers.Authorization = `Bearer ${refreshResponse.data.accessToken}`; // 원래 요청 재시도 return api(originalRequest); } catch (refreshError) { // Refresh Token 실패, 만료되었거나 유효하지 않을 수 있습니다. // 사용자 로그아웃 또는 로그인으로 리디렉션. console.error("Refresh token failed:", refreshError); // 저장된 토큰을 지우고 로그인으로 리디렉션 setAccessToken(null); // localStorage.removeItem('accessToken'); window.location.href = '/login'; return Promise.reject(refreshError); } } return Promise.reject(error); } ); // --- 사용자 로그인 예시 --- async function loginUser(username, password, rememberMe) { try { const response = await axios.post('/api/auth/login', { username, password, rememberMe, // "Remember me" 선호도 전송 }); setAccessToken(response.data.accessToken); // 백엔드에서 Refresh Token을 HTTP-only 쿠키로 설정합니다. console.log("Logged in successfully!"); // 대시보드 또는 홈페이지로 리디렉션 } catch (error) { console.error("Login failed:", error.response?.data?.message || error.message); } } // 예시 사용법: // loginUser('user@example.com', 'password123', true); // api.get('/user/profile').then(res => console.log(res.data));
백엔드 (Node.js with Express & JWT)
const express = require('express'); const jwt = require('jsonwebtoken'); const cookieParser = require('cookie-parser'); // HTTP-only 쿠키 파싱용 const csrf = require('csurf'); // CSRF 보호용 const app = express(); app.use(express.json()); app.use(cookieParser()); const JWT_SECRET = 'your_jwt_secret_key'; // 강력한 환경 변수 사용 const REFRESH_TOKEN_SECRET = 'your_refresh_token_secret_key'; // 강력한 환경 변수 사용 // CSRF 보호 설정, CSRF 토큰 자체는 쿠키로 사용 const csrfProtection = csrf({ cookie: true }); app.use(csrfProtection); // 전역 또는 특정 경로에 적용 // 더미 사용자 데이터 const users = [{ id: 1, username: 'user@example.com', password: 'password123' }]; // 토큰 생성 도우미 const generateTokens = (user, rememberMe) => { const accessToken = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '15m' }); // 짧은 수명 // 'rememberMe'에 따른 Refresh Token 만료 const refreshTokenExpiry = rememberMe ? '30d' : '1h'; // 30일 대 1시간 const refreshToken = jwt.sign({ userId: user.id }, REFRESH_TOKEN_SECRET, { expiresIn: refreshTokenExpiry }); return { accessToken, refreshToken }; }; // Access Token 검증 미들웨어 const authenticateAccessToken = (req, res, next) => { const authHeader = req.headers.authorization; if (!authHeader) return res.status(401).json({ message: 'No access token provided' }); const token = authHeader.split(' ')[1]; if (!token) return res.status(401).json({ message: 'Token format is Bearer <token>' }); jwt.verify(token, JWT_SECRET, (err, user) => { if (err) return res.status(401).json({ message: 'Invalid or expired access token' }); req.user = user; next(); }); }; // --- AUTH ENDPOINTS --- // 로그인 app.post('/api/auth/login', (req, res) => { const { username, password, rememberMe } = req.body; const user = users.find(u => u.username === username && u.password === password); if (!user) { return res.status(401).json({ message: 'Invalid credentials' }); } const { accessToken, refreshToken } = generateTokens(user, rememberMe); // Refresh Token을 HTTP-only secure 쿠키로 설정 res.cookie('refreshToken', refreshToken, { httpOnly: true, // 클라이언트 측 JS에서 액세스 불가 secure: process.env.NODE_ENV === 'production', // 프로덕션에서는 HTTPS로만 전송 sameSite: 'Lax', // CSRF 보호 - 교차 사이트 요청과 함께 전송 방지 maxAge: (rememberMe ? 30 * 24 * 60 * 60 * 1000 : 60 * 60 * 1000), // 30일 또는 1시간 }); // CSRF 토큰을 클라이언트가 액세스할 수 있는 쿠키 또는 헤더에 설정 const csrfToken = req.csrfToken(); res.cookie('XSRF-TOKEN', csrfToken); // 클라이언트 측 액세스용 res.json({ accessToken, csrfToken }); // CSRF 토큰도 응답 본문에 전송 }); // 토큰 새로고침 app.post('/api/auth/refresh-token', csrfProtection, (req, res) => { // CSRF 보호 적용 const refreshToken = req.cookies.refreshToken; if (!refreshToken) { return res.status(401).json({ message: 'No refresh token provided' }); } jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, user) => { if (err) { // 만료/유효하지 않은 Refresh Token 삭제 res.clearCookie('refreshToken'); return res.status(403).json({ message: 'Invalid or expired refresh token' }); } // 토큰 로테이션: 새 Access Token과 함께 새 Refresh Token 발급 const { accessToken, refreshToken: newRefreshToken } = generateTokens(user, true); // Refresh Token 사용 시 'rememberMe'를 true로 가정 res.cookie('refreshToken', newRefreshToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Lax', maxAge: 30 * 24 * 60 * 60 * 1000, // 30일 }); const csrfToken = req.csrfToken(); res.cookie('XSRF-TOKEN', csrfToken); res.json({ accessToken, csrfToken }); }); }); // 로그아웃 (DB에 저장된 경우 Refresh Token 무효화) app.post('/api/auth/logout', (req, res) => { // 실제 앱에서는 Refresh Token을 데이터베이스에서 무효화해야 할 수 있습니다. res.clearCookie('refreshToken'); res.clearCookie('XSRF-TOKEN'); // CSRF 토큰도 삭제 res.status(200).json({ message: 'Logged out successfully' }); }); // --- 보호된 라우트 --- // 보호된 라우트 예시 app.get('/api/user/profile', authenticateAccessToken, (req, res) => { res.json({ message: `Welcome, user ${req.user.userId}! Your profile data here.` }); }); app.listen(3000, () => console.log('Server running on port 3000'));
보안 고려 사항
- Refresh Token용 HTTP-only 쿠키: 이것이 중요합니다. JavaScript가 Refresh Token에 액세스하는 것을 방지하여 공격자가 쿠키를 훔치기 위해 악성 스크립트를 삽입할 수 있는 XSS 공격에 면역되게 합니다.
- 쿠키에 대한
Secure
플래그: 프로덕션 환경에서는secure
플래그를true
로 설정해야 합니다. 이는 쿠키가 HTTPS 연결을 통해서만 전송되도록 하여 도청으로부터 보호합니다. - 쿠키에 대한
SameSite
플래그: Refresh Token 쿠키에SameSite
속성(Lax
또는Strict
등)을 설정하면 브라우저가 교차 사이트 요청과 함께 쿠키를 보내지 않도록 지시하여 CSRF 공격을 완화하는 데 도움이 됩니다. - Refresh Endpoint에 대한 CSRF 보호:
SameSite
만으로는/refresh-token
엔드포인트에 추가 CSRF 보호 기능을 구현하는 것이 좋습니다. 이는 일반적으로 클라이언트가 새로고침 요청과 함께 전송하는 일반 (HTTP-only가 아닌) 쿠키 또는localStorage
에 저장된 CSRF 토큰을 포함하며, 서버가 이를 검증합니다. - 토큰 무효화: Refresh Token을 무효화하는 메커니즘을 구현합니다(예: 데이터베이스에 저장하고 로그아웃 시 또는 계정 침해 시 무효화 표시). 이는 보안 사고 대응에 매우 중요합니다.
- 토큰 로테이션: 각 성공적인 토큰 새로고침 시 새 Refresh Token을 발급하고 이전 토큰을 무효화하는 것은 모범 사례입니다. 공격자가 Refresh Token을 훔치더라도 다음 합법적인 새로고침 작업 후에는 무용지물이 됩니다.
- 속도 제한: 속도 제한을 구현하여 로그인 및 토큰 새로고침 엔드포인트를 무차별 대입 공격으로부터 보호합니다.
- 환경 변수: 민감한 비밀(JWT 비밀 등)을 코드에 직접 하드코딩하지 마십시오. 환경 변수를 사용하세요.
애플리케이션 시나리오
이 Refresh Token 전략은 다음 용도에 이상적입니다.
- 단일 페이지 애플리케이션(SPA): 페이지 전체 새로고침 없이 지속적인 로그인이 필요한 React, Vue, Angular 애플리케이션.
- 모바일 애플리케이션 (하이브리드/PWA): 모바일 사용자에게 원활한 로그인 환경을 제공합니다.
- 장기 인증된 상태를 요구하는 모든 클라이언트 측 애플리케이션과 강력한 보안을 유지합니다.
결론
안전한 "Remember Me" 기능을 구현하는 것은 사소한 작업이 아니지만, Refresh Token을 세심하게 활용하고 강력한 보안 모범 사례를 따르면 사용자 경험과 애플리케이션 보안을 모두 크게 향상시킬 수 있습니다. 수명이 짧은 Access Token과 수명이 길고 HTTP-only인 Refresh Token을 결합하고 토큰 로테이션 및 CSRF 보호와 같은 조치로 강화하는 접근 방식은 JavaScript 애플리케이션의 지속적인 인증을 위한 탄력적이고 업계 표준 솔루션을 제공합니다. 이는 사용자 편의성에 대한 요구와 민감한 사용자 데이터를 보호해야 하는 의무 사이의 효과적인 균형을 이루고 있습니다.