관리 메뉴

거니의 velog

(37) 리덕스 툴킷 6 본문

SpringBoot_React 풀스택 프로젝트

(37) 리덕스 툴킷 6

Unlimited00 2024. 3. 6. 20:30

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