SPA 환경에서 인증 기능을 구현하다 보면, 처음엔 단순해 보이던 로그인 기능이 점점 복잡해집니다.
Access Token은 어디에 저장할지, Refresh Token은 어떻게 갱신하고 보호할지,
보안과 사용자 경험(UX)까지 함께 고려하려면 의외로 많은 고민이 필요합니다.
저도 실제 서비스에서 인증 구조를 직접 설계하고 운영하며 여러 시행착오를 겪었고,
그 과정에서 배운 내용을 바탕으로, 프론트엔드에서 인증 구조를 설계할 때 꼭 짚고 넘어가야 할 주제들을 정리해보았습니다.
보안 구조를 제대로 설계하려면, 인증과 인가를 분리해서 생각하고 설계하는 것부터 시작해야 합니다.
JWT는 인증 정보를 JSON 형태로 담고, 위변조 방지를 위해 서명을 추가한 토큰 기반 인증 방식입니다.
Header.Payload.Signature클레임은 토큰의 주체, 유효기간, 발급 정보, 권한등을 나타내며,
서버는 이 값을 바탕으로 사용자를 식별하거나 토큰의 유효성을 판단합니다.
👉 표준 클레임 목록은 RFC 7519 표준 문서에 정의되어 있습니다.
⚠️ Payload는 Base64URL로 인코딩만 되어 있습니다.
localStorage.setItem('accessToken', token);
localStorage.setItem('refreshToken', refreshToken);localStorage: XSS 공격에 무방비refreshToken도 localStorage에 저장: 재사용 공격 위험Access Token을 memory-only로 저장하면 XSS 대응은 강해지지만
특히 Refresh Token을 HttpOnly Cookie로 저장하는 경우, 도메인이 다르면 브라우저가 쿠키를 전송하지 않아 CORS 문제가 발생할 수 있습니다.
이 문제를 어떻게 해결할 수 있을지 이어서 살펴보겠습니다.
Refresh Token을 HttpOnly Secure Cookie에 저장하면 XSS에는 안전하지만,
도메인이 다르면 브라우저가 쿠키를 전송하지 않아 인증 요청이 실패하는 문제를 초래합니다. 이는 CORS 정책과 관련이 있습니다.
브라우저는 보안 정책상, 아래 세 가지 조건이 모두 충족되지 않으면 쿠키 전송을 차단합니다.
프론트엔드: https://app.my-service.com
백엔드 API: https://api.my-service.comCORS 문제를 아예 없애는 가장 깔끔한 방법은, 프론트엔드와 백엔드를 같은 도메인, 같은 포트로 접근 가능하게 구성하는 것입니다. 이를 위해 로드밸런서에서 경로 기반 라우팅을 활용할 수 있습니다.
protocol + domain + port가 모두 동일해야 함https://example.com/app → 프론트 앱
https://example.com/api/user → API 서버이 구성의 장점
SameSite=Strict 설정도 안전하게 사용 가능어쩔 수 없이 프론트와 백엔드를 다른 도메인으로 구성해야 하는 상황이라면, 다음 조건을 모두 만족시켜야 합니다:
SameSite=None; Secure로 설정Access-Control-Allow-Origin: https://app.my-service.com
Access-Control-Allow-Credentials: true이 경우에도 보안적으로는 주의할 부분이 많고, 쿠키 전송과 관련한 이슈는 브라우저마다 동작이 다를 수 있으니 테스트가 필수입니다.
[요약]
SPA에서 Access Token이 만료되었을 때, 프론트엔드는 가능한 자연스럽게 토큰을 갱신하고 요청을 이어가야 합니다. 하지만 동시에 여러 요청이 실패하면 토큰 갱신을 중복 수행하거나 무한 루프에 빠질 수 있습니다.
_retry 플래그 활용*실제 코드보다 간결하게 작성했습니다.
1let isRefreshing = false;
2let refreshSubscribers: ((token: string) => void)[] = [];
3
4const onRefreshed = (token: string) => {
5 refreshSubscribers.forEach((cb) => cb(token));
6 refreshSubscribers = [];
7};
8
9instance.interceptors.response.use(
10 (res) => res,
11 async (error: AxiosError) => {
12 const original = error.config as RetryableRequest;
13 if (error.response?.status === 401 && !original._retry) {
14 original._retry = true;
15
16 if (isRefreshing) {
17 return new Promise((resolve, reject) => {
18 refreshSubscribers.push((token) => {
19 if (token) {
20 original.headers.Authorization = `Bearer ${token}`;
21 resolve(axios(original));
22 } else {
23 reject(error);
24 }
25 });
26 });
27 }
28
29 isRefreshing = true;
30 try {
31 const { accessToken } = await authService.refresh();
32 onRefreshed(accessToken);
33 isRefreshing = false;
34
35 original.headers.Authorization = `Bearer ${accessToken}`;
36 return axios(original);
37 } catch (refreshError) {
38 onRefreshed('');
39 isRefreshing = false;
40 return Promise.reject(refreshError);
41 }
42 }
43
44 return Promise.reject(error);
45 },
46);장점
프론트엔드 인증 구조는 단순한 로그인 폼 구현을 넘어,
보안, 사용자 경험, 유지보수, 확장성까지 고려해야 하는 설계 문제입니다.
이 글에서 소개한 내용들은 실제 서비스 운영 과정에서 마주했던 이슈들과,
그에 관해 살펴본 내용을 정리한 글입니다.
인증 구조를 고민할 때 결국 다음과 같은 질문으로 돌아오게 되더라고요.
어디에 저장할 것인가? 언제, 어떻게 갱신할 것인가? 어떤 위협을 어떻게 막을 것인가?
이 세 가지에 대한 기준이 어느 정도 정리되어 있다면,
어떤 방식이든 구조를 좀 더 안정적으로 가져갈 수 있을 겁니다.
로그인 기능을 고민 중인 누군가에게,
이 글이 작게나마 도움이 되는 자료가 되었기를 바랍니다.