[React+Spring] Redux Toolkit 을 이용한 로그인 처리

HJ·2024년 2월 1일
0

React+Spring

목록 보기
7/11
post-thumbnail

아래 내용은 코드로 배우는 React with 스프링부트 API서버 강의와 함께 설명이 부족한 부분에 대해서 조사하여 추가한 내용입니다.


Redux


Redux 는 JavaScript 앱의 상태 관리를 위한 상태 컨테이너 라이브러리입니다. 여기서 중요한 점은 데이터 같은 것이 아닌 로그인과 같은 애플리케이션의 상태라는 점입니다.

Redux 의 핵심 아이디어는 애플리케이션의 상태를 예측 가능한 단일 스토어(Store)에 저장하고, 상태의 변경을 관리하는 것입니다. 애플리케이션의 모든 상태는 이 단일 스토어에 저장되며, 컴포넌트 간에 상태를 전달하는 대신, 각 컴포넌트는 필요한 상태를 스토어에서 가져와 사용합니다.

  1. 액션( Action ) : 상태 변경의 원인이 되는 객체. 앱에서 일어나는 모든 상태 변경은 액션을 통해 이루어집니다.

  2. 리듀서( Reducer ) : 현재 상태와 액션을 받아 새로운 상태를 반환하는 함수. 액션에 따라 상태를 어떻게 변경할지 정의합니다.

  3. 스토어( Store ) : 앱의 상태를 저장하는 객체. 단일 스토어에 모든 상태가 저장되어 있으며, 상태 변경을 구독하고 상태를 업데이트하는 데 사용됩니다.

  4. 디스패치( Dispatch ) : 액션을 스토어에 전달하는 메서드. 스토어의 상태를 변경하려면 액션을 디스패치해야 합니다.

  5. 미들웨어( Middleware ) : 액션과 리듀서 사이에서 동작하는 플러그인. 비동기 작업, 로깅 등의 기능을 추가할 수 있습니다.




Redux 설정


Store 설정

[ store ]

import { configureStore } from "@reduxjs/toolkit";

export default configureStore({
    reducer: { }
})

Redux Toolkit의 configureStoreRedux 스토어를 설정하고 반환하는 역할을 합니다. 리듀서는 앱의 상태를 변경하는 함수로, 스토어에 저장된 데이터를 어떻게 업데이트할지 정의합니다. reducer 옵션에는 이러한 리듀서들을 포함하는 객체를 전달합니다.


[ index.js ]

import { Provider } from 'react-redux';
import store from "./store"

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);

Store 는 애플리케이션의 상태를 관리하기 때문에 애플리케이션 전체에 영향을 미치는 코드에 적용해야 합니다. 이때 <Provider store={}> 를 사용해서 store 를 등록할 수 있습니다.



Reducer 설정

Reducer 는 현재 상태액션을 받아 새로운 상태를 반환하는 함수입니다.

Redux Toolkit 에서 createSlice 는 Redux 리듀서 및 액션을 생성하는 유틸리티 함수 중 하나입니다. createSlice 함수를 사용하여 생성된 것을 슬라이스(Slice) 라고 합니다.

슬라이스는 리듀서와 그에 딸린 액션들을 포함하는 논리적인 모듈이라고 생각할 수 있습니다. createSlice 를 사용하면 액션 생성자와 리듀서가 자동으로 생성되며 슬라이스에 정의된 각 액션에 대한 상태 변경 로직이 리듀서에 구현됩니다.


[ loginSlice ]

import { createSlice } from "@reduxjs/toolkit";

const initState = {
    email: ''
}

const loginSlice = createSlice({
    name: 'loginSlice',
    initialState: initState,    // 초기상태
    reducers: {
        // 액션을 정의
        login: () => {
            ...
        },
        logout: () => {
            ...
        }
    }
})

export const {login, logout} = loginSlice.actions;  // login, logout 을 외부에서 호출할 수 있도록 설정

export default loginSlice.reducers; // 생성된 리듀서를 추출

createSlice 내부에서 initialState 를 통해 관리해야 할 상태를 지정하고, reducers 안에 액션을 정의할 수 있습니다. 정의된 액션 생성자들은 export 키워드를 사용하여 외부에서 사용할 수 있도록 설정하였습니다.

export default configureStore({
    reducer: { 
        "loginSlice" : loginSlice
    }
})

위에서 생성한 slice 를 configureStore 에 등록하여 사용할 수 있습니다.




Component 에 적용하기


[ useSelector ]

useSelector 는 React Redux 에서 제공하는 훅으로, Redux 스토어의 상태를 읽어오는 데 사용됩니다.

import { useSelector } from 'react-redux';

function BasicMenu() {
    const loginState = useSelector(state => state.loginSlice)   // 로그인 상태인지, 로그아웃 상태인지 ( loginSlice 가 가진 정보 )

    return (
        {loginState.email ?
            <>
                <li className="pr-6 text-2xl">
                    <Link to={'/todo/'}>ToDo</Link>
                </li>
            </> : <></>
        }
    )
}

useSelector 훅을 통해 Redux 스토어의 상태 중에서 loginSlice 의 상태를 읽어옵니다.
state => state.loginSlice 는 콜백 함수로서, Redux 스토어의 전체 상태(state)를 받아와서 그 중에서 loginSlice 부분을 선택합니다.

그러면 loginState 에는 loginSlice 에 정의된 상태인 { email: '' } 와 같은 구조의 객체가 담기게 됩니다. 그리고 이후에는 loginState.email 등과 같은 방식으로 해당 상태의 값을 읽어와서 사용할 수 있습니다.

다른 컴포넌트에서도 useSelector(state => state.loginSlice) 를 사용하면 로그인이 되었는지 상태 정보를 사용할 수 있게 됩니다.



[ useDispatch ]

useDispatch 훅은 React 애플리케이션에서 Redux 액션을 디스패치(dispatch)하는 데 사용되는 함수를 제공합니다. 액션은 스토어에 상태 변경을 알리는 객체입니다.

상태 변경은 액션(Action)을 디스패치하여 이루어지며, 리듀서(Reducer)는 액션에 따라 상태를 업데이트합니다. useDispatch 훅은 이 중에서 액션을 디스패치하는 역할을 수행합니다.

즉, Redux 스토어에 액션을 디스패치 하는 것이 useDispatch 이며, 디스패치된 액션은 Redux 스토어에 등록된 리듀서에 의해 처리되어 상태가 업데이트 됩니다.

function LoginComponent() {
    const [loginParam, setLoginParam] = useState(initState);
    const dispatch = useDispatch();

    const handleClickLogin = () => {
        dispatch(login(loginParam))
    }
}

dispatch는 실행할 액션함수명을 적은 후, 해당 액션함수의 파라미터에 변경할 상태값을 추가하면 됩니다. 위의 예시에서는 loginParam 이라는 상태값을 loginSlice 의 reducers 에 정의된 login 액션함수에 전달합니다.



[ 전달된 정보 사용하기 ]

const loginSlice = createSlice({
   ...
    reducers: {
        login: (state, action) => {
            console.log("state = ", state);
            console.log("action = ", action);
            return {
                email: action.payload.email
            }
        }
        ...
    }
})

state 는 기존의 상태를, action 은 처리하고 싶은 데이터( 전달 받은 파라미터 )를 의미합니다. 로그를 보면 action 의 payload 내부에 전달한 값들이 들어있는 것을 알 수 있습니다. loginSlice 는 전달 받은 정보를 이용해 상태를 업데이트하고 이를 반환합니다.

새롭게 변한 상태에 의해 영향을 받는 것은 useSelector 입니다. 그렇기 때문에 BasicMenu 에서 useSelector 를 통해 상태를 가져오는 loginState 의 값이 변하게 되고, 로그인 하지 않은 상태에서 보이지 않도록 되어 있던 ToDo 메뉴가 보이게 됩니다.




createAsyncThunk 를 사용한 로그인


createAsyncThunk 은 Redux Toolkit에서 제공하는 유틸리티 함수 중 하나로, 비동기 작업을 처리하는 Redux Thunk 를 간편하게 생성하는 데 사용됩니다.

Thunk 액션 생성자를 통해 비동기 작업을 처리하고, 그 결과에 따라 자동으로 pending, fulfilled, rejected 액션을 생성합니다.

  1. 작업이 시작될 때 pending 액션을 생성하여 상태를 업데이트합니다.

  2. 작업이 성공하면 fulfilled 액션을 생성하고, 작업의 결과를 payload 로 설정하여 상태를 업데이트합니다.

  3. 작업이 실패하면 rejected 액션을 생성하고, 에러 정보를 error 및 payload 로 설정하여 상태를 업데이트합니다.


1. 비동기 작업을 위한 Redux Thunk 생성

// loginSlice
export const loginPostAsync = createAsyncThunk('loginPostAsync', (param) => {
    return loginPost(param)
})

Redux Toolkit 에서 비동기 작업을 처리하기 위한 Thunk 액션 생성자를 생성하는 부분입니다. 위에서는 createAsyncThunk 함수를 사용하여 비동기 Thunk 액션을 정의하고 있습니다.

'loginPostAsync' 는 Thunk 액션의 유형을 식별하는 문자열입니다. 이 문자열은 생성되는 액션의 유형을 고유하게 식별하는 데 사용됩니다.

(param) => { return loginPost(param) } 은 비동기 작업을 처리하는 함수를 정의합니다. 이 함수는 param 이라는 매개변수를 받아서, 서버에 로그인을 요청하는 API 인 loginPost 함수를 호출하고 해당 함수의 반환값(Promise)을 리턴합니다.


2. Thunk 액션에 대한 상태 업데이트 정의

// loginSlice
const loginSlice = createSlice({
    ...
    extraReducers: (builder) => {
        builder
            .addCase(loginPostAsync.fulfilled, (state, action) => {
                console.log("fulfilled action = ", action);
                return action.payload;
            })
            .addCase(loginPostAsync.pending, (state, action) => {
                console.log("pending action = ", action);
            })
            .addCase(loginPostAsync.rejected, (state, action) => {
                console.log("rejected action = ", action);
            })
    }
})

extraReducers 를 사용하여 비동기 Thunk 액션에 대한 상태 업데이트를 정의하는 부분입니다. 이를 통해 비동기 작업의 성공(fulfilled), 진행 중(pending), 실패(rejected)에 따라 Redux 상태를 업데이트할 수 있습니다.

builder 는 createSlice에서 제공하는 객체로, 여러 개의 리듀서 케이스를 처리할 수 있게 해주는 메서드를 제공하는 빌더 객체입니다. addCase 메서드를 사용하여 각각의 액션에 대한 핸들러를 정의할 수 있는데 액션 유형과 처리 함수를 전달 받습니다.


3. Redux Thunk 호출을 통한 비동기 작업 수행

// LoginComponent
const handleClickLogin = () => {
    // dispatch(login(loginParam))
    dispatch(loginPostAsync(loginParam));
}

기존에 dispatch(login(loginParam)) 를 사용하던 방식에서 Thunk 액션을 사용하는 방식으로 변화되었습니다.

createAsyncThunk 를 통해 생성된 Thunk 액션은 dispatch 를 통해 디스패치됩니다.
Thunk 함수를 호출하면 자동으로 해당 비동기 작업에 대응하는 pending, fulfilled, rejected 액션이 생성되고 상태가 업데이트됩니다.


4. 로그 확인

처음 시작할 때는 payload 가 undefined 인 것을 확인할 수 있지만 성공 시 호출되는 fulfilled 의 payload 를 보면 서버에서 전달해준 정보들이 담겨 있는 것을 확인할 수 있습니다. 이를 통해 로그인 상태를 업데이트합니다.




로그인 성공 처리


function LoginComponent() {
    ...
    const handleClickLogin = () => {
        dispatch(loginPostAsync(loginParam))
            .unwrap()
            .then(data => {
                console.log('after unwrap data = ', data);
                if(data.error) {
                    alert('로그인에 실패하였습니다');
                } else {
                    navigate({pathname:'/'}, {replace:true});
                }
        })
    }
}

Thunk 액션은 기본적으로 비동기 작업의 결과로 Promise 를 반환합니다.
unwrap()createAsyncThunk 와 함께 사용되는 메서드로 Thunk 액션에서 반환된 Promise 를 언래핑합니다.

then(data => {...})unwrap() 메서드로 추출된 Promise 가 해결될 때 실행되는 콜백 함수입니다. 비동기 작업이 성공하면 data 변수에 작업의 결과가 전달됩니다.

{ replace: true } 는 브라우저의 히스토리 스택에 새 항목을 추가하지 않고 현재 항목을 대체하도록 지시합니다. 즉, 뒤로가기 버튼을 눌렀을 때 다시 로그인 창이 뜨지 않도록 합니다.

위의 로그는 LoginComponent 에서 로그인 실패한 뒤의 로그입니다. 비동기 작업 처리 이후에 payload 에 담긴 데이터unwrap() 이후의 data 에 담긴 데이터가 동일한 것을 확인할 수 있습니다.




로그인 관련 HOOK 생성


[ 로그인 처리 ]

export const useLogin = () => {
    const navigate = useNavigate();
    const dispatch = useDispatch();
    const loginState = useSelector(state => state.loginSlice);

    // 비동기로 로그인을 처리하는 함수를 호출
    const doLogin = async(loginParam) => {
        const action = await dispatch(loginPostAsync(loginParam));
        return action.payload;
    }

    const moveToPath = (path)    => {
        navigate({pathname: path}, {replace:true});
    }
    ...
}

컴포넌트에서 로그인을 하기 위에 useDispatch, 로그인 후 페이지 이동을 위해 useNavigate, 로그인 상태를 알기 위해 useSelector 를 사용해야 했는데 이 모든 것을 하나의 HOOK 으로 만들어 사용할 수 있습니다.


const {doLogin, moveToPath} = useLogin();
const handleClickLogin = () => {
    doLogin(loginParam)
        .then(data => {
            if(data.error) {
                alert("로그인에 실패하였습니다");
            } else {
                moveToPath("/")
            }
        })
}

HOOK 에서 로그인을 요청하는 함수, 페이지를 이동하는 함수를 가져와 로그인을 시도하고, 로그인이 성공하면 페이지를 이동하도록 합니다.



[ 로그인 하지 않은 경우 페이지 이동 ]

const isLogin = loginState.email ? true : false;    // 로그인 여부

const moveToLoginReturn = () => {
    return <Navigate replace to="/member/login" />
}

HOOK 에서 Navigate 를 통해 페이지가 변환되도록 하는 함수를 정의합니다.


function AboutPage() {
    const {isLogin, moveToLoginReturn} = useLogin();

    // 로그인 하지 않았다면 로그인 페이지로 이동
    if(!isLogin) {
        return moveToLoginReturn();
    }
    ...
}

HOOK 에서 가져온 로그인 상태를 보고, 로그인이 되지 않았다면 HOOK 의 moveToLoginReturn() 을 반환하여 로그인 페이지로 이동하도록 합니다.




쿠키를 이용한 로그인 유지


새로고침은 현재 페이지를 새로 로드하며, 이로 인해 React 컴포넌트 트리가 다시 마운트되고 초기화됩니다. 그래서 리액트 애플리케이션에서 새로고침을 했을 때 애플리케이션의 상태(state)가 초기화되기 때문에 로그아웃됩니다.

이를 해결하기 위해 로그인 상태를 쿠키나 로컬 스토리지에 저장하여 새로고침 후에도 상태가 유지되도록 할 수 있으며, 해당 강의에서는 react-cookie 를 사용합니다.


const cookies = new Cookies();

export const setCookie = (name, value, days) => {
    const expires = new Date();
    expires.setUTCDate(expires.getUTCDate + days);  // 쿠키를 유지할 날짜를 더해준다

    return cookies.set(name, value, {
        expires: expires,
        path: '/'
    })
}

setCookie 라는 함수를 생성하여 쿠키의 이름, 값, 유지할 일 수를 전달 받습니다. 그 후 cookies.set 메서드를 사용하여 쿠키를 설정합니다.

cookies.set 메서드는 react-cookie 라이브러리에서 제공되는 메서드로서, 브라우저 쿠키를 설정하는데 사용됩니다. 이 메서드는 쿠키의 특정 속성들을 설정하여 쿠키를 생성하거나 업데이트합니다.

cookies.set(name, value, [options]);

이때 options 는 쿠키의 설정 옵션을 지정하는 객체이며 아래와 같은 속성들이 있습니다.

  1. path : 쿠키의 유효 경로를 지정합니다. 기본값은 애플리케이션 전체로 지정됩니다.

  2. expires : 쿠키의 만료 날짜를 설정합니다. Date 객체로 직접 지정하거나, 만료 기간을 일수로 나타내는 숫자를 사용할 수 있습니다.

  3. maxAge : 쿠키의 최대 수명을 초 단위로 설정합니다. expires와 중복되지만, 이 속성이 지정되면 expires를 무시합니다.

  4. secure : HTTPS 연결에서만 쿠키를 전송하도록 지정합니다.

  5. httpOnly : JavaScript를 통해 쿠키에 접근할 수 없도록 지정합니다.


extraReducers: (builder) => { builder
    .addCase(loginPostAsync.fulfilled, (state, action) => {
        const payload = action.payload;
        if(!payload.error) {
            setCookie("member", JSON.stringify(payload), 1);
        }
        ...
    })
}

여기서 주의해야 할 점은 쿠키의 value 에는 문자열이 들어가야 하기 때문에 서버에서 json 형식으로 받아온 데이터를 위의 코드처럼 문자열로 변환해서 저장해야 합니다.


const loadMemberCookie = () => {
    return getCookie("member");
}

const loginSlice = createSlice({
    name: 'loginSlice',
    initialState: loadMemberCookie() || initState,
    ...
})

또 loginSlice 에서 initState 를 비워놓았기 때문에 새로고침해도 쿠키는 유지되지만 initialState 에 로그인 상태가 유지되지 않습니다. 그렇기 때문에 initialState 를 쿠키에 있는 정보로 초기화 하는 과정이 필요합니다.




Access Token 처리


[ Interceptor ]

Axios 인터셉터(Interceptor)는 Axios에서 제공하는 기능 중 하나로, HTTP 요청과 응답을 가로채고 수정하는 기능을 제공합니다.

이를 사용하면 전역 수준에서 HTTP 요청과 응답을 중간에 가로채어 수정할 수 있어 코드 중복을 줄이고 일관된 처리를 적용할 수 있습니다. Axios 인터셉터는 axios.interceptors 객체를 통해 설정할 수 있습니다.

인터셉터는 요청 인터셉터( Request Interceptor )응답 인터셉터( Response Interceptor )로 나뉘게 됩니다. 이러한 인터셉터는 여러 개를 등록할 수 있으며, 등록된 순서대로 실행됩니다.

인터셉터 함수는 use() 를 사용하여 등록하며, use 메서드의 첫 번째 콜백은 성공 시 실행되는 함수이고, 두 번째 콜백은 오류 시 실행되는 함수입니다.


요청 인터셉터(Request Interceptors)

요청 인터셉터는 HTTP 요청을 보내기 전에 실행되는 함수입니다. 주로 아래와 같은 용도로 사용됩니다.

  1. 헤더 추가 : 모든 요청에 공통으로 헤더를 추가하는 경우

  2. 인증 토큰 처리 : 모든 요청에 인증 토큰을 추가하는 경우

import axios from 'axios';

axios.interceptors.request.use(
  config => {
    // 요청을 보내기 전에 실행되는 코드
    // config 객체를 수정하여 요청을 수정할 수 있음
    return config;
  },
  error => {
    // 요청 오류 처리
    return Promise.reject(error);
  }
);

응답 인터셉터(Response Interceptors)

응답 인터셉터는 HTTP 응답을 받은 후 실행되는 함수입니다. 주로 아래와 같은 용도로 사용됩니다.

  1. 에러 처리 : HTTP 응답이 에러 상태 코드를 포함하는 경우 에러를 처리

  2. 응답 데이터 변형 : 받은 응답 데이터를 수정.

import axios from 'axios';

axios.interceptors.response.use(
  response => {
    // 2xx 범위에 있는 상태 코드는 이 함수를 트리거 & 응답 성공을 반환 하기 전에 수행
    // response 객체를 수정하여 응답을 수정할 수 있음
    return response;
  },
  error => {
    // 2xx 외의 범위에 있는 상태 코드는 이 함수를 트리거 합니다.
    // 응답 오류 처리
    return Promise.reject(error);
  }
);

커스텀 인스턴스

const instance = axios.create();
instance.interceptors.request.use(function () {/*...*/});

커스텀 인스턴스에서도 인터셉터를 추가할 수 있습니다.



[ 커스텀 Interceptor ]

const jwtAxios = axios.create();

const beforeReq = (config) => {
    const memberInfo = getCookie("member")
    ...
    const {accessToken} = memberInfo;
    config.headers.Authorization = `BEARER ${accessToken}`
    return config;
}
...
jwtAxios.interceptors.request.use(beforeReq, requestFail)
jwtAxios.interceptors.response.use(responseSuccess, responseFail)

export default jwtAxios;

요청을 보내기 전에 cookie 에 있는 accessToken 을 꺼내서 요청 헤더의 Authorization 으로 추가합니다.
그 후 jwtAxios.interceptors.request.use 를 통해 beforeReq 함수를 요청 인터셉터로 등록합니다.

다만 api 를 요청할 때 axios.get(), axios.post() 로 호출하던 함수들을 jwtAxios.get(), jwtAxios.post() 로 변경해야 해당 요청 전에 header 에 access token 을 담아 보낼 수 있습니다.




Refresh Token 을 이용한 Access Token 갱신


1. 토큰 재발급

const refreshJwt = async(accessToken, refreshToken) => {
    const header = {headers: {
        'Authorization': `BEARER ${accessToken}`
    }}
    const res = await axios.get(`${API_SERVER_HOST}/api/refresh?refreshToken=${refreshToken}`, header)
    return res.data;
}

refreshJwt 함수는 주어진 Access Token 과 Refresh Token 을 사용하여 서버에 토큰을 갱신하는 비동기 함수입니다.

Axios를 사용하여 서버의 /api/refresh 엔드포인트로 GET 요청을 보냅니다. 이때 Authorization 헤더에는 원래 Access Token 을 넣고, 쿼리 파라미터로 Refresh Token 을 전달합니다.


2. 재발급된 토큰으로 원래 요청을 재시도

// 성공 응답 전 호출
const beforeSuccessRes = async (res) => {
    const data = res.data;
    console.log("data = ", data);
    
    // 토큰 만료 응답을 받은 경우
    if(data && data.Token_Expired) {
        console.log("Token is Expired");
        const memberCookieValue = getCookie("member");
        const result = await refreshJwt(memberCookieValue.accessToken, memberCookieValue.refreshToken);

        // 쿠키 값 변경
        memberCookieValue.accessToken = result.accessToken;
        memberCookieValue.refreshToken = result.refreshToken;

        // 쿠키 갱신
        setCookie("member", JSON.stringify(memberCookieValue), 1);

        // 원래 요청에서 헤더에 담긴 access token 을 수정하여 다시 요청을 보낸다
        const originalRequest = res.config;
        originalRequest.headers.Authorization = `BEARER ${result.accessToken}`
        return await axios(originalRequest);
    }
    return res;
}

beforeSuccessRes 함수는 Axios 응답을 가로채고, 특정 조건에 따라 토큰을 갱신하고 원래 요청을 재시도합니다.

만약 응답 데이터에 Token_Expired 라는 속성이 존재하면 토큰이 만료된 상태로 간주합니다. 그때 refreshJwt 함수를 사용하여 토큰을 갱신하고, 갱신된 토큰으로 쿠키 값을 변경하고 쿠키를 갱신합니다.

그 후 원래 요청의 헤더에 새로운 AccessToken 을 적용하여 요청을 재시도하며, 최종적으로 갱신된 토큰이 포함된 새로운 Axios 요청이 반환됩니다.


/list 요청을 보냈을 때 data 를 보면 토큰이 만료됐을 때 오는 Token_Expired 가 응답에 존재하는 것을 확인할 수 있고, 토큰 갱신을 위한 조건문 내부에 있는 Token is Expired 가 출력되는 것을 확인할 수 있습니다.

그 후 /refresh 로 갱신 요청을 해서 신규 Access Token 을 받은 후 이를 가지고 다시 원래 요청인 /list 를 요청하는 것을 확인할 수 있습니다.

0개의 댓글