아래 내용은 코드로 배우는 React with 스프링부트 API서버 강의와 함께 설명이 부족한 부분에 대해서 조사하여 추가한 내용입니다.
Redux 는 JavaScript 앱의 상태 관리를 위한 상태 컨테이너 라이브러리입니다. 여기서 중요한 점은 데이터 같은 것이 아닌 로그인과 같은 애플리케이션의 상태라는 점입니다.
Redux 의 핵심 아이디어는 애플리케이션의 상태를 예측 가능한 단일 스토어(Store)에 저장하고, 상태의 변경을 관리하는 것입니다. 애플리케이션의 모든 상태는 이 단일 스토어에 저장되며, 컴포넌트 간에 상태를 전달하는 대신, 각 컴포넌트는 필요한 상태를 스토어에서 가져와 사용합니다.
액션( Action ) : 상태 변경의 원인이 되는 객체. 앱에서 일어나는 모든 상태 변경은 액션을 통해 이루어집니다.
리듀서( Reducer ) : 현재 상태와 액션을 받아 새로운 상태를 반환하는 함수. 액션에 따라 상태를 어떻게 변경할지 정의합니다.
스토어( Store ) : 앱의 상태를 저장하는 객체. 단일 스토어에 모든 상태가 저장되어 있으며, 상태 변경을 구독하고 상태를 업데이트하는 데 사용됩니다.
디스패치( Dispatch ) : 액션을 스토어에 전달하는 메서드. 스토어의 상태를 변경하려면 액션을 디스패치해야 합니다.
미들웨어( Middleware ) : 액션과 리듀서 사이에서 동작하는 플러그인. 비동기 작업, 로깅 등의 기능을 추가할 수 있습니다.
import { configureStore } from "@reduxjs/toolkit";
export default configureStore({
reducer: { }
})
Redux Toolkit의 configureStore
는 Redux 스토어를 설정하고 반환하는 역할을 합니다. 리듀서는 앱의 상태를 변경하는 함수로, 스토어에 저장된 데이터를 어떻게 업데이트할지 정의합니다. reducer 옵션에는 이러한 리듀서들을 포함하는 객체를 전달합니다.
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 는 현재 상태와 액션을 받아 새로운 상태를 반환하는 함수입니다.
Redux Toolkit 에서 createSlice
는 Redux 리듀서 및 액션을 생성하는 유틸리티 함수 중 하나입니다. createSlice
함수를 사용하여 생성된 것을 슬라이스(Slice) 라고 합니다.
슬라이스는 리듀서와 그에 딸린 액션들을 포함하는 논리적인 모듈이라고 생각할 수 있습니다. createSlice
를 사용하면 액션 생성자와 리듀서가 자동으로 생성되며 슬라이스에 정의된 각 액션에 대한 상태 변경 로직이 리듀서에 구현됩니다.
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
에 등록하여 사용할 수 있습니다.
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 훅은 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 은 Redux Toolkit에서 제공하는 유틸리티 함수 중 하나로, 비동기 작업을 처리하는 Redux Thunk 를 간편하게 생성하는 데 사용됩니다.
Thunk 액션 생성자를 통해 비동기 작업을 처리하고, 그 결과에 따라 자동으로 pending, fulfilled, rejected 액션을 생성합니다.
작업이 시작될 때 pending 액션을 생성하여 상태를 업데이트합니다.
작업이 성공하면 fulfilled 액션을 생성하고, 작업의 결과를 payload 로 설정하여 상태를 업데이트합니다.
작업이 실패하면 rejected 액션을 생성하고, 에러 정보를 error 및 payload 로 설정하여 상태를 업데이트합니다.
// 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)을 리턴합니다.
// 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
메서드를 사용하여 각각의 액션에 대한 핸들러를 정의할 수 있는데 액션 유형과 처리 함수를 전달 받습니다.
// LoginComponent
const handleClickLogin = () => {
// dispatch(login(loginParam))
dispatch(loginPostAsync(loginParam));
}
기존에 dispatch(login(loginParam))
를 사용하던 방식에서 Thunk 액션을 사용하는 방식으로 변화되었습니다.
createAsyncThunk
를 통해 생성된 Thunk 액션은 dispatch
를 통해 디스패치됩니다.
Thunk 함수를 호출하면 자동으로 해당 비동기 작업에 대응하는 pending, fulfilled, rejected 액션이 생성되고 상태가 업데이트됩니다.
처음 시작할 때는 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 에 담긴 데이터가 동일한 것을 확인할 수 있습니다.
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 는 쿠키의 설정 옵션을 지정하는 객체이며 아래와 같은 속성들이 있습니다.
path : 쿠키의 유효 경로를 지정합니다. 기본값은 애플리케이션 전체로 지정됩니다.
expires : 쿠키의 만료 날짜를 설정합니다. Date 객체로 직접 지정하거나, 만료 기간을 일수로 나타내는 숫자를 사용할 수 있습니다.
maxAge : 쿠키의 최대 수명을 초 단위로 설정합니다. expires와 중복되지만, 이 속성이 지정되면 expires를 무시합니다.
secure : HTTPS 연결에서만 쿠키를 전송하도록 지정합니다.
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 를 쿠키에 있는 정보로 초기화 하는 과정이 필요합니다.
Axios 인터셉터(Interceptor)는 Axios에서 제공하는 기능 중 하나로, HTTP 요청과 응답을 가로채고 수정하는 기능을 제공합니다.
이를 사용하면 전역 수준에서 HTTP 요청과 응답을 중간에 가로채어 수정할 수 있어 코드 중복을 줄이고 일관된 처리를 적용할 수 있습니다. Axios 인터셉터는 axios.interceptors
객체를 통해 설정할 수 있습니다.
인터셉터는 요청 인터셉터( Request Interceptor )와 응답 인터셉터( Response Interceptor )로 나뉘게 됩니다. 이러한 인터셉터는 여러 개를 등록할 수 있으며, 등록된 순서대로 실행됩니다.
인터셉터 함수는 use()
를 사용하여 등록하며, use 메서드의 첫 번째 콜백은 성공 시 실행되는 함수이고, 두 번째 콜백은 오류 시 실행되는 함수입니다.
요청 인터셉터는 HTTP 요청을 보내기 전에 실행되는 함수입니다. 주로 아래와 같은 용도로 사용됩니다.
헤더 추가 : 모든 요청에 공통으로 헤더를 추가하는 경우
인증 토큰 처리 : 모든 요청에 인증 토큰을 추가하는 경우
import axios from 'axios';
axios.interceptors.request.use(
config => {
// 요청을 보내기 전에 실행되는 코드
// config 객체를 수정하여 요청을 수정할 수 있음
return config;
},
error => {
// 요청 오류 처리
return Promise.reject(error);
}
);
응답 인터셉터는 HTTP 응답을 받은 후 실행되는 함수입니다. 주로 아래와 같은 용도로 사용됩니다.
에러 처리 : HTTP 응답이 에러 상태 코드를 포함하는 경우 에러를 처리
응답 데이터 변형 : 받은 응답 데이터를 수정.
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 () {/*...*/});
커스텀 인스턴스에서도 인터셉터를 추가할 수 있습니다.
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 을 담아 보낼 수 있습니다.
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
를 요청하는 것을 확인할 수 있습니다.