작품 목록 페이지를 리팩토링하며, URL을 통해 페이지에 접근하였을 때, 목표로 하는 데이터가 표시되지 않으며, URL과 필터링 메뉴 부분의 상태가 일치하지 않는 문제를 발견하였다.
기존에 구현된 방식이 state가 변경되면 history.pushState를 통해 화면에 표시되는 url을 바꿔주는 식이기 때문에, URL로 접근하게되면 searchParameter들이 내부 상태에 반영되지 않아 발생하는 문제였다.
<에러 화면>
<의도한 화면>
따라서 URL과 렌더링된 화면의 UI state가 항상 동일하게 만들어주어야 했다.
목표는 데이터 흐름을 일관적으로 유지시키기 위해 데이터의 source를 하나로 일원화하는 것이다.
기존에 Recoil을 사용하여 state에 변경되면 URL을 변경하였는데, 생각해보면 state가 먼저가 아닌 URL이 먼저고 그에 따라 state를 업데이트 하는 것이 합당하다.
그렇다면 recoil과 같은 상태관리 라이브러리를 사용하지 않고, 매번 유저가 필터링 옵션을 클릭할때마다 url을 변경하고 변경된 url로 페이지를 이동해주면 될까?
이는 한계가 있는 접근 방법이다.
url은 string 형태이기때문에, 쿼리스트링이 극단적으로 늘어나거나 업데이트가 잦은 경우 업데이트 및 조작이 상대적으로 비효율적이며, 업데이트가 즉각적으로 이루어지지 않아야 하는 경우(예를 들어 '검색' 버튼이 따로 존재하는 경우) 쿼리검색을 실행하기 전 임시 상태를 공유할 방법도 마땅치 않다.
따라서
1) 최초 UI state는 URL을 기반으로
2) 이후에는 관리와 업데이트가 용이하고 효율적인 Object 형태로 데이터를 관리
3) 이 데이터를 컴포넌트 간 쉽게 공유
할 수 있도록 URL과 recoil을 결합하는 방식으로 문제를 해결하기로 결정하였다.
또한 state가 변경되는 경우에도 URL에 이를 반영해주어야 하는데
이 부분은 AtomEffect를 사용해, queryObject가 변경될때마다 replaceState하여 url이 자동적으로 변경되게 하였다.
Recoil에서는 Recoil/Sync라는 부속 라이브러리를 통해 URL-Persistence를 지원하고 있기는 하지만, 굳이 외부 라이브러리 의존성을 추가하고 필요하지 않은 기능을 위해 400kb 가량의 클라이언트 번들을 늘리는 것은 불필요하다고 생각되어 로직을 직접 구현해보기로 결정하였다.
또한 복잡한 업데이트 로직은 custom hook을 만들어 추상화하였다.
최종적인 로직과 코드는 다음과 같다.
// useQueryObject hook
import { useRecoilState, useRecoilValue } from "recoil";
import { queryObjectAtom, queryStringSelector } from "@store/atoms";
export function useQueryObject() {
const [queryObject, setQueryObject] = useRecoilState(queryObjectAtom);
const queryString = useRecoilValue(queryStringSelector);
const updateArr = (key: string, value: string, isChecked: boolean) => {
const currentValues = queryObject[key as "keywords" | "categories"] || [];
return isChecked
? [...currentValues, value]
: currentValues.filter((item: string) => item !== value);
};
const updateQueryObject = (target: EventTarget & HTMLInputElement) => {
const { value, checked: isChecked, name } = target;
const updatedQueryObject = {
...queryObject,
page: 1,
[name]: ["keywords", "categories"].includes(name)
? updateArr(name, value, isChecked)
: isChecked
? value
: "",
};
setQueryObject(updatedQueryObject);
};
const updatePage = (value: number) =>
setQueryObject({ ...queryObject, page: value });
return {
queryObject,
queryString,
updateQueryObject,
updatePage,
};
}
/// atoms.ts
"use client";
import { atom, selector } from "recoil";
import qs from "query-string";
import { QS_PARSE_OPTIONS, QS_STRINGIFY_OPTIONS } from "@/utils/qsOption";
const SetQueryObjectAtomEffect = ({ onSet }: any) => {
onSet((newValue: any) => {
const { pathname } = location;
const newUrl = qs.stringify(newValue, QS_STRINGIFY_OPTIONS as any);
const as = `${pathname}${newUrl ? "?" : ""}${newUrl}`;
window.history.replaceState(null, "", as);
});
};
interface IInitialQueryObject {
keywords: string[];
nationalities: string;
categories: string[];
sorting: string;
dateYear: string;
page: number;
}
const initialQueryObject = (): IInitialQueryObject => {
const defaultQueryObject: IInitialQueryObject = {
keywords: [],
nationalities: "",
categories: [],
sorting: "",
dateYear: "",
page: 1,
};
if (!globalThis.location) return defaultQueryObject;
const search = globalThis.location.search;
const parsed = qs.parse(search, QS_PARSE_OPTIONS as any);
return {
keywords: Array.isArray(parsed.keywords) ? parsed.keywords : ([] as any),
nationalities:
typeof parsed.nationalities === "string" ? parsed.nationalities : "",
categories: Array.isArray(parsed.categories)
? parsed.categories
: ([] as any),
sorting: typeof parsed.sorting === "string" ? parsed.sorting : "",
dateYear: typeof parsed.dateYear === "string" ? parsed.dateYear : "",
page: typeof parsed.page === "string" ? parseInt(parsed.page) : 1,
};
};
export const queryObjectAtom = atom({
key: "queryObject",
default: initialQueryObject(),
effects: [SetQueryObjectAtomEffect],
});
export const queryStringSelector = selector({
key: "queryString",
get: ({ get }) => {
const queryObject = get(queryObjectAtom);
console.log(queryObject);
const queryString = qs.stringify(queryObject, QS_STRINGIFY_OPTIONS as any);
return queryString;
},
});
// rendering component
const { queryString } = useQueryObject();
const { data } = useSWR<FictionsResponse>(
`${process.env.NEXT_PUBLIC_HOST}/api/fictions?${queryString}`
);
useHrefChangeNotifier();
...
function handlePageUpdate({
currentTarget,
}: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
updatePage(Number(currentTarget.textContent));
}
...
최근 별도의 상태관리 라이브러리를 사용하지 않고 URL을 상태로 취급하는 접근법이 각광받고 있다.
간단한 어플리케이션이라면 굳이 URL에 대한 정보를 recoil에 다시 한번 저장해서 관리할 필요가 없을 수 있다.
이러한 방식의 예상되는 이점은 다음과 같다.
router.push로 간단하게 페이지 전환이 가능하며, history stack에 자동적으로 추가되기 때문에 뒤로가기 앞으로 가기 등에 대해 신경쓰지 않아도 된다.
하지만 본문에서처럼 state 혹은 derived state가 여러 컴포넌트에서 공유되는 상황이 있을 수 있고, 복잡한 상황에서의 상태 업데이트 효율성을 고려하면 장기적인 관점에서는 Recoil을 사용하는 것이 좀 더 적절하다고 판단된다.
또한 router.push로 페이지를 이동하는 것은 불필요한 추가 데이터를 로드할 가능성이 있어 기존 페이지에서 필요한 부분만 클라이언트 사이드 페칭을 통해 새로운 데이터를 렌더링 하는 것보다 더 효율적이기는 힘들다.
/// 단일 필드 표현
1. foo[0]=1&foo[1]=2&foo[2]=3
2. foo[]=1&foo[]=2&foo[]=3
3. foo=1&foo=2&foo=3
/// 복수 필드 표현
4. foo=1,2,3
5. foo=1 2 3
queryString에 복수의 파라미터가 있을때 전달하는 데에는 여러가지 방식이 존재한다. 표준이 따로 정해져있지 않아, 보통 서버의 요구사항에 맞추는 것이 일반적이다.
단일 필드표현은 서버쪽에서 텍스트를 파싱하기 용이하여, 그 중에서도 2번이 제네럴하게 사용되어진다고 한다.
하지만 Next.js를 사용하여 서버를 직접 구성하고 있으며, 복수 필드의 간결함과 사용자 경험 및 가시성을 고려하여 4번 comma spread 방식을 선택하였다.
query를 파싱하고 stringify하는 함수를 직접 만들어 사용했으나, 쿼리와 로직이 복잡해짐에 따라 에러가 발생하는 경우가 있었다.
query-string이라는 라이브러리를 통해 코드를 단순화하였고, skipEmptyString 설정으로 불필요한 쿼리를 생략하여 url 축약하였다
이전
https://fictiondbs.com/fictions?keywords=all&nationalities=all&categories=all&sorting=all&releaseTimeFilter=all&dateYear=all&page=1
이후 (필터 1가지 적용)
http://localhost:3000/fictions?nationalities=영미권
useEffect를 사옹해 queryObject가 바뀔때마다 state를 push하거나 replace하고 있었으나, 로직의 일관성과 관심사 분리를 위해 atomEffect를
사용해 로직을 atom쪽으로 이동하였다.
뒤로가기, 앞으로 기능을 고려하여 pushState를 사용하였으나 검색 페이지에서는 불필요한 기능이라 생각되어 replaceState로 변경하였다.
Recoil/sync라는 별도의 라이브러리를 통해 URL persistance를 구현할 수 있다.
AtomEffect로 필요한 로직을 간단히 구현할 수 있었기에, 번들 사이즈를 굳이 늘리지 않기로 결정하였다.