실전 프로젝트에서 내가 속한 팀은 react-query를 이용하기로 했다. 막상 정할때는 그냥 백엔드와 소통 과정에서 서버쪽 데이터를 사용할 상황이 많을거라 예상해서 react-query를 사용하자고 정했는데, 이런 설명만으론 중간 멘토링 과정 때 부족할 거 같아서 왜 사용했는지 생각해 보기로 했다.
그 과정에서 좋은 글을 찾아서 내 방식대로 정리해봤다.
제목과 같이 redux는 전역 상태관리 라이브러리다. 그런데 주 목적이 상태관리 라이브러리라면, 이 라이브러리를 서버간 비동기 통신에 사용한다는 것은 이치에 맞지 않다고 생각했다.
우리는 DB에 저장된 데이터를 api를 통해서 가져오는 상황이 매우 많았다. 물론 Redux를 통해서 비동기적으로 데이터를 가져오고 업데이트를 할 수도 있었지만, 이렇게 될 경우 코드가 너무 길어지게 된다.
다음이 그 예시다.
// Todo.tsx
import { useEffect } from 'react';
import { selectTodoList } from 'features/todos/todos.selector';
import {
requestFetchTodos,
requestPostTodos,
} from 'features/todos/todos.slice';
import { useForm } from 'react-hook-form';
import { useDispatch, useSelector } from 'react-redux';
function Todo() {
const dispatch = useDispatch();
const data = useSelector(selectTodoList);
const { register, handleSubmit } = useForm<{
contents: string;
}>();
useEffect(() => {
// 컴포넌트가 마운트 될 때 서버에 저장된 Todo 정보를 불러옵니다.
dispatch(requestFetchTodos());
}, [dispatch]);
const onSubmit = handleSubmit((value) => {
// 사용자가 Form을 Submit하면 서버에 새로운 Todo 정보를 저장합니다.
dispatch(requestPostTodos(value.contents));
});
return (
<div>
<header>
<form onSubmit={onSubmit}>
<input
type="text"
placeholder="What needs to be done?"
autoComplete="off"
{...register('contents')}
/>
</form>
</header>
<div>
<ul>
{data?.map(({ id, contents }) => (
<li key={id}> {contents} </li>
))}
</ul>
</div>
</div>
);
}
export default Todo;
// features/todos/todos.slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { TodoItem } from 'types/todo';
export interface TodoListState {
fetchTodos: {
data?: TodoItem[];
isLoading: boolean;
error?: Error;
};
postTodos: {
isLoading: boolean;
error?: Error;
};
}
const initialState: TodoListState = {
fetchTodos: {
data: undefined,
isLoading: false,
error: undefined,
},
postTodos: {
isLoading: false,
error: undefined,
},
};
export const todoListSlice = createSlice({
name: 'todoList',
initialState,
reducers: {
// 이전 State의 값을 바탕으로 다음 State의 값을 새로 만드는 순수함수 "Reducer"
// redux-toolkit은 immer를 내부적으로 사용하므로 조금 더 자연스럽게 Reducer를 구성할 수 있게끔 도와줍니다.
requestFetchTodos: (state) => {
state.fetchTodos.isLoading = true;
},
successFetchTodos: (state, action: PayloadAction<TodoItem[]>) => {
state.fetchTodos.data = action.payload;
state.fetchTodos.isLoading = false;
state.fetchTodos.error = undefined;
},
errorFetchTodos: (state, action: PayloadAction<string>) => {
state.fetchTodos.data = undefined;
state.fetchTodos.isLoading = false;
state.fetchTodos.error = action.payload;
},
requestPostTodos: (state, _: PayloadAction<string>) => {
state.postTodos.isLoading = true;
},
successPostTodos: (state) => {
state.postTodos.isLoading = false;
},
errorPostTodos: (state, action: PayloadAction<string>) => {
state.postTodos.isLoading = false;
state.postTodos.error = action.payload;
},
},
});
export const {
requestFetchTodos,
successFetchTodos,
errorFetchTodos,
requestPostTodos,
successPostTodos,
errorPostTodos,
} = todoListSlice.actions;
export default todoListSlice.reducer;
// features/todos/todos.saga.ts
import { PayloadAction } from '@reduxjs/toolkit';
import axios from 'axios';
import { call, put, takeEvery } from 'redux-saga/effects';
import { TodoItem } from '../../types/todo';
import {
errorFetchTodos,
errorPostTodos,
requestFetchTodos,
requestPostTodos,
successFetchTodos,
successPostTodos,
} from './todos.slice';
async function getTodoList() {
const { data } = await axios.get<TodoItem[]>('./todos');
return data;
}
function* requestFetchTodoTask() {
try {
const data: TodoItem[] = yield call(getTodoList);
yield put(successFetchTodos(data));
} catch (e) {
yield put(errorFetchTodos(e.message));
}
}
async function postTodoList(contents: string) {
await axios.post('/todos', { contents });
}
function* requestPostTodoTask(action: PayloadAction<string>) {
try {
yield call(postTodoList, action.payload);
yield put(successPostTodos());
} catch (e) {
yield put(errorPostTodos(e.message));
}
}
function* successPostTodoTask() {
// 서버에 새로운 Todo 추가 요청 성공 시
// 서버에서 Todo 목록을 다시 받아오기 위해 Action Dispatch
yield put(requestFetchTodos());
}
function* todoListSaga() {
yield takeEvery(requestFetchTodos.type, requestFetchTodoTask);
yield takeEvery(requestPostTodos.type, requestPostTodoTask);
yield takeEvery(successPostTodos.type, successPostTodoTask);
}
export default todoListSaga;
이렇게 일부분만 가져왔는데도 너무나 길다. 하나의 api를 요청하려고 여러개의 action과 reducer가 필요한 상황인데다가, 코드가 한눈에 파악되지 않아서 리팩토링에 어려움을 겪을 상황이다.
당연하게도 제목과 같다. redux는 api 통신 라이브러리가 아닌, 전역 상태관리 라이브러리다. 그렇기에 api 통신을 위한 규격화된(?) 방식이 없다 보니 api 상태관리 방법이 개발자 스타일에 따라 다 달라질 수 있다.
이는 Redux가 전역 상태관리 라이브러리이기 때문에 나타나는 문제인데, 물론 Redux 미들웨어로 비동기 상태를 관리하고 그 값을 저장할 수 있지만, 내부적인 구현은 개발자의 스타일에 따라 달라질 수 있다.
이렇게 되면 협업 과정에서 문제가 생길수도 있고, 이런 단점을 안고 사용하기보단, 차라리 정해진 방식이 있는 라이브러리 사용이 더 도움될 것 같았다.
앞서 말한바와 같이, Redux로 비동기 통신을 하다보면 코드가 너무 길어지게 된다.(장황한 BoilerPlate) 이렇게 되면 유지보수에 어려움이 있을 뿐더러, Redux는 비동기 데이터 전문 라이브러리가 아니기 때문에 이에 관련된 코드들을 개발자가 직접 짜야 한다. 만약 개발자쪽에서 UX 향상을 위한 코드를 직접 짜게되면, 개발 리소스가 과다하게 소모되어 나아가 프로젝트 규모가 점점 커져서 코드의 복잡도가 올라가면, 유지보수에 대한 부담도 커지게 된다.
일단 첫번째 이유는 비동기 통신이 주 목적이었다. 아무래도 서버와 통신을 자주 해야 하는 프로젝트이다 보니, 당연하게도 비동기 통신이 주 목적인 라이브러리를 채택해야 했고, 결과적으로 redux를 사용할 때보다 더 나은 경험을 하고있다.
그리고 비동기 데이터 동기화를 통해 지속적으로 최신 상태의 데이터를 업데이트 할 수 있었다.
import React, { useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
import user from '../img/user.webp'
import like from '../img/like.png'
import cmt from '../img/comment.png'
import post from '../img/post.png'
import save from '../img/save.png'
import menu from '../img/menu.svg'
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from 'react-query'
import { addComment, deletePost, getCookie, getPost } from '../api/crud'
import { token } from '../api/crud'
import jwtDecode from 'jwt-decode'
import PostModal from './PostModal'
import EditPostModal from './EditPostModal'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useInView } from 'react-intersection-observer'
import instance from '../api/instance/instance'
import LikeCountAcction from '../components/LikeCountAction';
import FollowContents from '../components/FollowContents';
function Post() {
const [showComment, setShowComment] = useState(false)
const [comment, setComment] = useState([]);
const [contents, setContents] = useState("")
const [useName, setUserName] = useState("")
const [imgUrl, setImgUrl] = useState("")
const [id, setId] = useState(0)
const [postModal, setPostModal] = useState(false);
const [editPostModal, setEditPostModal] = useState(false)
const { ref, inView } = useInView();
const navigate = useNavigate();
const showPostModal = () => {
setPostModal(true)
}
const showEditPostModal = () => {
setEditPostModal(true)
}
const onComment = (newComment) => {
setComment(newComment)
}
const onContents = (newContents) => {
setContents(newContents)
}
const onUserName = (newName) => {
setUserName(newName)
}
const onUrl = (newUrl) => {
setImgUrl(newUrl)
}
const onId = (newId) => {
setId(newId)
}
const { isLoading, isError, data } = useQuery(['post'], getPost)
const queryClient = useQueryClient();
const deletePostMutation = useMutation(deletePost, {
onSuccess: () => {
queryClient.invalidateQueries('post')
}
})
if (isLoading) {
return <h1>로딩중...</h1>
}
if (isError) {
return <h1>Error...</h1>
}
console.log(data.data)
const onDeletePostHandler = (postId) => {
alert("정말로 삭제하시겠습니까?")
deletePostMutation.mutate(postId)
}
const decode_token = jwtDecode(getCookie())
return (
<>
{
data.data.map((item, i) => (
<Container key={item.id} >
<UserInfo>
<img src={user} alt='유저' />
<UserInfoText>{item.username}</UserInfoText>
<FollowContents />
<HandlerContainer>
{
decode_token.sub === item.username ? <DeleteButton onClick={() => {onDeletePostHandler(item.id)}}>삭제</DeleteButton> : null
}
{
decode_token.sub === item.username ? <EditPost><Link to={`/editpost/${item.id}`}>수정</Link></EditPost> : null
}
</HandlerContainer>
{/* <img src={menu} alt="메뉴"/> */}
</UserInfo>
<PostContent>
<img
src={item.imageUrl}
alt='이미지'
/>
</PostContent>
<PostCommentContainer>
<PostCommentButton>
{/* <img src={like} alt="좋아요" /> */}
<LikeCountAcction />
<img src={cmt} alt="댓글 보기" onClick={() => {
onId(item.id)
onComment(item.comments);
onContents(item.contents)
onUserName(item.username)
onUrl(item.imageUrl)
showPostModal()
}} />
<img src={post} alt="공유" />
<img src={save} alt="저장" />
</PostCommentButton>
<PostDescription showComment={showComment}>
<h5>
{item.contents}
</h5>
<div className='description_button'>
<span setPostModal={setPostModal}>상세보기</span>
<p onClick={() => setShowComment(!showComment)}>더 보기</p>
</div>
</PostDescription>
<CommentInput>
{/* <form>
<Link to={`/board/${item.id}`}>댓글 달기</Link>
</form> */}
</CommentInput>
</PostCommentContainer>
<div>
</div>
</Container>
))
}
{
postModal && <PostModal setPostModal={setPostModal} comment={comment} contents={contents} useName={useName} imgUrl={imgUrl} id={id} />
}
</>
)
}
export default Post
useQuery 훅을 통해 서버에 저장되어있는 상태를 불러와 사용할 수 있다.
const { isLoading, isError, data } = useQuery(['like'], likedMusic)
const { isLoading, isError, data } = useQuery(
// data 부분에 우리가 사용할 정보들이 담겨져있다.
queryKey], -> 이 쿼리키를 통해 요청에 대한 응답 데이터를 캐싱
fetchFn) -> 이 Query 요청을 수행하기 위한 Promise를 Return 하는 함수
// 가장 기본적인 형태의 React Query useMutation Hook 사용 예시
const { mutate } = useMutation(
mutationFn, // 이 Mutation 요청을 수행하기 위한 Promise를 Return 하는 함수 (required)
options, // useMutation에서 사용되는 Option 객체 (optional)
);
useMutation Hook으로 수행되는 Mutation 요청은 HTTP METHOD POST, PUT, DELETE 요청과 같이 서버에 Side Effect를 발생시켜 서버의 상태를 변경시킬 때 사용한다. useMutation Hook의 첫번째 파라미터는 이 Mutation 요청을 수행하기 위한 Promise를 Return 하는 함수이며, useMutation의 return 값 중 mutate(또는 mutateAsync) 함수를 호출하여 서버에 Side Effect를 발생시킬 수 있다.
react-query를 통해서 redux를 사용했을때와 같은 기능을 하는 코드를 짜면 코드의 양을 엄청나게 줄일 수 있다.
// quires/useTodosQuery.ts
// API 상태를 불러오기 위한 React Query Custom Hook
import axios from 'axios';
import { useQuery } from 'react-query';
import { TodoItem } from 'types/todo';
// useQuery에서 사용할 UniqueKey를 상수로 선언하고 export로 외부에 노출합니다.
// 상수로 UniqueKey를 관리할 경우 다른 Component (or Custom Hook)에서 쉽게 참조가 가능합니다.
export const QUERY_KEY = '/todos';
// useQuery에서 사용할 `서버의 상태를 불러오는데 사용할 Promise를 반환하는 함수`
const fetcher = () => axios.get<TodoItem[]>('/todos').then(({ data }) => data);
const useTodosQuery = () => {
return useQuery(QUERY_KEY, fetcher);
};
export default useTodosQuery;
위의 redux를 사용한 투두리스트와 동일한 기능을 하지만, 코드는 엄청나게 줄어들었다. 이 덕분에 유지보수 측면에서도 엄청 편해진다.
const {
data,
dataUpdatedAt,
error,
errorUpdatedAt,
failureCount,
isError,
isFetched,
isFetchedAfterMount,
isFetching,
isIdle,
isLoading,
isLoadingError,
isPlaceholderData,
isPreviousData,
isRefetchError,
isRefetching,
isStale,
isSuccess,
refetch,
remove,
status,
} = useQuery(queryKey, queryFn);
react-query는 위와 같이 규격화된 방식을 제공함으로써 효율적으로 개발할 수 있게 도와준다.