📌로그인에 새 생명을! Refresh Token Rotation(RTR) 도입기
서비스에서 가장 중요한 기능 중 하나는 로그인이다.
사용자 인증은 당연히 돼야 하고 로그인 상태가 유지되어야 하며 로그인 만료 시 사용자 경험이 깨지면 안 된다.
그런데 우리 서비스에서는 JWT 기반 로그인을 하고 있음에도 불구하고 로그인이 일정 시간 후에 자동으로 끊기는 문제가 있었다.
처음엔 단순히 유효기간을 늘리면 될 줄 알았지만… 그건 근본적인 해결이 아니었다...
그래서 이번에 아예 리프레시 토큰을 기반으로 한 ‘RTR(Refresh Token Rotation)’ 구조를 도입하게 되었다.
기존 문제 상황: 로그인 만료 → 다시 로그인
처음에는 로그인 시 서버로부터 다음과 같은 응답을 받았습니다.
응답 본문
{
"code": "SU",
"message": "Success.",
"token": "eyJhbGciOiJIUzI1NiJ9...",
"expirationTime": 3600
}
그리고 프론트에선 이렇게 처리했다.
로그인 처리 코드
const signInResponse = (responseBody) => {
const { code, token, expirationTime } = responseBody;
if (code === 'SU' && token && expirationTime) {
const now = new Date().getTime();
const expires = new Date(now + expirationTime * 1000);
setCookie('accessToken', token, { expires, path: '/' });
onLogin(true);
window.location.reload();
}
};
이게 무슨 문제냐면...
이 구조는 액세스 토큰만 존재하고 토큰 유효 시간이 끝나면 로그인이 바로 만료된다.
사용자 입장에선 페이지 새로고침하다가 갑자기 튕기는 경험을 하게 되죠.
"어라, 방금 로그인했는데 왜 다시 로그인하라는 거야..?"
그래서 도입한 RTR(Refresh Token Rotation)이란?
RTR은 리프레시 토큰을 주기적으로 갱신해주는 구조이다.
JWT의 짧은 유효시간을 유지하면서도 리프레시 토큰을 통해 백그라운드에서 조용히 로그인 세션을 연장해주는 방식으로 볼 수 있다.
구체적으로는
- 로그인 시 서버는 액세스 토큰과 함께 리프레시 토큰을 쿠키에 심어준다.
- 사용자가 API를 호출하다가 액세스 토큰이 만료되면,
- 프론트는 리프레시 토큰을 활용해 새로운 액세스 토큰을 자동으로 발급받는다.
- 동시에 새로운 리프레시 토큰도 쿠키로 내려받고 기존 토큰을 회전(Rotation) 한다.
즉, 유효시간이 짧은 액세스 토큰은 계속 바뀌고 리프레시 토큰도 한 번 쓰고 버리는 구조이다.
⭐리프레시 토큰이 추가된 로그인 응답 구조⭐
🤔 응답 구조의 변화 그리고 쿠키에 담긴 고민
리프레시 토큰을 도입하면서 응답 구조도 이전과는 완전히 달라졌다.
기존에는 로그인 성공 시 accessToken 하나만 응답 본문에 담겨 왔고
프론트에서는 그걸 꺼내서 localStorage나 cookie에 저장하는 단순한 방식이었다.
하지만 이제는 다르다.
응답 본문
{
"code": "SU",
"message": "Success.",
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"expirationTime": 3600
}
응답 헤더
Set-Cookie: refresh_token=ad60ae5c-f821-...; HttpOnly; Secure; Path=/;
👀 이전과 다른 점은?
- accessToken은 그대로 본문에 내려오지만,
- refreshToken은 응답 헤더에 쿠키로 담겨 온다.
그리고 이때 쿠키에 설정된 옵션이 중요하다.
🔐 HttpOnly, Secure란?
옵션 설명
HttpOnly | 자바스크립트로 쿠키에 접근할 수 없도록 막음 → XSS 방어에 탁월 |
Secure | HTTPS에서만 쿠키 전송 가능 → 중간자 공격(MITM) 방지 |
Path=/ | 사이트 전역에서 쿠키 사용 가능 |
즉, 리프레시 토큰은 보안적으로 "절대 사용자가 직접 만질 수 없는 쿠키"로 만들어졌고
브라우저는 이후 모든 요청마다 자동으로 이 쿠키를 서버에 전송하게 된다.
❓왜 이렇게 했을까?
우리가 refreshToken을 굳이 HttpOnly Cookie로 설정한 이유는 단순하다.
- JS에서 접근할 수 없으니 해킹에 안전하다.
→ localStorage에 저장하면 XSS에 취약하다. - axios 요청 시 자동 전송된다.
→ withCredentials: true만 설정하면 별도 로직 없이도 리프레시 토큰이 서버로 전송된다. - 로그인 연장 로직이 단순해진다.
→ 토큰 재발급 API에서도 refreshToken을 따로 다루지 않아도 된다.
결과적으로, 이 구조 덕분에
사용자는 리프레시 토큰의 존재를 인식하지 않아도 되고,
우리는 좀 더 안전하고 자동화된 로그인 유지 구조를 만들 수 있게 된 거다.
💡 참고로!
리프레시 토큰이 자동 쿠키로 처리되는 대신, accessToken은 여전히 우리가 직접 꺼내서 헤더에 넣어줘야 한다.
그래서 accessToken은 보통 일반 쿠키나 메모리 혹은 localStorage 등에 저장해두고 API 요청 시 수동으로 붙이는 방식이 대부분이다.
개선된 로그인 처리 코드
export const signInRequest = async (requestBody, setCookie) => {
try {
// 로그인 API 호출 (POST) - 사용자 정보와 함께 요청
const response = await axios.post(SIGN_IN_URL(), requestBody, {
withCredentials: true, // 쿠키를 포함한 요청 허용 (리프레시 토큰 수신용)
headers: { "Content-Type": "application/json" }, // 요청 본문은 JSON 형식
});
// 응답 본문에서 액세스 토큰 추출
const accessToken = response.data.accessToken;
// 응답 헤더에서 리프레시 토큰 추출
const refreshToken = response.headers['refresh-token'];
// 액세스 토큰을 쿠키에 저장 (만료 시간 설정)
setCookie('accessToken', accessToken, {
path: '/', // 전체 경로에 대해 쿠키 유효
expires: new Date(Date.now() + 3600 * 1000), // 1시간 후 만료
});
// 리프레시 토큰이 있을 경우, 보안 옵션과 함께 쿠키로 저장
if (refreshToken) {
setCookie('refreshToken', refreshToken, {
path: '/',
httpOnly: true, // JS에서 접근 불가 → XSS 보호
secure: true, // HTTPS에서만 전송
});
}
// 응답 데이터와 헤더를 반환 (다른 로직에서 활용 가능)
return { data: response.data, headers: response.headers };
} catch (error) {
// 오류 발생 시, 기본 에러 응답 형태로 반환
return {
data: { code: 'ERROR', message: '로그인 요청 중 오류 발생' },
headers: {},
};
}
};
🤔 토큰 저장 방식, 왜 쿠키를 선택했나?
저장 위치 장점 단점
localStorage | 간단함 | XSS에 매우 취약 |
sessionStorage | 탭 단위 세션 유지 | 새 창/탭 열면 로그인 풀림 |
cookie (선택) | 자동 요청, HttpOnly 가능, SSR에 유리 | CSRF 방지 필요, 관리 어려움 |
우리는 리프레시 토큰을 HttpOnly 쿠키로 관리하려 했기 때문에 자연스럽게 쿠키 저장 방식을 선택하게 되었다.
쿠키는 자동으로 서버에 전송되므로, 프론트에서 민감한 토큰을 직접 다룰 필요가 없어 보안상 이점이 있다.
쿠키를 쓰다보니 생긴 문제
토큰을 쿠키에 저장하다보니 문제가 생겼다.
브라우저를 닫으면 쿠키가 함께 사라진다는 점이다.
현재 accessToken은 expires 옵션을 통해 유효시간을 설정했지만,
브라우저를 완전히 종료한 뒤 다시 열면 쿠키가 초기화되어 사용자 입장에서는 로그인이 풀린 것처럼 느껴진다.
"로그인했는데 브라우저 껐다 켜니까 또 로그인하라고 하네?"
이런 상황은 UX 측면에서도 좋지 않고 로그인 유지에 대한 신뢰를 떨어뜨릴 수 있다는 생각이 들어서 토큰 저장 방식을 수정하거나 다른 조처를 해야겠다.
👉 토큰을 쿠키가 아닌 다른 방식(localStorage 등)에 저장하거나
👉 쿠키 설정을 조정하여 브라우저 종료에도 유지되는 구조로 바꾸는 방안을 고민하고 있다.
이 문제를 어떻게 해결할지는 다음 글이나 개선기에서 다뤄보려 한다. :)
'[refactor: advICE]' 카테고리의 다른 글
[refactor] Lv.3 React 프로젝트에서 명명 규칙 통일하기 (feat. PascalCase) (0) | 2025.03.28 |
---|---|
[refactor] Lv.2.2 RTR 도입기: 진짜 로그아웃은 Refresh Token까지 지우는 것부터 (1) | 2025.03.27 |
[refactor] Lv.2.1 RTR 도입기: 액세스 토큰 만료? 자동 재발급으로 해결하기 (0) | 2025.03.25 |
[refactor] Lv.1 아키텍처 개선기: FSD 도입 이야기 (0) | 2025.03.19 |
[refactor] Lv.0 리팩토링 시작 (0) | 2025.02.06 |