책임을 하나씩 관리하기
지역 상태로 관리하던 필터를 search param으로 옮기고, hook의 책임 나누기
Table Of Contents
검색 필터링을 구현하자
지금 진행하고 있는 프로젝트에서는 암호화폐 관련 정보를 모아 볼 수 있다.
검색 편의성을 위해 텍스트 검색 외에도 기간, 암호화폐, 거래소, 중요도 등을 필터링하는 기능도 제공한다!
(시작 날짜, 중요도 필터를 적용한 모습)
하지만 기존 필터는 지역 상태로 관리되고 있었다.
// SearchArea.tsx export default function SearchArea() { const [option, setOption] = useState(defaultOption); function onChangeOption() { /* ... */ } return ( <> {/* 옵션 렌더링 & 변경을 위해 option, onChangeOption을 넘깁니다 */} <SearchOption optioin={option} onChangeOption={onChangeOption} /> {/* 옵션애 따라 데이터를 받아오기 위해 option을 넘깁니다 */} <SearchResult option={option} /> </> ); }
// SearchOption.tsx export default function SearchOption({ option, onChangeOption }) { return ( <> <DateSelector value={option.from} onChange={(newValue) => onChangeOption("from", newValue)} /> <DateSelector value={option.to} onChange={(newValue) => onChangeOption("to", newValue)} /> <ImportanceSelector value={option.importance} onChange={(newValue) => onChangeOption("importance", newValue)} /> {/* ... */} </> ); }
부모 컴포넌트에서 option
라는 state를 만들고, 이를 자식 컴포넌트로 내려주게 된다.
SearchOption
이라는 자식 컴포넌트는 다시DateSelector
,ImportanceSelector
등의 자식 컴포넌트로option
값을 한번 더 내려준다.SearchResult
라는 자식 컴포넌트는 안에서option
값이 변경될 때마다 새로 데이터를 받아 화면에 표시한다.
이렇게 필터를 지역 상태로 구현하면, 이런 문제가 생긴다!
- 새로고침하면 필터 내용이 초기화된다.
- 검색하다가 실수로 페이지를 나가면 다시 필터를 하나하나 걸어야 한다🥺
- 필터를 건 상태로 페이지 공유도 불가능하다.
- props drilling이 발생한다.
- 중간에 있는
SearchOption
컴포넌트에서는 사실상option
,onChangeOption
을 사용하지 않는데도 props로 받아서 전달해야 하는 상황이다.
- 중간에 있는
Search Params를 이용하자
우선 필터를 지역 상태에서 꺼내서 URL의 Search Params에 저장하기로 했다.
Search params를 활용하면 위에서 언급한 문제점들을 모두 해결할 수 있다.
- 새로고침해도 필터가 URL에 들어 있기 때문에 필터 정보가 남아있다.
- 그대로 다른 사용자에게 공유가 가능하다.
- props drilling이 해결된다
- 필터 데이터를 부모 컴포넌트에서 내려 줄 필요 없이, 필요한 컴포넌트에서 직접 URL을 읽어 사용하면 된다.
Custom Hook으로 params 관리하기
그러던 중, Toss Frontend Fundamentals에서 책임을 하나씩 관리하기라는 글을 읽게 되었다.
딱 내가 겪고 있는 상황아잖아...?!🤔
지역 상태에서 URL로 옮기는 것 까지는 좋은데, 이 로직을 전부 usePageState
같은 하나의 hook에 담게 되면 한 hook에서 다루는 맥락이 너무 커진다.
- 즉, 한 hook에서 시작 날짜, 종료 날짜, 화폐 이름, 거래소 이름, 이벤트 중요도... 등을 모두 다루게 된다는 뜻이다.
- 새로운 쿼리 파라미터가 늘게 되면 이 hook 역시 그만큼 늘어나게 된댜! 이렇게 Hook이 무제한으로 늘어나면 구현이 길어지고, 어떤 역할을 하는 hook인지 파악하기 어려워진다.
- 또한 새로운 쿼리 파라미터를 추가하는 사람이 2명 이상인 경우, 파일에서 충돌이 일어날 수도 있다.
💡 이 문제를 해결하기 위해서 쿼리 파라미터별로 Hook을 분리해볼 수 있다.
- 아래처럼
useDateParam
같은 hook을 만들면 다른 페이지에서 날짜 필터가 필요한 경우에 재사용할 수 있기 때문에 효율성이 좋다. - 각각의 책임이 분리되면 테스트 코드도 간단하게 작성할 수 있다.
- 여러 명이 동시에 다른 필터를 수정 및 추가하려 해도 충돌이 덜 나게 된다.
Hook의 책임 분리하기
날짜 관리하기: useDateParam
검색 필터에서 숫자, 문자열과 같은 간단한 데이터는 useQueryParam
같은 Hook을 하나 만든 다음에 돌려 쓸 수 있다.
하지만 날짜(Date)는 직접 URL에 저장할 수 없고, 값을 파싱/포맷할 때 유효성 검증이 필요하기 때문에 별도의 Hook을 마련해야 한다.
function formatDate(value: Date): string { ... } function parseDate(value: string): Date | null { ... } export default function useDateParam(key: string) { const searchParams = useSearchParams(); const router = useRouter(); const value = parseDate(searchParams.get(key) || ""); function setValue(newValue: Date) { if (typeof window === "undefined") return; const newParams = new URLSearchParams(window.location.search); newParams.set(key, formatDate(newValue)); router.replace(`?${newParams.toString()}`, { scroll: false }); } return [value, setValue] as const; }
이제 useDateParam을 사용하여 DateSelector에 적용할 수 있다.
export default function SearchOption() { const [fromDate, setFromDate] = useDateParam("from"); const [toDate, setToDate] = useDateParam("to"); return ( <> <DateSelector value={fromDate} onChange={setFromDate} /> <DateSelector value={toDate} onChange={setToDate} /> </> ); }
seDateParam("from");
처럼 사용하면 URL의 search params 중에서 "from" 을 key로 가지는 값을 관리할 수 있다.
이렇게 SearchOption이 props를 받을 필요 없이 쿼리 문자열에서 직접 값을 가져오고, 변경할 수 있다!
한 key에 여러 value 관리하기: useMultipleParam
일부 검색 필터는 하나의 값만 가지는 것이 아니라, 여러 개의 값을 가질 수도 있다.
예를 들어, 중요도(importance) 필터에서 low, mid 이벤트만 검색하고 싶다면 아래처럼 URL을 구성할 수 있다.
?importance=LOW&importance=MID
하지만 기존의 useDateParam
같은 방식으로 값을 저장하면, 값이 하나만 저장되는 문제가 발생할 수 있다.
이를 방지하기 위해, 하나의 key에 여러 개의 값을 저장할 수 있도록 getAll()
과 append()
를 사용한 Hook을 만든다.
export default function useMultipleParam(key: string = "option") { const searchParams = useSearchParams(); const router = useRouter(); // 현재 URL에서 해당 key의 모든 값을 가져온다. const getCurrentQuery = () => searchParams.getAll(key); const value = getCurrentQuery(); function setValue(newValue: OptionValue[]) { if (typeof window === "undefined") return; const newParams = new URLSearchParams(window.location.search); newParams.delete(key); newValue.forEach((v) => newParams.append(key, v as string)); router.replace(`?${newParams.toString()}`, { scroll: false }); } return [value, setValue] as const; }
이제 useMultipleParam
을 활용해 MultiSelector
가 검색 필터를 설정할 수 있도록 변경할 수 있다.
아래 코드에서는 useMultipleParam("importance");
처럼 사용해서 URL의 search params 중에서 "importance"를 key로 가지는 값을 관리한다.
export default function SearchOption() { const [importance, setImportance] = useMultipleParam("importance"); return ( <> <ImportanceSelector value={importance} onChange={setImportance} /> </> ); }
아쉬운 점
window.location.search
?
지금은 Hook 안에서 URL의 search param을 받아올 때, window.location.search
를 이용하고 있다.
원래 Next.js에서 제공해주는 useSearchParams
hook을 사용했지만, 문제가 발생했다...
화면을 열고 from 값을 변경한 뒤, to 값을 변경하면 from값이 초기값으로 되돌아가버렸다. 다시 from 값을 변경하려 하면 to 값이 다시 초기값으로 돌아온다...!😂
처음에는 setValue
함수를 useCallback
으로 감싸고, searchParams
의 값을 의존성 배열에 넣어서 setValue
가 항상 최신 searchParams
값을 참조하도록 해봤다. 하지만 실패했다...
searchParams
라는 객체 레퍼런스가 동일해서 문제가 생기지 않는 것일까 싶어 의존성 배열에 searchParams.toString()
를 대신 넣어줬는데, 이 방법도 문제를 해결해주지는 못했다...🥹
임시방편으로 window.location.search
를 활용해 작동은 하게 만들었지만, window.location
은 Next.js의 route 객체와 완전히 동기화되지 않는다는 점에서 사용을 권장하지 않는다고 한다.
Next.js docs에 따르면 useSearchParams
는 URL이 변경되면 자동으로 업데이트된다고 하는데, 왜 여기서는 자동으로 업데이트 되지 않는지...?
Next.js부터 React까지 문서를 계속 읽고있는데 아무도... 이에 대한 언급을 해 놓지 않는다진짜서럽다 원인을 알게 되면 좋겠다...
결론
최종적으로 폴더 구조는 아래처럼 구성했다.
SearchPage ├── _components │ ├── SearchOption │ │ ├── DateSelector │ │ ├── ImportanceSelector │ │ └── ... │ └── SearchResult └── _utils ├── useDateParam └── useMultiParam
SearchParam을 관리하는 Hook들을 따로 _utils
폴더를 만들어서 모아놨다.