
답변 받았습니다! 에서 사람들이 가장 많이 쓰는 기능은 강의 평가 기능이다. 사람들은 강의를 눌러 해당 강의의 평가들을 보고, 뒤로가기를 눌러 다시 목록으로 돌아오고, 다시 다른 강의를 눌러 평가를 보고... 를 반복한다. 즉, 페이지 내 이동이 꽤나 잦다. 하지만 원래 로직대로라면 페이지가 이렇게 이동할 때마다 다시 데이터 fetching이 이루어졌고, 원격 상태 관리를 위해 코드도 매우 길어서 react-query의 도입을 결정하였다.
위에서 말했듯 우리 웹사이트는 페이지 내 이동이 잦다. 하지만 현재 모든 컴포넌튿르은 useEffect를 사용하여 컴포넌트가 최초 렌더될 때마다 데이터를 api에서 불러왔는데, 30초간 리뷰 하나 읽고 뒤로가기 할때마다 강의 목록을 다시 받는것은 닥봐도 비효율적이다. 강의 목록은 경험상 거의 변화가 없음에도 계속 다시 받아오는 것이다. 이걸 해결하려면 캐싱이 필요한 터였다.
강의 목록을 불러오던 원래 코드를 보자.
useEffect(() => {
const fetchDataFromApi = async () => {
try {
setIsLoading(true);
const response = await apiGet(`/courses`);
setCourses(response.data);
setIsLoading(false);
handleScrolling();
} catch (error) {
setIsLoading(false);
}
};
fetchDataFromApi();
}, []);
컴포넌트가 최초 렌더될 때마다 로딩 상태를 설정하고, 데이터를 받아오고, 스크롤 위치를 기억하고 돌아올때 다시 복구해주는 함수를 호출하는 과정을 거쳤다. 리액트 쿼리로 같은 기능은 아래 한줄로 대체 가능하다:
const { isLoading, courses, error }: { isLoading: boolean; courses: Course[]; error: any } = useCourses();
로딩 상태를 내가 직접 관리할 필요가 없는 것이다! 또한....
Out of the box, "scroll restoration" for all queries (including paginated and infinite queries) Just Works™️ in TanStack Query.
이건 솔직히 깜짝 놀랐다. 리액트 쿼리를 처음 익힐때 공부한 모든 자료들에서 말해주지 않았던 기능인 터라...
원래 session storage를 사용하여 강의를 눌러 리뷰를 보고 뒤로가기 할때 전의 스크롤 위치를 기억하고 복구하도록 직접 코드를 짰는데, 놀랍게도 react-query는 이를 기본으로 지원한다!! (공식 문서 링크) 안그래도 정말 필요한 기능이었기에, 내가 쓸 코드도 줄어드니 안쓸 이유가 없었다.
애초에 프로젝트에 Redux를 사용하지 않아 redux-thunk, redux-saga, RTK Query는 고려하지 않았다.
두개의 라이브러리 모두 stale-while-revalidate 전략을 사용하여, 클라이언트 쪽에선 캐시된 데이터를 반환하면서 서버로부턴 비동기적으로 새 데이터를 받아온다. 게다가 SWR도 내가 위에 말했던 스크롤 복원 기능을 가지고 있다. 비슷한 기능을 하고 있지만, 리액트 쿼리가 더 프로젝트에 적합하다 판단한 이유들은 아래와 같다.
Devtools가 굉장히 잘 되어있어서 개발하면서 편했다. SWR은 써드파티 라이브러리를 사용해야 devtools 사용이 가능하다고 한다.
커뮤니티 기능에선, 댓글등이 실시간으로 달릴 수 있어 데이터가 stale 해지는 시간을 강의리뷰들과는 다르게 지정해야 한다. SWR에는 stale time에 대한 개념이 없는것으로 검색된다.
리액트 쿼리는 SWR이 지원하지 않는 자동 가비지 콜렉션을 지원하여, 지정된 시간동안 쿼리가 사용되지 않으면 치워준다. 또한 SWR과 다르게 쿼리가 업데이트 될때에만 refetch를 진행하여 컴포넌트를 업데이트해준다.
마지막으로, 자료를 찾아보거나 사용법을 익힐때 SWR 보다는 리액트 쿼리에 대한 자료가 압도적으로 더 많아보였다. 나같은 초짜에겐 자료가 많은걸 쓰는게 훨씬 더 유리하다.
PR 링크: 링크
// CourseList.tsx
useEffect(() => {
const fetchDataFromApi = async () => {
try {
setIsLoading(true);
const response = await apiGet(`/courses`);
setCourses(response.data);
setIsLoading(false);
handleScrolling();
} catch (error) {
setIsLoading(false);
}
};
fetchDataFromApi();
}, []);
위가 아까도 봤던 코드다. 로딩 상태를 설정하고, 데이터를 받아온 후 set하고, 다시 로딩 상태를 해제하고 스크롤 복구 함수를 실행한다. 이는 모두 컴포넌트가 최초 렌더될때마다 이루어졌다.
도입 후를 보자.
// CourseApi.ts
export async function getCourses() {
try {
const response = await api('/courses');
return response.data;
} catch(error) {
console.error('Error fetching courses: ' + error);
throw error;
}
}
//useCourses.ts
import { useQuery } from "@tanstack/react-query";
import { getCourses } from "./CourseApi";
import { api, apiGet } from "../APIHandler";
export function useCourses() {
const {
isLoading,
data: courses,
error,
} = useQuery({ queryKey: ["courses"], queryFn: getCourses, staleTime: 5*60*1000});
return { isLoading, error, courses };
}
// CourseList.tsx
const { isLoading, courses, error }: { isLoading: boolean; courses: Course[]; error: any } = useCourses();
if (!courses) {
return <Loader />;
}
useCourses.ts로 hook의 형식으로 따로 data fetching 로직을 분리하여 코드가 깔끔해지는 효과와 함께, 로딩/에러 상태가 리액트 쿼리 덕에 뒤에서 알아서 관리되니 컴포넌트에서 따로 set해줄 필요가 없다. 로딩 상태일땐 Loader 로딩 상태 화면을 보여주고, courses 데이터를 바로 사용하여 화면에 강의 목록을 띄울 수 있다.
강의 목록은 추가만 가능하고, 변화가 잘 없다. 이미 있는 강의에 리뷰를 쓰는 사람들이 새로운 강의를 추가해서 쓰는 사람들보다 훨씬 많다. 따라서 리뷰를 보고 다시 강의 목록 화면으로 돌아왔을 때 새 수업이 생길 확률은 낮고, 사람들은 리뷰를 보고 해당 컴포넌트로 뒤로가기를 눌러 돌아오는 빈도가 높음으로 넉넉하게 5분간 데이터가 캐싱되도록 staleTime을 설정했다. 쿼리 키는 직관적인 ["courses"]다.
게다가 자동 스크롤 복원 기능도 있으니, 해당 기능을 포함해서 컴포넌트 내부에서 약 30줄가량 코드가 줄어들었다!
PR 링크: 링크
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmitted) return;
setSubmit(true);
const data = {
course_name,
course_credit,
course_category,
kaikeYuanxi,
isYouguan,
};
await apiPost('/courses', data).then((response) => {
console.log(response.data);
alert("수업 등록에 성공했습니다!");
navigate(`/courses`);
})
.catch((error) => {
console.log(error);
});
};
원래 코드다.
// CourseApi.ts
export async function addCourse({
course_name,
course_credit,
course_category,
kaikeYuanxi,
isYouguan,
}: addCourseProps) {
try {
const data = {
course_name,
course_credit,
course_category,
kaikeYuanxi,
isYouguan,
};
const response = await api.post("/courses", data);
console.log(response.data);
} catch (error) {
console.error("Error fetching courses: " + error);
throw error;
}
}
// useAddCourses.ts
export function useAddCourses() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const {
mutate: createCourse,
} = useMutation({
mutationFn: addCourse,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["courses"] });
alert("수업 등록에 성공했습니다!");
navigate(`/courses`);
},
onError: () => {
alert("수업 등록에 실패했습니다.");
}
});
return { createCourse };
}
// AddCourse.tsx
const { createCourse } = useAddCourses();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSubmitted) return;
setSubmit(true);
const data = {
course_name,
course_credit,
course_category,
kaikeYuanxi,
isYouguan,
};
createCourse(data);
setSubmit(false);
};
여기서의 핵심은, 새로운 강의가 등록되면 ["courses"] 키의 데이터를 invalidate하는 것이다. 이를 설정하지 않으면 리액트 쿼리는 5분 내의 기간동안은 새로고침 하지 않는이상 새롭게 추가된 강의가 없는 캐싱된 강의 목록을 보여줄 것이고, 이는 원격 상태관리 라이브러리를 사용할때 가장 조심해야 하는 부분이다.
새 강의가 추가되면 원래 데이터는 invalidate되니, 강의 목록에 돌아갔을 때 자동으로 refetch가 이루어져 새로운 상태에 맞춰진다. 신기하다!
useEffect(() => {
const fetchDataFromApi = async () => {
try {
setIsLoading(true);
Promise.all([
apiGet(`/courses/${courseId}/reviews`),
apiGet(`/courses/${courseId}/name`),
])
.then(([reviewsResponse, nameResponse]) => {
const initializedReviews = reviewsResponse.data.map(
(review: Review) => ({
...review,
})
);
setReviews(initializedReviews);
setCourseName(nameResponse.data.course_name);
setIsLoading(false);
window.scrollTo(0, 0);
})
.catch((error) => {
if (error.response.data.message === "강의를 찾을 수 없습니다." && error.response.status === 404) {
navigate("/courses");
alert("존재하지 않는 수업입니다.");
}
setIsLoading(false);
});
} catch (error) {
console.error("Error in fetching data:", error);
setIsLoading(false);
}
};
fetchDataFromApi();
}, [courseId, navigate]);
원래 코드다. 로딩상태 관리뿐만 아니라, 보다시피 상당히 할게 많다. 해당 강의의 리뷰들 뿐만 아니라, 어떤 강의의 리뷰들을 보고있는지 알기 위해 Promise를 사용해 동시에 강의 이름도 받아오고, 이름과 리뷰들을 각각 set state 함수를 사용하여 처리해야 한다. 또한, 만약 에러가 404이면 존재하지 않는 수업임도 알려야 한다. 이는 url에 강의 번호를 잘못 쳤을때의 경우를 위해서다.
이제 리액트 쿼리를 도입해보자.
// ReviewApi.ts
export async function getReviews(courseId: string | undefined) {
try {
const [reviewResponse, nameResponse] = await Promise.all([
api.get(`/courses/${courseId}/reviews`),
api.get(`/courses/${courseId}/name`),
]);
const reviews = reviewResponse.data.map((review: Review) => ({
...review,
}));
const names = nameResponse.data.course_name;
return { reviews, names };
} catch (error) {
const axiosError = error as AxiosError;
if (axiosError?.response?.status === 404) {
alert("존재하지 않는 수업입니다.");
}
console.error("Error fetching courses: " + error);
throw error;
}
}
// useReviews.ts
export function useReviews(courseId: string | undefined) {
const {
isLoading,
data: reviewData,
error,
} = useQuery({
queryKey: ["reviews", courseId],
queryFn: () => getReviews(courseId),
staleTime: 5 * 60 * 1000,
retry: (failCount, error) => {
const axiosError = error as AxiosError;
if(axiosError?.response?.status === 404) return false; // 존재하지 않는 강의는 재시도 하지 않음
return failCount < 3;
},
});
return { isLoading, error, reviews: reviewData?.reviews, course_name: reviewData?.names };
}
// CourseReviews.tsx
const { isLoading, error, reviews, course_name } = useReviews(courseId);
useEffect(() => {
window.scrollTo(0, 0);
}, [courseId]);
useEffect(() => {
if (error) {
navigate("/courses");
}
}, [error, navigate]);
if (!reviews || isLoading) {
return <Loader />;
}
여기서 발견한 점은, 만약 return loader가 있는 if문을 useEffect 위로 올리면 렌더시 실행되는 hook의 순서와 갯수가 바뀌기에 에러가 뜬다.
에러가 404를 반환하면 존재하지 않는 수업이라는 뜻이기 때문에 더이상 재시도를 하지 않게 하고, 이외의 경우에는 2번만 더 시도를 하는 방식으로 쿼리문을 작성했다.
리액트 쿼리를 도입하면서 api 로직을 렌더링 로직과 분리 할 수 있게 되었고, 원격 상태도 관리가 용이해졌다. 도입한 목적인 캐싱도 상황에 따라서 세부적으로 조절할 수 있게 되었고, 수강신청 기간 사람들이 한창 많이 사용할 때에는 stale time을 좀 줄이는등 변화도 유동적으로 할 수 있게 되었다.