데브월드 트러블 슈팅 5
아티클 검색 결과 최적화
문제 상황
사용자가 위치한 페이지에 따라 다른 검색 결과를 제공해야 한다.
- 유저 프로필 페이지
- 방문한 프로필 페이지의 유저가 작성한 아티클만 보여줘야 한다.
- 메인 페이지
- 데브월드의 등록된 아티클 중에서 공개, 작성 완료 상태의 아티클만 보여줘야 한다.
- 워크스페이스 페이지
- 로그인한 유저의 모든 아티클을 보여줘야 한다.
문제 해결을 위한 아이디어
1. 유저 프로필 페이지
- 백엔드 API 설계를 보면 특정 유저의 아티클을 가져오는 API가 있습니다.
async paginateUserPublicArticles(dto: PaginateUserPublicArticleDto) {
const exists = await this.userRepository.exists({
where: {
devName: dto.devName,
},
});
if (!exists) {
throw new BadRequestException('유저 정보를 찾을 수 없습니다.');
}
return await this.commonService.paginate(
dto,
this.articlesRepository,
{
relations: {
author: true,
thumbnails: true,
},
select: {
id: true,
author: {
devName: true,
},
title: true,
description: true,
createdAt: true,
likeCount: true,
commentCount: true,
thumbnails: true,
articleImage: true,
},
where: {
author: {
devName: dto.devName,
},
},
},
'articles/users',
);
}
- Dto
// 생략
@IsString()
@IsOptional()
where__title__i_like?: string;
@IsString()
@IsOptional()
where__description__i_like?: string;
-
또한 pagination 로직을 처리하는 코드가 있습니다. 이 과정에서 쿼리스트링으로 where__으로 시작하면 where 쿼리를 처리하도록 설계되어 있습니다.
프론트엔드에서도 사용자가 검색어로 입력하는 값을 적절하게 쿼리스트링으로 변환해주면 됩니다.
2. 메인 페이지와 워크스페이스 페이지
- 백엔드 API의 설계는 같지만 조금 다르게 처리해주어야 합니다.
프로필 페이지에서는 URL을 업데이트해서 보여지는 화면에 검색 결과를 렌더링했다면, 메인 페이지와 워크스페이스 페이지는 검색모달을 이용해서 검색어를 입력하고 검색 결과를 제공해서 사용자가 클릭하면 적절한 페이지로 이동시키기 때문입니다. 또한 워크스페이스에서 검색을 하는 경우 같은 검색모달을 사용하지만 내가 작성한 게시글만 보여주어야 하기 때문입니다. 이는 같은 UI지만 다른 검색 결과를 제공해야 한다는 차이가 있습니다.
- 또한 UI는 같기 때문에 커스텀훅으로 분리하여 재사용 가능하도록 처리하는 것이 효율적입니다.
해결 방법
-
프로필 페이지
-
메인 페이지
-
워크스페이스 페이지
내가 작성하지 않은 게시글은 검색되지 않습니다.
문제 해결
1. 프로필 페이지
- 검색어를 입력하면 URI를 변경
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useCallback } from "react";
export const ArticleSearch = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const createQueryString = useCallback(
(params: URLSearchParams, name: string, value: string) => {
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
return params;
},
[searchParams]
);
const handleSearchArticle = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
let params = new URLSearchParams(searchParams.toString());
params = createQueryString(params, "where__title__i_like", value);
params = createQueryString(params, "where__description__i_like", value);
router.push(pathname + "?" + params.toString());
};
return (
<div className="inline-flex items-center rounded-lg border border-solid text-sm h-10 px-3 relative w-full text-zinc-400 focus-within:border-primary">
<input
type="text"
className="outline-none border-none bg-transparent w-full h-full"
maxLength={150}
placeholder="아티클을 검색할 수 있어요"
onChange={handleSearchArticle}
/>
</div>
);
};
- api에 요청보내기
import { useInfiniteQuery } from "@tanstack/react-query";
import {
ParamsObjType,
getUserArticlesApiResponseType,
} from "../model/get-user-articles-api.type";
import { getUserArticlesApi } from "../api/get-user-articles-api";
import { BASE_URL } from "@/shared/api/base-url";
export const useGetUserArticlesQuery = (paramsObj: ParamsObjType) => {
const queryParams = Object.keys(paramsObj).reduce(
(acc, key) => {
if (paramsObj[key] !== null) {
acc[key] = paramsObj[key];
}
return acc;
},
{ devName: paramsObj.devName }
);
const queryString = new URLSearchParams(queryParams).toString();
const INITIAL_URL = `${BASE_URL}/articles/users?${queryString}`;
const {
data: articles,
isLoading,
isError,
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
...result
} = useInfiniteQuery<getUserArticlesApiResponseType, Error>({
queryKey: ["artcles/user", ...Object.entries(queryParams).flat()],
queryFn: ({ pageParam = INITIAL_URL }) => getUserArticlesApi(pageParam),
initialPageParam: INITIAL_URL,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.next || undefined,
});
return {
articles,
isLoading,
isError,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
...result,
};
};
- 검색 모달 (메인 페이지, 워크스페이스)
import { create } from "zustand";
interface QueryParams {
where__title__i_like: string | null;
where__description__i_like: string | null;
take: string;
}
interface State {
query: QueryParams;
}
interface Actions {
setSearchQuery: (query: string | null) => void;
resetQuery: () => void;
}
export const useSearchQueryStore = create<State & Actions>((set) => ({
query: {
where__description__i_like: null,
where__title__i_like: null,
take: "10",
},
setSearchQuery: (query) =>
set((state) => ({
query: {
...state.query,
where__title__i_like: query,
where__description__i_like: query,
},
})),
resetQuery: () =>
set({
query: {
where__description__i_like: null,
where__title__i_like: null,
take: "10",
},
}),
}));
사용하는 곳에서는 path 값으로 구분하여 사용할 수 있도록 설계했습니다.
import { useSearchQueryStore } from "@/app/_store/search-all-articles-store";
import {
getSearchPublicArticlesApi,
getSearchWorkspaceArticlesApi,
} from "../api/get-search-articles.api";
import { useInfiniteQuery } from "@tanstack/react-query";
import qs from "qs";
import { BASE_URL } from "@/shared/api/base-url";
const PublicUrl = `${BASE_URL}/articles?`;
const WorkspaceUrl = `${BASE_URL}/articles/workspace?`;
export const useGetSearchArticles = (
path: string,
enabled: boolean = false
) => {
const { query } = useSearchQueryStore();
const trimQueryString = Object.fromEntries(
Object.entries(query).map(([key, value]) => [
key,
typeof value === "string" ? value.trim() : value,
])
);
const INITIAL_URL = path === "public_articles" ? PublicUrl : WorkspaceUrl;
const getSearchResultsApi =
path === "public_articles"
? getSearchPublicArticlesApi
: getSearchWorkspaceArticlesApi;
const queryString = qs.stringify(trimQueryString, { skipNulls: true });
const {
data: searchResults,
isLoading,
isError,
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
refetch,
...result
} = useInfiniteQuery({
queryKey: ["search", path, queryString],
queryFn: () => getSearchResultsApi(INITIAL_URL + queryString),
initialPageParam: INITIAL_URL,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.next || undefined,
enabled,
});
return {
searchResults,
isLoading,
isError,
fetchNextPage,
hasNextPage,
refetch,
...result,
};
};
export const useGetSearchArticles = (
path: string,
enabled: boolean = false
) => {};
// 생략
const { searchResults, isError, isLoading, fetchNextPage, hasNextPage } =
useGetSearchArticles(path, open);
const handleInputOnchange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
debounced(value);
};