본문 바로가기
나도 공부한다/삽질

Interceptor에서 navigate 사용하기 (feat. react-router 6에서 사라진 history hook 사용하는 법)

by 꾸빵이 2024. 2. 11.

※ 이 글은 저의 삽질 과정이 그대로 담겨있는 글로, 사담이 많습니다. 결과만 알고 싶으신 분은 해결방법 - 결론을 읽어주세요

 

 

목차

1. 구현 상황

2. 문제 상황

3. 해결 방법

- 1차 시도

- 2차 시도

- 3차 시도

- 4차 시도

- 5차 시도

- 결론

- 참고 자료

     

    구현 상황

    axiosConfig.js 파일을 따로 만들었다.

    axiosConfig.js에서는 요청을 보낼 때 마다 인터셉터로 요청을 가로채서 헤더의 Authorization에 토큰을 넣도록 설정했다.

    그리고 백엔드쪽에서 응답을 보낼때 갱신된 토큰을 보내주므로 응답을 받을 때마다 응답을 가로채서 토큰을 새걸로 갈아끼도록 설정했다. 여기까지는 일반적인 인터셉터 사용법이기 때문에 문제가 되지 않았다. 응답으로 에러 코드가 오는 경우에 에러 핸들링 해주는 코드를 추가하려할 때 문제가 발생했다.

     


     

    문제 상황

    401 코드가 오는 경우, 재로그인이 필요하다고 창을 띄운 후 로그인 페이지로 돌아가게 만들고 싶었다. 이렇게 하려면 useNavigate 훅을 사용해야하는데 당연히 될리가 없다. react hook은 컴포넌트 안에서만 써야하니까... 

     

    Error: Invalid hook call. Hooks can only be called inside of the body of a function component.

     

     


    해결 방법

    1차 시도

    'interceptors navigate' 키워드로 구글링하던 중 다음 글을 발견했다. 이 분은 403 에러가 떴을 때 메인페이지로 돌아가게 만들고 싶으셨나보다. 첫번째로 달린 댓글은 window.location.href를 사용하라고 했다. 그리고 window.location.href는 페이지를 리로드 시키므로 좋지 않은 방법이라고 대댓글이 달렸다.

     

    여기서 아차 싶었던게, 대충 작동만 되면 된다는 급한 마음에 window.location.~ 을 생각없이 사용한 기억이 있다. window.location의 동작 원리를 생각해본적도 없고 페이지 리로드가 SPA에 왜 좋지 않은지 생각해본적도 없다. 그래서 이 부분에 대해 더 공부하고 블로그에 새 글을 올렸다! 아무튼 이 이슈 페이지는 별 도움이 되지 않았다.

    window.location.href = '/';

    https://github.com/axios/axios/issues/5144

     

    How to redirect with the interceptors? · Issue #5144 · axios/axios

    Describe the issue Hi guys, im developing a React app with Nodejs and axios. My app has a auth by route, so i need to redirect the user for the login page if he's not allowed in this route, i've al...

    github.com

     

    [window.location.href와 window.location.reload](https://shanepark.tistory.com/206)

     

    2차 시도

    위의 깃허브 이슈 페이지에서도 그렇고 간혹가다 history 훅을 사용하라는 블로그 글들이 있는데, useHistory 훅은 react-router-5 버전에서만 사용할 수 있다. 저거 하나때문에 다운그레이드 시키기 싫었다. 

     

    3차 시도

    navigate 훅을 써야하는 인터셉터를 분리해서 컴포넌트로 만든 후에 BrowserRouter 안에 넣어주기

    가장 간단한 방법이지만 인터셉터끼리 묶여있어야 더 보기 좋기도 하고, 약간 편법(?)을 쓴 느낌이라 찝찝해서 더 찾아봤다.

    function App(props) {
       return (
           <BrowserRouter>
               {<여기!! />}
               <Routes>
                  {/* other routes here */}
               </Routes>
           </BrowserRouter>
       );
    }

     

     

    4차 시도

    버전 6.1.0부터 unstable_HistoryRouter로 history 라우터를 제공한다고 한다. 이렇게 말이다. 근데 이 기능을 소개한 글들이 최소 2년은 된거라 확실하지 않은 상황이었다. 검색을 더 해보고 싶었다.

    import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom";
    import history from '../path/to/history';
    
    ...
    
    <HistoryRouter history={customHistory}>
      ... 앱 코드 ...
    </HistoryRouter>

     

    5차 시도

    구글링을 하던 중 굉장히 독특한 글을 찾았다. react-router 6버전에서 history 훅을 사용하는 방법인 것같다. 위에서 말했다 시피 6으로 업데이트 되면서 history 훅은 사라졌기 때문에 매우 흥미로웠다. 

    https://stackoverflow.com/questions/69953377/react-router-v6-how-to-use-navigate-redirection-in-axios-interceptor%EF%BB%BF

     

    React router v6 how to use `navigate` redirection in axios interceptor

    import axios from "axios"; import { useNavigate } from "react-router-dom"; export const api = axios.create({ baseURL: "http://127.0.0.1:8000/", headers: { "

    stackoverflow.com

     

    영어를 잘하진 못하지만 나름대로 열심히 번역해본 결과 다음과 같다. (이럴 때마다 영어 공부의 필요성을 간절히 느낀다ㅠ)

     

    우선 history 객체를 전달받을 수 있는 사용자 정의 라우터 컴포넌트를 만들어야 한다. 이전 버전에서는 history 객체를 직접 생성하고, 이를 React Router와 다른 외부 자바스크립트 로직(redux-thunks, axios 유틸리티 등)에서 사용할 수 있었다. 그러나 v6에서는 내부적으로 history 컨텍스트를 관리하므로, 사용자 정의 라우터 컴포넌트를 통해 이를 구현해야 한다. v6 에서 라우터들은 자신만의 내부 history 컨텍스트를 관리하기 때문에 history 객체를 복제하고 그 상태를 관리하여 기본 Router 컴포넌트의 새로운 API에 맞게 props를 전달해야한다.

     

    무슨 소리인지 이해가 안갔다. 지금부터 하나씩 뜯어보겠다. 이거 다 뜯어보는데 하루종일 걸렸다... createBrowserHistory 등 한번도 본적 없는 게 나와서 약간 막막했다. 심지어 나는 react-router 5버전을 써본적이 없어서 history도 뭔지 몰랐다. 자바스크립트 기초 지식인데 기초공부를 대충하고 리액트를 바로 쓰면 나처럼 된다^^

     

    우선 BrowserRouter 작동 방식과 history 객체에 대해 알아야 코드가 읽히고, history 객체에 대해 알려면 자바스크립트 객체를 알아야한다.

    자바스크립트 객체는 크게 세가지로 나뉜다.

     

    블로그 글을 보면 자바스크립트 객체 종류를 세가지라고 소개하는 사람도 있고 네가지라고 소개하는 사람도 있고 사용자 정의 객체는 포함시키지 않는 사람고 있고 BOM과 DOM을 하나로 묶는 사람도 있고 아무튼 다 말이 달라서 혼란스러웠다. 심지어 Host Object를 부르는 방법도 다 다르다. 브라우저 내장 객체라는 사람도 있고 사용자 정의 객체라는 사람도 있고...; TCP school이 그래도 더 정확할 것이라 생각되어 TCP school을 기준으로 공부했다.
    https://tcpschool.com/javascript/js_standard_object

     

    • 자바스크립트 내장 객체:
      - 구동 시점 : 자바스크립트 엔진이 구동되는 시점. 자바스크립트 엔진에 이미 정의되어있는 객체이다. 별도의 설정이나 설치 없이 사용이 가능하다.
      - 종류 : Object, Number, String, Boolean, Date, RegExp, Error

    • 브라우저 객체 모델 (BOM)
      - 정의 : 브라우저와 관련된 객체
      - 구동 시점 : 자바스크립트 내장객체가 구성된 후에 구성되고, 자바스크립트 엔진이 구동되는 시점에 사용 가능.
      - 특이 사항
        - 웹 브라우저의 기능을 객체처럼 다루어 브라우저의 정보에 접근하거나 기능을 제어할 수 있다.
        - W3C 표준 모델은 아니다. 따라서 정해진 표준이 없어 브라우저마다 세부사항이 다르다.
      - 종류
        - window : 브라우저 창이 열릴 때마다 하나씩 생긴다. BOM의 최상위 객체이다.
        - location : 현재 문서에 대한 URL 정보를 갖고 있다. href, reload, replace 등의 속성을 갖고 있음
        - navigator : 현재 사용하는 브라우저 및 브라우저 환경에 대한 정보를 갖고 있다. 주로 브라우저 호환성을 위해 사용된다. 접속한 기기가 모바일인지 파악할 때도 쓰인다.
        - history: 브라우저의 주소 기록을 보관함. history API를 이용하여 SPA에서 브라우저 뒤로가기 같은 네비게이션을 관리할 수 있게 해준다.
        - 이 외에도 document, screen 등이 있다. 이 글에서 필요한 history 객체를 설명하기 위한 배경지식이니 깊게 다루지 않겠다.

    •  문서 객체 모델 (DOM)
      - 정의 : XML이나 HTML 문서에 접근하기 위한 인터페이스. node 구조를 트리로 표현한 모델이다.
      - 구동 시점 : 자바스크립트 내장객체가 구성된 후에 구성되고, 자바스크립트 엔진이 구동되는 시점에 사용 가능.
      - 특이 사항 : 자바스크립트 같은 스크립팅 언어로 DOM에 접근이 가능하다.
      - 종류
        - Core DOM : 모든 문서 타입을 위한 DOM 모델
        - HTML DOM : HTML 문서를 위한 DOM 모델
        - XML DOM : XML 문서를 위한 DOM 모델

     

    여기서 필요한 건 BOM의 history 객체이다.

     

    이게 BrowserRouter과 무슨 관계냐하면, BrowserRouter 컴포넌트는 Router 컴포넌트를 렌더링할때 props 로 history 객체를 전달한다.

    Router 컴포넌트는 마운트되는 순간에 props로 전달받은 history 객체의 프로퍼티인 location 객체를 자신의 지역 상태에 저장한다.

    props로 전달받은 history 객체를 구독하여(history.listen 메소드) 브라우저의 현재 URL이 변경될때마다 자신의 지역상태에 해당하는 location 객체가 새로운 location 객체로 대체되도록 한다.

     

    v6에서는 history 객체를 자동으로 생성하고 컴포넌트 단위로 history 객체를 컨트롤하기 때문에 커스텀도 어렵고 컴포넌트 밖에서 history 객체를 컨트롤하기 어렵다. 애플리케이션 외부에서 라우팅 상태를 제어하고 싶을 땐 사용자 정의 라우터 컴포넌트를 만들어서 써야한다.

     

    저 스택오버플로우 글을 보고 잘못 생각했던 것이, history를 사용하려면 반드시 사용자 정의 라우터 컴포넌트를 만들어야하는줄 알았다. 코드 순서 때문에 더 헷갈렸다. 단순히 특정 상황에서 애플리케이션의 라우팅 상태를 제어할 때는 라우팅 로직을 직접 구현하지 않아도 된다.

     

    우선 1번 2번 파일이 짝이고 3번 4번 파일이 짝이다.

    나는 에러 코드가 401일 때 로그인 페이지로 보내는 것을 원했으므로 1, 2번만 하면 됐다.

     

    1. history.js 파일을 따로 분리하여 history 객체 만들어주기

    이전에는 useHistory 훅을 사용할 수 있었으나 사라졌으므로 history 객체는 history 패키지의 createBrowserHistory() 함수를 호출해서 직접 가져와야한다.

    import { createBrowserHistory } from "history";
    
    const history = createBrowserHistory();
    
    export default history;

     

     

    2. axios 인터셉터에서 history 사용하기

    만든 history 객체를 import하고 history 객체 사용법처럼 쓰면 끝이다.

    import axios from "axios";
    import history from '../path/to/history';
    
    export const api = axios.create({
      baseURL: "http://127.0.0.1:8000/",
      headers: {
        "content-type": "application/json",
      },
    });
    
    api.interceptors.response.use(
      function (response) {
        return response;
      },
      function (er) {
        if (axios.isAxiosError(er) && er.response && er.response.status == 401) {
          history.replace("/login"); // 로그인 페이지로 이동
        }
        return Promise.reject(er);
      }
    );

     

     

    그렇다면 3, 4번은 언제 어떻게 왜 쓰는 것일까?

    라우팅 상태를 외부 객체인 history와 동기화하고 이를 통해 라우팅 로직을 더 세밀하게 제어하고자할 때 쓰인다.

     

    3. CustomRouter 코드


    history.js에서 전달받은 history 객체와 Router 컴포넌트를 같이 사용하고 있다.

    외부에서 생성된 history 객체를 React Router와 통합하는 것이다. 이를 통해 애플리케이션의 다른 부분에서 라우팅을 제어할 수 있다.

     

    useState로 history 객체의 현재 상태(현재 경로와 액션)를 컴포넌트의 상태로 관리한다.

    history.listen을 사용하여 history 객체의 변경사항을 구독하고 useLayoutEffect로 해당 변경사항을 업데이트한다.

    이렇게 상태가 업데이트될 때마다 Router 컴포넌트에 새로운 locationnavigationType을 전달하여 라우팅을 동적으로 관리한다.

     

    history action은 현재 페이지에 오게된 액션을 말한다. (PUSH, REPLACE, POP)

    import { Router } from "react-router-dom";
    
    const CustomRouter = ({ history, ...props }) => {
      const [state, setState] = useState({
        action: history.action,
        location: history.location
      });
    
      useLayoutEffect(() => history.listen(setState), [history]);
    
      return (
        <Router
          {...props}
          location={state.location}
          navigationType={state.action}
          navigator={history}
        />
      );
    };

     

    그런데 이 코드를 잘 보면 useLayoutEffect가 호출될 때마다 리스너를 통해 이벤트가 등록되고 있는데 이벤트를 해제하는 코드가 없다. useEffect 안에서 이벤트를 등록해줄때는 컴포넌트가 언마운트 되거나 의존성 배열의 값이 변경될 때마다 이벤트 리스너를 해제해야한다고 알고 있다. 그렇지 않으면 이벤트가 계속 추가되기만 하여 같은 이벤트가 여러번 발생할 수도 있고 메모리 누수가 생길수도 있기 때문이다. history.listen은 리스닝을 중지하는 함수를 반환하므로 코드를 이렇게 바꿔주면 리스너를 해제해줄 수 있다.

     

    아마 이렇게 해주면 더 좋은 코드가 될 수 있지 않을까....?? 작성한 사람이 왜 해제를 안해줬는지 모르겠지만 나는 아직 초보라 다른 사람 코드를 수정하기엔 조심스러운 면이 있다. 만약 내 의견이 틀렸다면 꼭 댓글을 달아주셨으면 좋겠다..

    useLayoutEffect(() => {
      // history 객체의 변경을 감지하는 리스너를 등록
      const unlisten = history.listen(({ location, action }) => {
        // history 객체의 변경에 따라 컴포넌트 상태를 업데이트
        setState({
          action: action,
          location: location,
        });
      });
    
      // 컴포넌트가 언마운트되거나, 의존성 배열에 있는 값이 변경될 때 리스너 해제
      return () => {
        unlisten();
      };
    }, [history]); // 의존성 배열에 history를 포함시켜, history 객체가 변경될 때 이펙트를 재실행

     

     

    4. 내가 만든 라우터 컴포넌트에 history import 해주기

    이제 CustomRouter 컴포넌트에서는 history, location, match 객체를 props로부터 제공받아 사용할 수 있게된다.

    CustomRouter는 애플리케이션의 최상위 컴포넌트나 라우팅을 설정하는 부분에서 한 번만 사용한다.

    import customHistory from '../path/to/history';
    
    ...
    
    <CustomRouter history={customHistory}>
      ... 앱 코드 ...
    </CustomRouter>

     

     

    그렇게 생각보단 순조롭게 끝나는줄 알았으나... 문제가 또 발생했다.

     

    아니 v6에서 이렇게 쓰면 된다며...... 저 방법을 사용한 다른 블로그 글도 봤는데 v6도 계속 업데이트를 하다보니 이제는 안 되나보다. 시간이 되면 공식문서를 찾아봐야겠다.

     

    에러는 history.js에서 발생한다고 써있었지만 뭔가 이상해서 axiosConfig.js에 있는 history 관련 코드를 싹 지웠다. 그랬더니 에러는 안난다. 느낌이 쎄해서 BrowserRouter 코드를 뜯어봤다. history 객체를 인자로 받고있지 않다.....

     

    스택오버플로우 댓글을 열어봤다. 드류리스씨 저한테 왜 그러시나요? 당신의 잘못은 아니지만..

     

    다시 원점으로 돌아왔다. 혹시 history 객체를 인자로 받는 라우터가 있을까 싶어서 아래로 쭉쭉 내려보다가 historyRouter라는 라우터를 발견했다. 4차 시도에서 만난 그 애다. unstable이라 안쓰고 다른 방법 찾은건데 이럴거면 그냥 아무 방법이나 되는 거 쓸걸 그랬나 싶다.

     


    결론

    3차 시도에서 발견한 방법을 사용하기로 했다.

    import axios from 'axios';
    import { useEffect } from 'react';
    import { useNavigate } from 'react-router-dom';
    
    function AxiosInterceptor() {
        const navigate = useNavigate();
    
        useEffect(() => {
            const reqInterceptor = axios.interceptors.request.use(
                (config) => {
                    console.log('요청');
                    const token = sessionStorage.getItem('token');
                    if (token) {
                        config.headers['Authorization'] = `${token}`;
                    }
                    return config;
                },
                (error) => {
                    return Promise.reject(error);
                }
            );
    
            const resInterceptor = axios.interceptors.response.use(
                (response) => {
                    const token = response.data.accessToken;
                    if (token) {
                        sessionStorage.setItem('token', token);
                    }
                    return response;
                },
                (error) => {
                    const res = error.response;
                    if (res.status === 400 && res.data.message) {
                        alert(res.data.message);
                        return res;
                    } else if (res.status === 401 || res.status === 403) {
                        alert('재로그인이 필요해요');
                        navigate('/landing/login');
                    } else if (res.status === 404) {
                        return navigate('/not-found');
                    } else if (res.status === 500) {
                        alert('서버에 문제가 있어요 잠시 기다려주세요!');
                    }
                    return Promise.reject(error);
                }
            );
    
            return () => {
                axios.interceptors.request.eject(reqInterceptor);
                axios.interceptors.response.eject(resInterceptor);
            };
        }, [navigate]);
    
        return null;
    }
    
    export default AxiosInterceptor;

     

     


    참고 자료


    https://stackoverflow.com/questions/69953377/react-router-v6-how-to-use-navigate-redirection-in-axios-interceptor

     

    React router v6 how to use `navigate` redirection in axios interceptor

    import axios from "axios"; import { useNavigate } from "react-router-dom"; export const api = axios.create({ baseURL: "http://127.0.0.1:8000/", headers: { "

    stackoverflow.com

    https://dev.to/arianhamdi/react-hooks-in-axios-interceptors-3e1h

     

    React hooks in Axios interceptors

    As you know, you can not use React hooks in a place other than React component or custom hooks. In...

    dev.to

     

    개인적으로 여기 아래에 있는 블로그 및 문서들은 꼭 읽어보면 좋겠다. 구글링하면서 정말 좋은 글을 많이 발견했다.

     

    https://it-eldorado.tistory.com/111

     

    [JavaScript] HTML5 History API, history 패키지 (feat. react-router-dom)

    React를 공부하면서 react-router-dom 패키지에서 제공하는 클라이언트 사이드 라우팅의 동작 원리를 알고 싶어졌다. 그러다 보니 react-router-dom 패키지에서 클라이언트 사이드 라우팅을 구현할 때 내

    it-eldorado.tistory.com

     

    https://velog.io/@yoonvelog/Redux-thunk-%EC%97%90%EC%84%9C-history

     

    velog

     

    velog.io

     

    https://react.vlpt.us/react-router/04-extra.html

     

    4. 리액트 라우터 부가기능 · GitBook

    4. 리액트 라우터 부가기능 이번엔 알아두면 유용한 리액트 라우터의 부가기능들을 알아보겠습니다. history 객체 history 객체는 라우트로 사용된 컴포넌트에게 match, location 과 함께 전달되는 props

    react.vlpt.us

    https://velog.io/@yoonvelog/Redux-thunk-%EC%97%90%EC%84%9C-history

    https://geniee.tistory.com/32

     

    DOM (Document object Model) 완벽 정복하기

    Front-End 개발자라면 반드시 거쳐가는 단어 DOM. DOM이란 정확히 무엇일까? DOM이라는 단어가 눈과 귀에 익숙함에도 막상 DOM이 무엇이냐고 물어봤을 때 "브라우저 개발자 툴에서 Element 객체 말할 때

    geniee.tistory.com

    https://velog.io/@yoonvelog/Redux-thunk-%EC%97%90%EC%84%9C-history

     

    velog

     

    velog.io