이전 포스트에서는 네트워크 통신의 상태에 대한 이야기를 했었다. 이번엔 기본적인 API 통신 구조부터, Optimistic UI, 전역 상태 업데이트를 묶어서 실제 예제를 만들어보자. 예제는 만만한 투두리스트!
계정이 존재합니다.
- 계정마다 투두리스트를 만들 수 있어요.
- 그렇다고 로그인 기능이 있는 건 아니고, 서버에 생성해서 유저를 생성하는 형태로 만들었어요.
닉네임을 변경할 수 있어요.
- 전역상태로 관리하고 있는 정보가 바뀌는 케이스를 고려했어요.
투두폴더가 존재합니다.
- 그냥 투두로 만들면 심심하니까 폴더를 만들어 보았어요.
나머지는 전형적인 투두 리스트와 같아요.
- 투두폴더 CRUD, 투두 CRUD
todolist-for-api 폴더로 시작할 거에요.
- 서버는 mock-server 폴더에 생성해요.
- 클라이언트는 client 폴더에 생성해요.
서버는 JSON Server로 만들어요.
- 따로 서버를 만들기엔 품이 커져서, 가벼운 JSON Server로 진행해요.
클라이언트는 React + TS로 만들어요.
- 전역 상태 관리는 라이브러리를 사용하지 않고,
useSyncExternalStore
를 사용해요.- 서버사이드 상태는
React Query
를 사용해서 관리해요.
JSON Server는 별다른 구조 없이 json 하나만 있으면 서버가 열리는 구조이기 때문에, 설치 후 db.json만 열어주면 준비 끝입니다.
설치
pnpm init pnpm add json-server
package init 필요 없이 그냥 json-server를 전역으로 설치 후 json-server --watch db.json
으로 열어주어도 되지만, pnpm을 이용해서 관리해주려고 해요. 이왕 하는 거 script도 추가해주겠습니다.
package.json
{ ... "start": "json-server --watch db.json --routes routes.json --port 3001", ... }```
db.json과 routes.json는 아래에서 생성해줄게요.
서버에서 쓰이는 모델 구조는 다음과 같아요.
db.json
{ "users": [ { "id": "bf3dea1f-382d-4061-af89-378be81bcdee", "nickname": "가냘픈 강아지" }, ], "folders": [ { "id": "03c1b658-8d9d-42b3-8c92-48934af1da6a", "userId": "bf3dea1f-382d-4061-af89-378be81bcdee", "name": "가냘픈 강아지 폴더A", "createdAt": 1700908392868 },], "todos": [ { "id": "3f005c45-3145-4fd3-b6aa-45be75ce87a7", "userId": "bf3dea1f-382d-4061-af89-378be81bcdee", "folderId": "03c1b658-8d9d-42b3-8c92-48934af1da6a", "content": "가냘픈 강아지 폴더A 할일1", "completed": true, "createdAt": 1700908514743 }, ] }```
모델이라고 해놓고 값 때려박힌 거 가져오기(ㅋㅋ)
JSON Server 특성상 한 번에 받아오는 구조는 불가능한 것 같더라요. 그래서 RDB처럼 folders는 userId를 받아오고, todos는 userId와 folderId를 받아오는 형태로 만들었어요.
라우팅은 /users/:userId/folders/:folderId/todos/:todoId
의 형태를 따르기 위해서 아래와 같이 routes.json을 생성해주었어요. routes.json에 관한 설정은 이 포스트를 참고해주세요.
routes.json
{ "/users/:userId": "/users/:userId", "/users/:userId/folders": "/folders?userId=:userId", "/users/:userId/folders/:folderId": "/folders/:folderId", "/users/:userId/folders/:folderId/todos": "/todos?userId=:userId&folderId=:folderId", "/users/:userId/folders/:folderId/todos/:todoId": "/todos/:todoId" }
여기까지 하고 실행시켜주면 끝!
client 폴더로 이동해서 리액트를 설치해 주고 시작해봅시다! 설치 과정은,, 생략(ㅋㅋ)
Domain 기준으로 나눌까, Feature 기능으로 나눌까 어쩔까 생각하다가, 그렇게 품이 크지도 않을 것 같아서 일반적으로 나누는 구조로 나눴어요.
src 내부 구조
├─API ├─Constants ├─Hooks ├─Pages ├─Store ├─Types └─Utils```
또 이렇게 폴더가 나누고 구조가 복잡해지면 가져올 때 경로가 ../../../ 따위로 보기 싫게 가져와지니까 절대 경로를 설정해주어요.
vite.config.js
export default defineConfig({ plugins: [react()], resolve: { alias: [ { find: '@API', replacement: '/src/API' }, { find: '@Constants', replacement: '/src/Constants' }, { find: '@Hooks', replacement: '/src/Hooks' }, { find: '@Pages', replacement: '/src/Pages' }, { find: '@Store', replacement: '/src/Store' }, { find: '@Types', replacement: '/src/Types' }, { find: '@Utils', replacement: '/src/Utils' }, { find: '@', replacement: '/src' }, ], }, });
Typescript를 쓰고 있다면 여기에도 설정해주어야 해요.
tsconfig.json
{ "compilerOptions": { // ... /* Alias */ "baseUrl": ".", "paths": { "@API/*": ["src/API/*"], "@Constants/*": ["src/Constants/*"], "@Hooks/*": ["src/Hooks/*"], "@Pages/*": ["src/Pages/*"], "@Store/*": ["src/Store/*"], "@Types/*": ["src/Types/*"], "@Utils/*": ["src/Utils/*"], "@/*": ["src/*"] } }, "include": ["src", "**/*.ts", "**/*.tsx"], // ... }
중요한 부분은 baseUrl과 include 부분. 오른쪽 Value에 써져있는 Path를 어디 기준으로 돌려줄 거냐를 정해주는 것이 baseUrl이에요. 그리고 안쪽에 있는 파일들을 포함시키기 위해 **/*.ts
, **/*.tsx
을 추가해주었어요.
처음 App에서는 서비스에서 필요한 Provider를 제공해주는 것이 좋아요. React Query와 React Router Dom은 Provider가 필요하므로, App에서 제공해주도록 해요.
RouterProvider router 설정하기
const router = createBrowserRouter([ { path: '/', element: <LandingPage />, }, { path: '/home', element: <MainPage />, }, ]); return <RouterProvider router={router} />```
/
로 들어오면 유저 목록을 확인하거나 생성할 수 있는 LandingPage
로 이동하도록 하고, 유저를 선택하면 /home
으로 이동시켜 MainPage
를 보게 할거에요. LandingPage와 MainPage는 /src/Pages의
QueryClientProvider client 설정하기
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, }, }, }); return ( <QueryClientProvider client={queryClient}> <RouterProvider router={router} /> </QueryClientProvider> )```
React Query를 쓰기 위해서는 QueryClient를 생성해줘야 해요. 리액트 쿼리에 내에서 발생하는 통신을 이 녀석이 관리해준다고 생각하면 좋아요. 이 QueryClient를 생성할 땐 기본 옵션을 설정할 수 있는데요, 이 기본옵션은 이후 서비스에서 쓰이는 모든 쿼리 및 뮤테이션에 적용돼요.
기본 옵션으로 staleTime을 지정해주었어요. 기본 단위는 ms이어서, 분 단위로 편히 알아보기 위해 1000 * 60 * 5
형태로 주었어요. 1000ms는 1초고, 60초는 1분이니까!
그 다음 LandingPage를 구성해줄 거에요. 이 페이지에서는 다음과 같은 기능을 포함해요.
LandingPage의 기능
- 유저 목록 불러오기
- Loading시에 Loading 문구 띄우기
- Refetching시에 맨 밑에 Reteching 문구 띄우기
- 유저 생성하기
- Mutate시에 Loading 문구 및 disabled 설정
- 유저 누르면 MainPage로 넘어가기
먼저, 이렇게 상태에 따라 보여줄 화면이 다르다면 아이디얼한 케이스를 먼저 구현하는 것이 좋아요. 그러므로 유저 목록을 불러오고 아무런 문제 없이 데이터를 잘 받아왔다고 가정하에 구현해보아요.
이번엔 디자인보단 구조가 더 중점이니까 스타일링은 하지 않겠다
유저 목록 타이틀이 있고, 그 아래에 유저 목록들이 출력되고, 유저를 새로 만드는 버튼이 존재해요. 동적인 데이터가 아닌 정적으로 먼저 구현해보아요.
정적으로 먼저 짜보기
return ( <div> <div> <h2>유저 목록</h2> <ul> <li>유저A</li> <li>유저B</li> <li>유저C</li> </ul> </div> <div> <button>유저 생성</button> </div> </div> );```
이제 여기에서 변할 수 있는 부분들을 체크해보면, 당연히 유저A, B, C 부분이 반복되고 변하는 걸 알 수 있어요. 그러니까 여길 한번 바꿔봅시다.
const userList = [ { id: 0, name: "유저A" }, { id: 1, name: "유저B" }, { id: 2, name: "유저C" }, ]; <div> <h2>유저 목록</h2> <ul> {userList.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> </div>```
이런 식으로 아이템에 들어갈 데이터를 미리 정적으로 만들어주고 아이템에 뿌려주면, userList에 들어있는 데이터에 따라 표현되니까 아주 편해졌지요?
여기에서~ 만약 보여줄 데이터들이 많아지면 어떻게 될까요? 예를 들어 name뿐만 아니라 여러 정보를 표현한다고 생각해볼게요.
const userList = [ { id: 0, name: "유저A", age: 26, city: 'seoul', gender: 'male' }, { id: 1, name: "유저B", age: 22, city: 'gwangju', gender: 'female' }, { id: 2, name: "유저C", age: 27, city: 'gyongju', gender: 'female' }, ];```
그러면 다음과 같이 설정해줘야 할 것 같네요.
{userList.map((user) => ( <li key={user.id}> {user.name} {user.age} {user.city} {user.gender} </li> ))}```
그런데 여기에 만약 표시해줄 정보가 더 많아진다면.. 생각만해도 복잡하고 가독성도 안 좋아질 것 같지 않나요? 그래서 보통 이렇게 데이터만 표시를 해주는 녀석은 따로 컴포넌트로 빼주는데, 이걸 Presenter 컴포넌트라고 표현해요.
// Presenter Component const UserItem = (user) => { return ( <li> {user.name} {user.age} {user.city} {user.gender} </li> ); }; <ul> {userList.map((user) => ( <UserItem key={user.id} user={user} /> ))} </ul>```
어때요? 메인에서는 어떤 데이터가 표시되는지에만 관심을 둘 수 있고, Presenter Component에서는 이 데이터가 어떻게 표현되는지에만 관심을 둘 수 있어서 괜찮은 분리가 된 것 같아요.
여기에서 조금만 더 딥하게 들어가보자면, UserItem의 관심사는 이미 user의 정보를 표시해주는 것임을 알고 있어요. 그렇다면 이 컴포넌트 안에서 쓰이는 name, age 등의 정보는 당연히 어떤 녀석을 가리키는 걸까요? 바로 user의 정보를 가리키는 걸 알 수 있어요.
그렇기 때문에 user의 정보를 그대로 넘기는 게 아니라, destructuring을 해서 넘겨주는 방법이 있어요.
destructuring user
// Presenter Component const UserItem = ({ name, age, city, gender }) => { return ( <li> {name} {age} {city} {gender} </li> ); }; <ul> {userList.map((user) => ( <UserItem key={user.id} {...user} /> ))} </ul>```
UserItem에서 Prop을 Destructuring을 해서 받고, 상위에서는 user를 Desturcturing해서 넣어주고 있어요. 저 코드는 아래의 코드와 같아요.
<UserItem key={user.id} name={user.name} age={user.age} city={user.city} gender={user.gender} />
보여줄 데이터가 많아지면 계속 추가해줄 뻔한 걸 destructuring을 통해 신경쓰지 않아도 되었습니다~_~ 다만 이제 저희가 일단 필요한 건 name뿐임으로 구조는 남겨주고 name만 살려둘게요. 사실 이러면 그냥 li 태그를 직접 반환하는 게 낫지만, 어디까지나 배우는 과정이니까요!
// Presenter Component const UserItem = ({name}) => { return ( <li>{name}</li> ); }; <ul> {userList.map((user) => ( <UserItem key={user.id} {...user} /> ))} </ul>```
이제 표시해주는 구조는 잡아줬으니, 실제로 데이터를 불러와보자구요! 먼저 useQuery를 import해서 다음과 같이 적어줄게요.
useQuery로 불러오기
import { useQuery } from "@tanstack/react-query"; const { data: userList } = useQuery({ queryKey: ["userList"], queryFn: () => fetch("http://127.0.0.1:3001/users").then((res) => res.json()), }); console.log(userList)
어우우 뭐가 많은 것 같은데, 전혀 복잡한 게 아니니까 천천히 살펴보자요.
먼저 쿼리라는 개념을 잡고 가볼까요? 쿼리는 서버에 데이터를 가져오는/보내는 요청을 뜻해요. 그렇다면 이 요청에 대한 여러가지 정보가 있지 않겠어요? 지금 데이터를 불러오는 중인지, 어떤 데이터가 왔는지, 어떤 에러가 났는지 등등 해당 요청에 대한 필요한 정보가 있을 거에요. 이렇게 해당 요청에 대해 필요한 모든 정보를 담아둔 녀석을 쿼리라고 해요. useQuery는 이런 쿼리를 만들어주는 Hook이 되는 거구요!
queryFn는 실제로 데이터를 호출하는 함수에요. 여기엔 axios로 호출할 수도 있고 뭐 다른 식으로 원하는 API Endpoint에 접근해서 데이터를 반환하는 메소드가 있다면 뭐든지 가능해요. 단, Promise 리턴 형태여야 합니다! 당연히 네트워크 로직이니, 비동기로 동작하니까 말이죵.
queryKey는 위의 쿼리에 대한 라벨이라고 생각하면 될 것 같아요. 만약 키값에 대해 이해하고 있다면, 키값으로 이해하면 됩니다! 라벨을 붙이게 되면 이제 쿼리에 대해 userList라는 별칭이 붙게 돼요. (별칭이라는 말은 통용되는 게 아니라 단순 설명을 위한 단어)
이 별칭은 React Query에서 꽤 중요하게 동작하는 메커니즘 중 하나에요. 그래서 잘 이해하는 게 좋은데, 이렇게 한번 설명을 해볼게요. 우리는 살면서 사회적으로 부여되는 이름들이 여럿 있지요. 학생회에서는 홍보국장이, 동아리에서는 총무가, 회사에서는 막내 등등.
그러면 이 이름에서 기대되는 역할들이 존재해요. 홍보국장은 홍보를 열심히 한다거나, 총무는 돈을 관리한다거나, 막내는 가만히 있거나 도와준다거나 하는 식으로요. 그래서 특정 상황에서 이름을 부르면, 그 역할을 수행하기 마련이죠. 콘텐츠 회의 중에 홍보국장! 이렇게 부르면 홍보에 관한 기안을 내놓는다던지, 동아리 회식에서 총무! 부르면 돈을 걷는다던지, 회사에서 일을 할 때 막내! 부르면 혼나러 간다든지 도와주러 간다든지 하는 식으로요.
이 쿼리도 마찬가지에요. userList라는 queryKey를 얻었으면, 앞으로 이 userList를 부를 때마다 쿼리가 동작하는 거죠. 간단하죠?
또, 이 queryKey는 하나의 값이 들어갈 수 있는 게 아니라, 여러 값이 들어갈 수 있어요. 다음과 같이요.
const { data: userList } = useQuery({ queryKey: ["userList", "typeA"], queryFn: () => fetch("http://127.0.0.1:3001/users").then((res) => res.json()), });```
이렇게 하면 해당 쿼리는 userList라는 이름과, typeA라는 이름을 동시에 가지게 되는데, 중요한 것은 이 queryKey엔 순서가 존재한다는 것입니다! "userList", "typeA"
로 적은 것과 "typeA", "userList"
로 적은 것은 서로 다른 queryKey이에요. 더 큰 범주가 앞에 나오는 것이 일번적이고, 이 말은 곧 이 키값들은 뒤로 갈수록 좁은 범위를, 구체적인 영역을 의미해요.
따라서 위에 선언된 쿼리는 userList 다음에 typeA라는 영역에 대한 쿼리라는 의미가 됩니다. 여기엔 단순히 정적 데이터만 들어가는 것이 아니라 동적인 데이터도 들어갈 수 있는데요, 다음과 같은 상황이 많아요.
const { data: todoList } = useQuery({ queryKey: ["todoList", userId], queryFn: () => fetch(`http://127.0.0.1:3001/users/${userId}/todos`).then((res) => res.json()), });```
이번엔 userList가 아니라 todoList를 불러오는 상황이라고 가정해봅시다. 이 todoList를 불러오기 위해서는 userId가 필요한데요, 이 userId를 todoList 라벨 뒤에 넣어줬습니다. 그러면 이 쿼리는 userId에 대한 todoList를 불러오는 역할을 기대하게 되는거죠.
그래요, 그러면 userList라는 이름을 붙여서 쿼리를 만드는 것도 알았고, todoList라는 이름을 붙여서 쿼리를 만드는 것도 알았고, todoList 다음에 userId라는 이름을 붙여서 userId에 해당하는 todoList를 불러오는 쿼리라는 것도 알게 됐어요. 그래서 이게 왜 중요한 걸까요?
이게 왜 중요한 메커니즘이냐면~ 이 queryKey는 '캐시'를 저장하는 데 필요한 녀석이라서요!
React Query는 데이터의 최신화를 특징으로 가지고 있어요. 데이터를 무작정 서버에서 가져와서 데이터를 업데이트해주는 게 아니라, 일정 시간 내에 동일한 요청이 온다면 요청하지 않고 기존에 가지고 있는 데이터, 즉 캐시를 넘겨줘요. 이렇게 함으로써 네트워크 통신량을 줄일 수 있고 사용자는 더 빠른 데이터를 얻을 수 있어요.
이때, 동일한 요청인지 아닌지를 판단하는 요소가 queryKey이에요. 만약 userList라는 queryKey를 가진 쿼리를 호출하고 나서, 이 후 일정 시간 안에 또다시 이 쿼리를 호출했다면 이전에 호출했던 쿼리의 결과값을 가지고 있었으니, 이 캐시를 넘겨주는 거에요.
queryKey가 여러 개일 때도 마찬가지에요. ["todoList", userId, folderId]
라는 쿼리키값을 가지고 있었다고 가정하면, 이 순서에 맞는 데이터를 불러온 뒤, 이 결과값을 캐시로 저장해요. 이후 동일한 userId와 folderId가 들어온다면 이 캐시를 반환해주고, 만약 둘 중 하나의 데이터라도 다르다면 쿼리를 다시 불러오게 되는 거죠.
sangpok이라는 유저가 폴더A와 폴더B를 가지고 있고, 요청이 들어오면 5분 동안 캐싱을 한다고 가정해봅시다. 폴더A를 클릭하면 ["todoList", "sangpok", "폴더A"]
를 가진 쿼리가 생성이 될거고, 해당 쿼리의 결과값이 캐시로 저장될 거에요. 또 폴더B를 클릭하면 ["todoList", "sangpok", "폴더B"]
라는 쿼리가 생성이 되겠지요? 역시 캐시에 저장될 테구요. 이때 다시 폴더A를 클릭하면 5분 안에 다시 요청이 들어온 셈이므로 서버에 데이터를 요청하지 않고 캐시를 가져와서 반환해주어요! 5분이 지나고 요청을 하면 서버에 다시 데이터를 요청하구요.
그런데 이런 경우도 있지 않을까요? 해당 쿼리가 최신 데이터가 아니라고 분명히 말할 수 있을 때 말이죠. 예를 들어 다음과 같은 상황입니다.
sangpok이 가지고 있는 폴더를 요청하는 쿼리가 있고 ["folders", "sangpok"]
이라는 키값을 가지고 있다고 봅시다. 또한 이 쿼리의 캐싱은 1시간을 유지한다고 가정해보아요. 그러면 1시간 동안은 해당 쿼리를 요청시 캐시를 반환하는 거에요.
이때, 폴더를 추가하는 요청이 발생했어요. 이 요청은 서버에 성공적으로 적용되었고 이제 sangpok의 폴더엔 폴더C가 추가되었어요. 그리고 다시 폴더 목록을 가져오는 쿼리를 요청했지만, 안타깝게도 갱신되지 않아요. 그 이유는 캐시가 1시간동안 유지가 되고 있기에 이전의 결과값을 반환할 뿐이거든요.
이렇듯 해당 쿼리가 가지고 있는 데이터가 최신의 값이 아니라는 걸 확신할 때, 해당 쿼리가 오래된 값이라고 일러주는 동작을 재검증(invalidate)이라고 말해요. 재검증을 당한 쿼리는 서버와 통신해서 새로운 값을 가져오게 되고, 비로소 sangpok의 갱신된 폴더 목록을 가지게 되는 거에요.
그래서 보통 서버 데이터가 변경됐음을 확신하면 이 재검증을 통해서 새로운 데이터를 받아오도록 로직을 짜곤 합니다.
useQuery로 불러오기
import { useQuery } from "@tanstack/react-query"; const { data: userList } = useQuery({ queryKey: ["userList"], queryFn: () => fetch("http://127.0.0.1:3001/users").then((res) => res.json()), }); console.log(userList)
자, 돌아와서. 그러면 위의 코드가 이제 얼추 이해가 가죠? userList라는 키를 가진 쿼리를 만들어서 호출해주고 있어요. 그리고 data를 뽑아내서 userList라고 별칭해주고 콘솔로 출력해보고 있어요. 어떤 결과가 출력되는지 확인해봅시다.
처음엔 undefined
가 출력되고, 그 이후엔 유저 목록을 성공적으로 출력하고 있어요. 조금 살펴볼게요.
처음에 undefined
가 뜬 이유는 데이터가 없기 때문이에요. 당연한 소리겠지만! 데이터가 없는 이유가 아직 서버에서 응답이 오지 않았기 때문이란 걸 알아야 해요. 응답이 오지 않았다는 것은, 응답이 오는 데까지 시간이 걸린다는 뜻이고, 그렇다면 이것은? 바로 로딩을 뜻하는 거에요. 즉, 로딩 중에는 data가 undefined로 반환된다는 뜻이죠.
그러면 이 로딩을 data === undefined 라는 걸 검사해줘서 얻어내야 하는 걸까요? 적절한 추론이지만, 안타깝게도 data가 있어도 데이터를 불러오는 것일 수도 있어요. 아까 위에서 언급한 재검증(invalidate)가 그러한 케이스에요. 해당 데이터가 오래된 것 같으니 확인해 봐라! 하는 재검증이 이루어지면, data가 유지되지만 다시 데이터를 불러오는 상태일 수 있어요.
그렇다면 여기에서 짚어봐야 할 것은, 로딩 중이라는 것의 정의가 두 가지로 나뉘고 있다는 것입니다. 우리가 말하는 로딩은 (1) 최초로 데이터를 가져올 때도, (2) 다시 데이터를 불러올 때도 로딩이라고 부르고 있어요. 이 둘의 차이는 무엇일까요?
바로 위에서 언급했듯, data가 있는 상태에서 불러오는 것과, 없는 상태에서 불러오는 것의 차이에요. 이 두 가지의 로딩을 구분하는 게 생각보다 중요한데, 그 이유는,, 이전 포스팅에서 참고 바람(ㅋㅋ)
아무튼, 하여간, 그래서, React Query는 상태에 대한 다양한 Flag를 만들어두었어요. 아까 쿼리는 요청에 대한 모든 정보를 담아둔다고 했었죠? 이 쿼리에 바로 요 Flag도 포함되어 있어요.
꽤 많은 Flag를 제공하고 있는데, 이 중에서 다음의 Flag를 알아두면 좋아요.
Query Status flag
isLoading
,isPending
: 쿼리를 실행중이고, data가 없을 때 (= 최초로 가져올 때)isFetching
: 쿼리를 실행 중일 때isRefetching
: 쿼리를 실행 중이고, data가 있을 때 (= 다시 가져올 때)isSuccess
: 쿼리가 성공적으로 끝났을 때isError
: 쿼리에서 오류가 났을 때
다시 말하지만 자세한 내용은 이전 포스팅으로 갈무리!
그래서 다음과 같이 쿼리의 상태에 따라 어떤 화면을 보여줄지 분기할 수 있어요.
const { data: userList, isSuccess, isPending, isRefetching, isError, error, } = useQuery({ queryKey: ["userList"], queryFn: () => fetch("http://127.0.0.1:3001/users").then((res) => res.json()), }); <div> <h2>유저 목록</h2> {isPending && <p>유저 목록을 불러오는 중입니다...</p>} {isError && <p>허걱 에러다!</p>} {isSuccess && ( <ul> {userList && userList.map((user) => <UserItem key={user.id} {...user} />)} </ul> )} {isRefetching && <p>유저 목록을 불러오는 중입니다...</p>} </div>```
각각의 상태는 하나의 렌더를 보여줄지 말지를 결정하고 있다는 사실을 눈여겨보면 좋을 것 같아요. 좋아요. 네트워크 상태에 따라서 분기는 해주었는데, 하나 더 필요한 것이 있어요. 바로 성공적으로 불러왔지만 유저가 한 명도 없을 때의 경우가 처리되지 않았어요. 그 경우를 위해 다시 분기해봅시다.
<div> <h2>유저 목록</h2> {isPending && <p>유저 목록을 불러오는 중입니다...</p>} {isError && <p>허걱 에러다!</p>} {isSuccess && (userList && userList.length !== 0 ? ( <ul> {userList.map((user) => <UserItem key={user.id} {...user} />)} </ul> ) : ( <p>새로운 유저를 생성해보세요!</p> ))} {isRefetching && <p>유저 목록을 불러오는 중입니다...</p>} </div>```
쿼리 요청이 성공적으로 끝났다면 userList의 값은 undefined이 아닌 값이 들어오게 돼요. 그러므로 userList를 체크할 필요는 없어지지만, typescript는 이를 인지하지 못하기 때문에 같이 검사해주었어요. 그렇게 가져온 userList의 크기가 0이 아니라면 목록을 보여주고, 0이라면 새로운 유저를 생성해보라는 문구가 보여지게 돼요.
이렇게 상태에 따른 분기와 아이템이 없을 때를 분기해보았는데, 어떤가요? 알아보기 쉬운가요? 만약 알아보기 쉬우시다면,, 천부적인 재능입니다. 질투납니다.
물론 지금까지 경우를 따져가며 왔기 때문에 자연스러운 코드라고 생각이 들 수 있지만, 이 코드를 처음 본 사람이 봤을 때도 쉽게 파악할 수 있을까요? 이정도의 코드야 가볍게 읽을 수도 있겠지만, 볼륨이 커지고 복잡해진다면,, 가독성이 굉장히 구려질 겁니다.
그래서 우리는 분기 렌더링을 위한 새로운 Flag를 만들어 줄거에요. 다음과 같이요!
const initialLoading = isPending; const hasNoUserList = isSuccess && userList && userList.length === 0; const hasUserList = isSuccess && userList && userList.length !== 0; <div> <h2>유저 목록</h2> {initialLoading && <p>유저 목록을 불러오는 중입니다...</p>} {isError && <p>허걱 에러다!</p>} {hasUserList && ( <ul> {userList.map((user) => ( <UserItem key={user.id} {...user} /> ))} </ul> )} {hasNoUserList && <p>새로운 유저를 생성해보세요!</p>} {isRefetching && <p>유저 목록을 불러오는 중입니다...</p>} </div>```
어떤가요? 각 상태에 대해서 어떤 화면을 분기해줄 건지 한 눈에 보이지 않나요? 여기에서 조금 더 깔끔하게 만들어주려면 저 userList 부분을 따로 컴포넌트로 빼주면 될 것 같아요.
const UserList = ({ userList }) => { return ( <ul> {userList.map((user) => ( <UserItem key={user.id} {...user} /> ))} </ul> ); }; <div> <h2>유저 목록</h2> {initialLoading && <p>유저 목록을 불러오는 중입니다...</p>} {isError && <p>허걱 에러다!</p>} {hasUserList && <UserList userList={userList} />} {hasNoUserList && <p>새로운 유저를 생성해보세요!</p>} {isRefetching && <p>유저 목록을 불러오는 중입니다...</p>} </div>```
이제 유저 목록을 불러오는 상태에 따라 한 눈에 알아볼 수 있을 만큼 가독성이 확보된 코드가 되었어요. 이렇게 아래 자식 컴포넌트들은 어떻게 보여줄지에 집중하고, 부모 컴포넌트는 어떤 컴포넌트를 보여줄지 결정해주는 방식, 즉 로직을 담당하는 패턴을 Container-Presenter 패턴
이라고 해요.
다만 이 패턴은 리액트에서 Hook이라는 개념이 나오기 전에 구축됐던 개념이라 더 이상 사용하지 말라고 권장하는 패턴이기도 해요. 이 패턴을 만들었던 창시자가 만든 걸 후회한다고까지 말했었는데~ 이건 당시 맥락에 대한 이해가 필요해요.
분명 View와 로직을 구분하는 패턴은 유용하지만, 사람들이 이에 대한 이해가 없이 맹목적으로 따르다보니 너무 많은 분리가 이루어지기도 하고, 어떤 걸 Container로 하고 어떤 걸 Presenter로 구분짓는지에 대한 모호함이 생겼기 때문이에요.
현재는 Hook이라는 개념이 나왔기 때문에, 로직에 대한 부분은 Custom Hook으로 처리해버리고 자식들이 필요한 상태는 Context API로 직접 불러오는 형태가 널리 쓰이고 있어요.
그렇지만! 이런 패턴이 왜 나왔는지에 대한 문제 인식과, Container와 Presenter로 나누는 인사이트에 대한 이해가 더 중요하다고 생각해요. 이 패턴은 옛날꺼라 안 좋은 거고, 요샌 이렇게 해야 해! 가 아니라, 예전엔 이런 문제가 있어서 이렇게 해결을 봤는데, 이게 요새로 따지면 이렇게 변한 거였구나~ 하는 이해로요.
우리가 지금까지 만들어온 Container/Presenter는 필요에 따라 적절하게 구분되었기에 지양하는 패턴이라도 괜찮습니다. 그리고 그렇게 복잡한 구조가 전혀 아니기 때문에..^~^
지금까지 만든 데모를 살펴보면 다음과 같아요.
isRefetching 상태를 보여주기 위해 유저 생성 버튼에 invalidateQueries 메서드를 잠깐 넣어줬어요. 이제 유저를 생성해볼까요?
유저를 생성하는 건 서버 데이터를 조작하는 행위니까, Mutation에 해당해요. 그러므로 useMutation이라는 훅을 사용해봅시다.
useMutation
const createMutation = useMutation({ mutationKey: ["createUser"], mutationFn: (formdata) => fetch("http://127.0.0.1:3001/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formdata), }).then((res) => res.json()), });
이 useMutation도 마찬가지로 키에 맞는 쿼리를 생성하고 뮤테이션 쿼리를 반환해요. 기본적으로 useQuery와 비슷하지만 다른 것은 useMutation은 mutate라는 메소드를 반환한다는 점입니다.
useQuery는 컴포넌트가 로드될 때 같이 실행됩니다. 보통 페이지에 필요한 데이터는 바로 필요하기 때문이에요. 하지만 데이터를 조작하는 요청을 보내는 타이밍은 페이지가 로드될 때가 아니라 사용자의 행동에 트리거 되는 경우가 많죠. 즉, 컴포넌트 렌더 이후에 발생하는 일이에요.
그렇기 때문에 useMutation에서는 중간에 요청할 수 있는 메소드를 만들어서 반환해주고, 그 메소드가 바로 mutate이에요. 이 mutate에 필요한 데이터를 담아서 호출해주면, 위에서 선언해준 mutationFn으로 넘어가게 돼서 formdata에 인자가 넘어가게 됩니다. 예를 들면 다음과 같아요.
mutate({ id: 0, nickname: "sangpok" }) /** mutationFun의 formdata가 받게 되는 data: { id: 0, nickname: "sangpok" } */
그리고 또한 이 뮤테이션 쿼리 역시 요청에 따른 상태를 확인할 수 있어요.
각 Flag에 대한 설명은 위의 useQuery의 쿼리와 똑같아요. 다만 다른 점은 이 Mutation은 캐시가 없다는 점에서 isPending만 존재한다는 점이겠네요!
그러면 이제 버튼을 누르면 이 mutate를 호출해봅시다.
유저 생성하기
const createMutation = useMutation({ mutationKey: ["createUser"], mutationFn: (formdata) => fetch("http://127.0.0.1:3001/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formdata), }).then((res) => res.json()), }); const handleCreateUser = () => { const adjectives = ["착한", "나쁜", "게으른", "부지런한", "재밌는"]; const nouns = ["강아지", "거북이", "여우", "사자", "토끼"]; const indicatorA = Math.floor(Math.random() * adjectives.length); const indicatorB = Math.floor(Math.random() * nouns.length); const newUser = { id: uuid(), nickname: `${adjectives[indicatorA]} ${nouns[indicatorB]}`, }; createMutation.mutate(newUser); }; <div> <button onClick={handleCreateUser}>유저 생성</button> </div>
약간의 재치로 임의의 닉네임을 생성해서 초기에 지정해주려고 해서, 주어진 형용사와 명사를 골라 임의의 별명을 만들어주었고, id는 uuid를 통해 유니크한 아이디를 만들어주었어요.
사실 이렇게 실제 아이디를 만들어주는 작업은 서버쪽에서 진행해야 하지만~ JSON Server에서 그 기능을 구현하려면 따로 좀 작업해야 하는 게 있어서, 그냥 직접 유저 정보를 만들어서 전달해주고 있다는 점을 인지하고 있으면 될 것 같아요.
유저 정보를 만들고 이 mutate를 호출하면 서버로 전송되고 성공적으로 응답이 돌아온다고 가정할게요. 그렇다면,, 성공적으로 응답이 돌아왔다는 것을 어떻게 알 수 있을까요?
React Query에서는 다양한 상황에 따라 콜백함수를 만들어놨는데, 응답이 성공했을 경우의 콜백함수도 역시 만들어 놓았어요. 이 콜백한수는 useMutation에서 지정해주면 되어요.
재밌는 건 지금부터인데~ 각 콜백함수마다 다양한 인자들이 보이는 걸 확인할 수 있어요. 이 인자들 덕분에 저희는 적절한 로직을 짤 수 있답니다. 이 콜백함수는 불려지는 순서가 있는데요, 순서는 다음과 같아요.
useMutation 콜백함수 순서
onMutate
-> (onSuccess
oronError
) ->onSettled
Mutation이 발생하면 mutationFn이 호출되기 전에 먼저 onMutate 콜백함수가 호출되어요. 이 콜백함수에서 중요한 게, 콜백함수에서 return 값을 넘겨주게 되면, 이 값은 이후의 생명주기인 onSuccess/onError/onSettled에서 context라는 이름으로 사용할 수 있어요.
그 다음 응답이 성공하면 onSuccess로, 실패하면 onError로 가게 되고 각각의 콜백함수에는 필요한 인자가 넘겨지게 되어요. 그리고 마무리로 onSettled가 호출됩니다. try - catch문에서 finally 역할을 한다고 보면 됩니다.
그러면 이제 Mutation에 성공하면 userList를 다시 불러와봅시다!
const queryClient = useQueryClient(); onSuccess(data, variables, context) { queryClient.invalidateQueries({ queryKey: ["userList"] }); },
위에서 그렇게 말하던 invalidateQueries가 드디어 모습을 드러냈어요(ㅋㅋ)
혹시 포스팅 초반에 언급했던 QueryClient가 기억나시나요? 이 녀석이 React Query에서 발생하는 쿼리를 담당하고 관리해주고 있다고 했는데요, 이 QueryClient를 컴포넌트 내부에서 사용하려면 useQueryClient라는 훅을 사용해서 얻어와요. 그러면 App 에서 넘겨줬던 그 QueryClient 인스턴스가 반환돼서 사용할 수 있답니다.
또, 아까 재검증을 설명할 때 Mutate가 진행됐다고 해도, 쿼리를 다시 불러오지 않는다고 했었어요. 그렇기 때문에 너가 가진 데이터가 오래된 데이터야! 라고 말해주는 게 재검정(invalidate)라고 했어요. 그 재검정을 언제 진행해야 할까요? 그 순간이 바로 Mutation이 성공했을 때, 즉, onSuccess 콜백이 호출됐을 때입니다.
따라서, userList에 대한 쿼리 정보를 가지고 있는 녀석은 QueryClient 이므로 onSuccess 콜백 함수에서 queryClient에게 userList 쿼리에 재검증을 요청해줘! 하는 코드가 바로 위의 코드가 되겠습니다.
이렇게 되면 userList를 가진 쿼리는 Refetching을 진행하게 되고, 갱신된 서버의 데이터를 가지고 오게 되어 최신의 데이터를 가지게 될 수 있는 것입니다! 재밌지 않나요?
지금까지의 진행을 보면 위와 같은 모습인데요, 어딘가 조금 부자연스러운 느낌이 들지 않으신가요? 맞습니다. 버튼을 클릭하고 나서 유저 목록이 갱신될 때까지 어떠한 피드백도 없어서 생성이 진행되고 있는 건가 알 수 없습니다. 따라서 생성 요청에 대한 상태에 따라 UI 피드백을 해보도록 해요.
<div> <button onClick={handleCreateUser} disabled={createMutation.isPending}> {createMutation.isPending ? "생성중..." : "유저 생성"} </button> </div>
아까 말했듯, Mutation에는 캐시가 없기 때문에 isPending Flag만 존재해요. 그렇기에 isPending이 설정됐다는 소리는 Mutation이 시작됐다는 뜻이고, isPending이 false가 되면 성공을 했든 에러가 났든 뮤테이션 쿼리가 끝났다는 이야기입니다. 따라서 isPending에 따라서 분기해주었어요.
최고의 에러 처리는 에러를 낼 상황을 주지 않는 것! 생성을 시도하면 생성 버튼을 클릭할 수 없게 만들어주었어요. 그후 성공했으니 invalidate가 발생하면서 userList에 대한 Refetching이 이루어진 걸 확인할 수 있어요.
그런데 모종의 이유로 Mutate에 실패했다고 가정해봅시다. 우리가 쓰는 서비스에서 뭔가 서비스에 장애가 일어나면 바라는 기능 중에 하나는 바로 '재시도' 인데요, 방금 보낸 쿼리를 다시 시도할 수 있길 원하지요.
이 재시도를 구현하려면 어떻게 해야할까요? onError 콜백에서 다시 mutate를 호출해줘야 할까요? (예시를 위해 적었다지만 생각만해도 끔찍하네요!)
사실 이 재시도에 대한 목적을 다시 의식해볼 필요가 있어요. 그리고 깨달아야 합니다. 재시도는 필요가 없다는 사실을요. 좀 더 정확하게 말하자면, '재시도하기'라는 버튼은 필요하지 않습니다. 그 이유는 다음과 같아요.
재시도하기 버튼이 필요 없는 이유
- React Query 내부에서, 쿼리가 실패하면 자동으로 retry를 해요.
- 우리는 이미 다시 시도할 수 있는 버튼을 가지고 있어요.
React Query의 기본 옵션으로 제공되는 기능 중에 retry라는 옵션이 존재해요. 이 retry에는 실패시 재시도할 횟수를 지정할 수 있어서, 만약 실패하면 지정된 횟수만큼 자동으로 재시도를 진행해요. 기본값은 3으로 지정되어 있어요.
그 다음 제일 중요한 이유인데, 우린 이미 mutate를 보낼 수 있는 버튼이 존재해요. 바로 '유저 생성' 버튼이요! 이 버튼을 누르면 다시 시도하는 거나 마찬가지인 거니까요.
그러므로, 우리는 오류가 났다고 '알리는 것'에 집중해야 합니다. 이는 UX Writing하고도 관련이 있는데요, 어떤 오류가 났는지, 왜 오류가 났는지, 해결할 수 있는 방법에 대해서 일러주기만 하면 됩니다. 사용자는 오류가 난 상황에서는 재시도를 하는 게 중요한 게 아니라, 어떻게 해결할 수 있는지를 원하기 때문이에요.
따라서 오류에 대해서 알려주기만 하면 됩니다.
서버가 불안정하다는 이유를 알려주었고, 그래서 생성에 실패했다는 현재 상태를 알려주었어요. 다시 시도해보라는 해결 방법을 알려주었고, 그래도 안 되면 문의를 해보라는 추가적인 정보를 알려주었어요. 거기에 관리자에게 연락할 수 있는 수단까지 연결해두었으니, 이보다 친절할 수가요!
분명 이것보단 더 친절하잖아요 ...
ㅋㅋ여튼 그래서 에러를 알려주는 것은 useQuery에서 써봤듯이 isError을 사용하면 됩니다.
<div> <button onClick={handleCreateUser} disabled={createMutation.isPending}> {createMutation.isPending ? "생성중..." : "유저 생성"} </button> {createMutation.isError && ( <p style={{ color: "red" }}>먼가 잘못됨;;</p> )} </div>
잘못된 예시를 쓰는 것 같지만 중요한 건 에러 처리를 한다는 점이니까.....
이제 코드를 전체적으로 확인하면서 정리가 필요한 부분들은 확인해보도록 해요.
const LandingPage = () => { const queryClient = useQueryClient(); const { data: userList, isSuccess, isPending, isRefetching, isError, error, } = useQuery({ queryKey: ["userList"], queryFn: () => fetch("http://127.0.0.1:3001/users").then((res) => res.json()), }); const initialLoading = isPending; const hasNoUserList = isSuccess && userList && userList.length === 0; const hasUserList = isSuccess && userList && userList.length !== 0; const createMutation = useMutation({ mutationKey: ["createUser"], mutationFn: (formdata) => fetch("http://127.0.0.1:3001/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formdata), }).then((res) => res.json()), onSuccess(data, variables, context) { queryClient.invalidateQueries({ queryKey: ["userList"] }); }, }); const handleCreateUser = () => { const adjectives = ["착한", "나쁜", "게으른", "부지런한", "재밌는"]; const nouns = ["강아지", "거북이", "여우", "사자", "토끼"]; const indicatorA = Math.floor(Math.random() * adjectives.length); const indicatorB = Math.floor(Math.random() * nouns.length); const newUser = { id: uuid(), nickname: `${adjectives[indicatorA]} ${nouns[indicatorB]}`, }; createMutation.mutate(newUser); }; return ( <div> <div> <h2>유저 목록</h2> {initialLoading && <p>유저 목록을 불러오는 중입니다...</p>} {isError && <p>허걱 에러다!</p>} {hasUserList && <UserList userList={userList} />} {hasNoUserList && <p>새로운 유저를 생성해보세요!</p>} {isRefetching && <p>유저 목록을 불러오는 중입니다...</p>} </div> <div> <button onClick={handleCreateUser} disabled={createMutation.isPending}> {createMutation.isPending ? "생성중..." : "유저 생성"} </button> {createMutation.isError && ( <p style={{ color: "red" }}>먼가 잘못됨;;</p> )} </div> </div> ); };
뭔가 코드가 정리되지 않은 듯한 느낌이 들지 않으신가요? 들지 않으신다면,, 유감
현재 LandingPage에서는 쿼리가 두 개 쓰이고 있어요. 유저 목록을 읽기 위한 쿼리와, 유저를 생성하기 위한 쿼리입니다. 현재 각각의 쿼리에서 반환되는 값은 어느 정도 겹치는 상황이기도 해요. 예시로 isPending을 들어볼게요.
["userList"] Query역시 isPending을 반환하고 있고, ["createUser"] Query 역시 isPending을 반환하고 있어요. 그리고 각각의 로딩 상태를 나타내주고 싶기 때문에 isPending을 써야하는 상황이에요. 어떻게 구분해줄 수 있을까요?
첫 번째 방법은 alias를 이용하는 방법이에요. userList Query를 받아올 때, data를 userList로 alias 한 것 처럼, isPending을 isUserListPending으로 바꾸고, createUser Query 역시 isPending을 isCreateUserPending으로 바꾸는 거죠.
const { isPending: isUserListPending } = useQuery({ queryKey: ["userList"], ... }) const { isPending: isCreataeUserPending } = useMutation({ mutationKey: ["createUser"], ...})
확실하게 구분이 되는 것 같죠? 하지만,, 우리는 isPending만 겹치는 것이 아니라 isError 역시 겹쳐요. 그러면 이것도 isUserListError와 isCreateUserError로 바꾸고, 에러를 받기 위해 error도 userListError와 createUserError로 바꾸고...
구분은 되지만 두 쿼리에서 나오는 필요한 모든 flag를 죄다 저렇게 만들기엔 너무나 번거롭기도 하고 이쯤 되니 가독성도 그렇게 좋아보이진 않아요.
그렇다면,, 두 번째 방법은 어떨까요? 두 번째 방법은 createMutation을 만들어서 쓴 것처럼 쿼리에서 반환하는 녀석들을 다 받아서 객체로 쓰는 거죠.
const userListQuery = useQuery({ queryKey: ["userList"], ... }) const createUserMutation = useMutation({ mutationKey: ["createUser"], ...})
꽤나 정갈하고 괜찮은 방법 같은데요! 이거라면 isPending뿐만 아니라 메소드와 상태들 역시 유연하게 빼서 쓸 수도 있구요.
그러나 이 역시,, 필요하지 않은 상태나 메소드를 불필요하게 가져와요. 이는 코드를 더 무겁게 만들 수 있고, 특정 상태값만 필요한 경우에는 불필요한 코드를 작성하게 될 수도 있어요. 또한, 여러 쿼리와 뮤테이션을 다루는 경우에는 코드가 복잡해질 수 있답니다.
이래도 안 된다, 저래도 안 된다, 그럼 어쩌라고요!
그러면 세 번째 방법을 써야죠. 바로 컴포넌트를 분리하는 것입니다. userList를 읽어서 표현하는 UserListView 컴포넌트, Mutation을 요청하는 CreateUserView 컴포넌트로요!
LandingPage /UserListView
const UserListView = () => { const { data: userList, isSuccess, isPending, isRefetching, isError, error, } = useQuery({ queryKey: ["userList"], queryFn: () => fetch("http://127.0.0.1:3001/users").then((res) => res.json()), }); const initialLoading = isPending; const hasNoUserList = isSuccess && userList && userList.length === 0; const hasUserList = isSuccess && userList && userList.length !== 0; return ( <div> <h2>유저 목록</h2> {initialLoading && <p>유저 목록을 불러오는 중입니다...</p>} {isError && <p>허걱 에러다!</p>} {hasUserList && <UserList userList={userList} />} {hasNoUserList && <p>새로운 유저를 생성해보세요!</p>} {isRefetching && <p>유저 목록을 불러오는 중입니다...</p>} </div> ); };
LandingPage /CreateUserView
const CreateUserView = () => { const queryClient = useQueryClient(); const { mutate, isError, isPending } = useMutation({ mutationKey: ["createUser"], mutationFn: (formdata) => fetch("http://127.0.0.1:3001/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formdata), }).then((res) => res.json()), onSuccess(data, variables, context) { queryClient.invalidateQueries({ queryKey: ["userList"] }); }, }); const handleCreateUser = () => { const adjectives = ["착한", "나쁜", "게으른", "부지런한", "재밌는"]; const nouns = ["강아지", "거북이", "여우", "사자", "토끼"]; const indicatorA = Math.floor(Math.random() * adjectives.length); const indicatorB = Math.floor(Math.random() * nouns.length); const newUser = { id: uuid(), nickname: `${adjectives[indicatorA]} ${nouns[indicatorB]}`, }; mutate(newUser); }; return ( <div> <button onClick={handleCreateUser} disabled={isPending}> {isPending ? "생성중..." : "유저 생성"} </button> {isError && <p style={{ color: "red" }}>먼가 잘못됨;;</p>} </div> ); };
LandingPage
const LandingPage = () => { return ( <div> <UserListView /> <CreateUserView /> </div> ); };
Hook 덕분에 굳이 부모에서 내려주지 않아도 되고, 각각의 컴포넌트에서는 필요한 상태 및 메소드만 받아서 쓸 수 있어요. 구분짓기 위한 alias를 하지 않아도 되고요!
엄청나게 줄어든 LandingPage가 보이시나요? 아주 만족스럽습니다. 컴포넌트로 분리되고 나니, 조금 더 분리해보고 싶은 욕구가 들지 않으시나요? 안 들어도 어쩔 수 없습니다. 여러분은 수동적 독자라 제가 말하고 싶은 대로 공급받거든요. 헤헤.
LandingPage /UserListView
const UserListView = () => { const { data: userList, isSuccess, isPending, isRefetching, isError, error, } = useQuery({ queryKey: ["userList"], queryFn: () => fetch("http://127.0.0.1:3001/users").then((res) => res.json()), }); const initialLoading = isPending; const hasNoUserList = isSuccess && userList && userList.length === 0; const hasUserList = isSuccess && userList && userList.length !== 0; return ( <div> <h2>유저 목록</h2> {initialLoading && <p>유저 목록을 불러오는 중입니다...</p>} {isError && <p>허걱 에러다!</p>} {hasUserList && <UserList userList={userList} />} {hasNoUserList && <p>새로운 유저를 생성해보세요!</p>} {isRefetching && <p>유저 목록을 불러오는 중입니다...</p>} </div> ); };
먼저 UserListView를 살펴보겠습니다. 잘 정리된 것 같지만 관심사를 분리할 수 있는 여지가 엿보입니다. 바로 useQuery부분입니다.
전체적으로 UserListView의 목적은 userList를 받아와서 표시해주되, 쿼리 상태에 따라서 분기를 해주는 것에 목적을 두고 있어요. 그러니까, 로딩 중인지, 에러가 났는지, userList에 데이터가 있는지만 궁금하지 이 데이터가 어디에서 오는지, 어떤 가공을 거치는지에 대해서는 궁금하지 않아요.
하지만 useQuery를 보게 되면 현재 이 쿼리의 키값은 뭔지, endpoint는 어디이고 어떤 가공을 하고 있는지 확인할 수 있어요. UserListView에서는 궁금하지 않은 정보죠.
그렇기에 이 네트워크 로직은 UserListView에서 분리될 필요가 있어요. 로직을 분리하기 위해서는? Custom Hook!
그렇습니다. useQuery를 쓰는 Custom Hook을 만드는 게 좋아보입니다. 당장 만들어봅시다.
const useUserList = () => { return useQuery({ queryKey: ["userList"], queryFn: () => fetch("http://127.0.0.1:3001/users").then((res) => res.json()), }); }; const UserListView = () => { const { data: userList, isSuccess, isPending, isRefetching, isError, } = useUserList(); const initialLoading = isPending; const hasNoUserList = isSuccess && userList && userList.length === 0; const hasUserList = isSuccess && userList && userList.length !== 0; // ...
useUserList라는 Custom Hook을 만들었습니다. 이제 이 Hook만 쓰면 UserListView는 useQuery에 어떤 인자가 들어가는지 신경쓰지 않고 상태에만 집중할 수 있게 되었어요.
이런 식으로 Custom Hook은 로직과 View를 분리하는 데에 아주 큰 효과를 가지고 있어요. 원하는 만큼 Hook을 만들어서 써도 되구요. 만약 저 상태들에서 파생되는 flag도 그냥 받아오고만 싶다면 Custom Hook으로 만들면 되는 겁니다!
const useUserListView = () => { const { data: userList, isSuccess, isPending, isRefetching, isError, } = useUserList(); const initialLoading = isPending; const hasNoUserList = isSuccess && userList && userList.length === 0; const hasUserList = isSuccess && userList && userList.length !== 0; return { userList, isRefetching, isError, initialLoading, hasNoUserList, hasUserList, }; }; const UserListView = () => { const { userList, isRefetching, isError, initialLoading, hasNoUserList, hasUserList, } = useUserListView(); return ( <div> <h2>유저 목록</h2> {initialLoading && <p>유저 목록을 불러오는 중입니다...</p>} {isError && <p>허걱 에러다!</p>} {hasUserList && <UserList userList={userList} />} {hasNoUserList && <p>새로운 유저를 생성해보세요!</p>} {isRefetching && <p>유저 목록을 불러오는 중입니다...</p>} </div> ); };
지금까지 저희가 해온 순서를 정리해볼게요.
관심사에 따른 분리 순서
- 쿼리에 따라 컴포넌트 분리
- 네트워크 로직을 Hook으로 분리
- 네트워크 상태에 따른 렌더 상태를 Hook으로 분리
그럼 이제 이런 질문이 들어야 합니다. 그런데 이거.. 갈수록 복잡해지는 거 아닌가? 그렇습니다. 정확하게는 점점 깊은 추상화가 이루어졌습니다. 여기에서 더 추상화를 진행하려면 할 수도 있겠지요.
다만,, 지금까지는 꽤 적절한 추상화가 이루어졌다고 말할 수 있어요. 무턱대고 분리를 한 게 아니고 각각의 관심사에 맞게 추상화가 이루어졌기 때문입니다. userList를 출력하기 위해 연계되는 것들끼리 묶었다고 볼 수 있으니까요.
그렇지만 여기에서 고민을 해야 합니다. 무조건 컴포넌트가 Hook으로만 이루어져야 하는가? 어디까지 추상화를 진행해야 하는 것일까?
늘 그렇듯 정답은.. 없습니다. 프로젝트의 규모에 따라서, 또 팀의 규칙에 따라서도 다 다르기 때문입니다. 중요한 것은 추상화를 '왜' 하는지와 '어떻게' 할지 입니다. 보기 싫다고 죄다 묶어서 훅으로 보내버리면,,,~~ 유지보수도 힘들고 의존성을 파악하기도 힘들고 코드 읽기도 힘들어지기 때문이에요.
하지만 언제나 고민하는 태도는 중요하다구요! 끝없이 고민하는 태도만이 결정을 내릴 때 괜찮을 선택을 하게끔 만든다는 것 잊지 말자구요.
뭐어,, 추상화는 여기까지 하고, 다시 useUserList로 돌아가볼게요.
const useUserList = () => { return useQuery({ queryKey: ["userList"], queryFn: () => fetch("http://127.0.0.1:3001/users").then((res) => res.json()), }); };
사실 여기에도 분리할 수 있는 여지가 존재합니다. 여기에서는 관심사의 분리라기 보단 선언적 코딩에 가까운 분리가 있겠네요.
이 코드를 보고 데이터를 '얻는' 코드구나! 하고 확실하게 알아볼 수 있는 부분은 사실 없습니다. 여러분들이 fetch에 대한 기초 지식이 있기 때문에 데이터를 얻는 코드구나 하고 아는 것이죠. 여기에서 어느 코드도 '얻는다'고 표현한 게 없습니다. 그러면, 다음과 같은 코드는 어떻게 보이나요?
const getUserList = () => fetch("http://127.0.0.1:3001/users").then((res) => res.json()); const useUserList = () => { return useQuery({ queryKey: ["userList"], queryFn: getUserList, }); };
이제 useQuery는 getUserList라는 비동기 함수를 얻어서 동작한다는 걸 알 수 있습니다. fetch에 대한 지식이 없는 사람이 보아도 대충 아, userList를 얻는 동작을 하는 함수겠거니 싶겠죠.
여기에서 더... 아주아주 더 들어가보자고요. 지금 getUserList의 목적은 무엇인가요? userList를 얻는 것 뿐이지, 어떻게 가공되는지는 궁금하지 않아요. 또한 API라는 입장에서, origin이 뭔지 궁금하지 않고 endpoint가 궁금할 뿐이에요. 그러면 다음과 같이 바꿔볼까요?
const API_URI = "http://127.0.0.1:3001"; const Fetcher = { GET: (endpoint) => fetch(`${API_URI}${endpoint}`).then((res) => res.json()) }; const getUserList = () => Fetcher.GET("/users");
Fetcher라는 객체를 만들어서, 각 HTTP Method에 맞게 fetch를 호출하고 있어요. 그러면 getUserList는 이제 Fetcher에게 GET요청을 endpoint만 보내서 하면 userList를 얻게 되는 거에요. 어때요, 너무 간편하지 않나요?
마찬가지로 유저를 만드는 API도 만들어볼게요.
const API_URI = "http://127.0.0.1:3001"; const Fetcher = { GET: (endpoint) => fetch(`${API_URI}${endpoint}`).then((res) => res.json()), POST: (endpoint, formdata) => fetch(`${API_URL}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formdata), }).then((res) => res.json()), }; const getUserList = () => Fetcher.GET("/users"); const createUser = (newUser) => Fetcher.POST("/users", newUser)
만드는 동작은 POST니까, POST에 대한 fetch를 만들어주고 enpoint와 데이터를 넘겨주기만 하면 끝이에요. 그런데 이 getUserList와 createUser가 API 관련 동작인지 아닌지가 헷갈리니까, 다음과 같이 만들어 줄게요.
const API_URI = "http://127.0.0.1:3001"; const Fetcher = { GET: (endpoint) => fetch(`${API_URI}${endpoint}`).then((res) => res.json()), POST: (endpoint, formdata) => fetch(`${API_URL}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formdata), }).then((res) => res.json()), }; const getUserList = () => Fetcher.GET("/users"); const createUser = (newUser) => Fetcher.POST("/users", newUser) const API = { getUserList, createUser }
API 객체를 생성한 뒤, getUserList와 createUser를 담아주었어요. 그러면 이 API 객체를 이용해서 함수를 얻어낼 수 있으니까, getUserList가 어디에서 나온 녀석인지 좀 더 명확해지겠죠? 다음의 코드를 참고해보세요.
const useUserList = () => { return useQuery({ queryKey: ["userList"], queryFn: API.getUserList, }); }; const useCreateUser = () => { return useMutation({ mutationKey: ["createUser"], mutationFn: API.createUser, onSuccess() { queryClient.invalidateQueries({ queryKey: ['userList'] }); }, }); };
마지막으로~ 이렇게 만들어진 useCreateUser를 사용하는 CreateUserView는 이렇게 변하게 되겠죠?
LandingPage /CreateUserView
const CreateUserView = () => { const { mutate, isError, isPending } = useCreateUser() const handleCreateUser = () => { const adjectives = ["착한", "나쁜", "게으른", "부지런한", "재밌는"]; const nouns = ["강아지", "거북이", "여우", "사자", "토끼"]; const indicatorA = Math.floor(Math.random() * adjectives.length); const indicatorB = Math.floor(Math.random() * nouns.length); const newUser = { id: uuid(), nickname: `${adjectives[indicatorA]} ${nouns[indicatorB]}`, }; mutate(newUser); }; return ( <div> <button onClick={handleCreateUser} disabled={isPending}> {isPending ? "생성중..." : "유저 생성"} </button> {isError && <p style={{ color: "red" }}>먼가 잘못됨;;</p>} </div> ); };
기분이니까 여기에서 랜덤 닉네임 만드는 걸 따로 함수로 빼고, 빼는 김에 새로운 유자 정보를 만드는 함수도 따로 만들어줄게요.
LandingPage /CreateUserView
const generateRandomNickname = () => { const adjectives = ["착한", "나쁜", "게으른", "부지런한", "재밌는"]; const nouns = ["강아지", "거북이", "여우", "사자", "토끼"]; const indicatorA = Math.floor(Math.random() * adjectives.length); const indicatorB = Math.floor(Math.random() * nouns.length); return `${adjectives[indicatorA]} ${nouns[indicatorB]}` } const generateNewUser = () => ({ id: uuid(), nickname: generateRandomNickname() }) const CreateUserView = () => { const { mutate, isError, isPending } = useCreateUser() const handleCreateUser = () => { mutate(generateNewUser()); }; return ( <div> <button onClick={handleCreateUser} disabled={isPending}> {isPending ? "생성중..." : "유저 생성"} </button> {isError && <p style={{ color: "red" }}>먼가 잘못됨;;</p>} </div> ); };
자아, 이로써 CreateUserView에서는 내부에서 어떤 동작이 일어나는지 알 필요 없이 useCreateUser Hook만을 통해 유저를 생성하는 요청을 할 수 있고, 그 요청의 상태에 따라 UI 피드백을 해줄 수 있는 구조가 완성되었어요.
놀라운 사실은,, 지금까지 분리한 코드들이 모두 한 파일 안에 있다는 점이에요. 이것이 나쁜 건 아니지만 각각의 관심사에 맞게끔 파일로 분리시켜주는 것이 유지보수도 쉬워지고 찾기가 더 쉬워질 거에요.
포스팅 초반에 언급했던 폴더 구분이 있었는데요, 다음과 같았습니다.
src 내부 구조
├─API ├─Constants ├─Hooks ├─Pages ├─Store ├─Types └─Utils```
일단~ 제일 위에 있는 API부터 해봅시다. API 폴더 안에 index.ts를 만들고 아래와 같이 분리해주세요.
API/index.ts
import { API_URI } from '@Constants/index'; const Fetcher = { GET: (endpoint) => fetch(`${API_URI}${endpoint}`).then((res) => res.json()), POST: (endpoint, formdata) => fetch(`${API_URL}${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(formdata), }).then((res) => res.json()), }; export const getUserList = () => Fetcher.GET("/users"); export const createUser = (newUser) => Fetcher.POST("/users", newUser);
그 다음~ API_URI를 지정해줘야 하므로 Constants 안에 API_URI를 만들어줄게요.
Constants/index.ts
export const API_URL = "http://localhost:3001";
API를 이용한 Hook도 따로 빼줄게요. Hooks폴더에 index.ts를 만들고 아래와 같이 적어주세요.
Hooks/index.ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as API from "@API/index"; export const useUserList = () => { return useQuery({ queryKey: ["userList"], queryFn: API.getUserList, }); }; export const useCreateUser = () => { const queryClient = useQueryClient(); return useMutation({ mutationKey: ["createUser"], mutationFn: API.createUser, onSuccess() { queryClient.invalidateQueries({ queryKey: ["userList"] }); }, }); };
그리고~ LandingPage에서 Components 폴더를 만들어서 UserListView와 CreateUserView를 나눠보자구요.
LandingPage/Components/UserListView.tsx
import { useUserList } from "@Hooks/index"; const UserItem = ({ nickname }) => { /** ... */ } const UserList = ({ userList }) => { /** ... */ } const useUserListView = () => { /** ... */ } export const UserListView = () => { /** ... */ }
LandingPage/Components/CreateUserView.tsx
import { useCreateUser } from "@Hooks/index"; import { v4 as uuid } from "uuid"; const generateRandomNickname = () => { /** ... */ } const generateNewUser = () => ({ /** ... */ }) export const CreateUserView = () => { /** ... */ }
여기에서 좀 더 볼륨이 커지면 UserListView에서도 UserItem이나 UserList같은 자식 컴포넌트도 따로 분리하고, 지금은 여기에서만 쓰이는 Hook이 하나 밖에 없지만 늘어난다면 따로 Hook 파일로 빼기도 하고, CreateUserView에서 generate~와 같은 Util 함수들도 늘어나면 Util 폴더로 빼기도 하는데, 지금의 경우엔 그정도까지의 분리는 코드를 더 보기 힘들게 만들어서 이정도의 파일 구분만 해두면 좋을 것 같아요.
남은 것
- Optimistic UI를 적용한 Todo Folder / Todo CRUD
- useMutation에서 캐시를 직접 지정해줘서 Optimistic Update 구현
- 여러 Mutation이 있을 때 pending Data 관리
- Type 지정해서 달아주기
- Model 만들기
- useQuery의 Genric에 Response Type 달기
이제 더 해야하는데,, 너무 길어져서 여기에서 끊고 언젠가 기회가 된다면 ㅋㅋ 이어가보겠다... 이거 적느라 프로젝트 진행을 못하고 있어 ㅠ