일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 객체 비교
- 컬렉션 타입
- 정수형타입
- 한국건설관리시스템
- 예외처리
- abstract
- NestedFor
- 자바
- GRANT VIEW
- 생성자오버로드
- EnhancedFor
- 집합_SET
- 제네릭
- cursor문
- 오라클
- 어윈 사용법
- 인터페이스
- 메소드오버로딩
- 예외미루기
- 자동차수리시스템
- 참조형변수
- 사용자예외클래스생성
- 대덕인재개발원
- oracle
- 추상메서드
- 컬렉션프레임워크
- 다형성
- exception
- Java
- 환경설정
- Today
- Total
거니의 velog
(37) 리덕스 툴킷 6 본문
5. Axios 인터셉터와 Refresh Token
* 로그인에 대한 저장과 애플리케이션 내의 상태 유지가 완료되었지만, API 서버의 호출에는 JWT 방식으로 Access Token 때문에 정상적으로 실행되지 못한다. API 서버의 /api/todo/ 혹은 /api/products/ 로 시작하는 경로를 호출했을 때는 에러가 발생하는 것을 볼 수 있다.
* API 서버는 Access Token을 체크하기 때문에 Axios의 호출 시에 Access Token을 전달해야만 한다. 또한, Access Token이 만료되었을 때는 Refresh Token으로 Access Token을 갱신하는 처리가 필요하다.
* Axios의 경우에는 인터셉터(interceptor) 기능을 제공해서 Axios의 요청이나 응답 시에 추가적인 작업을 수행할 수 있는데 이를 통해서 쿠키로 보관된 Access Token을 처리하는 작업이나 자동으로 Refresh Token을 사용하는 처리를 할 수 있다.
* 프로젝트의 util 패키지에 jwtUtil.js 파일을 추가하고 Axios의 요청/응답 시에 동작할 함수들을 정의한다.
import axios from "axios";
const jwtAxios = axios.create();
//before request
const beforeReq = (config) => {
console.log("before request.............");
return config;
};
//fail request
const requestFail = (err) => {
console.log("request error............");
return Promise.reject(err);
};
//before return response
const beforeRes = async (res) => {
console.log("before return response...........");
return res;
};
//fail response
const responseFail = (err) => {
console.log("response fail error.............");
return Promise.reject(err);
};
jwtAxios.interceptors.request.use(beforeReq, requestFail);
jwtAxios.interceptors.response.use(beforeRes, responseFail);
export default jwtAxios;
* jwtUtil 에는 요청과 관련해서 beforeReq(), requestFail() 을 지정하고, 응답 관련해서는 beforeRes(), responseFail()을 설정한다.
* JWT를 사용해야 하는 todoApi.js 나 productsApi.js 에서는 기존의 axios 대신 jwtAxios를 이용하도록 변경해 준다(memberApi.js는 JWT 토큰을 사용하지 않는다).
* 기존의 axios.get() 을 사용하는 부분을 jwtAxios를 이용하도록 수정한다(todoApi.js와 productsApi.js 에서 axios를 이용하는 부분은 모두 수정해 준다). 아래 코드와 같이 axios 부분은 모두 jwtAxios로 변경한다.
[productsApi.js]
import { API_SERVER_HOST } from "./todoApi";
import jwtAxios from "../util/jwtUtil";
const host = `${API_SERVER_HOST}/api/products`;
export const postAdd = async (product) => {
const header = { headers: { "Content-Type": "multipart/form-data" } };
// 경로 뒤 '/' 주의
const res = await jwtAxios.post(`${host}/`, product, header);
return res.data;
};
export const getList = async (pageParam) => {
const { page, size } = pageParam;
const res = await jwtAxios.get(`${host}/list`, {
params: { page: page, size: size },
});
return res.data;
};
export const getOne = async (tno) => {
const res = await jwtAxios.get(`${host}/${tno}`);
return res.data;
};
export const putOne = async (pno, product) => {
const header = { headers: { "Content-Type": "multipart/form-data" } };
const res = await jwtAxios.put(`${host}/${pno}`, product, header);
return res.data;
};
export const deleteOne = async (pno) => {
const res = await jwtAxios.delete(`${host}/${pno}`);
return res.data;
};
[todoApi.js]
import jwtAxios from "../util/jwtUtil";
export const API_SERVER_HOST = "http://localhost:8080";
const prefix = `${API_SERVER_HOST}/api/todo`;
export const getOne = async (tno) => {
const res = await jwtAxios.get(`${prefix}/${tno}`);
return res.data;
};
export const getList = async (pageParam) => {
const { page, size } = pageParam;
const res = await jwtAxios.get(`${prefix}/list`, {
params: { page: page, size: size },
});
return res.data;
};
export const postAdd = async (todoObj) => {
const res = await jwtAxios.post(`${prefix}/`, todoObj);
return res.data;
};
export const deleteOne = async (tno) => {
const res = await jwtAxios.delete(`${prefix}/${tno}`);
return res.data;
};
export const putOne = async (todo) => {
const res = await jwtAxios.put(`${prefix}/${todo.tno}`, todo);
return res.data;
};
* 아직 jwtUtil.js의 처리가 완전하지 않기 때문에 화면은 동일하게 에러가 발생하지만, 로그를 통해서 jwtUtil.js의 동작 여부를 확인할 수 있다.
(1) Access Token의 전달
* Access Token은 로그인 상황에서 쿠키에 보관되어 있고, jwtUtil.js에서는 이를 이용해서 API 서버 호출 전에 Authorization 헤더를 추가하도록 구성한다. 만일 로그인 쿠키가 없다면 무조건 예외를 발생시킨다.
import axios from "axios";
import { getCookie } from "./cookieUtil";
const jwtAxios = axios.create();
//before request
const beforeReq = (config) => {
console.log("before request.............");
const memberInfo = getCookie("member");
if (!memberInfo) {
console.log("Member NOT FOUND");
return Promise.reject({ response: { data: { error: "REQUIRE_LOGIN" } } });
}
const { accessToken } = memberInfo;
// Authorization 헤더 처리
config.headers.Authorization = `Bearer ${accessToken}`;
return config;
};
(...)
* Access Token의 유효시간이 10분이므로 브라우저에서 새로 로그인한 후에 Todo 메뉴를 확인해서 정상적으로 처리되는지 확인한다(Products는 /api/products/list 를 처리하는 ProductController의 list()와 @PreAuthorize의 권한 설정이 'ROLE_USER' 인지 확인).
// 페이지 및 정렬 조건을 전달받아 상품 목록을 조회하는 메서드입니다.
@PreAuthorize("hasAnyRole('ROLE_USER')") // ROLE_USER는 list를 볼 수 있는 권한을 가진다.
@GetMapping("/list")
public PageResponseDTO<ProductDTO> list(PageRequestDTO pageRequestDTO) {
// 전달받은 페이지 요청 정보를 로그로 출력합니다.
log.info("list...." + pageRequestDTO);
// ProductService의 getList 메서드를 호출하여 상품 목록을 조회하고 반환합니다.
return productService.getList(pageRequestDTO);
}
* 개발자 도구에서는 API 서버의 호출 시에 Authorization 헤더가 전송되는 것을 확인할 수 있다.
[유효시간이 지난 Access Token]
* Access Token의 유효 시간이 10분이므로 로그인 후 10분 동안은 정상적인 API 서버 호출이 가능하지만, 10분이 지난 후에는 만료된 Access Token이므로 에러가 발생한다.
* API 서버의 로그를 살펴보면 'Expired' 메시지를 출력하는 것을 볼 수 있다. 상태코드는 403(Forbidden)이 전송된다.
* Access Token의 유효시간이 지난 후에는 사용자가 로그인 결과로 받은 Refresh Token을 이용해서 자동으로 Access Token을 갱신해 주어야 하는데, 이런 방식을 '사일런트 리프레쉬(silent refresh)'라고 한다.
"사일런트 리프레시" 는 사용자에게 알림이나 화면 갱신 없이 백그라운드에서 자동으로 발생하는 페이지 리프레시를 의미합니다. 이는 주로 웹 애플리케이션에서 사용되며, 사용자의 인터랙션 없이도 데이터를 최신 상태로 유지하거나 세션을 갱신하는 데 유용합니다.
일반적으로 사일런트 리프레시는 다음과 같은 상황에서 활용될 수 있습니다:
1. 세션 유지 : 사용자의 세션이 만료되지 않도록 하기 위해 주기적으로 토큰을 갱신하거나 인증 상태를 확인하는 데 사용됩니다.
2. 데이터 갱신 : 백엔드에서 받은 데이터가 주기적으로 갱신되는 경우, 사용자에게 알리지 않고도 화면을 최신 상태로 유지할 수 있습니다.
3. 실시간 업데이트 : 웹 소켓 또는 다른 실시간 통신 메커니즘을 사용하여 백엔드로부터 새로운 정보를 받아오는 경우에 활용될 수 있습니다.
구체적으로 사일런트 리프레시를 구현하려면, 주기적으로 특정 작업(예: AJAX 요청, 토큰 갱신 등)을 수행하는 타이머나 인터벌을 설정하여 백그라운드에서 이러한 작업을 실행하는 방식을 사용합니다. 이를 통해 사용자는 알림 없이도 페이지의 상태가 최신으로 유지될 수 있습니다.
(2) Refresh Token을 이용한 자동 갱신
* Access Token에 문제가 있다면 리액트 쪽에서는 가지고 있는 Refresh Token으로 /api/member/refresh 경로를 호출해서 Access Token을 갱신해 주어야 한다.
* jwtToken.js에는 Access Token, Refresh Token 으로 /api/member/refresh 를 호출하는 함수를 추가한다.
import axios from "axios";
import { getCookie, setCookie } from "./cookieUtil";
import { API_SERVER_HOST } from "../api/todoApi";
const jwtAxios = axios.create();
const refreshJWT = async (accessToken, refreshToken) => {
const host = API_SERVER_HOST;
const header = { headers: { Authorization: `Bearer ${accessToken}` } };
const res = await axios.get(
`${host}/api/member/refresh?refreshToken=${refreshToken}`,
header
);
console.log("----------------------");
console.log(res.data);
return res.data;
};
* 추가한 refreshJWT() 는 beforeRes() 에서 응답 데이터가 ERROR_ACCESS_TOKEN와 같이 Access Token 관련된 메시지인 경우 Refresh Token을 활용해서 다시 호출한다.
//before return response
const beforeRes = async (res) => {
console.log("before return response...........");
console.log(res);
const data = res.data;
if (data && data.error === "ERROR_ACCESS_TOKEN") {
const memberCookieValue = getCookie("member");
const result = await refreshJWT(
memberCookieValue.accessToken,
memberCookieValue.refreshToken
);
console.log("refreshJWT RESULT", result);
memberCookieValue.accessToken = result.accessToken;
memberCookieValue.refreshToken = result.refreshToken;
setCookie("member", JSON.stringify(memberCookieValue), 1);
}
return res;
};
* 브라우저에서 만료된 토큰으로 호출하면 화면에서는 에러가 발생하기는 하지만, API 서버의 /api/member/refresh 를 정상적으로 호출하는 것을 확인할 수 있다(POST 방식이 조금 더 낫긴 하지만 예제에서는 눈에 보이도록 GET방식을 이용했다).
[갱신된 토큰의 저장과 재호출]
* 만료된 Access Token을 사용했을 경우 자동으로 Refresh Token을 사용하는 상황까지 완료되었다면 남은 작업은 갱신된 토큰들을 다시 저장하고 원래 원했던 호출을 다시 시도하는 작업을 추가해야 한다.
import axios from "axios";
import { getCookie, setCookie } from "./cookieUtil";
import { API_SERVER_HOST } from "../api/todoApi";
(...)
//before return response
const beforeRes = async (res) => {
console.log("before return response...........");
//console.log(res);
//'ERROR_ACCESS_TOKEN'
const data = res.data;
if (data && data.error === "ERROR_ACCESS_TOKEN") {
const memberCookieValue = getCookie("member");
const result = await refreshJWT(
memberCookieValue.accessToken,
memberCookieValue.refreshToken
);
console.log("refreshJWT RESULT", result);
memberCookieValue.accessToken = result.accessToken;
memberCookieValue.refreshToken = result.refreshToken;
setCookie("member", JSON.stringify(memberCookieValue), 1);
//원래의 호출
const originalRequest = res.config;
originalRequest.headers.Authorization = `Bearer ${result.accessToken}`;
return await axios(originalRequest);
}
return res;
};
* jwtUtil.js의 수정 결과는 만료된 Access Token을 가지고 호출했을 때 Refresh Token을 사용해서 갱신한 뒤에 자동으로 원래 호출하려던 API 서버의 경로를 다시 호출하게 된다.
* 토큰이 갱신되면 쿠키의 값 역시 다시 저장되기 때문에 결과적으로 1일 동안 별도의 로그인 없이 API 서버 사용이 가능하다.
[토큰에 따른 예외 처리]
* Axios를 이용한 호출에서 예외 처리가 필요한 상황은 Access Token이 아예 없거나 권한이 없어서 문제가 발생하는 경우이다. 이에 대한 처리는 React-Router를 이용하거나 useCustomLogin 훅을 예외에 따라서 동작하는 함수를 추가해서 처리할 수 있다. 예제에서는 개발자가 직접 많은 일을 할 수 있는 방식으로 useCustomLogin을 이용한다.
import { useDispatch, useSelector } from "react-redux";
import { Navigate, useNavigate, createSearchParams } from "react-router-dom";
import { loginPostAsync, logout } from "../slices/loginSlice";
const useCustomLogin = () => {
(...)
// 토큰에 따른 예외 처리
const exceptionHandle = (ex) => {
console.log("Exception------------------------");
console.log(ex);
const errorMsg = ex.response.data.error;
const errorStr = createSearchParams({ error: errorMsg }).toString();
if (errorMsg === "REQUIRE_LOGIN") {
alert("로그인 해야만 합니다.");
navigate({ pathname: "/member/login", search: errorStr });
return;
}
if (ex.response.data.error === "ERROR_ACCESSDENIED") {
alert("해당 메뉴를 사용할 수 있는 권한이 없습니다.");
navigate({ pathname: "/member/login", search: errorStr });
return;
}
};
return {
loginState,
isLogin,
doLogin,
doLogout,
moveToPath,
moveToLogin,
moveToLoginReturn,
exceptionHandle,
};
};
export default useCustomLogin;
* 예를 들어 components/products/ListComponent.js 에는 Axios 호출 시 발생하는 예외를 다음과 같이 처리할 수 있다.
import React, { useEffect, useState } from "react";
import { getList } from "../../api/productsApi";
import useCustomMove from "../../hooks/useCustomMove";
import FetchingModal from "../common/FetchingModal";
import PageComponent from "../common/PageComponent";
import { API_SERVER_HOST } from "../../api/todoApi";
import useCustomLogin from "../../hooks/useCustomLogin";
const host = API_SERVER_HOST;
const initState = {
dtoList: [],
pageNumList: [],
pageRequestDTO: null,
prev: false,
next: false,
totalCount: 0,
prevPage: 0,
nextPage: 0,
totalPage: 0,
current: 0,
};
const ListComponent = () => {
const { page, size, refresh, moveToList, moveToRead } = useCustomMove();
const [serverData, setServerData] = useState(initState);
const [fetching, setFetching] = useState(false);
const { exceptionHandle } = useCustomLogin();
useEffect(() => {
setFetching(true);
getList({ page, size })
.then((data) => {
console.log(data);
setServerData(data);
setFetching(false);
})
.catch((err) => exceptionHandle(err));
}, [page, size, refresh]); // exceptionHandle 을 의존성 배열에 넣으면 무한루프에 걸림
return (
(...)
);
};
export default ListComponent;
* 위의 코드를 적용하고 user1@aaa.com과 같이 권한이 없는 사용자가 /products/list 에 접근하게 되면 아래 화면과 같이 경고창이 뜨고, 로그인 페이지로 이동하게 된다. 이 때 파라미터가 같이 전달된다(http://localhost:3000/member/login?error=ERROR_ACCESSDENIED). 이를 이용해서 화면에 메시지를 출력할 수 있다(아래의 화면은 로그인 하지 않은 사용자가 직접 주소로 접근하는 경우이다).
'SpringBoot_React 풀스택 프로젝트' 카테고리의 다른 글
(39) 리액트 소셜 로그인 2 (0) | 2024.03.07 |
---|---|
(38) 리액트 소셜 로그인 1 (0) | 2024.03.06 |
(36) 리덕스 툴킷 5 (0) | 2024.03.06 |
(35) 리덕스 툴킷 4 (0) | 2024.03.06 |
(34) 리덕스 툴킷 3 (0) | 2024.03.06 |