티스토리 뷰
어떤 상태 관리 라이브러리를 선택할까?(State, Context API, Redux, React-Query, Recoil)
geunu 2022. 8. 28. 21:56State와 Props
리액트에서 상태관리를 위해 기본적으로 지원해주는 state와 props를 사용할 수 있지만 문제점이 있습니다.
state와 props는 기본적으로 단방향 하향식으로 데이터를 전달한다. (부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달합니다)
특징으로는 구조가 단순하여 유지보수하기 쉬우며, 단방향 하향식으로 데이터 전달되기 때문에 보통 최상위 컴포넌트 App컴포넌트에서 상태 관리를 한다.
이것은 전역적으로 상태 관리를 할 때는 비효율적이다. 아래의 그림과 같이 App컴포넌트에서 상태관리를 한다고 가정했을 때, App컴포넌트에서 관리하는 value값을 G컴포넌트로 전달하려면 복잡하게 (App -> A -> B -> E -> G)까지 여러 과정을 거쳐야한다. (이런 문제를 Prop Drilling이라고 한다)
컴포넌트 수가 많아지고 상태 값들이 많아진다면, 거쳐가야 하는 컴포넌트도 많을 것이고 코드도 복잡하게 될 것이다.
또한 불필요한 리렌더링이 발생할 수도 있다. 이것은 리렌더링 발생 조건 중 하나인 부모 컴포넌트가 렌더링되면 자식 컴포넌트가 리렌더링 되기 때문이다. (예를 들어서 App컴포넌트 렌더링 -> 자식 컴포넌트 리렌더링 -> 그 자식 컴포넌트 리렌더링 -> 그 자식 컴포넌트 리렌더링 -> ... 끝까지 반복적으로 불필요한 리렌더링이 발생할 수 있다)
따라서 해당 컴포넌트에서나 몇개 안거치는 컴포넌트에서는 state, props를 사용해도 상관 없지만,
전역적으로 필요한 상태라면 여러 컴포넌트를 거치지 않고 바로 상태를 가져와서 사용할 수 있게끔 하는 것이 효율적이다. 그래서 다른 상태관리 라이브러리들(Redux, Context API 등)을 사용하는 이유이다!
Context API
리액트에 내장되어 있는 Context API는 전역 상태 관리를 할 수 있게 해주어 state의 Prop Drilling 문제점을 해결해줄 수 있다. 그러나 더 큰 문제점이 존재하는데 어떤 상태값이 변경하면, 다른 컴포넌트들이 불필요한 리렌더링이 발생한다.
불필요한 리렌더링 상황을 막기 위해 다른 방법들을 시도하지만, 코드가 지저분해지고 보일러 플레이트 코드가 너무 많은 문제점이 존재한다고 한다. 따라서 다른 라이브러리를 선택하는 것이 좋을 것 같고, 사용해본 적이 없기에 여기까지만 언급하려고 한다.
Redux
Redux는 예전에 많이 사용했지만, 현재는 단점으로 인해 추세가 낮아지고 있는 상태 관리 라이브러리이다.
Redux의 경우 클라이언트 상태를 전역적으로 관리할 때 효율적이다. 그러나 서버 상태(네트워크 요청을 통해 받아온 데이터)를 관리할 때는 Redux + 미들웨어를 사용한다. Redux는 모든 동작들이 동기적으로 작동하기 때문에, 미들웨어를 사용함으로써 비동기적인 동작을 제어할 수 있게 해줘서 네트워크 요청 로딩, 성공, 실패에 대한 처리를 해줄 수 있다.
이전 프로젝트에서 Redux + Redux-saga 조합으로 사용하여 다음과 같이 전역 클라이언트 상태 관리 + 서버 상태 관리를 했다.
Redux, Redux-saga를 사용한 프로젝트에서의 상태관리는 다음과 같이 했었다.
-단일 컴포넌트에서의 데이터는 useState (전역 데이터X, 네트워크 요청 데이터X)
-전역적으로 필요한 데이터는 Redux (전역 데이터O, 네트워크 요청 데이터X)
-네트워크 요청 데이터는 Redux + Redux-saga (네트워크 요청 데이터O)
이것들을 코드로 설명하기 위해 예를 들어보고자 한다.
로그인 컴포넌트에서는 email값과 password값이 존재한다. 이 때 email값과 password값을 전역적으로 사용되는 값이라고 가정 한다면 이 때 Redux를 사용한다.
- modules/login.ts -
아래는 Redux의 login 모듈(액션 타입, 액션 생성 함수, 리듀서)을 작성한 코드이다.
import { LoginAction, LoginState } from './type';
//액션 타입
const LOGIN_CHANGE_INPUT = 'login/LOGIN_CHANGE_INPUT' as const;
//액션 생성 함수
export const loginChangeInput = (name: string, value: string) => ({
type: LOGIN_CHANGE_INPUT,
payload: { name, value },
});
//초기값
const initialState: LoginState = {
email: '',
password: '',
};
//리듀서
function login(state = initialState, action: LoginAction) {
switch (action.type) {
case LOGIN_CHANGE_INPUT:
return { ...state, [action.payload.name]: action.payload.value };
default:
return state;
}
}
export default login;
- modules/type.ts -
타입 스크립트를 사용했기에 타입 선언 파일을 따로 작성하였다.
import { loginChangeInput } from './login';
export type LoginAction = ReturnType<typeof loginChangeInput>;
export type LoginState = {
email: string;
password: string;
};
- 로그인 컴포넌트 -
useSelector를 통해 스토어로부터 얻고자 하는 상태를 바로 가져올 수 있다.
useDispatch를 통해 액션 생성 함수를 이용하여 액션 객체를 생성을 통해 상태를 업데이트를 할 수 있다.
const Login = () => {
const { email, password } = useSelector((state: RootState) => ({
email: state.login.email,
password: state.login.password,
}));
const dispatch = useDispatch();
const onChange = (e: { target: { name: string; value: string } }): void => {
dispatch(loginChangeInput(e.target.name, e.target.value));
};
return (
<>
<form>
<input
type="text"
placeholder="아이디를 입력하세요."
name="email"
onChange={onChange}
value={email}
/>
<input
type="password"
placeholder="비밀번호를 입력하세요."
name="password"
onChange={onChange}
value={password}
/>
<button className="loginbutton">로그인</button>
</>
);
};
export default Login;
1. email값 또는 password값을 useSelector를 통해 스토어로부터 얻고자 하는 상태를 바로 가져올 수 있다.
- 위에서는 이름 그대로 email, password로 받아서 input의 value에 넣어줬다.
2. email값 또는 password값을 업데이트 하기 위해서의 과정은 다음과 같다.
2-1. 로그인 컴포넌트의 input onChange 이벤트를 통해 onChange 함수를 호출했다.
2-2. onChange 함수의 dispatch를 통해 login 모듈의 액션 생성 함수를 이용하여 액션 객체를 생성했다.
- 이 때 인자에 있는 것은 액션의 action.payload 값을 의미한다.
2-3. 액션 객체가 생성됨에 따라 리듀서의 있는 해당 액션 타입에 따라 상태를 업데이트 시킨다.
2-4. useSelector를 통해 접근했었던 상태가 업데이트 되면 해당 로그인 컴포넌트가 리렌더링 되며, 업데이트된 상태를 사용할 수 있게 된다.
그림으로 설명하자면 Redux의 흐름은 다음과 같다.
(액션 -> 디스패치 -> 리듀서 -> 스토어(상태) -> View)
하지만 로그인 상태는 네트워크 요청을 통한 로그인 요청을 해야하기 때문에 Redux-Saga를 추가하여 사용해야 한다.
(여기 포스트는 Redux만 다루고자 하는 것이 아니기 때문에 최대한 간략하게 하려고 해도 Redux의 복잡함이 느껴진다ㅠ)
- modules/login.ts -
import { AxiosResponse } from 'axios';
import { call, put, takeLatest } from 'redux-saga/effects';
import * as api from '../../../apis/user';
import { UpdateUserID } from '../user/user';
import { LoginAction, LoginState } from './type';
const LOGIN_CHANGE_INPUT = 'login/LOGIN_CHANGE_INPUT' as const;
const LOGIN_REQUEST = 'login/LOGIN_REQUEST' as const;
const LOGIN_SUCCESS = 'login/LOGIN_SUCCESS' as const;
const LOGIN_FAILURE = 'login/LOGIN_FAILURE' as const;
export const loginChangeInput = (name: string, value: string) => ({
type: LOGIN_CHANGE_INPUT,
payload: { name, value },
});
export const loginRequest = (id: string, password: string) => ({
type: LOGIN_REQUEST,
payload: { id, password },
});
export const loginSuccess = () => ({
type: LOGIN_SUCCESS,
});
export const loginFailure = (e: any) => ({ type: LOGIN_FAILURE, payload: e });
function* loginReqeustSaga(action: ReturnType<typeof loginRequest>) {
try {
const response: AxiosResponse = yield call(
api.login,
action.payload.id,
action.payload.password
);
yield put(loginSuccess());
yield put(UpdateUserID(response.data.data.userId));
} catch (e) {
yield put(loginFailure(e));
}
}
export function* LoginSaga() {
yield takeLatest(LOGIN_REQUEST, loginReqeustSaga);
}
const initialState: LoginState = {
id: '',
password: '',
loading: false,
error: null,
};
const login = (state = initialState, action: LoginAction) => {
switch (action.type) {
case LOGIN_CHANGE_INPUT:
return { ...state, [action.payload.name]: action.payload.value };
case LOGIN_REQUEST:
return { ...state, loading: true, error: null };
case LOGIN_SUCCESS:
return { ...state, loading: false, error: null };
case LOGIN_FAILURE:
return { ...state, error: action.payload, loading: false };
default:
return state;
}
};
export default login;
- modules/type.ts -
import {
loginChangeInput,
loginFailure,
loginRequest,
loginSuccess,
} from './login';
export type LoginAction =
| ReturnType<typeof loginChangeInput>
| ReturnType<typeof loginRequest>
| ReturnType<typeof loginSuccess>
| ReturnType<typeof loginFailure>;
export type LoginState = {
id: string;
password: string;
loading: boolean;
error: any;
};
로그인을 하기 까지의 과정은 다음과 같다.
1. loginRequest 생성 함수를 통해 액션 객체 생성 (액션 안에 action.payload에는 전달받은 email값과 password값이 존재한다)
2. 리듀서의 해당 액션 타입에 따른 상태 업데이트
3. LoginSaga() 실행
4. LoginSaga 내에 있는 loginRequestSaga() 실행
-> 사가는 자바스크립트 제너레이터를 사용하는 것이며, 이것은 비동기적인 동작을 하게끔 도와준다.
-> 쉽게 말해 async/await 대신에 사용한다고 생각하면 된다.
5. loginRequestSaga() 내에서 네트워크 요청을 하며,
-> 성공 시 loginSucess() 액션 생성 함수 실행 -> 리듀서 해당 액션 타입 실행 -> 상태 업데이트
-> 실패 시 loginFailure() 액션 생성 함수 실행 -> 리듀서 해당 액션 타입 실행 -> 상태 업데이트
사실 Redux와 Redux-saga의 문제점을 언급하기 위해 위의 코드들을 언급했다.
1. 너무 장황한 코드이며, 복잡하여 러닝커브가 높다.
2. 다른 모듈(액션 타입, 액션 생성 함수, 리듀서)을 작성할 때도 요청, 성공, 실패라는 액션을 만들어서 사용하며, 저런 복잡하고 장황한 코드들이 비슷하게 반복되어서 사용된다. (=보일러 플레이트)
3. 개인적으로 React-Query를 사용해본적이 있는데 React-Query는 네트워크 요청에 대해 { data, isLoading, error }를 아주 쉽게 사용하게 해준 반면, Redux + Redux-saga의 경우 매우 복잡했다.
최근에 redux toolkit이 나와 createSlice를 사용하여 위의 문제점들을 보완해준다고 했음에도 여전히 크게 달라지지는 않는다고 한다. 그래서 Redux 선택하기에는 큰 단점들이 존재한다.
Recoil
Recoil은 가장 최근에 나온 전역 상태 관리 라이브러리이다. (사진을 보면 2년 됐다) 그래서 npm 다운로드 수가 낮은 거 같다. 또한 Recoil은 react 전용이고 react에 최적화되어 있으며, 상태 관리 라이브러리 중 러닝커브가 가장 낮아 사용하기 가장 쉽다. 해당 공식 문서만 봐도 간략함이 느껴진다...! WoW
하지만 가장 최근에 나왔기에 devtools 지원이 안되지만 이것을 개발한 페이스북이 꾸준히 업데이트 중이기에 믿음이 간다! 또한 현재 몇몇 기업들 중에서도 Recoil를 사용하는 것을 봤다. 나보다 잘하신 분들이 현업에서 선택하셨는데 당연히 좋은 이유가 있기에 선택한 것 아니겠나..! 그런데 recoil은 아직 안정성이 조금 떨어진다. 공식 문서에서도 실험적인 부분들이나 불안정하다고 소개하는 부분들이 있다. 버전도 0.7.5이다. 그래서 조금만 더 생각해봐야 겠다.
Recoil은 atom과 selector로 나눌 수 있다.
먼저 atom을 통해 쉽게 전역 상태 관리를 할 수 있다. 쉽게 설명하자면 atom을 떠다니는 비눗방울이라고 생각하고, 개발하다가 필요할 때는 떠다니는 비눗방울 중 원하는 것에 접근하여 상태값 사용 또는 업데이트 할 수 있게 해준다.
아래의 코드에서와 같이 하나의 atom을 만들어 전역적으로 고유한 값으로 key를 설정해주고, 상태를 선언해주면 된다.
- recoil/login.ts -
import { atom } from 'recoil';
export interface loginState {
email: string;
password: string;
}
export const loginState = atom<loginState>({
key: 'loginState',
default: {
email: '',
password: '',
},
});
atom에 접근(구독)할 때는 useResetRecoilState(), useRecoilValue(), useSetRecoilState(), useRecoilState() 중에서 사용한다.
- useResetRecoilState(): 초기화, 컴포넌트를 구독하지 않는다.
- useRecoilValue(): 상태값만 가져옴, 컴포넌트를 구독한다.
- useSetRecoilState(): 상태값 업데이트 함수만 가져옴, 컴포넌트를 구독하지 않는다.
- useRecoilState(): 상태값, 상태값 업데이트 함수 둘 다 가져옴, 컴포넌트를 구독한다.
여기서 구독한다는 뜻은 atom의 상태값이 업데이트될 때 해당 컴포넌트가 리렌더링 된다는 뜻이다.
하지만, 구독한다고 해서 아무때나 해당 컴포넌트가 리렌더링이 되는 것이 아니고, 현재 구독하고 있는 컴포넌트가 마운트 된 상태이어야 한다. 또한 사용법은 useState()와 거의 동일하다.
- 로그인 컴포넌트 -
import { useRecoilState } from 'recoil';
import { loginState } from '../../data/recoil/login';
const Login = () => {
const [loginstate, setLoginState] = useRecoilState<loginState>(loginState);
const { email, password } = loginstate;
const onChangeInput = (e: { target: { name: string; value: string } }) => {
setLoginState({ ...loginstate, [e.target.name]: e.target.value });
};
return (
<>
<input
type="text"
placeholder="이메일"
onChange={onChangeInput}
name="email"
value={email}
/>
<input
type="password"
placeholder="비밀번호"
onChange={onChangeInput}
name="password"
value={password}
/>
<div>이메일: {email}</div>
<div>비밀번호: {password}</div>
</>
);
};
export default Login;
Redux와 비교했을 때 액션 생성, 디스패치 등의 복잡한 과정을 거치지 않고 간략한 코드로 전역 상태 관리 기능은 똑같이 구현해냈다. 그래서 Recoil이 확실히 사용하기 편했다.
그러나 클라이언트 전역 상태 관리는 간단하게 구현을 했지만, 아직 서버 상태(비동기 요청을 통한 상태)에 대해서는 구현하지 않았다. 비동기 처리를 하기 위해서는 Recoil의 selector의 get을 사용하면 된다.
그 전에 간단하게 selector에 대해서 설명을 하면 selector는 get과 set으로 나누어져 있다.
selector의 get은 상태를 업데이트 시키지 못하며, 오직 반환만을 할 수 있다. get의 목적은 (atom)상태를 원하는 대로 변형해서 리턴 받거나, 특정 조건에 맞는 (atom)상태만 리턴 받을 수 있다.
import { atom, selector } from 'recoil';
export interface loginState {
email: string;
password: string;
}
export const loginState = atom<loginState>({
key: 'loginState',
default: {
email: '',
password: '',
},
});
export const loginSelectState = selector({
key: 'loginSelectState',
get: ({ get }) => {
const login = get(loginState);
return '이메일은 ' + login.email + '입니다';
},
});
const Components = () => {
const emailText = useRecoilValue(loginSelectState);
return (
<>{emailText}</>
)
}
selector의 get()을 통해 atom의 상태를 가져올 수 있다. 이 때 get()을 통해 atom의 상태를 가져올 때 의존성이 생기는데, 이것의 의미는 atom의 상태가 변화될 때마다 자동적으로 해당 selector가 호출된다. (일부러 selector를 호출하기 위해, atom의 상태를 변경할 수도 있다)
selector의 set은 상태를 업데이트 시킬 수 있으며 상태를 초기화해줄 수도 있지만, selector의 get과 다르게 리턴하지는 못한다. 또한 selector의 get은 필수로 선언해야하며 set은 선택적이고, set은 비동기 처리를 하지 못한다.
마지막으로 get이 리턴하는 타입과 set 콜백에 전달되는 newValue의 타입이 반드시 일치해야 한다.
간단한 설명 후 Recoil 공식 문서에 나와 있는대로 selector의 get으로 네트워크 요청을 해보려고 한다.
const dataSelector = selector({
key: 'dataSelector',
get: async ({}) => {
const response = await axios.get('/api/data');
return response.data
},
});
const Components = () => {
const data = useRecoilValue(dataSelector);
return (
<>{data}</>
)
}
그러나 위의 코드처럼 간단하게 데이터를 받아오는 것으로만 사용한다면 괜찮지만, 로딩 처리, 에러 처리룰 해주기에는 조금 까다롭다.
1. 로딩 처리를 해주기 위해서는 suspense를 사용할 수 있지만 이것은 오직 로딩처리만 해준다.
2. 로딩 처리, 에러 처리, 성공 처리를 해주는 loadable이 있기도 하다. 이것은 loadable을 통해 atom 또는 selector의 현재 상태(hasValue, hasError, loading)를 반환해준다.
-더 정확히 말하면 반환받는 값에는 현재 상태를 나타내는 state(hasValue, hasError, loading)과 content(각 성공,에러,로 딩일 때마다 다른 내용이 들어있다, console.log()을 찍어보면 쉽게 이해될 것이다)
const dataSelector = selector({
key: 'dataSelector',
get: async ({}) => {
const response = await axios.get('/api/data');
return response.data
},
});
const Components = () => {
const dataLoadable = useRecoilValueLoadable(dataSelector);
switch (dataLoadable.state) {
case 'hasValue':
return <div>{userNameLoadable.contents}</div>;
case 'loading':
return <div>로딩중...</div>;
case 'hasError':
throw userNameLoadable.contents;
}
아주 편하게 사용할 수 있지만, 단점이 존재한다.
1. 첫 번째로 컴포넌트마다 저런 로직(hasValue, hasError, loading)을 반복해서 사용한다. 조금은 커스터마이징 할 수 있지만 로직의 큰 틀은 비슷하다.
2. 두 번째로 selector가 호출이 될 때마다 ‘로딩 → 결과’가 반복된다. selector는 구독하고 있는 컴포넌트가 마운트될 때마다, selector안에서 get()을 사용하여 atom의 상태를 가져오고 atom의 상태가 변화될 때마다 selector는 호출된다.
3. 세 번째로 이벤트 발생 했을 때 selector 호출 하는 것이 없다. 다른 방법으로 selector안에서 get()을 사용하여 atom의 상태를 가져오는 로직을 만들고 → atom의 상태를 변화시켜서 → selector를 호출하게 만들 수 있다. 그러나 이것은 복잡하다
4. 네 번째로 직접 atom에서 isLoading, isError에 대한 상태를 만들고, selector의 get에서 비동기 요청을 할 때 각 상태를 업데이트 시키는 것을 생각할 수 있겠지만, selector의 get은 상태값을 업데이트 시키는 못하고 상태값을 반환하기만 할 수 있다.또한 set을 사용하려면 get을 반드시 정의해줘야 하며, get이 리턴하는 타입과 set 콜백에 전달되는 newValue의 타입이 반드시 일치해야 한다. selector의 set을 이용하여 자유롭게 상태를 업데이트 하기에는 무리가 있다.
그러면 selector의 set을 통해 상태를 업데이트 시킬 수 있다고 생각할 수 있지만, set에서 비동기 처리를 하면 오류가 발생하며, set은 비동기 처리가 안된다고 나와있다.
그래서 atom의 usetransaction을 사용하여 더 자유롭게 처리를 하려고 했지만, 비동기 처리가 안된다고 나와있다.
릴리즈 초창기인 Recoil로만 100% 상태 관리를 하기에는 안정성 측면에서 불안하다고도 생각도 든다.
그래서, 처음에는 최대한 Recoil로만 상태 관리를 하려고 했지만, 결론적으로 서버 상태를 위해서 훨씬 간단하며 기능도 다양한 React-Query를 선택하는 것이 효율적이라고 생각한다.
현재 프로젝트의 상태 관리 구조는 다음과 같다.
-단일 컴포넌트에서의 데이터는 useState (전역 데이터X, 네트워크 요청 데이터X)
-전역적으로 필요한 데이터는 Recoil (전역 데이터O, 네트워크 요청 데이터X)
-네트워크 요청 데이터는 React-Query (네트워크 요청 데이터O)
다음 포스트에서 이것을 코드로 어떻게 구현했는지를 설명해보려고 한다.
'프론트엔드' 카테고리의 다른 글
useState 일괄처리 (batch update) (0) | 2022.10.21 |
---|---|
달력 만들고 해당 날짜에 할 일 보여주기 (2) | 2022.09.19 |
웹팩 데브 서버 설정하기 (0) | 2022.08.12 |
바벨 로더 설정하기 (0) | 2022.08.11 |
CSS 로더 설정하기 (0) | 2022.08.11 |
- Total
- Today
- Yesterday