
이 게시글은 현업에서 사용하는 React 고급 웹 개발 기법 배우기 (Feat. TodoList) 시리즈중 하나입니다
- Vite 시작하기, TailwindCss로 TodoList UI 구성하기
- React Hook 활용하여 TodoList 기능 개발하기, github page로 배포하기
- MSW로 API Mocking하기, React Query 사용하기
- React Testing Library & Jest로 테스트 하기. CI 적용하기
1. MSW란?
MSW는 Mock Service Worker로 클라이언트의 http 요청을 가로채 mocking한 응답을 반환해주는 라이브러리입니다. 이를 이용하여 서버와 통신하는 것 처럼 흉내낼 수 있습니다.
만약 여러분이 프로젝트를 하는데 아직 서버의 API 개발이 안되었거나 문제가 있는 상황이면 서버의 api를 mocking해서 미리 프론트엔드 개발을 진행할 수 있는 것이죠 뿐만 아니라 에러를 고의로 내는 api를 mocking하여 예외 처리를 할 수도 있고 이렇게 만든 api를 활용하여 테스트를 할 수도 있습니다.
오늘 과정을 거치면 사실 여러분이 api서버를 직접 만들어서 배포하지 않는 이상 프로덕션 환경에서 작동하지 않을겁니다. 서버의 API가 필요한 앱을 개발할 것이기 때문이죠
2. API Mocking하기
2.1 종속성 설치하기
pnpm add -D msw axios
axios는 브라우저에서는 XHR, node에서는 http 모듈 기반으로 자동하는 promise기반 http 클라이언트입니다. fetch라는 웹 api가 등장했지만 여전히 많이 쓰고있고 간편한 라이브러리죠
2.2 기본 설정
다음 명령어로 msw를 초기화해줘야 합니다.
npx msw init public/ --save
지난번에 배포를 위해서 baseUrl을 /vite-todo-list/로 바꾸었는데요 이는 msw 초기화를 방해합니다. 따라서 개발 환경에서는 baseUrl을 /로 바꾸도록 하겠습니다.
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
base: mode === "production" ? "/vite-todo-list/" : "/",
plugins: [react(), tsconfigPaths()],
server: {
host: "localhost",
port: 3000,
},
}));
2.3 초기화 및 핸들러 설정
main.ts에서 msw 관련 설정을 해줍니다.
// main.ts
import axios from "axios";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
async function init() {
if (import.meta.env.DEV) {
const { worker } = await import("./mocks");
return worker.start();
} else {
axios.defaults.baseURL = "http://some.api.url";
}
}
await init();
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
루트에서 await을 지원하기에 위와 같이 init() 이후에 렌더링되도록 합니다.
msw는 개발 환경에서 api를 mocking하는 것이기에 DEV환경일 때만 작동하도록 해주는 것입니다. 이처럼 동적으로 import해줘야 배포 환경에서 번들 사이즈가 커지지 않습니다! (중요!)
반대로 실제 환경에서는 msw를 실행하지 않고 axios 설정을 한다거나 할 수 있겠죠?
여기서 ./mocks에 worker를 정의해줍니다.
// src/mocks/index.ts
import { setupWorker } from "msw/browser";
import { todoHandlers } from "./todo/handlers";
export const worker = setupWorker(...todoHandlers);
위와같이 해주면 되구요 이제 todo 도메인 관련 핸들러를 작성해줄겁니다.
// src/mocks/todo/handler.ts
import { http } from "msw";
import * as service from "./service";
export const todoHandlers = [
http.get("/todo", service.getTodos),
http.post("/todo", service.addTodo),
http.get("/todo/:id", service.getTodo),
http.patch("/todo/:id", service.editTodo),
http.patch("/todo/:id/toggle", service.toggleTodo),
http.delete("/todo/:id", service.deleteTodo),
];
이렇게 각 엔드포인트에 대해 서비스 코드를 대응해줍니다. 이제 서비스 코드를 작성해야겠죠?
2.4 서비스 코드 작성
먼저 데이터베이스 역할을 할 파일을 만들어줍니다.
(저는 0버전을 많이 썼었는데 이번에 2버전을 하려다 보니 엄청 많이 바뀌었더군요..)
// src/mocks/todo/repository.ts
import { type Todo } from "@components/todo-list/use-todo-list";
export const mockTodos: Todo[] = [
{
id: "1",
content: "마라탕 먹기",
completed: false,
},
{
id: "2",
content: "치킨 먹기",
completed: false,
},
{
id: "3",
content: "탕수육 먹기",
completed: false,
},
{
id: "4",
content: "소금빵 먹기",
completed: true,
},
{
id: "5",
content: "아메리카노 먹기",
completed: true,
},
];
다음은 서비스 코드입니다.
// src/mocks/todo/service.ts
import { HttpResponse, HttpResponseResolver, delay } from "msw";
import { loadTodos, saveTodos } from "./repository";
export const getTodos: HttpResponseResolver = async () => {
await delay(200);
return HttpResponse.json(loadTodos(), { status: 200 });
};
export const getTodo: HttpResponseResolver = async ({ params }) => {
try {
const { id } = params;
await delay(200);
return HttpResponse.json(
loadTodos().find((todo) => todo.id === id),
{ status: 200 }
);
} catch (e) {
let message = "Unknown Error";
if (e instanceof Error) message = e.message;
return HttpResponse.json({ message }, { status: 400 });
}
};
export const addTodo: HttpResponseResolver = async ({ request }) => {
try {
const { content } = (await request.json()) as { content: string };
const todos = loadTodos();
const newTodo = {
id: String(todos.length + 1),
content,
completed: false,
};
todos.push(newTodo);
saveTodos(todos);
await delay(200);
return HttpResponse.json(newTodo, { status: 201 });
} catch (e) {
let message = "Unknown Error";
if (e instanceof Error) message = e.message;
return HttpResponse.json({ message }, { status: 400 });
}
};
export const editTodo: HttpResponseResolver = async ({ request, params }) => {
try {
const { id } = params;
const { content } = (await request.json()) as { content: string };
const todos = loadTodos();
const index = todos.findIndex((todo) => todo.id === id);
todos[index].content = content;
saveTodos(todos);
await delay(200);
return HttpResponse.json(todos[index], { status: 200 });
} catch (e) {
let message = "Unknown Error";
if (e instanceof Error) message = e.message;
return HttpResponse.json({ message }, { status: 400 });
}
};
export const toggleTodo: HttpResponseResolver = async ({ params }) => {
try {
const { id } = params;
const todos = loadTodos();
const index = todos.findIndex((todo) => todo.id === id);
todos[index].completed = !todos[index].completed;
saveTodos(todos);
await delay(200);
return HttpResponse.json(todos[index], { status: 200 });
} catch (e) {
let message = "Unknown Error";
if (e instanceof Error) message = e.message;
return HttpResponse.json({ message }, { status: 400 });
}
};
export const deleteTodo: HttpResponseResolver = async ({ params }) => {
try {
const { id } = params;
const todos = loadTodos();
const index = todos.findIndex((todo) => todo.id === id);
todos.splice(index, 1);
saveTodos(todos);
await delay(200);
return HttpResponse.json({ status: 200 });
} catch (e) {
let message = "Unknown Error";
if (e instanceof Error) message = e.message;
return HttpResponse.json({ message }, { status: 400 });
}
};
try catch로 그냥 에러를 퉁쳤고 데이터베이스 레이턴시를 고려하여 딜레이를 주었습니다.
이정도면 그럴 듯 한가요? 프로젝트 규모가 더 커지면 더 그럴싸하게 만들 수 있겠지만 지금 프로젝트에서는 그럴 필요는 없으니 일단 이정도만 하죠 ㅎㅎ
자 이제 완성되었습니다. 잘 작동하는지 확인해볼까요?
임시로 다음 코드를 useTodoList에 작성하여 mocking이 잘 되는지 확인해봅시다.
const useTodoList = (id: string) => {
// ...
const test = async () => {
const todoList = await axios.get("/todo");
console.log(todoList.data);
};
useEffect(() => {
test();
}, []);
// ...
}
다음과 같이 잘 보이나요? 콘솔 로그와 함께 MSW에서 찍어주는 로그도 보입니다.
3. React Query란?
프론트엔드 상태 관리 패러다임은 계속 변해 왔습니다. 전역으로 상태를 관리하고 필요한 곳에서 가져다 쓰는 방식에서 점차 서버에서 필요한 데이터를 그때그때 가져오는 방식으로 바뀌며 store의 크기가 점점 작아지는 것 같습니다.
현대 웹 사이트 대부분은 화면을 그릴 때 서버에서 가져오는 데이터에 많이 의존하는데요, 이 많은 서버의 데이터를 전부 전역 저장소에 저장하려다 보니 불필요한 데이터 흐름이 많이 발생한다는 겁니다.
서버에서 가져온 데이터는 시간이 지날수록 신뢰성이 떨어지죠(stale) 또한 update api를 호출해 데이터를 변경(mutation)한다면 보통은 다시 데이터를 받아와야 하는 상황이 많죠, 거기에 짧은 시간에 동일한 get을 많이 보낼 경우 캐싱, 화면 이동이나 새로고침할 때 캐싱, 화면이 포커스 되었을 때 다시 패칭, 에러가 발생했을 때 롤백 처리 등 클라이언트에서 더욱 복잡한 서버 데이터 관리 패턴이 요구되었고 이를 직접 일일이 구현해주는 것은 굉장히 어렵습니다. 이를 해결해준 것이 react-query라고 보시면 되겠습니다.
react만 지원하는게 아니라서 최근에 TanStack Query로 이름을 바꿨죠
4. React Query 적용하기
이제 todos를 서버에서 가져오도록 수정해봅시다. 물론 개발환경에서는 mocking한 데이터를 받아오게 되겠죠?
4.1 API 호출 함수 정의
먼저 API를 호출하는 코드를 분리하겠습니다.
import { type Todo } from "@components/todo-list/use-todo-list";
import axios from "axios";
export const getTodos = async () => (await axios.get<Todo[]>("/todo")).data;
export const getTodo = async (id: string) => (await axios.get<Todo>(`/todo/${id}`)).data;
export const addTodo = async (content: string) => (await axios.post<Todo>("/todo", { content })).data;
export const editTodo = async (id: string, content: string) => (await axios.patch<Todo>(`/todo/${id}`, { content })).data;
export const toggleTodo = async (id: string) => (await axios.patch<Todo>(`/todo/${id}/toggle`)).data;
export const deleteTodo = async (id: string) => (await axios.delete<void>(`/todo/${id}`)).data;
4.2 비즈니스 로직 수정
기존 코드에서 어떻게 바뀌었는지 유심히 살펴보면서 하나씩 바꿔보세요!
// src/components/todo-list/use-todo-list.ts
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import * as todoApi from "@libs/api/todo";
export interface Todo {
id: string;
content: string;
completed: boolean;
}
export type TabState = "All" | "Active" | "Completed";
const useTodoList = () => {
const quertClient = useQueryClient();
const { data: todos } = useQuery({
queryKey: ["todos"],
queryFn: todoApi.getTodos,
});
const [currentTab, setCurrentTab] = useState<TabState>("All");
const filteredTodos = todos?.filter((todo) => {
if (currentTab === "All") {
return true;
} else if (currentTab === "Active") {
return !todo.completed;
} else if (currentTab === "Completed") {
return todo.completed;
}
});
const remainTodosAmount = todos?.filter((todo) => !todo.completed).length;
const completedTodoExists = todos?.some((todo) => todo.completed);
const { mutate: addTodo } = useMutation({
mutationFn: ({ content }: Pick<Todo, "content">) =>
todoApi.addTodo(content),
onSuccess: () => quertClient.invalidateQueries({ queryKey: ["todos"] }),
});
const { mutate: editTodo } = useMutation({
mutationFn: ({ id, content }: Pick<Todo, "id" | "content">) =>
todoApi.editTodo(id, content),
onSuccess: () => quertClient.invalidateQueries({ queryKey: ["todos"] }),
});
const { mutate: deleteTodo } = useMutation({
mutationFn: ({ id }: Pick<Todo, "id">) => todoApi.deleteTodo(id),
onSuccess: () => quertClient.invalidateQueries({ queryKey: ["todos"] }),
});
const deleteCompletedTodo = () => {
todos
?.filter((todo) => todo.completed)
.forEach((todo) => deleteTodo({ id: todo.id }));
};
const { mutate: toggleTodo } = useMutation({
mutationFn: ({ id }: Pick<Todo, "id">) => todoApi.toggleTodo(id),
onSuccess: () => quertClient.invalidateQueries({ queryKey: ["todos"] }),
});
const toggleTodoAll = () => {
if (!todos) return;
const areAllCompleted = todos.every((todo) => todo.completed);
todos.forEach((todo) => {
if (areAllCompleted) toggleTodo({ id: todo.id });
else if (!todo.completed) toggleTodo({ id: todo.id });
});
};
return {
state: {
todos,
currentTab,
filteredTodos,
remainTodosAmount,
completedTodoExists,
},
action: {
addTodo,
editTodo,
deleteTodo,
deleteCompletedTodo,
toggleTodo,
toggleTodoAll,
setCurrentTab,
},
};
};
export default useTodoList;
useQeury는 서버 데이터를 읽고 useMutation은 데이터를 변경하는 명령어입니다. 이러한 것을 CQRS 패턴을 따른다고 볼 수 있겠네요
todos가 undefined일 수 있음과, 파라미터 형태가 좀 달라질 수 밖에 없어서 주의해주세요
4.3 View 수정
이를 기반으로 View에서 함수 형태랑 undefined인 부분 처리를 좀 바꿔줍니다.
// src/components/todo-list/header.tsx
interface HeaderProps {
addTodo: ({ content }: Pick<Todo, "content">) => void;
toggleTodoAll: () => void;
}
// src/components/todo-list/footer.tsx
interface FooterProps {
currentTab: TabState;
setCurrentTab: (tab: TabState) => void;
completedTodoExists: boolean | undefined;
remainTodosAmount: number | undefined;
deleteCompletedTodo: () => void;
}
// ...
<p className="absolute left-4 text-sm text-stone-500">
{remainTodosAmount ?? 0} item left
</p>
// ...
// src/components/todo-list/todo-item.tsx
interface TodoItemProps {
todo: Todo;
toggleTodo: ({ id }: Pick<Todo, "id">) => void;
editTodo: ({ id, content }: Pick<Todo, "id" | "content">) => void;
deleteTodo: ({ id }: Pick<Todo, "id">) => void;
}
콘솔을 보면 MSW에서 로그를 찍어주는데요 네트워크 요청을 실제로 보내는데 msw에서 가로채서 대신 응답해주는 원리입니다. 따라서 API 서버가 아직 개발이 안됐을 때도 요구사항에 따라 api를 mocking해서 프론트 개발을 진행할 수 있겠죠?
4.4 낙관적 업데이트 적용
react-query는 사실 굉장히 복잡한 라이브러리입니다. 따라서 테크닉이 다수 존재하는데요 같은 요구사항을 정말정말 다양한 react-query 스펙으로 다르게 구현할 수 있습니다. 그건 나중에 다뤄보도록 하구요.
위의 영상에서 동작을 보시면 딜레이 때문에 변경이 좀 늦게 번영되는 것을 볼 수 있습니다. 이는 api 응답이 느릴수록 사용자 경험이 정말 떨어질 수 있는 부분인데요(특히 모바일 환경에서) 이번엔 낙관적 업데이트를 이용해서 딜레이 없이 바로 변경 사항이 반영되게 구현해보겠습니다.
낙관적 업데이트는 서버에 요청이 반영되기를 기대하고 view를 미리 업데이트 해버리는 것입니다. 이를 store기반 상태관리를 할때는 정말 구현하기 귀찮겠지만 react-qeury를 이용하면 정말 간단합니다.
const { mutate: editTodo } = useMutation({
mutationFn: ({ id, content }: Pick<Todo, "id" | "content">) => todoApi.editTodo(id, content),
onMutate: async ({ id, content }: Pick<Todo, "id" | "content">) => {
await queryClient.cancelQueries({ queryKey: TODOS_QUERY_KEY });
const oldTodos = queryClient.getQueryData<Todo[]>(TODOS_QUERY_KEY);
queryClient.setQueryData<Todo[]>(TODOS_QUERY_KEY, (prev) => {
const index = prev!.findIndex((todo) => todo.id === id);
if (index !== -1) {
prev![index].content = content;
}
return prev;
});
return { oldTodos };
},
onError: (_, __, context) => {
if (context) queryClient.setQueryData<Todo[]>(TODOS_QUERY_KEY, context.oldTodos);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: TODOS_QUERY_KEY }),
});
- onMutate
- 변경이 일어날 때 실행됩니다.
- cancelQueries로 현재 일어나고 있는 refetch를 취소해 낙관적 업데이트 값이 덮어씌워지지 않도록 방지합니다.
- setQueryData로 현재 쿼리 값을 업데이트합니다.
- oldTodos를 반환하여 context에 저장해둡니다. 이를 통해 에러 발생 시 복구할 수 있습니다.
- onError
- 에러 발생 시 이전 값으로 복구합니다.
- onSettled
- mutate 완료 시 쿼리 값을 refetch하여 서버에 반영된 값을 다시 가져옵니다.
이걸 필요한 mutation 정의에 넣으면 되는데.. 저는 이게 너무 귀찮아보였습니다. 그래서 다음 유틸 함수를 만들어서 적용했습니다.
유틸 함수니까 가져다 사용하셔도 좋습니다 ^^ 타입 정의하는데에 고생좀 했네요
// @libs/util/use-optimistic-mutation.ts
import { useQueryClient, type QueryKey, type MutationOptions, useMutation } from "@tanstack/react-query";
interface OptimisticMutationOptions<TQueryData, TVariables, TData> extends MutationOptions<TData, Error, TVariables> {
queryKey: QueryKey;
updateFn: (props: TVariables) => (prev: TQueryData | undefined) => TQueryData | undefined;
}
export default function useOptimisticMutation<TQueryData = unknown, TVariables = void, TData = unknown>({
queryKey,
updateFn,
...options
}: OptimisticMutationOptions<TQueryData, TVariables, TData>) {
const queryClient = useQueryClient();
return useMutation({
...options,
onMutate: async (props: TVariables) => {
await queryClient.cancelQueries({ queryKey });
const oldData = queryClient.getQueryData<TQueryData>(queryKey);
queryClient.setQueryData(queryKey, updateFn(props));
return { oldData };
},
onError: (_: Error, __: TVariables, context: { oldData: TQueryData | undefined } | undefined) => {
if (context) queryClient.setQueryData<TQueryData>(queryKey, context.oldData);
},
onSettled: () => queryClient.invalidateQueries({ queryKey }),
});
}
그러면 다음과 같이 적용할 수 있습니다.
결과물
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import * as todoApi from "@libs/api/todo";
import useOptimisticMutation from "@libs/util/use-optimistic-mutation";
export interface Todo {
id: string;
content: string;
completed: boolean;
}
export type TabState = "All" | "Active" | "Completed";
const useTodoList = () => {
const TODOS_QUERY_KEY = ["todos"];
const { data: todos } = useQuery({
queryKey: ["todos"],
queryFn: todoApi.getTodos,
});
const [currentTab, setCurrentTab] = useState<TabState>("All");
const filteredTodos = todos?.filter((todo) => {
if (currentTab === "All") {
return true;
} else if (currentTab === "Active") {
return !todo.completed;
} else if (currentTab === "Completed") {
return todo.completed;
}
});
const remainTodosAmount = todos?.filter((todo) => !todo.completed).length;
const completedTodoExists = todos?.some((todo) => todo.completed);
const { mutate: addTodo } = useOptimisticMutation<Todo[], Pick<Todo, "content">>({
queryKey: TODOS_QUERY_KEY,
mutationFn: ({ content }) => todoApi.addTodo(content),
updateFn:
({ content }) =>
(prev) => {
if (prev) {
return [
...prev,
{
id: String(Date.now()),
content,
completed: false,
},
];
}
return [];
},
});
const { mutate: editTodo } = useOptimisticMutation<Todo[], Pick<Todo, "id" | "content">>({
queryKey: TODOS_QUERY_KEY,
mutationFn: ({ id, content }) => todoApi.editTodo(id, content),
updateFn:
({ id, content }) =>
(prev) => {
const index = prev!.findIndex((todo) => todo.id === id);
if (index !== -1) {
prev![index].content = content;
}
return prev;
},
});
const { mutate: deleteTodo } = useOptimisticMutation<Todo[], Pick<Todo, "id">>({
queryKey: TODOS_QUERY_KEY,
mutationFn: ({ id }) => todoApi.deleteTodo(id),
updateFn:
({ id }) =>
(prev) =>
prev?.filter((todo) => todo.id !== id),
});
const { mutate: deleteCompletedTodo } = useOptimisticMutation<Todo[]>({
queryKey: TODOS_QUERY_KEY,
mutationFn: () => {
if (!todos) return Promise.reject();
return Promise.all(todos.filter((todo) => todo.completed).map((todo) => todoApi.deleteTodo(todo.id)));
},
updateFn: () => (prev) => prev?.filter((todo) => !todo.completed),
});
const { mutate: toggleTodo } = useOptimisticMutation<Todo[], Pick<Todo, "id">>({
queryKey: TODOS_QUERY_KEY,
mutationFn: ({ id }) => todoApi.toggleTodo(id),
updateFn:
({ id }) =>
(prev) =>
prev?.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo)),
});
const { mutate: toggleTodoAll } = useOptimisticMutation<Todo[]>({
queryKey: TODOS_QUERY_KEY,
mutationFn: () => {
if (!todos) return Promise.reject();
const areAllCompleted = todos.every((todo) => todo.completed);
return Promise.all(
todos.map((todo) => {
if (areAllCompleted) todoApi.toggleTodo(todo.id);
else if (!todo.completed) todoApi.toggleTodo(todo.id);
})
);
},
updateFn: () => (prev) => {
if (prev) {
const areAllCompleted = prev.every((todo) => todo.completed);
return prev.map((todo) => ({
...todo,
completed: !areAllCompleted,
}));
}
return [];
},
});
return {
state: {
todos,
currentTab,
filteredTodos,
remainTodosAmount,
completedTodoExists,
},
action: {
addTodo,
editTodo,
deleteTodo,
deleteCompletedTodo,
toggleTodo,
toggleTodoAll,
setCurrentTab,
},
};
};
export default useTodoList;
타입 알아서 추론되고, 작성할 코드량 자체가 많이 줄어서 비즈니스 로직에 집중할 수 있죠. 물론 완전한 유틸 함수로 만드려면 더 다듬어야 할 부분이 있습니다. 그 부분은 여러분 프로젝트에서 한번 도전해보세요!
여기까지 오셨다면 낙관적 업데이트로 인해 뷰의 업데이트가 바로바로 보이는 것을 볼 수 있습니다.
수고하셨습니다!
제가 개발하고 코드를 옮겨 적다보니 틀린 부분이 있을수도 있는데요 다음 github에 올려두었으니 참고 바랍티다.
msw + react-query 적용 버전: sjsjsj1246/vite-todo-list at ver3-reactQuery
msw + react-query + 낙관적 업데이트 적용 버전: https://github.com/sjsjsj1246/vite-todo-list/tree/ver3-reactQuery-optimistic
'FrontEnd' 카테고리의 다른 글
React Testing Library & Vitest로 테스트 하기, CI 적용하기 (0) | 2024.08.18 |
---|---|
React Hook 활용하여 TodoList 기능 개발하기, github pages로 배포하기 (2) | 2024.01.25 |
Vite로 시작하기, TailwindCss로 TodoList UI 구성하기 (0) | 2024.01.21 |
현업에서 사용하는 React 고급 웹 개발 기법 배우기 (Feat. TodoList) (2) | 2024.01.20 |