2. 서버 데이터 흐름
쿼리와 뮤테이션의 바인딩 이름에 API 출처를 드러냅니다. 응답 가공은 query.select처럼 출처에 가까운 곳에서 처리하고, 실패 처리와 무효화는 호출한 곳에서 맡습니다.
2.1 쿼리, 뮤테이션 바인딩에 response, mutation 접두사를 붙입니다
지역 바인딩 이름으로 생성된 API 훅을 쉽게 찾을 수 있습니다.
쿼리와 뮤테이션의 지역 바인딩 이름은 생성된 훅 이름에서 만듭니다.
| 바인딩 | 이름 |
|---|---|
| 생성된 단일 API 훅 | use와 요청 종류만 나타내는 앞부분을 response 또는 mutation으로 바꾸고 나머지 이름을 유지합니다 |
| 여러 쿼리를 합친 바인딩 | response 뒤에 결과 이름을 씁니다. useSuspenseQueries를 사용하면 끝에 Suspense를 유지합니다 |
쿼리와 뮤테이션 바인딩 이름이 제각각입니다.
const responseGetProductListSuspense = useGetProductListSuspense();
const removeApi = useProductRemove();
지역 바인딩 접두사를 통일합니다.
/**
* 표에 그릴 상품 조회. 멈추는 동안은 섹션 소유자의 경계가 처리
*/
const responseProductListSuspense = useGetProductListSuspense();
/**
* 표에서 고른 상품 삭제. 성공 뒤 무효화는 부르는 화면 몫
*/
const mutationProductRemove = useProductRemove();
함께 보기
- TypeScript 2.3 파일과 심볼은 역할에 맞는 표기를 씁니다
- 12.1 React의 주요 경계 선언에 JSDoc 주석을 붙입니다
- 2.4 응답, 뮤테이션, 스토어는 JSX까지 원본 이름으로 읽습니다
2.2 응답 가공은 query.select에서 합니다
응답 가공을 쿼리에 모아 화면이 원본 구조에 의존하지 않게 합니다.
서버 응답은 query.select에서 도메인 필드로 가공하고, 화면에서는 그 결과를 렌더합니다.
| 작업 | 처리 위치 |
|---|---|
.map, .filter, 필드 이름 변경 등 응답 가공 | query.select |
가공한 항목을 .map으로 JSX에 대응시키기 | 화면 렌더. JSX 요소와 클릭 핸들러를 select 결과에 넣지 않습니다 |
| 여러 쿼리 결과를 함께 가공 | 규칙 2.3. select는 자기 쿼리 데이터만 받습니다 |
select는 인라인으로 적고, 해당 구독자가 읽는 결과만 바꾸며 쿼리 캐시의 원본을 덮어쓰지 않습니다.
인라인 함수가 다시 실행된다는 이유만으로 useCallback, useMemo를 더하지 않습니다.
실측 병목이 있을 때만 규칙 10.1의 예외 기준을 따릅니다.
렌더에서 응답 원본 구조를 가공합니다.
const responseProductListSuspense = useProductListSuspense();
<UiTable
rows={responseProductListSuspense.data.items.map((product) => ({
id: product.id,
name: product.name,
}))}
/>;
query.select에서 화면에 필요한 형태로 가공합니다.
/**
* 표가 그대로 쓰는 필드 이름으로 목록 변환. 화면은 응답 구조를 모름
*/
const responseProductListSuspense = useProductListSuspense(
{},
{
query: {
select: (response) => ({
items: response.data.items.map((product) => ({id: product.id, name: product.name})),
}),
},
},
);
<UiTable rows={responseProductListSuspense.data.items} />;
함께 보기
- 12.1 React의 주요 경계 선언에 JSDoc 주석을 붙입니다
- 2.1 쿼리, 뮤테이션 바인딩에
response,mutation접두사를 붙입니다 - 2.4 응답, 뮤테이션, 스토어는 JSX까지 원본 이름으로 읽습니다
2.3 여러 쿼리를 합칠 때는 combine을 씁니다
여러 응답의 가공 위치를 통일하고 화면 본문의 별칭을 줄입니다.
둘 이상의 쿼리 결과를 하나로 합칠 때는 값을 렌더하는 섹션에서 combine을 인라인으로 씁니다.
결과를 합칠 필요와 요청을 병렬로 시작할 필요는 따로 판단합니다.
flowchart LR
q1{"결과를 하나로<br>합치는가?"} -- 예 --> q2{"Suspense 쿼리인가?"} -- 예 --> r2("useSuspenseQueries + combine")
q1 -- 아니요 --> r1("합친 값을 만들지 않고<br>결과를 따로 읽기")
q2 -- 아니요 --> r3("useQueries + combine")| 상황 | 선택 |
|---|---|
| Suspense 쿼리 결과를 합침 | useSuspenseQueries + combine. isPending을 만들어 내보내지 않습니다 |
| 일반 쿼리 결과를 합침 | useQueries + combine. 실제 대기, 실패 상태도 함께 다룹니다 |
| 결과를 각각 렌더함 | 합친 값을 만들지 않습니다. Suspense 병렬 실행이 필요하면 useSuspenseQueries에서 결과를 따로 읽습니다 |
| 일반 쿼리의 뒤 요청이 앞 결과를 입력으로 받음 | enabled로 입력이 준비된 뒤 실행합니다 |
| Suspense 쿼리의 뒤 요청이 앞 결과를 입력으로 받음 | 같은 컴포넌트에서 useSuspenseQuery를 순서대로 호출합니다 |
useSuspenseQuery, useSuspenseQueries는 enabled를 받지 않습니다.
필수 입력이 없으면 쿼리를 호출하는 자식의 렌더를 보류합니다.
독립적인 Suspense 쿼리도 같은 컴포넌트에서 따로 호출하면 앞 요청부터 순서대로 실행됩니다.
한 쿼리만 가공하면 규칙 2.2를 따르고,
조합을 커스텀 훅으로 옮기는 기준은 규칙 1.6가 정합니다.
화면 본문에서 두 응답을 꺼내 합칩니다.
const responseProductListSuspense = useProductListSuspense();
const responseCategoryListSuspense = useCategoryListSuspense();
const rows = responseProductListSuspense.data.items.map((product) => ({
id: product.id,
categoryName: responseCategoryListSuspense.data.categories.find(
(category) => category.id === product.categoryId,
)?.name,
}));
값을 렌더하는 섹션이 인라인 combine으로 합칩니다.
export const PgProductTableSection = () => {
/**
* 분류 이름이 목록 응답에 없어 표 한 행에 두 응답을 함께 담음
*/
const responseProductRowsSuspense = useSuspenseQueries({
queries: [productListQueryOptions(), categoryListQueryOptions()],
combine: ([productResult, categoryResult]) => {
// 분류 응답의 id는 유일. 모든 행이 같은 분류 목록을 찾으므로 Map은 한 번만 생성
const categoryById = new Map(categoryResult.data.categories.map((category) => [category.id, category]));
return {
rows: productResult.data.items.map((product) => ({
id: product.id,
name: product.name,
categoryName: categoryById.get(product.categoryId)?.name,
})),
};
},
});
return <UiTable rows={responseProductRowsSuspense.rows} />;
};
함께 보기
2.4 응답, 뮤테이션, 스토어는 JSX까지 원본 이름으로 읽습니다
별칭을 추적하지 않고 사용처에서 값의 출처를 확인할 수 있습니다.
response..., mutation..., *Store는 JSX까지 원본 이름으로 읽습니다.
핸들러, 이펙트 안에서도 responseProductSearchSuspense.data.products처럼 출처를 유지합니다.
| 함께 판단할 내용 | 기준 |
|---|---|
| 객체 구조분해와 별칭 | TypeScript 규칙 4.3 |
| 쿼리 결과 가공 | 규칙 2.2에 따라 query.select에서 처리합니다. 받는 쪽의 별칭은 깊이를 줄이지 못하고 출처만 지웁니다 |
| 프롭스 접근 | 규칙 5.1 |
구조분해로 출처가 흐려집니다.
const {items, total} = responseProductListSuspense.data;
<Fragment>
<UiList rows={items} />
<p className={clsx("pg_products__count")}>총 {total}건</p>
</Fragment>;
원본 객체의 속성을 직접 읽어 출처를 유지합니다.
<Fragment>
<UiList rows={responseProductListSuspense.data.items} />
<p className={clsx("pg_products__count")}>총 {responseProductListSuspense.data.total}건</p>
</Fragment>;
이펙트 의존성도 구조분해한 이름으로 적어 출처가 드러나지 않습니다.
const {products} = responseProductSearchSuspense.data;
useEffect(() => {
if (products.length > 0) {
return;
}
reportEmptySearch(urlParams.keyword);
}, [products, urlParams.keyword]);
이펙트 안에서도 원본 이름 그대로 씁니다.
/**
* 검색 결과가 있으면 빈 검색 보고 생략. 결과가 없을 때만 한 번 보고
*/
useEffect(() => {
if (responseProductSearchSuspense.data.items.length > 0) {
return;
}
reportEmptySearch(urlParams.keyword);
}, [responseProductSearchSuspense.data, urlParams.keyword]);
함께 보기
2.5 뮤테이션 실패는 호출한 자리에서 처리합니다
저장 실패를 놓치지 않고 호출한 자리에서 처리합니다.
뮤테이션 실패는 입력 문맥을 유지할 수 있도록 호출한 자리에서 처리합니다.
기본은 mutate와 useMutation의 onError, onSuccess이며, 핸들러에서는 호출만 합니다.
호출 방식 고르기
flowchart LR
q1{"결과를 기다린 뒤<br>핸들러가 이어 실행되는가?"} -- 아니요 --> r1("mutate 와<br>onError, onSuccess")
q1 -- 예 --> r2("mutateAsync 와 try/catch")| 상황 | 선택 |
|---|---|
| 호출 뒤 핸들러가 더 할 일이 없음 | mutate + onError, onSuccess |
| 결과를 기다린 뒤 핸들러가 계속 실행되어야 함 | mutateAsync + try/catch |
거부된 mutateAsync Promise는 오류 경계가 자동으로 받지 않으므로 catch에서 실패를 표시하거나 다시 던집니다.
throwOnError로 렌더에서 오류를 다시 던지는 경우는 규칙 7.3를 따릅니다.
실패와 중복 실행 처리
| 함께 판단할 내용 | 기준 |
|---|---|
| 같은 뮤테이션의 호출 방식 | 호출하는 곳마다 mutate와 mutateAsync를 섞지 않습니다 |
| 실패 처리 | 빈 catch로 삼키지 않습니다. 표시할 내용은 제품에 맞게 정합니다 |
| 중복 실행 방지 | 버튼을 isPending으로 disabled 처리하고, 핸들러 첫 줄에서도 isPending이면 이른 반환으로 끝냅니다 |
| 성공 뒤 캐시 갱신 | 규칙 2.6를 따릅니다 |
await만 쓰고 거부된 Promise를 처리하지 않습니다.
const handleSaveButtonClick: MouseEventHandler<HTMLButtonElement> = async (_event) => {
await mutationProductSave.mutateAsync({data: toProductSaveRequest(props.formValues)});
void navigate("/products");
};
후속 작업이 없는 호출은 성공, 실패 콜백으로 처리합니다.
/**
* 저장 성공 시 목록 화면으로 이동. 실패 문구는 폼 위에 표시
*/
const mutationProductSave = useProductSave({
mutation: {
onSuccess: () => {
void navigate("/products");
},
onError: (error) => {
setSubmitErrorMessage(toSubmitErrorMessage(error));
},
},
});
const handleSaveButtonClick: MouseEventHandler<HTMLButtonElement> = (_event) => {
if (mutationProductSave.isPending) {
return;
}
mutationProductSave.mutate({data: toProductSaveRequest(props.formValues)});
};
버튼과 핸들러 첫 줄에서 중복 저장을 막습니다.
/**
* 버튼 disabled와 별개로 겹쳐 들어온 저장을 한 번 더 차단
*/
const handleSaveButtonClick: MouseEventHandler<HTMLButtonElement> = (_event) => {
if (mutationProductSave.isPending) {
return;
}
mutationProductSave.mutate({data: toProductSaveRequest(props.formValues)});
};
<UiButton disabled={mutationProductSave.isPending} onClick={handleSaveButtonClick}>
저장
</UiButton>;
결과를 기다리는 후속 작업에는 try/catch를 씁니다.
/**
* 첨부를 먼저 올린 뒤 그 식별자로 상품 저장
*/
const handleSaveButtonClick: MouseEventHandler<HTMLButtonElement> = async (_event) => {
if (mutationAttachmentUpload.isPending || mutationProductSave.isPending) {
return;
}
try {
const uploaded = await mutationAttachmentUpload.mutateAsync({files: draftFiles});
await mutationProductSave.mutateAsync({
data: toProductSaveRequest(props.formValues, uploaded.attachmentIds),
});
void navigate("/products");
} catch (error) {
setSubmitErrorMessage(toSubmitErrorMessage(error));
}
};
함께 보기
2.6 뮤테이션이 바꾼 쿼리는 invalidateQueries로 다시 맞춥니다
저장으로 바뀐 서버 상태를 관련 쿼리에 반영합니다.
뮤테이션이 바꾼 서버 상태는 해당 데이터를 소유한 쿼리 키로 invalidateQueries하여 다시 맞춥니다.
낙관적 갱신과 사용자가 누르는 새로 고침 버튼은 이 규칙의 대상이 아닙니다.
갱신 방법 고르기
sequenceDiagram
participant S as 화면
participant M as 뮤테이션
participant A as 서버
participant C as 쿼리 캐시
S->>M: 저장 호출
M->>A: 저장 요청
A-->>M: 성공 응답
M->>C: 관련 키 무효화
C->>A: 활성 쿼리 다시 요청
A-->>S: 새 응답| 성공 뒤 갱신 방법 | 판정 |
|---|---|
invalidateQueries | 변경된 서버 상태를 가리키는 관련 키를 함께 지정합니다 |
setQueryData로 응답을 목록에 직접 반영 | 쓰지 않습니다. 화면에서 대신 계산한 정렬, 집계가 서버와 어긋날 수 있습니다 |
현재 키의 refetch() | 관련 키 전체를 맞추는 수단으로 쓰지 않습니다. 다른 필터, 페이지 키와 요약 쿼리는 남습니다 |
무효화를 고르는 기준은 구독자 수가 아니라 관련 키의 범위입니다.
invalidateQueries는 일치하는 쿼리를 오래된 상태로 표시하지만, 비활성 쿼리까지 즉시 다시 요청한다고 가정하지 않습니다.
무효화 호출 조건
| 호출 조건 | 처리 |
|---|---|
| 쿼리 키 지정 | 문자열을 직접 적지 않고 쿼리 훅이 내보낸 키를 씁니다 |
| 무효화 대상이 여럿임 | 성공 콜백에서 나란히 호출합니다 |
| 다시 읽기를 마쳐야 저장 중 표시나 후속 동작을 끝낼 수 있음 | 성공 콜백에서 무효화 Promise를 반환하거나 await합니다 |
| 호출 위치 | 규칙 2.5를 따르고, 이펙트로 옮기지 않습니다 |
현재 목록 키만 다시 읽어 다른 목록 조건과 요약 키를 놓칩니다.
const mutationProductSave = useProductSave({
mutation: {
onSuccess: () => {
void responseProductListSuspense.refetch();
},
},
});
바뀐 데이터를 소유한 키를 무효화합니다.
const queryClient = useQueryClient();
/**
* 저장이 목록과 요약 집계를 함께 바꾸므로 두 키를 나란히 무효화
*/
const mutationProductSave = useProductSave({
mutation: {
onSuccess: () => {
void queryClient.invalidateQueries({queryKey: productListQueryKey()});
void queryClient.invalidateQueries({queryKey: productSummaryQueryKey()});
},
},
});
캐시를 손으로 조립하고 키를 문자열로 적습니다.
const mutationProductSave = useProductSave({
mutation: {
onSuccess: (saved) => {
queryClient.setQueryData(["products"], (previous = []) => [...previous, saved]);
},
},
});
함께 보기