웹과 앱을 모두 고려한 현실적인 인증 전략 (fetch 기반 구현)
로그인 이후 토큰을 어디에 저장할 것인가는 단순한 구현 문제가 아닙니다.
이 선택은 보안 모델, 사용자 경험(UX), 그리고 웹과 앱을 함께 운영할 수 있는지 여부까지 결정합니다.
이번 글에서는 우리가 최종적으로 선택한 인증 구조인 Refresh Token은 HttpOnly 쿠키, Access Token은 메모리 라는 전략을, 프론트엔드(fetch 기반) 구현과 앱(WebView) 대응 관점까지 포함해 정리합니다.
1. 왜 다시 토큰 저장 전략을 고민했을까?
전통적으로 많이 사용되던 방식은 다음과 같습니다.
- Access Token → 쿠키
- Refresh Token → 쿠키
구현은 간단하지만, 실무에서는 다음과 같은 문제가 반복해서 드러났습니다.
- Access Token이 쿠키에 있으면 CSRF 공격 대상
- LocalStorage / SessionStorage는 XSS에 취약
- 인증 상태 제어가 어려움
- WebView / 모바일 앱 환경과의 궁합이 애매함
결국 질문은 이렇게 바뀌었습니다.
“웹뿐 아니라 앱까지 함께 가져갈 수 있는 인증 구조는 무엇일까?”
2. 우리가 선택한 구조
최종적으로 선택한 구조는 아래와 같습니다.
| 구분 | Refresh Token | Access Token |
|---|---|---|
| 저장 위치 | HttpOnly + Secure Cookie | 메모리 |
| JS 접근 | 불가 | 가능 |
| XSS 노출 | 매우 낮음 | 낮음 |
| CSRF 영향 | 있음 | 없음 |
| 수명 | 김 | 짧음 |
| 브라우저 종료 | 유지 | 소멸 |
핵심은 역할 분리입니다.
- Refresh Token → 유출 시 피해가 크므로 최대한 보호
- Access Token → 짧은 생명, 빠른 폐기
3. 이 구조가 웹 + 앱에 적합한 이유
1) WebView에서도 자연스럽게 동작한다
- Refresh Token은 쿠키 기반 → WebView / 모바일 환경에서도 OS 레벨로 안전하게 관리
- Access Token은 메모리 → 로컬 저장소에 남지 않음
2) Native App 확장에도 유리하다
Access Token을 “메모리에서만 관리한다”는 개념은 모두에서 동일하게 적용할 수 있습니다. 플랫폼이 바뀌어도 인증 모델 자체를 다시 설계할 필요가 없습니다.
3) 인증 실패 UX를 명확하게 만들 수 있다
- Refresh 성공 → 세션 유지
- Refresh 실패 → 세션 종료
이 기준이 명확해지면서, 사용자에게도
“세션이 만료되었습니다. 다시 로그인해 주세요.”
라는 일관된 UX를 제공할 수 있습니다.
4. 프론트엔드 구현 원칙 (fetch 기반)
이번 구조에서는 axios interceptor를 사용하지 않고,
fetch 기반 인증 게이트웨이 함수 하나로 모든 인증 흐름을 통합했습니다.
핵심 원칙
- Access Token은 메모리 전용
- Authorization 헤더는 요청 시점에만 주입
- 401 발생 시 Refresh → 재시도
- Refresh 중복 호출 방지
5. Access Token은 메모리에만 저장한다
// authTokenStore.ts
let accessToken: string | null = null;
export const getAccessToken = () => accessToken;
export const setAccessToken = (token: string | null) => {
accessToken = token;
};
export const clearAccessToken = () => {
accessToken = null;
};
- localStorage ❌
- sessionStorage ❌
- cookie ❌
브라우저 탭 종료 시 자동으로 초기화되며,
공격자가 훔쳐갈 “영속 데이터” 자체가 남지 않습니다.
6. fetch 기반 인증 게이트웨이
Authorization 헤더 적용
function applyAuthorization(
init: RequestInit,
accessToken: string | null,
): RequestInit {
const headers = new Headers(init.headers ?? undefined);
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
return {
...init,
headers,
credentials: init.credentials ?? 'include', // Refresh 쿠키 전송
};
}
7. Refresh Token 자동 재발급 (중복 방지 포함)
중요한 포인트
- 동시에 여러 요청이 401을 받아도 Refresh는 한 번만
- Refresh 요청은 다시 Refresh하지 않도록 보호
- Refresh 실패 시 세션 종료로 판단
let refreshingPromise: Promise<string | null> | null = null;
export async function apiFetch(
input: RequestInfo | URL,
init: ApiRequestInit = {},
): Promise<Response> {
const { skipAuth = false, skipRefresh = false, ...requestInit } = init;
const accessToken = skipAuth ? null : getAccessToken();
const response = await fetch(
input,
applyAuthorization(requestInit, accessToken),
);
if (response.status !== 401 || skipRefresh) {
return response;
}
if (!refreshingPromise) {
refreshingPromise = refreshAccessToken({ skipRefresh: true })
.then((newToken) => {
newToken ? setAccessToken(newToken) : clearAccessToken();
return newToken;
})
.finally(() => {
refreshingPromise = null;
});
}
const refreshedToken = await refreshingPromise;
if (!refreshedToken) return response;
return fetch(input, applyAuthorization(requestInit, refreshedToken));
}
이 구조는 axios interceptor와 동일한 안정성을 제공합니다.
8. 새로고침 시 인증 상태 복구
메모리는 새로고침 시 초기화되므로,
앱 부팅 시 Refresh를 한 번 호출합니다.
export async function bootstrapAuth(): Promise<boolean> {
const token = await refreshAccessToken({
skipAuth: true,
skipRefresh: true,
});
if (!token) return false;
setAccessToken(token);
return true;
}
- 성공 → 정상 진입
- 실패 → 로그인 화면 이동
9. CSRF는 어디서만 신경 쓰면 될까?
이 구조의 장점은 CSRF 방어 범위가 매우 좁아진다는 것입니다.
- Access API → Authorization Header → CSRF 대상 아님
- Refresh API → 쿠키 사용 → CSRF 방어 적용
필요하다면 Double Submit Cookie 전략을 사용할 수 있습니다.
fetch('/api/v1/users/refresh', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': getCookie('csrfToken'),
},
credentials: 'include',
});
10. 트레이드오프
단점
- 새로고침 시 Access Token 소멸
- 초기 로딩 시 Refresh 요청 필요
얻는 것
- 더 작은 공격 표면
- 더 명확한 책임 분리
- 웹과 앱을 모두 아우르는 인증 모델
11. 마무리
중요한 것은 “토큰을 어디에 저장했는가”가 아니라 “무엇을 얼마나 오래 신뢰하는가”다.
Refresh Token은 보호하고 Access Token은 빠르게 쓰고 버린다
이 전략은 단순히 웹을 위한 것이 아니라, 웹 + 앱(WebView / 모바일)을 함께 운영하는 팀에게 가장 현실적인 선택지다.