SPA에서 안전하게 로그인 기능 구현하기 – JWT, 토큰 저장, 보안 전략까지
로그인 기능 구현 시, 어떤 고민들이 있었나요?
SPA 환경에서 인증 기능을 구현하다 보면, 처음엔 단순해 보이던 로그인 기능이 점점 복잡해집니다.
Access Token은 어디에 저장할지, Refresh Token은 어떻게 갱신하고 보호할지
보안과 사용자 경험(UX)까지 함께 고려하려면 의외로 많은 고민이 필요합니다.
저도 실제 서비스에서 인증 구조를 직접 설계하고 운영하며 여러 시행착오를 겪었고,
그 과정에서 배운 내용을 바탕으로, 프론트엔드에서 인증 구조를 설계할 때 꼭 짚고 넘어가야 할 주제들을 정리해보았습니다.
📌 다룰 내용
- 인증(Authentication) vs 인가(Authorization)
- JWT(Json Web Token) 왜 쓰고 어떤 점을 주의해야 할까?
- Access/Refresh Token 어디에 저장해야 안전할까?
- HttpOnly 쿠키가 왜 전송이 안될까? 그리고 해결 전략
- 인증 흐름: 로그인부터 토큰 갱신까지
- Axios로 자동 토큰 갱신, race condition 없이 처리하기
- 마무리
1. 인증 vs 인가 – 개념부터 짚고 가자
개념 | 의미 | 예시 |
인증 (Authentication) | 사용자가 누구인지 확인 | “이 이메일과 비밀번호가 맞는 사람인가?” |
인가 (Authorization) | 사용자가 어떤 권한을 가졌는지 확인 | “이 사용자가 관리자 페이지에 접근할 수 있나?” |
보안 구조를 제대로 설계하려면, 인증과 인가를 분리해서 생각하고 설계하는 것부터 시작해야 합니다.
2. JWT(Json Web Token) 왜 쓰고 어떤 점을 주의해야 할까?
JWT란?
JWT는 인증 정보를 JSON 형태로 담고, 위변조 방지를 위해 서명을 추가한 토큰 기반 인증 방식입니다.
Header.Payload.Signature
구성 요소 | 설명 |
Header | 서명 알고리즘, 토큰 타입 (예: RS256 ) |
Payload | 사용자 및 토큰 정보 (예: sub , exp , aud ) |
Signature | 위조 방지를 위한 서명 |
Payload에는 다음과 같은 클레임(Claims)이 포함됩니다.
클레임은 토큰의 주체, 유효기간, 발급 정보, 권한등을 나타내며,
서버는 이 값을 바탕으로 사용자를 식별하거나 토큰의 유효성을 판단합니다.
👉 표준 클레임 목록은 RFC 7519 표준 문서에 정의되어 있습니다.
필드 | 설명 |
sub | 사용자 ID (주체 식별) |
exp , nbf , iat | 유효 시간 관련 정보 |
aud , iss | 발급자/대상자 검증용 |
jti | 토큰 고유 ID (재사용 방지) |
role , permissions | 인가 관련 정보 (선택) |
⚠️ Payload는 Base64URL로 인코딩만 되어 있습니다.즉, 누구나 내용은 읽을 수 있으므로 민감한 정보는 절대 넣지 마세요.
장점과 단점
장점 | 단점 |
서버 세션 없이 상태 관리 가능 | 탈취되면 만료 전까지 유효 (취소 불가) |
다양한 플랫폼 간 통신에 적합 | 토큰 크기 큼 (Payload 포함) |
RESTful한 설계에 잘 어울림 | Payload는 암호화되지 않아 내용 노출 가능 |
서명(Signature) 알고리즘
알고리즘 | 방식 | 특징 | 추천 상황 |
HS256 | 대칭키 | 단순, 빠름 | 내부 시스템 |
RS256 | 비대칭키 | 공개키로 검증 가능 | 외부 서비스 연동, 다중 서비스 구조 |
3. Access/Refresh Token 어디에 저장해야 안전할까?
흔한 안티패턴
localStorage.setItem('accessToken', token); localStorage.setItem('refreshToken', refreshToken);
localStorage
: XSS 공격에 무방비
refreshToken
도 localStorage에 저장: 재사용 공격 위험
추천 전략
토큰 | 저장 위치 | 특징 |
Access Token | memory-only or localStorage | 수명 짧고, 요청에만 사용 |
Refresh Token | HttpOnly Secure 쿠키 | JS 접근 불가, 자동 전송, XSS 방어 |
Access Token을 memory-only로 저장하면 XSS 대응은 강해지지만페이지 새로고침 시 상태 초기화 문제도 함께 고려해야 합니다.
특히 Refresh Token을 HttpOnly Cookie로 저장하는 경우, 도메인이 다르면 브라우저가 쿠키를 전송하지 않아 CORS 문제가 발생할 수 있습니다.
이 문제를 어떻게 해결할 수 있을지 이어서 살펴보겠습니다.
4. HttpOnly 쿠키가 왜 전송이 안될까? 그리고 해결 전략
Refresh Token을
HttpOnly Secure Cookie
에 저장하면 XSS에는 안전하지만,도메인이 다르면 브라우저가 쿠키를 전송하지 않아 인증 요청이 실패하는 문제를 초래합니다. 이는 CORS 정책과 관련이 있습니다.
😵 왜 쿠키가 전송되지 않을까?
브라우저는 보안 정책상, 아래 세 가지 조건이 모두 충족되지 않으면 쿠키 전송을 차단합니다.
조건 | 설명 |
같은 Origin | 프로토콜, 도메인, 포트까지 모두 동일해야 함 |
SameSite 정책 | SameSite=Strict 인 경우 Cross-Origin 요청에는 쿠키 전송 안 됨 |
📛 예시: 서로 다른 Origin일 때 생기는 문제
프론트엔드: https://app.my-service.com 백엔드 API: https://api.my-service.com
- 브라우저는 위 둘을 서로 다른 Origin으로 인식
- 이 상태에서 인증 요청을 보내면
SameSite=Strict
일 경우 → 쿠키 전송 차단SameSite=None; Secure
이더라도 → 서버가 CORS 설정을 하지 않았다면 401 에러
해결 전략: 브라우저가 같은 Origin처럼 인식하게 만들자
CORS 문제를 아예 없애는 가장 깔끔한 방법은, 프론트엔드와 백엔드를 같은 도메인, 같은 포트로 접근 가능하게 구성하는 것입니다. 이를 위해 로드밸런서에서 경로 기반 라우팅을 활용할 수 있습니다.
브라우저 관점에서 "같은 Origin"이란?
protocol + domain + port
가 모두 동일해야 함
예: ALB(Application Load Balancer)로 경로 기반 분기
요청 경로 | 포워딩 대상 |
/api/* | 백엔드 API 서버 |
/ , /app/* | 프론트엔드 서버 (Next.js 등) |
https://example.com/app → 프론트 앱 https://example.com/api/user → API 서버
이 구성의 장점
- 브라우저 입장에서 동일 Origin → 쿠키 자동 전송 가능
SameSite=Strict
설정도 안전하게 사용 가능
- CORS 설정 자체가 필요 없어짐
만약 도메인을 분리해야 한다면?
어쩔 수 없이 프론트와 백엔드를 다른 도메인으로 구성해야 하는 상황이라면, 다음 조건을 모두 만족시켜야 합니다:
- 쿠키는
SameSite=None; Secure
로 설정
- 백엔드 서버에서 아래와 같은 CORS 헤더를 응답에 포함
Access-Control-Allow-Origin: https://app.my-service.com Access-Control-Allow-Credentials: true
이 경우에도 보안적으로는 주의할 부분이 많고, 쿠키 전송과 관련한 이슈는 브라우저마다 동작이 다를 수 있으니 테스트가 필수입니다.
[요약]
- 가능한 한 동일 Origin 구조로 통합해서 문제 자체를 없애는 게 가장 안정적
- 프록시, ALB, nginx 등에서 경로 기반 라우팅을 활용
- 다른 도메인을 쓸 경우에는 CORS 정책을 제대로 설정하고, 브라우저 호환성을 꼭 테스트
5. 인증 흐름: 로그인부터 토큰 갱신까지
sequenceDiagram participant Client participant API_Server participant Auth_Server Client->>API_Server: [1] 요청 (Access Token 포함) API_Server-->>Client: [2] 401 Unauthorized Client->>API_Server: [3] Refresh Token으로 갱신 요청 API_Server->>Auth_Server: [4] Refresh Token 검증 Auth_Server-->>API_Server: [5] 새 Access Token 발급 API_Server-->>Client: [6] 토큰 전달 Client->>API_Server: [7] 재요청 (Access Token 포함) API_Server-->>Client: [8] 정상 응답
6. Axios로 자동 토큰 갱신, race condition 없이 처리하기
SPA에서 Access Token이 만료되었을 때, 프론트엔드는 가능한 자연스럽게 토큰을 갱신하고 요청을 이어가야 합니다. 하지만 동시에 여러 요청이 실패하면 토큰 갱신을 중복 수행하거나 무한 루프에 빠질 수 있습니다.
요구사항
- Access Token 만료 시 → 자동으로 Refresh API 요청
- 동시에 여러 요청이 401을 받아도 → 단 한 번만 갱신
- 갱신 실패 시 → 모든 대기 요청 실패 처리
- 무한 루프 방지 →
_retry
플래그 활용
구현 코드
*실제 코드보다 간결하게 작성했습니다.
let isRefreshing = false; let refreshSubscribers: ((token: string) => void)[] = []; const onRefreshed = (token: string) => { refreshSubscribers.forEach((cb) => cb(token)); refreshSubscribers = []; }; instance.interceptors.response.use( (res) => res, async (error: AxiosError) => { const original = error.config as RetryableRequest; if (error.response?.status === 401 && !original._retry) { original._retry = true; if (isRefreshing) { return new Promise((resolve, reject) => { refreshSubscribers.push((token) => { if (token) { original.headers.Authorization = `Bearer ${token}`; resolve(axios(original)); } else { reject(error); } }); }); } isRefreshing = true; try { const { accessToken } = await authService.refresh(); onRefreshed(accessToken); isRefreshing = false; original.headers.Authorization = `Bearer ${accessToken}`; return axios(original); } catch (refreshError) { onRefreshed(''); isRefreshing = false; return Promise.reject(refreshError); } } return Promise.reject(error); }, );
패턴 | 설명 |
Observer 패턴 | 갱신 중인 상태에서 요청들이 구독자로 등록되어, 새 토큰 발급 시 일괄 재시도 |
Fail Fast | 갱신 실패 시, 모든 대기 요청에 실패 알림 전달 |
Retry 제한 | _retry 플래그로 무한 요청 루프 차단 |
장점
- 토큰 만료 시 사용자 입장에서 끊김 없는 UX 제공
- 서버에 중복 refresh 요청을 보내지 않음
- 갱신 실패 → 한 번에 로그인 만료 처리 가능 (예: 토스트 안내, 리디렉션 등)
🔚 마무리하며
프론트엔드 인증 구조는 단순한 로그인 폼 구현을 넘어,
보안, 사용자 경험, 유지보수, 확장성까지 고려해야 하는 설계 문제입니다.
이 글에서 소개한 내용들은 실제 서비스 운영 과정에서 마주했던 이슈들과,
그에 관해 살펴본 내용을 정리한 글입니다.
인증 구조를 고민할 때 결국 다음과 같은 질문으로 돌아오게 되더라고요.
어디에 저장할 것인가? 언제, 어떻게 갱신할 것인가? 어떤 위협을 어떻게 막을 것인가?
이 세 가지에 대한 기준이 어느 정도 정리되어 있다면,
어떤 방식이든 구조를 좀 더 안정적으로 가져갈 수 있을 겁니다.
로그인 기능을 고민 중인 누군가에게,
이 글이 작게나마 도움이 되는 자료가 되었기를 바랍니다.