초기에는 엑세스토큰만을 사용하여 로그인을 관리했는데, 엑세스토큰의 만료 시간이 짧아 사용자가 자주 로그인을 다시 해야 하는 불편함이 발생했습니다.
즉, 사용자가 로그인한 후에도 짧은 시간마다 토큰 만료로 인해 인증이 끊기고, 다시 로그인을 해야 하는 상황이 문제였습니다.
해결책으로는 리프레쉬 토큰(Refresh Token)을 도입하는 것입니다.
@PostMapping("/login")
public ResponseEntity<JwtAuthResponse> loginUser(@Valid @RequestBody UserLoginRequestDto requestDto,
HttpServletResponse response) {
String accessToken = userService.loginUser(requestDto);
// 쿠키에 refresh token 담기
response.addCookie(userService.craeteCookie(requestDto.getEmail()));
return ResponseEntity.status(HttpStatus.OK).body(new JwtAuthResponse(AuthenticationScheme.BEARER.getName(), accessToken));
}
/**
* refresh Token 을 쿠키에 담기
*
* @return Cookie
*/
public Cookie craeteCookie(String email) {
String cookieName = "refreshToken";
String cookieValue = jwtProvider.generateRefreshToken(email); // 쿠키벨류엔 글자제한이 이써, 벨류로 만들어담아준다.
// refreshToken db 저장
refreshTokenService.saveRefreshToken(email, cookieValue);
Cookie cookie = new Cookie(cookieName, cookieValue);
// 쿠키 속성 설정
cookie.setHttpOnly(true); //httponly 옵션 설정
// cookie.setSecure(true); //https 옵션 설정
cookie.setPath("/"); // 모든 곳에서 쿠키열람이 가능하도록 설정
cookie.setMaxAge(60 * 60 * 24); //쿠키 만료시간 설정
return cookie;
}
@Service
public class RefreshTokenService {
private final RedissonClient redissonClient;
public RefreshTokenService(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
// RefreshToken 저장
public void saveRefreshToken(String username, String refreshToken) {
RBucket<String> bucket = redissonClient.getBucket("refreshToken:" + username);
bucket.set(refreshToken, Duration.ofDays(30));
}
// RefreshToken 가져오기
public String getRefreshToken(String username) {
RBucket<String> bucket = redissonClient.getBucket("refreshToken:" + username);
return bucket.get();
}
// RefreshToken 삭제
public void deleteRefreshToken(String username) {
RBucket<String> bucket = redissonClient.getBucket("refreshToken:" + username);
bucket.delete();
}
// RefreshToken 검증
public boolean validateRefreshToken(String username, String refreshToken) {
String storedToken = getRefreshToken(username);
return storedToken != null && storedToken.equals(refreshToken);
}
}
import axios from "axios";
// 🔹 Axios 인스턴스 생성
const instance = axios.create({
baseURL: "http://localhost:8080", // Spring Boot 서버 주소
withCredentials: true, // 쿠키 포함 요청
});
// 🔹 액세스 토큰을 가져오는 함수
const getAccessToken = () => localStorage.getItem("accessToken");
// 🔹 요청 인터셉터: 헤더에 액세스 토큰 추가
instance.interceptors.request.use(
(config) => {
const accessToken = getAccessToken();
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 🔹 리프레시 토큰을 사용해 새로운 액세스 토큰을 가져오는 함수
const refreshAccessToken = async () => {
try {
const response = await instance.post(
"/users/refresh",
{},
{
headers: { "Content-Type": "application/json" },
withCredentials: true, // HttpOnly 쿠키 포함
}
);
const newAccessToken = response.data.accessToken;
if (!newAccessToken) throw new Error("새로운 액세스 토큰 없음");
localStorage.setItem("accessToken", newAccessToken);
return newAccessToken;
} catch (error) {
console.error("❌ 리프레시 토큰 만료: 로그인 페이지로 이동");
localStorage.removeItem("accessToken");
window.location.href = "/login"; // 로그인 페이지로 리디렉션
return null;
}
};
// 🔹 응답 인터셉터: 401 응답 처리 (액세스 토큰 갱신 후 요청 재시도)
instance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // 재시도 방지 플래그 설정
const newAccessToken = await refreshAccessToken();
if (newAccessToken) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return instance(originalRequest); // 요청 재시도
}
}
return Promise.reject(error);
}
);
export default instance;
/**
* refreshToken 확인 및 accessToken 재발급
*
* @param email
* @return accessToken
*/
public String refresh(String email, String refreshToken) {
if(!refreshTokenService.validateRefreshToken(email, refreshToken)) {
throw new UnauthorizedException(ErrorCode.UNAUTHORIZED);
}
return jwtProvider.generateAccessToken(email);
}
/users/refresh API를 호출하여 리프레쉬 토큰을 사용해 새 엑세스토큰을 발급받습니다.엑세스토큰의 짧은 만료 시간 문제를 해결하기 위해 리프레쉬 토큰을 도입하였습니다.
리프레쉬 토큰은 HttpOnly 쿠키에 안전하게 저장되고, 엑세스토큰은 로컬스토리지에 저장되어 사용됩니다.
axios 인터셉터를 활용해 401 에러 발생 시 자동으로 리프레쉬 토큰을 사용해 새 엑세스토큰을 발급받아 원래의 요청을 재시도하도록 구현하여, 사용자가 자주 로그인할 필요 없이 자동 로그인이 유지되도록 해결했습니다.