[React] Redux Toolkit 을 이용한 로그인 상태 관리 (1)
Redux Toolkit 을 이용한 로그인 상태 관리
1. Redux ? Toolkit ?
리액트를 공부중이라면 State 변수를 들어봤을 것이다.
import React, {useState, useEffect} from 'react'
const Login = () => {
const [email, setEmail] = useState('')
const [pwd, setPwd] = useState('')
return (
<div className='loginform'>
//...
</div>
)
}
email, pwd 라는 state 변수는 현재 컴포넌트 내부에서만 사용되기 때문에, 컴포넌트 간의 상태를 공유할 수 없다.
만약 현재 로그인한 상태임을 확인하는 로직을 짜야할 때 useState 와 useEffect 만 사용할 줄 안다면,
서버와 통신해서 로그인 여부를 확인하는 코드를 매 페이지마다 useEffect 로 남겨야 할 것이다.
const [loginUser, setLoginUser] = useState({})
useEffect(
()=>{
axios.get('/api/member/getLoginUser')
.then((result)=>{
setLoginUser(result.data.loginUser)
}).catch((err)=>{ console.error(err) })
}, []
)
대략 이런 식으로 말이다.
위의 코드는 session 과 cookie 에 담아둔 로그인 정보를 서버에서 가져오는 코드다.
그런데 기존의 redux 방식을 더 간편하게 줄인 Redux Toolkit 을 이용하면
const loginUser = useSelector(state => state.user)
단 한 줄로 로그인 정보를 가져올 수 있다.
그래서 Redux 가 무엇이냐.
Redux 란 자바스크립트 애플리케이션의 상태 관리를 위한 라이브러리다.
state 변수와는 달리 애플리케이션의 상태를 중앙에서 관리하여 여러 컴포넌트 간에 상태를 쉽게 공유할 수 있다.
비동기 로직 처리, 상태 추적 및 디버깅에 용이하다.
상태를 자유롭게 관리할 수 있는 Redux 는 설정이 복잡하다는 단점이 있다.
Redux 의 단점인 복잡한 설정을 간소화한 도구가 바로 Redux Toolkit 이다.
복잡한 설정은 간단하게, 불필요한 설정을 제거해 코드량을 줄여주고, 더 많은 기본 기능을 제공하는 유용한 도구다.
2. Redux 사용을 위한 모듈 설치
Redux 또한 상태를 관리하는 역할을 한다고 했다.
state 변수와 다르게 Redux 에서의 '상태' 는 store 라는 중앙 집중식 저장소에 저장되고, action 을 통해 상태가 변경된다.
reducer 는 이러한 변경을 처리하며, 상태 업데이트가 발생할 때마다 애플리케이션이 예측 가능한 방식으로 작동하도록 보장한다.
이러니 설정이 복잡하지
이를 조금 더 자세히 풀면
애플리케이션 전체에서 공유되는 상태를 저장하는 store(중앙 저장소)가 있고, 그 안에서 상태를 논리적으로 나누어 관리하는 slice(작은 저장소)가 있다.
상태 변경을 요청하는 명령(action)이 전해지면 상태를 업데이트하는 로직을 실행(reducer)해 저장소(store/slice)의 상태를 업데이트한다.
아무튼, Redux 를 사용해 로그인 상태를 관리하기 위해서 설치해야하는 모듈은 다음과 같다
npm i redux react-redux @reduxjs/toolkit
▶ redux
npm i redux
상태 관리 라이브러리로, 중앙 집중식 저장소(store)를 제공하여 애플리케이션의 상태를 관리한다.
▶ react-redux
npm i react-redux
React 컴포넌트가 Redux 상태와 쉽게 상호작용하도록 도와주는 라이브러리다.
Provider, useSelector, useDispatch 같은 훅을 제공한다.
▶ @reduxjs/toolkit
npm i @reduxjs/toolkit
Redux 설정을 단순화하는 도구다.
기존 Reudx의 저장소를 생성하고 관리하는데 필요한 불필요한 설정 대신 createSlice, configureStorage 등의 간단한 API 를 제공하여 빠르고 간편한 개발을 돕는다.
3. slice (작은 저장소)
slice 는 store 를 잘게 쪼갠 것이다.
slice 안에는 변수와 함수도 만들어지기 때문에, 자바와는 형식이 조금 다른 클래스(객체)라고 생각하면 이해하기 쉽다.
하나의 슬라이스에 여러 자료를 객체 형식으로 담고, reducer(상태를 변경하는 함수)로 값을 관리한다.
reducer 안에는 여러가지 동작의 함수들이 담겨져 사용될 수 있다.
slice 에 값을 담아 export 하고, 외부에서 import 해서 사용한다.
우선 store 폴더 아래 로그인 유저 정보를 담을 userSlice.js 를 만들어주었다.
import {createSlice} from '@reduxjs/toolkit'
export const userSlice = createSlice(
{
name: 'user', // slice 이름
initialState: {
// 초기값
},
reducers: {
// 상태 변경을 위한 함수
}
}
)
export const { /* reducers 선언된 함수 */ } = userSlice.actions
export default userSlice
userSlice.js 의 기본 형태다. slice 안에는
1. 로그인 시 저장되는 유저 정보에 해당하는 변수들이 선언되고
2. 로그인 시 선언된 변수에 유저 정보가 들어가며
3. 로그아웃 시 변수값이 빈값으로 초기화된다.
userSlice.js 의 전체 코드는 다음과 같다.
import {createSlice} from '@reduxjs/toolkit'
export const userSlice = createSlice(
{
name: 'user', // slice 이름
initialState: {
nickname: '',
email: '',
provider: '',
profileimg: '',
profilemsg: '',
phone: '',
sns_id: '',
followers: [],
followings: [],
},
reducers: {
loginAction: (state, action)=>{
state.email = action.payload.email; // payload 를 거쳐서 감
state.nickname = action.payload.nickname;
state.phone = action.payload.phone;
state.profileimg = action.payload.profileimg;
state.profilemsg = action.payload.profilemsg;
state.provider = action.payload.provider;
state.sns_id = action.payload.sns_id;
},
logoutAction: (state)=>{
state.email = '';
state.nickname = '';
state.phone = '';
state.profileimg = '';
state.profilemsg = '';
state.provider = '';
state.sns_id = '';
state.followers = [];
state.followings = [];
},
}
}
)
export const { loginAction, logoutAction } = userSlice.actions
export default userSlice
▶ createSlice 임포트
import {createSlice} from '@reduxjs/toolkit'
slice 를 쉽게 생성하는 createSlice 를 toolkit 으로부터 import 한다.
▶ slice 이름 설정 및 초기값 설정 (initialState)
export const userSlice = createSlice({
name: 'user',
initialState: { /* 초기 상태 정의 */ },
reducers: { /* 리듀서 정의 */ },
});
▷ slice 생성
slice 를 생성하는 명령은 createSlice 다.
▷ export
① store(큰 저장소) 에 slice(작은 저장소) 를 추가하고
② 다른 컴포넌트에서 해당 slice 의 action을 사용하기 위해 import 해주어야 하기 때문에 export 해주었다.
내부에서만 사용할 것이라면 상관없지만, slice 는 보통 전역 상태 관리용임으로 export 가 붙는다고 생각하면 된다.
▷ name
name 은 slice 를 구분하는 식별자 역할을 한다.
상태를 변화시키는 함수(reducer) 를 만들고 실행(action) 할 때, reducer 의 이름을 호출한다.
(정확히는 reducer 이름으로 자동 생성된 액션 생성자를 호출하여 액션 객체를 만드는 것이다)
해당 slice 의 name 은 'user' 로 설정했다.
user slice 에서 loginAction 라는 reducer 를 정의했다면, user/loginAction 라는 액션 타입이 생성되는 것이다.
물론 slice 에 이름이 정해져 있지 않아도 실행은 할 수 있다. 이 경우 reducer 의 이름만 적으면 된다. (loginAction)
하지만 다른 slice 에서 loginAction 이라는 동일한 이름의 reducer 가 정의되면 충돌이 발생한다.
이를 미연에 방지하기 위해 정해주는 것이다.
export const userSlice = createSlice(
{
name: 'user', // slice 이름
initialState: {
nickname: '',
email: '',
provider: '',
profileimg: '',
profilemsg: '',
phone: '',
sns_id: '',
followers: [],
followings: [],
},
로그인 시 저장되어야 하는 정보는 다음과 같다.
본인은 sns 서비스를 실습하고 있어 만들어둔 member 컬럼을 포함해
유저의 팔로우, 팔로잉 목록까지 함께 불러올 예정이다.
▶ 변수값 조작을 위한 함수 (reducers)
reducers: {
// state : 함수에서 위(initialState)에 선언한 변수들에 접근하기 위한 객체 이름
// action : 외부에서 reducer 내에 정의된 함수를 호출하면서 전달해준 전달인수를 받는 매개변수
loginAction: (state, action)=>{
state.email = action.payload.email; // payload 를 거쳐서 감
state.nickname = action.payload.nickname;
state.phone = action.payload.phone;
state.profileimg = action.payload.profileimg;
state.profilemsg = action.payload.profilemsg;
state.provider = action.payload.provider;
state.sns_id = action.payload.sns_id;
},
logoutAction: (state)=>{
state.email = '';
state.nickname = '';
state.phone = '';
state.profileimg = '';
state.profilemsg = '';
state.provider = '';
state.sns_id = '';
state.followers = [];
state.followings = [];
}
}
▷ state
state 는 initialState 에 정의된 변수들에 접근하고 수정하는 상태 객체다.
변수의 상태를 나타내고 있다.
▷ action
상태를 변화시키기 위한 요청으로, 객체다.
▷ payload
action 객체 안의 실제 데이터를 의미한다.
▷ reducers
loginAction 와 logoutAction reducer 를 정의했다.
creatSlice 를 사용하여 reducer 를 정의하면 해당 reducer 이름을 기반으로 액션 생성자가 자동으로 생성된다.
loginAction 는
① 로그인 시 로그인 유저에 대한 정보가 전달되어 action 객체에 담아
② dispatch 를 통해 userSlice 로 전달하고
③ userSlice 에서 action 객체 속 실제 데이터가 담긴 payload 를 통해
④ 변수값을 변경한다.
dispatch 는 해당 reducer 사용을 위한 핵심 메소드로, 상태 변경 트리거 역할을 한다.
(일단은 변수값 변경을 위해 사용한다고 생각하자)
logoutAction 는
로그아웃 시 state 의 모든 값을 빈 값으로 초기화 하기 위한 함수다.
▶ slice export
// 생성한 각 함수를 별도의 이름으로 export 해서 개별적으로 사용되게 함
export const { loginAction, logoutAction } = userSlice.actions
export default userSlice.reducer // userSlice 의 reducer 만 export
userSlice.actions 는 createSlice 에서 자동으로 생성된 액션 생성자를 포함하는 객체다. (reducer 의 이름을 따라간다)
구조 분해를 통해 생성된 액션 생성자를 별도로 추출하여 export 했다.
액션을 디스패치할 때(action 객체를 store 로 보낼 때) 간단히 사용하기 위해서이다.
별도의 이름으로 export 하지 않으면 userSlice.actions.loginAction() 으로 접근 해야 하지만,
별도의 이름으로 export 하면 loginAction() 으로 접근할 수 있다.
4. store (중앙 저장소)
import { configureStore } from '@reduxjs/toolkit';
import userSlice from './userSlice';
export default configureStore({
reducer: { user: userSlice },
middleware: getDefaultMiddleware => getDefaultMiddleware({serializableCheck:false}),
});
▶ configureStore
import { configureStore } from '@reduxjs/toolkit';
configureStore 는 Redux store 설정을 간단하게 할 수 있도록 Redux Toolkit 에서 제공하는 함수다.
▶ reducer import
import userSlice from './userSlice';
앞서 createSlice 로 만들고 userSlice 라 정의해 export 한 reducer 도 import 한다.
store 의 reducer 로 등록하기 위해 필요하다.
▶ store 설정
export default configureStore({
reducer: { user: userSlice },
middleware: getDefaultMiddleware => getDefaultMiddleware({serializableCheck:false}),
});
configureStore 는 store 를 설정하고 반환한다.
export default 를 통해 store 설정을 모듈의 기본 export 로 내보냈다.
▷ reducer
reducer: { user: userSlice },
store 에 저장할 상태(state) 를 관리하는 reducer 함수들을 정의하는 부분이다.
userSlice 에서 지정한 name 을 key 값으로 하여 reducer 를 관리한다.
우리는 userSlice 의 name을 user 로 지정했기에 key 값은 user,
userSlice.reducer 를 export 했기 때문에 userSlice 로 reducer 를 가져오면 된다.
export default userSlice // userSlice 에서 정의된 전체 객체 export
만약 reducer 가 아닌 userSlice 전체 객체를 export 했다면
reducer: { user: userSlice.reducer },
reducer 를 정의할 때 userSlice.reducer 로 reducer 를 지정해주어야 한다.
index.js:4 Store does not have a valid reducer. Make sure the argument passed to combineReducers is an object whose values are reducers.
reducer 가 명시되지 않으면 위와 같은 오류가 발생할 수 있다.
(*꼭 reducer: { user: userSlice.reducer }, 로 받아야만 reducer 를 인식하는 경우도 있다.. 이유는 모르겠지만. )
▷ middleware
// 경고성 에러 내용 무시
middleware: (getDefaultMiddleware)=>
getDefaultMiddleware({serializableCheck: false, })
}
middleware 는 Redux 액션이 reducer 로 전달되기 전에 거치는 중간 단계다.
① 비동기 처리
② 액션 가로채기 (중간에서 수정, 추가 작업)
③ 성능 최적화 (복잡한 계산 미리 처리, 필요하지 않으면 취소)
등의 역할을 한다고 하는데..
serializableCheck: false 로
비직렬화 가능한 값(함수, undefined) 가 액션에 포함되지 않도록 방지하는 설정만 해주었다.
5. Reduc - React 연결
이제 완성된 Redux 설정과 React 를 연결하기 위한 설정을 해주자.
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import store from "./store/index";
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<BrowserRouter>
<Provider store={store}>
<App />
</Provider>
</BrowserRouter>
);
reportWebVitals();
React 애플리케이션의 최상위 진입점인 index.js 다.
기본 설정과 React Router 사용으로 인해 코드가 복잡해보이나
Redux 설정은 단 3가지만 작성하면 된다.
▶ 연결을 위한 컴포넌트 import
import { Provider } from 'react-redux';
import store from "./store/index";
Provider 는 React 컴포넌트 트리의 최상위에서 공급하여 하위 컴포넌트들이 store 에 접근할 수 있도록 한다. (GPT피셜)
쉽게 말해 React 애플리케이션 시작 지점에서 설정해두면 Provider 밑에 오는 모든 컴포넌트들에서 store 를 사용할 수 있게 되는 것이다.
그리고 React 애플리케이션 시작 지점은 npx create-react-app 명령을 통해 처음 만들었을 때 생긴 index.js 다.
(store 폴더 아래 만들었던 중앙 저장소인 index.js 와 혼동하지 않아야 한다.)
Provider 를 통해 store 를 공급하려면 store 도 import 해서 가져와야 한다.
열심히 만들었던 store 폴더 밑의 index.js 중앙 저장소도 함께 import 해주었다.
▶ store 공급
<Provider store={store}>
<App />
</Provider>
Provider 컴포넌트를 통해 Redux store 를 React 애플리케이션에 주입하는 설정이다.
React 애플리케이션의 루트(최상위) 컴포넌트인 App 을 감싸는 형태로 작성했다.
Provider 가 꼭 App 을 감싸지 않아도 된다.
하지만 유지보수와 구조적 단순함을 위해 최상위 컴포넌트인 App을 감싸는 걸 권장하고 있다.
이제 App 을 포함한 모든 하위 컴포넌트에서 store 에 접근할 수 있게 되었다.
6. 상태 업데이트(displatch)
이제 로그인을 담당하는 컴포넌트로 가서 로그인 시 상태를 업데이트해보자.
import React, { useState, useEffect } from 'react'
import axios from 'axios'
import { Cookies } from 'react-cookie'
import { useNavigate } from 'react-router-dom'
import { useDispatch } from 'react-redux'
import { loginAction } from '../../store/userSlice.js'
import SubImg from './SubImg'
import SubMenu from './SubMenu.js'
import '../../style/login.css'
const Login = () => {
const [userid, setUserid] = useState('')
const [pwd, setPwd] = useState('')
const navigate = useNavigate()
const dispatch = useDispatch()
const cookies = new Cookies()
function loginLocal(){
if(!userid){ return alert('아이디를 입력하세요') }
if(!pwd){ return alert('비밀번호를 입력하세요 ') }
axios.post('/api/member/loginLocal', null, {params: {userid, pwd}})
.then((result)=>{
if(result.data.msg === 'ok'){
dispatch( loginAction(result.data.loginUser) )
cookies.set('user', JSON.stringify(result.data.loginUser), {path: '/'})
navigate('/')
}else{
alert(result.data.msg)
setPwd('')
}
}).catch((err)=>{ console.error(err) })
}
return (
<article>
<SubImg/>
<div className='subPage'>
<SubMenu/>
<div className='memberform'>
<div className='field'>
<label>USER ID</label>
<input type="text" value={userid} onChange={
(e)=>{ setUserid(e.currentTarget.value) }
}/>
</div>
<div className='field'>
<label>PASSWORD</label>
<input type="password" value={pwd} onChange={
(e)=>{ setPwd(e.currentTarget.value) }
}/>
</div>
<div className='btns'>
<button onClick={()=>{ loginLocal() }}>LOGIN</button>
<button>JOIN</button>
</div>
<div className='sns-btns'>
// sns login buttons
</div>
</div>
</div>
</article>
)
}
export default Login
Login.js 는 대략 이런 식으로 작성되었다.
state 변수를 통해 userid, pwd(password) 를 받아오고, 그 값이 DB 에 있다면 로그인 처리를 해주는 것이다.
Redux 가 사용된 부분은 다음과 같다.
▶ 상태 업데이트 도구 useDispatch 와 상태 변경을 위한 액션 생성자 import
import { useDispatch } from 'react-redux'
import { loginAction } from '../../store/userSlice.js'
상태를 업데이트하기 위해 액션을 디스패치(dispatch) 하는 도구 useDispatch 와
구조 분해를 통해 각각 export 했던, 상태 변경을 위한 액션 생성자 loginAction 을 import 한다.
▶ dispatch 함수
const dispatch = useDispatch()
Redux 에서 상태(state) 를 변경하기 위해 액션(action) 을 store 로 전달하는 함수 dispatch를
useDispatch 훅을 사용하여 가져온다.
▶ 상태 업데이트
dispatch( loginAction(result.data.loginUser) )
앞서 axios 요청을 통해 loginUser 객체 데이터를 result 에 담아온 상태다.
액션 생성 함수인 loginAction 은 액션 객체를 반환한다.
액션 객체에는 리듀서가 어떤 작업을 수행해야 하는지 식별하는 고유한 값 type과
액션과 함께 전달되는 데이터 payload 가 존재한다.
{
type: 'user/login',
payload: result.data.loginUser,
}
loginAction 을 통해 반환된 액션 객체는
특별한 일을 하는 것은 아니나, 상태 변경을 위한 데이터를 포함하고 있어 상태 변경의 의도를 나타내기에 '트리거' 라고도 한다.
실제 상태를 변경하는 일은 dispatch 가 처리한다.
① React 컴포넌트에서 상태 변경 요청이 발생하면
② dispatch 가 액션 객체를 store 에 전달하고
③ store 는 reducer 를 호출하여 현재 state 와 액션 객체를 전달한다.
④ reducer 는 현재 state 와 액션 객체를 기반으로 새로운 state를 계산하고, 그 state저장한다.
7. 상태 조회(useSelector)
상태 조회는 더 간단하다.
▶ 상태 조회를 위한 useSelector import
import { useSelector } from 'react-redux';
Redux store 의 state 를 선택(select) 하여 리액트 컴포넌트에 접근할 수 있게 해주는 훅이다.
상태를 직접적으로 읽는 기능만 한다.
▶ 상태 받기
const loginUser = useSelector(state => state.user);
useSelector 는 하나의 함수 인자를 받는다. 근데 그게 전체 state 객체인
전체 state 객체를 인자로 받고(어쨌든 하나다.) 원하는 '부분 state'를 반환한다.
위에서는 store 의 상태(state)들 중에서 user 상태(state)를 선택했다.
useSelector 는 컴포넌트가 필요로 하는 상태를 '구독'한다고 생각하면 된다.
상태가 변경되어야 해당 상태가 사용하는 컴포넌트가 리렌더링 되며, 변경되지 않으면 리렌더링되지 않는다.
갱신은 빠르게, 불필요한 렌더링을 줄여 성능 최적화에 도움을 준다.
▶ 사용 예시
useEffect(
()=>{
if(!loginUser || !loginUser.userid){
alert('로그인이 필요한 서비스입니다.');
navigate('/');
}
setUserid(loginUser.userid);
setEmail(loginUser.email);
},[]
);
앞서 loginUser 라는 이름의 변수로 user 상태를 받았다.
그렇다.
loginUser 에 현재 로그인 한 유저의 정보가 모두 담기게 되었다.
loginUser 는 유저 정보를 담고 있는 객체이므로, 객체 속 세부 데이터까지 접근하기 위해서는 ' . ' 을 이용하면 된다.
loginUser.userid 로 userid 를, loginUser.email 로 email 을 가지고 올 수 있다.
문제는 앞선 방법으로만 redux 를 설정하면
브라우저를 닫는 건 물론, 페이지 새로고침만 해도 데이터는 사라진다.
우리는 로그인 데이터를 어느 페이지를 이용하든 유지시켜야 하기 때문에
다음 포스트에서는 persist 나 cookies 를 사용하여 로그인 상태를 유지시키는 법을 알아볼 것이다.