FeedB는 개발자들이 자신이 만든 토이 프로젝트나 사이드 프로젝트를 공유하고, 다른 사람들이 피드백을 남길 수 있는 서비스입니다.
이번 글에서는 제가 맡은 메인페이지에서 사람들이 올린 프로젝트 데이터를 어떻게 가져와 화면에 보이도록 구현을 했는지 자세히 말해볼 예정입니다. 그럼 레츠고 ✨
function Example() {
const { isPending, error, data } = useQuery({
queryKey: ['repoData'],
queryFn: () =>
fetch('https://api.github.com/repos/TanStack/query').then((res) =>
res.json(),
),
})
if (isPending) return 'Loading...'
if (error) return 'An error has occurred: ' + error.message
return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
<strong>👀 {data.subscribers_count}</strong>{' '}
<strong>✨ {data.stargazers_count}</strong>{' '}
<strong>🍴 {data.forks_count}</strong>
</div>
)
}
다들 보통 리엑트 쿼리를 사용하실 때 이렇게 쓰실 겁니다. 이번 프로젝트가 React-Query를 사용한 두번 째 프로젝트인데 저번 프로젝트에서 느꼈던 불편한 점 하나가 있었습니다. 위와 같이 사용해도 아무런 문제가 없는데 API 통신에서 문제가 생기면 가장 먼저 React-Query를 사용한 로직을 확인하는데 이 로직을 찾는데 어려움이 있고 invalidateQueries
같은 함수를 이용하여 캐싱해둔 데이터에 접근을 하려면 키값이 필요한데 이 키값을 보러 또 로직을 찾아야하는 어려움이 발생하였습니다.
그래서 저희가 이번 프로젝트에서 query Factory라는 걸 도입해 보았습니다.
쿼리 팩토리는 쿼리 키와 쿼리 함수를 생성하는 패턴입니다. 이를 통해 코드의 재사용성과 유지보수성을 높일 수 있습니다. 쿼리 팩토리를 사용하면 여러 곳에서 동일한 데이터를 일관되게 패치할 수 있습니다.
export const projectQueryKeys = createQueryKeys("project", {
list: (props: ProjectListParams) => ({
queryKey: ["projectList"],
queryFn: async () => await projectApi.getProjectList({ ...props }),
}),
detail: (projectId: number) => ({
queryKey: ["projectDetail"],
queryFn: async () => await projectApi.getProject(projectId),
}),
teamMember: (projectId: number) => ({
queryKey: ["teamMember"],
queryFn: async () => await projectApi.getTeamMember(projectId),
}),
ratings: (projectId: number, userId: number) => ({
queryKey: ["rating"],
queryFn: async () => await projectApi.getRatings(projectId, userId),
}),
totalRating: (projectId: number) => ({
queryKey: ["totalRating"],
queryFn: async () => await projectApi.getTotalRating(projectId),
}),
});
createQueryKeys
함수를 사용하여 프로젝트 데이터를 가져오는 다양한 쿼리를 정의할 수 있습니다.
위와 React Query와 쿼리 팩토리 패턴을 사용하여 데이터 패치를 더욱 효율적이고 일관되게 관리할 수 있었습니다.
쿼리 팩토리를 사용하면 쿼리 키와 함수를 재사용할 수 있어 코드의 유지보수성이 향상되는 효과까지 누릴 수 있었습니다.
const projectListQuery = projectQueryKeys.list({ page: 1, size: 12 });
const { data, fetchNextPage } = useInfiniteQuery({
queryKey: projectListQuery.queryKey,
queryFn: ({ pageParam = 1 }) => projectApi.getProjectList({ ...projectState, page: pageParam }),
initialPageParam: 1,
getNextPageParam: lastPage => {
const { customPageable } = lastPage;
if (customPageable.hasNext) {
return customPageable.page + 1; // 다음 페이지 번호 반환
}
return undefined; // 더 이상 페이지가 없으면 undefined 반환
},
});
미리 정의해둔 쿼리 키와 쿼리 함수를 가져와 useInfiniteQuery
훅에 전달하여 데이터 페칭을 할 수 있습니다.
async function MainPage() {
const queryClient = getQueryClient();
const projectListQuery = projectQueryKeys.list({ page: 1, size: 12 });
await queryClient.prefetchInfiniteQuery({
queryKey: projectListQuery.queryKey,
queryFn: projectListQuery.queryFn,
initialPageParam: 1 as never,
getNextPageParam: (lastPage: any) => {
const { customPageable } = lastPage;
if (customPageable.hasNext) {
return customPageable.page + 1; // 다음 페이지 번호 반환
}
return undefined; // 더 이상 페이지가 없으면 undefined 반환
},
});
const dehydratedState = dehydrate(queryClient);
return (
<HydrationBoundary state={dehydratedState}>
<main className="mx-auto my-16 grid w-[1200px] grid-cols-[230px_minmax(976px,_1fr)] grid-rows-[100px_minmax(800px,_1fr)]">
<SelectStack />
</main>
</HydrationBoundary>
);
}
export default MainPage;
API 통신으로 데이터를 가져오는 과정은 서버에 요청을 보내고 응답을 받는 시간을 필요로 합니다. 이로 인해 사용자는 프로젝트 리스트 UI가 화면에 나타날 때까지 기다려야 하며, 다소 시간이 소요될 수 있습니다.
보다 빠르게 사용자가 프로젝트 리스트 UI를 볼 수 있도록 React Query에서 제공해주는 prefetchInfiniteQuery
훅을 이용하여 서버단에서 데이터를 미리 불러올 수 있도록 하였습니다.
그리고 LCP (Largest Contentful Paint) 점수까지 높은 점수를 받을 수 있습니다.
LCP (Largest Contentful Paint)
사용자가 URL을 요청한 시점부터 페이지 내에서 시각적으로 가장 큰 콘텐츠를 그리는데에 걸리는 시간이다.
사용자는 콘텐츠가 보여지면서 URL이 실제로 로드되고 있구나를 알기때문에 빨리 그려지는 것이 중요하다.
"use client";
function ProjectSection() {
const { projectState } = useGetStack();
const { targetRef: lastCardInfo, isVisible } = useIntersectionObserver<HTMLDivElement>({ threshold: 1 });
const projectListQuery = projectQueryKeys.list({ page: 1, size: 12 });
const { data, fetchNextPage } = useInfiniteQuery({
queryKey: projectListQuery.queryKey,
queryFn: ({ pageParam = 1 }) => projectApi.getProjectList({ ...projectState, page: pageParam }),
initialPageParam: 1,
getNextPageParam: lastPage => {
const { customPageable } = lastPage;
if (customPageable.hasNext) {
return customPageable.page + 1; // 다음 페이지 번호 반환
}
return undefined; // 더 이상 페이지가 없으면 undefined 반환
},
});
useEffect(() => {
if (isVisible) {
fetchNextPage();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible]);
if (!data) {
return <section className="col-start-2 mt-10">로딩 중...</section>;
}
return (
<section className="col-start-2 mt-10">
<ProjectList projectList={data.pages} lastRef={lastCardInfo} />
</section>
);
}
export default ProjectSection;
prefatching한 데이터는 컴포넌트에서 다시 React Query 훅을 사용해 가져올 수가 있는데 자세한 내용은 여기를 참고해주세요.
그리고 미리 정리해둔 useIntersectionObserver
커스텀 훅을 사용하여 스크롤을 내리다가 observer가 인식이 되면 다음 페이지를 가져와서 프로젝트 리스트를 보여줄 수 있도록 구현을 해보았습니다.
데이터를 가져와 컴포넌트에 Props로 전달을 해주고 결과를 확인하려고 실행시켰는데 위와 같은 오류가 발생하였습니다.
const BASE_URL = process.env.NEXT_PUBLIC_AWS_URL;
const awsUrl = new URL(`https://${BASE_URL}`);
module.exports = {
images: {
remotePatterns: [
{
protocol: awsUrl.protocol.slice(0, -1),
hostname: awsUrl.hostname,
port: "",
pathname: "**",
},
],
},
};
이 오류는 next/image 컴포넌트를 사용할 때 발생하는 문제로, 이미지 소스(src)로 제공된 URL의 호스트명이 next.config.js 파일에 설정되지 않았기 때문입니다. 이 문제를 해결하려면 next.config.js 파일에서 images 객체에 해당 호스트명을 추가해야 합니다.
저희 프로젝트에서는 사용자가 자신의 프로젝트를 업로드하면 이미지가 AWS S3 버킷에 저장됩니다. 이렇게 저장된 이미지를 가져와서 사용하기 위해, next.config.js 파일에 AWS S3 버킷 호스트명을 추가해주었습니다.
const { projectState } = useGetStack();
const { data, fetchNextPage } = useInfiniteQuery({
queryKey: projectListQuery.queryKey,
queryFn: ({ pageParam = 1 }) => projectApi.getProjectList({ ...projectState, page: pageParam }),
...
const [projectState, setProjectState] = useState<projectStateType>({
projectTechStacks: [],
sortCondition: "RECENT",
searchString: "",
page: 1,
size: 16,
limit: 0,
});
데이터 요청 로직에 projectState
라는 매개변수가 들어간 걸 보실 수 있습니다.
이 매개변수는 현재 Request 상태를 나타내며, 저희는 이를 Context 안에 정의하여 다른 컴포넌트에서 접근하고 수정할 수 있도록 하였습니다.
이러한 구조를 통해 사용자는 사이드바나 검색바를 통해 필터링 조건이나 검색어를 입력하여 projectState
를 변경할 수 있습니다.
const projectListQuery = projectQueryKeys.list(projectState);
useEffect(() => {
if (stateUpdated) {
reactQueryClient.invalidateQueries({ queryKey: projectListQuery.queryKey });
setStateUpdated(false);
}
}, [stateUpdated]);
상태가 바뀔 때마다 서버에 다시 새로운 데이터를 요청하여 클라이언트측으로 가져와야하는데 이 과정을 invalidateQueries
함수를 사용해서 구현해보았습니다.
사용자가 처음 페이지를 접속할 때는 프로젝트 리스트를 미리 프리페칭하여 빠르게 UI를 확인할 수 있지만 그래도 가끔 데이터를 가져오는데 시간이 좀 걸릴 수도 있을 거 같다는 생각을 했습니다.
Skeleton UI를 추가하면 2가지 장점이 있습니다.
데이터가 로딩되는 동안 사용자에게 직접적인 시각적 피드백을 제공하여, 사용자가 데이터가 아직 로드되지 않았음을 알 수 있습니다. 이는 사용자가 대기하는 동안 불편함을 최소화하고, 사용자 경험을 개선하는 데 도움을 줍니다.
Skeleton UI는 사용자가 데이터 로딩을 기다리는 동안 페이지의 외관을 미리 인식할 수 있게 해줍니다. 이는 사용자가 페이지가 반응하고 있음을 인식하고, 사용자 경험의 지연을 최소화하는 데 도움이 됩니다.
장점으로 말하기는 좀 애매한데 CLS (Cumulative Layout Shift) 점수 또한 높일 수 있습니다.
그 이유는 스켈레톤 UI를 통해 데이터가 나올 자리를 표시하면서 최소값을 미리 제공하면 데이터가 로드 되었을때 화면 전환이 부드러워질 뿐더러 콘텐츠가 순차적으로 나타남에따라 덜컥거리며 로딩되는 것을 방지할 수 있기 때문입니다.
웹 페이지 수명 동안 시각적 요소의 예상치 못한 레이아웃 이동을 측정하는 지표입니다
https://tanstack.com/query/v4/docs/framework/react/community/lukemorales-query-key-factory 리엑트 쿼리 공식 문서
다음에는 댓글 페이지 구현 내용을 가져오겠습니다.