(1) 이해
1) 토큰 기반 인증
토큰(Token)은 사용자의 인증 정보를 암호화하여 만든 문자열이다. 이러한 문자열을 사용자 인증 과정에 활용하여 사용자의 신원을 확인하고 권한을 부여하는 방식을 토큰 기반 인증이라고 한다.
토큰 기반 인증시에 사용자가 로그인 정보를 서버에 제출하면, 서버는 이를 확인하고 토큰을 생성해 클라이언트로 전송한다. 클라이언트는 이 토큰을 저장해서 이후 모든 요청에 토큰을 포함시키고, 서버는 요청과 함께 온 토큰을 검증하여 사용자를 인증하게 된다.
토큰에는 일반적으로 사용자 ID, 권한 정보, 만료 시간 등의 정보가 포함되어 있다. 이 정보들은 서버에서 발급시에 비밀 키로 암호화하여 안전하게 보호된다. 이러한 구성은 토큰이 많은 정보를 담고, 다양한 설정 옵션을 가질 수 있게 하여 사용성을 높인다.
토큰 기반 인증에서 서버가 토큰을 발급한 뒤에는 클라이언트에 토큰을 저장하므로 서버 측에서는 상태를 저장하지 않는다(stateless). 때문에 세션 저장소를 사용하지 않으므로 서버의 부담이 적고, 여러 서비스 간 인증 공유가 쉬워 확장성이 좋다는 장점이 있다. 반면, 토큰의 크기로 인한 네트워크 부하가 증가할 수 있고, 토큰이 탈취될 경우 즉각적인 무효화가 어렵다는 위험성도 따른다. 따라서 토큰 기반 인증을 사용할 때에는 네트워크 비용에 대한 고려와 함께 클라이언트 측에서 토큰을 안전하게 저장하고 관리하는 등의 섬세한 보안 처리가 필요하다.
2) 세션 기반 인증과의 비교
세션 기반 인증은 서버에 세션 정보를 저장해야 하므로 서버의 메모리 부담이 크고, 여러 서버 간 세션 정보 공유가 어려워 확장성에 제약이 있다는 점 등의 한계점을 가지고 있다. 또한, 세션 ID를 쿠키에 저장하는 방식은 CSRF(Cross-Site Request Forgery) 공격에 취약할 수 있다. 세션 기반 인증의 이러한 특징들이 문제가 되는 경우에는 토큰 기반 인증이 좋은 대안이 될 수 있다.
반대로 사용자의 접근 권한을 실시간으로 변경해야 하는 관리자 시스템이나, 보안상 중요한 금융 거래 시스템에서는 서버에서 직접 세션을 제어하고 추적할 수 있는 세션 기반 인증이 더 적합할 수 있다. 또한, 단일 도메인에서 운영되는 전통적인 웹 애플리케이션의 경우, 세션 기반 인증의 구현이 더 간단하고 직관적일 수 있다.
특징 | 세션 기반 인증 | 토큰 기반 인증 |
1. 상태 저장 방식 | 서버가 세션 정보를 저장, 관리(stateful) | 클라이언트가 토큰 관리
서버는 상태 저장 하지 않음(stateless) |
2. 확장성 | 나쁨. 여러 서버 간 세션 정보 공유 필요 | 좋음. 서버 간 상태 공유 필요 없음 |
3. 리소스 사용 | 큼. 서버에 세션 저장소 필요 | 작음. 그러나 네트워크 트래픽이 증가할 수 있음 |
4. 보안 | 즉각적인 세션 무효화 가능.
서버에서 세션 제어권을 가짐 | 발급된 토큰은 즉각적인 무효화 불가.
그러나 짧은 만료 시간 설정으로 보완 가능 |
5. 클라이언트 구현 | 간단함 | 복잡함. 토큰 저장, 관리 관련 추가 구현 필요 |
6. 크로스 도메인 지원 | 기본적으로 같은 도메인 내에서만 동작함.
교차 사이트에서 사용 시 추가 설정이 필요 | 도메인에 구애받지 않음. 여러 서비스 간 인증 공유 용이 |
현대의 웹 개발 환경에서는 프로젝트의 요구사항에 따라 세션 기반 인증과 토큰 기반 인증 방식이 모두 사용된다. 세션 기반 인증은 세션 제어가 필요한 비교적 전통적인 웹 애플리케이션에서 강점을 발휘하며, 토큰 기반 인증은 모바일 애플리케이션, SPA(Single Page Application), 마이크로서비스 아키텍처 등 현대적인 웹 환경에서 주로 선택이 된다.
3) JWT(JSON Web Token)
현재 토큰 기반 인증에서 범용적으로 사용되는 토큰은 JWT이다. JWT(JSON Web Token)는 JSON 형식으로 데이터를 전송하기 위해 표준화된 방식의 토큰이다. 이 데이터는 디지털 서명과 함께 암호화되어 있어 신뢰할 수 있다.
JWT는 세 부분으로 구성되며, 각 부분은 점(.)으로 구분되어
[헤더].[페이로드].[서명]
의 구조를 가진다. JWT의 구조


- 헤더(Header): 알고리즘(alg)과 토큰 타입(typ) 정보가 Base64Url로 인코딩된 부분
- 페이로드(Payload): 사용자 정보와 토큰 관련 정보가 Base64Url로 인코딩된 부분
- 서명(Signature): 토큰의 위변조 여부를 검증하기 위한 문자열
JWT의 장점은 자체적으로 필요한 모든 정보를 담고 있어 별도의 조회 과정이 필요 없다는 것이다(stateless). 그러나 이로 인해 토큰의 크기가 커질 수 있고, 한번 발급된 토큰은 만료되기 전까지 무효화하기 어렵다는 단점도 있다.
(2) 프론트엔드의 토큰 관리와 보안
토큰 기반 인증에서 토큰은 클라이언트 측에 보관되므로 인증 권한도 클라이언트에 완전히 위임된다. 따라서 프론트엔드 개발자는 토큰 관리의 보안적 측면을 고려하고, 토큰의 저장과 전송, 만료와 갱신, 로그아웃 처리를 구현해야 한다.
1) 토큰 저장과 갱신
단일 토큰은 가장 단순한 형태의 토큰 기반 인증이다. 사용자가 로그인하면 서버는 하나의 토큰을 발급하고, 이후의 모든 인증된 요청에 이 토큰이 사용된다. 이 방식은 구현이 간단하고 직관적이지만, 보안 측면에서 약점을 가질 수 있다. 토큰의 유효 기간을 짧게 설정하면 사용자는 자주 로그인해야 하는 불편함을 겪게 되고, 반대로 길게 설정하면 토큰이 탈취될 경우 오랜 시간 동안 공격자가 시스템에 접근할 수 있는 위험이 있다.
그래서 더 보편적으로 사용되는 것이 OAuth 2.0에서 영향을 받은 접근-갱신 토큰 방식¹⁾이다. 이 방식은 유효 기간이 짧은 접근 토큰과 유효 기간이 긴 갱신 토큰을 함께 사용한다. 접근 토큰이 만료되면 애플리케이션은 갱신 토큰을 사용하여 새로운 접근 토큰을 얻는다. 이 과정은 사용자의 개입 없이 백그라운드에서 이루어지므로, 사용자 경험을 해치지 않으면서도 보안을 강화할 수 있다.
2) 토큰 전송
클라이언트는 로그인시 전달 받은 토큰을 저장해두고, 인증이 필요한 요청마다 서버에 토큰을 전송한다. 전송은 일반적으로 HTTP 요청의 Authorization 헤더 또는 쿠키를 통해 이루어진다.
- Authorization 헤더 사용
Authorization 헤더는
Authorization: Bearer <token>
형태로, 인증 타입은 Bearer
이고, <token>
은 실제 토큰 문자열을 나타낸다.fetch('https://api.example.com', { headers: { 'Authorization': 'Bearer ' + token } })
- 쿠키 사용
쿠키는 서버에서 설정하면 브라우저가 자동으로 관리하므로, 클라이언트에서 별도의 코드가 필요하지 않다.
토큰을 전송하면 서버는 Authorization 헤더나 쿠키에서 토큰 값을 추출한다. 그리고 추출한 토큰의 유효성을 검증하는데, JWT의 경우 서명을 확인한다. 토큰이 유효하다면 요청을 정상적으로 처리하지만, 토큰이 유효하지 않거나 만료되었다면 401 Unauthorized 응답을 반환한다.
3) 토큰 삭제
서비스에서 사용자가 로그아웃 시에는 클라이언트에 저장된 토큰을 제거해야 한다. 프론트엔드에서는 로그아웃 후 인증이 필요한 페이지로의 접근을 차단하고, 로그인 페이지로 리디렉트하는 등의 처리가 필요하다.
(3) 실습
1) 토큰 기반 인증 구현시 고려사항
- 로그인 구현시 고려 사항
- 사용자 입력은 서버로 보내기 전 클라이언트 측에서 우선 검증한다.
- 공용 컴퓨터에서의 보안을 위해
autocomplete="off"
로 자동 완성을 방지한다.
- 로그아웃 구현시 고려 사항
- 클라이언트에서 로그아웃 요청을 보내 서버 측에서 갱신 토큰을 무효화한다.
- 로그아웃 요청 성공시 로컬에 저장된 토큰 관련 모든 데이터를 삭제한다.
- 로그아웃 후 사용자를 안전한 페이지로 리디렉트하는 방법도 권장된다.
- 토큰 갱신 구현시 고려 사항
- 접근 토큰-갱신 토큰 사용으로 토큰 탈취 시의 위험을 줄인다.
- 토큰 갱신은 백그라운드에서 이루어지며, 갱신 실패 시 로그아웃 처리한다.
2) 프론트엔드 예시 코드
아래 코드는 HTML과 JavaScript를 사용하여 위 고려 사항을 충족하는 토큰 기반 인증을 구현한 예시이다. 접근-갱신 토큰 방법을 사용하며, 두 토큰을 쿠키에 저장하고, 인터셉터를 사용하여 토큰 갱신을 자동화한다. HTTP 통신에는 Axios를 사용하며, 백엔드 API로
/login
, /logout
, /refresh-token
, /user
이 구현되어 있다고 가정한다.<!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>토큰 기반 인증 예시</title> <script src="https://unpkg.com/axios/dist/axios.min.js"></script> </head> <body> <div id="auth-container"> <form id="login-form"> <input type="text" id="username" placeholder="username" autocomplete="off" required /> <input type="password" id="password" placeholder="password" autocomplete="off" required /> <button type="submit">로그인</button> </form> </div> <div id="user-info" style="display: none"> <p>안녕, <span id="user-name"></span>!</p> <button id="logout-btn">로그아웃</button> </div> <script src="app.js"></script> </body> </html>
// app.js const API_URL = "https://api.example.com"; // DOM 요소 참조 const authContainer = document.getElementById("auth-container"); const loginForm = document.getElementById("login-form"); const userInfo = document.getElementById("user-info"); const userNameSpan = document.getElementById("user-name"); const logoutBtn = document.getElementById("logout-btn"); // Axios 설정 axios.defaults.withCredentials = true; // 모든 요청에 쿠키 포함 axios.interceptors.response.use( // 인터셉터로 백그라운드 토큰 갱신 (response) => response, async (error) => { const originalRequest = error.config; if (error.response.status === 401 && !originalRequest._retry) { // 인증 에러시 originalRequest._retry = true; try { await refreshAccessToken(); // 접근 토큰 갱신 return axios(originalRequest); // 새 접근 토큰으로 요청 재전달 } catch (refreshError) { console.error("토큰 갱신 실패:", refreshError); logout(); return Promise.reject(refreshError); } } return Promise.reject(error); } ); // 로그인 처리 async function login(username, password) { try { const response = await axios.post(`${API_URL}/login`, { username, password }); // 서버로 로그인 요청 showUserInfo(response.data.username); // UI 업데이트 } catch (error) { console.error("로그인 실패:", error); alert("로그인에 실패했습니다. 사용자 이름과 비밀번호를 확인해주세요."); } } // 로그아웃 처리 async function logout() { try { await axios.post(`${API_URL}/logout`); // 서버로 로그아웃 요청 showLoginForm(); // UI 업데이트 window.location.href = '/login.html'; // 안전한 페이지로 리디렉트 } catch (error) { console.error("로그아웃 실패:", error); } } // 접근 토큰 갱신 async function refreshAccessToken() { try { await axios.post(`${API_URL}/refresh-token`); // 새 접근 토큰이 쿠키로 전달됨 } catch (error) { console.error("토큰 갱신 실패:", error); throw error; } } // UI 업데이트: 사용자 정보 표시 function showUserInfo(username) { authContainer.style.display = "none"; userInfo.style.display = "block"; userNameSpan.textContent = username; } // UI 업데이트: 로그인 폼 표시 function showLoginForm() { authContainer.style.display = "block"; userInfo.style.display = "none"; loginForm.reset(); } // 입력 값 검증 function validateInput(username, password) { if (username.length < 3) { alert("사용자 이름은 3자 이상이어야 합니다."); return false; } if (password.length < 8) { alert("비밀번호는 8자 이상이어야 합니다."); return false; } return true; } // 이벤트 리스너 loginForm.addEventListener("submit", async (e) => { e.preventDefault(); const username = document.getElementById("username").value; const password = document.getElementById("password").value; if (validateInput(username, password)) { await login(username, password); } }); logoutBtn.addEventListener("click", logout); // 초기 상태 확인 async function checkInitialState() { try { const response = await axios.get(`${API_URL}/user`); showUserInfo(response.data.username); } catch (error) { showLoginForm(); } } checkInitialState();