OTTER-LOG

useSearchParams

#react#routing
by otter2022년 7월 5일에 최종수정되었습니다.
잘못된 내용이 있으면 댓글을 달아주세요.

얼마전, 진행했던 과제에서 URL 쿼리를 최대한 이용해보려고 했다. 이 부분은 필수적인 부분이라고 생각했고, 어차피 적용한다면 이를 이용해 전역상태를 대체할 수 있을 것이라 생각했기 때문이다.

URL 쿼리

우선, 파라미터와 쿼리를 분리해서 생각하자.

profile/otter // 여기서 otter는 파라미터다. search?input=apple // 여기서 ?input=apple는 쿼리이다.

기존에, react에서 페이지를 이동할때는 파라미터만 사용했다. 파라미터로 구분해서 상세페이지로 이동할 수도 있고, 로그인된 사용자 페이지로 이동할 수 있도록 했다. 그런데, 특정 검색어를 검색한다던지 또는 필터링을 해야하는 상황이 있다고 생각해보면 이 페이지를 모두 파라미터로 하기에는 좋지 못하다. 이는, search 된 검색어의 정보를 담고 있다고 할 수 있으므로 쿼리로 적용하는 것이 맞다.

예를들어, 구글에 apple를 검색해보면 다음과 같은 url이 출력된다.

<https://www.google.com/search?q=apple&> ... // 이걸 통해서 알 수 있는 점은, ?로 시작하고 다음 정보가 있으면 &으로 넘겨준다는 것 // 한가지 짚고 넢어가야 할것은 '=' 으로 쿼리가 분리된다. (이걸 생각조차 하지 않아서, 망한 과제가 있다..)

URL 쿼리는 왜 필요할까?

그런데, url 쿼리는 왜 필요할까? react 는 싱글페이지로 동작하고 파라미터정도만 적용해도 나쁘지 않아보인다. 그런데, 사용자 입장에서 다음과 같은 맹점이 있다.

  • 특정 검색어로 검색된 페이지만 즐겨찾기 하고 싶을때
  • 특정 필터로 필터링된 페이지만 즐겨찾기 하고 싶을때
  • 뒤로 가고 싶을때, 새로고침할 때

파라미터로만 페이지를 구분해 놓았다면 위와같은 동작은 작동하지 않는다. 사용자는 무조건 해당 페이지로 진입 후 검색을 다시하거나, 또는 필터버튼을 다시한번 눌러야 한다. 당연히 이는 좋지 못한 사용자 경험일 것이다.

또 사용자가 뒤로가기 버튼을 눌렀을때 작동되는 방식이 다르다. 파라미터로만 작동된 페이지라면 이게 history api 에 들어가지지 않았으므로 파라미터를 기준으로 이전 페이지로 이동된다. 반면, 쿼리 스트링을 적용하면 이전 페이지로 이동하는게 원하는 방향으로 -정말 서치 이전의 페이지로- 이동될 수 있다.

이러한 부분은 새로고침에도 적용된다. 보통, 필터된 페이지 또는 검색어로 검색된 페이지는 데이터를 새로 불러와야할텐데 데이터를 부르는 도중 오류가 났거나, 페이지를 다시 렌더링해야 할때 새로고침으로 원하는 페이지로 이동할 수 있다.

쿼리 스트링 적용해보기

react-router-dom에서 제공해주는 메서드를 사용해서 쿼리 스트링을 적용해보자. 그냥, 자바스크립트에서 제공해주는 URLSearchParams을 (아마, react-router-dom에 있는 메서드들은 이런 걸 이용해서 만들었을 것이라 짐작하지만) 사용할수도 있지만 상태로 제공해주는 이점을 사용하기 위함이다.

지금, 여기서 적용하고자 하는 목표는 다음과 같다.

  • 각 버튼이 활성화되면, 쿼리스트링이 추가된다.
  • 각 버튼은 동시에 활성화될 수 있다.
  • 버튼을 다시한번 누르면, 비활성화 상태가 되며 쿼리스트링에서도 제거된다.
  • 버튼이 활성화되면 버튼의 색깔이 바뀐다. (쿼리스트링을 상태로 사용해서, 색깔을 변하게 하자)
  • 쿼리에 따라서 데이터 불러오기.

그리고, 이를 적용하고 확인해볼 사항은 다음과 같다.(위에 적혀있는 사용자 편의)

  • 각 쿼리스트링으로 접근했을 때 유지했던 상태가 남아있는가? (버튼의 활성화 - 버튼이 활성화 되었다면 api호출도 제대로 이루어졌을거라 생각하고, 페이크페치를 이용하려고 한다)
  • 새로고침이 가능한가?
  • 뒤로가기가 잘 작동되는가?

초기 세팅

// App import React from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import FilterPage from './FilterPage'; const App = () => { return ( <Router> <Routes> <Route path="/"> <Route index element={<div>main</div>} /> <Route path="filtered" element={<FilterPage />} /> </Route> </Routes> </Router> ); }; export default App; // 기본적인 파라미터 적용을 위해 간단히 작성했다. // FilterPage import React from 'react'; import styled from 'styled-components'; import { Link, useLocation, useNavigate, useSearchParams, } from 'react-router-dom'; // 일단 다 써보려고 import만 해두었다. const FilterPage = () => { return ( <Container> <button> 품절 상품 </button> <button> 세일 상품 </button> <button> 단독 상품 </button> </Container> ); }; export default FilterPage; const Container = styled.div` width: 100%; height: 200px; margin-top: 100px; display: flex; flex-direction: column; align-items: center; justify-content: space-between; `;

쿼리스트링으로 이동하기 : useSerachParams

react-router-dom에 있는 useLocationsearch를 직접 파싱해서 useNavigte 훅을 이용해 페이지를 이동시키는 방법도 있지만, 실제로 내가 해보았을때 코드가 너무 복잡해졌었다. 쿼리스트링이 적용되는 부분을 견고히 짠다면, useSearchParams 훅만으로 가능할 것 같아 이를 이용해서 진행해보고자 한다.

useSearchParamsset 함수는 다음과 같은 타입을 가진다.

// react-router-dom declare function useSearchParams( defaultInit?: URLSearchParamsInit ): [URLSearchParams, SetURLSearchParams]; type ParamKeyValuePair = [string, string]; type URLSearchParamsInit = | string | ParamKeyValuePair[] | Record<string, string | string[]> | URLSearchParams; // 이런 형식으로 입력해줄 수 있다. // string만 입력하면 filter= (key만 등록된다) // { key: string : value : stinrg | string[] } // Record타입에 맞추어 객체로 입력해주면, 우리가 원하는 효과를 볼 수 있다. type SetURLSearchParams = ( nextInit?: URLSearchParamsInit, navigateOpts?: : { replace?: boolean; state?: any } ) => void;

그래서 아래와 같이 적용해주자. 일단 data-set을 사용했지만 id를 사용할 수도 있을 것이다.

const FilterPage = () => { let [searchParams, setSearchParams] = useSearchParams(); const addQuery = (e: React.SyntheticEvent) => { const currentQuery = e.target.dataset.query.toString(); setSearchParams({ filter: currentQuery, }); }; return ( <Container> <button data-query='isSoldOut' onClick={addQuery}> 품절 상품 </button> <button data-query='isSale' onClick={addQuery}> 세일 상품 </button> <button data-query='isExclusive' onClick={addQuery}> 단독 상품 </button> </Container> ); }; export default FilterPage;

이렇게만 적용해줘도 각 버튼을 눌렀을때 쿼리스트링이 잘 입력된다.

품절 버튼을 눌렀다면, 위와같은 쿼리스트링이 입력될 것이다. 그런데, 아직 쿼리스트링이 여러개가 입력되지 않고 제거되지도 않는다. 코드를 조금 수정해주자.

여러개의 쿼리스트링 입력하기, 중복된 쿼리는 지워주기

let [searchParams, setSearchParams] = useSearchParams(); const addQuery = (e: React.SyntheticEvent) => { const currentQuery = e.target.dataset.query.toString(); // 현재 누른 타켓의 query const prevQuery = searchParams.getAll("filter"); // 이전에 가지고 있던 query를 불러오기 // 여러개가 될 수 있어, getAll 메서드를 사용했다. // 하나라면, get을 사용할 수 있을 것이다. if (prevQuery.includes(currentQuery)) { // 이전에 가지고 있던 쿼리가, 타겟의 쿼리를 가지고 있다면 (한번 더 눌렀다면) // 현재 누른 타겟의 쿼리는 제거해주자. const newQuery = prevQuery.filter((query) => query !== currentQuery); setSearchParams({ filter: newQuery, }); } else { // 아니라면, 쿼리를 추가해주자. setSearchParams({ filter: [...prevQuery, currentQuery], }); } };

이런 방식으로 이미 눌렀던 버튼을 비활성화하면 쿼리를 지워주고, 비활성화된 버튼을 눌러주면 쿼리를 추가해줄 수 있다. 위의 타입을보면 string[] 으로 입력이 되는데 이런 방식으로 입력되면 다음과 같은 쿼리스트링이 나온다.

여러개의 중복된 쿼리가 들어갔고, 이미 들어가 있는 쿼리에 대한 버튼을 한번 더 누르면 쿼리가 잘 삭제된다.

쿼리에 따라 분기하기

이제, 쿼리가 잘변하는 것을 확인했으니 쿼리에 따른 처리를 해주자. 우선 할 것은 쿼리에 존재하는 버튼의 색깔을 바꿔주는 것이다.

return ( <Container> <Button data-query="isSoldOut" onClick={addQuery} isActive={searchParams.getAll('filter').includes('isSoldOut')} > // 현재 searchParams에 isSoldOut이 들어있는지의 여부를 확인했다. // 그리고 이에 따라 t, f를 나누어 주었다. 품절 상품 </Button> <Button data-query="isSale" onClick={addQuery} isActive={searchParams.getAll('filter').includes('isSale')} > 세일 상품 </Button> <Button data-query="isExclusive" onClick={addQuery} isActive={searchParams.getAll('filter').includes('isExclusive')} > 단독 상품 </Button> </Container> ); }; export default FilterPage; // styledComponent를 사용중이라, 이런 방식으로 처리했다. const Button = styled.button<{ isActive: boolean }>` background-color: ${({ isActive }) => (isActive ? 'red' : 'auto')}; `;

이렇게하면 쿼리에 따라서 버튼 컴포넌트의 색깔이 바뀌는 걸 다음과 같이 확인할 수 있다.

코드가 살짝 복잡해보이긴 하지만, 사실 isSoldOut, isSale등은 상수로 따로 관리하고 있을 것이고 일반적으로 이 컴포넌트를 만들때부터 props로 받아왔을 값이라는 걸 생각해보면 괜찮다. 일반적으로 이렇게 되지 않았을까?

// props로 isSale등의 정보를 받아온다. <Button data-query={props.query} onClick={addQuery} isActive={searchParams.getAll("filter").includes(props.query)} > 품절 상품 </Button> // props를 그대로 이용한다. 'filter'로 정의해둔 `key`도 props.key로 이용할 수 있겠다.

쿼리에 따라서 데이터 불러오기

useEffectgetAll로 이루어진 배열을 정보로 받아 데이터를 불러올 수 있을 것 같다.

const fakeFetch = (query: string) => { return new Promise((resolve, reject) => { setTimeout(() => { resolve(query); }, 300); }); }; const FilterPage = () => { const [searchParams, setSearchParams] = useSearchParams(); const [data, setData] = useState([]); const addQuery = (e: React.SyntheticEvent) => { ... }; useEffect(() => { const currentQuery = searchParams.getAll('filter'); // filter 어레이에 있는 현재 쿼리를 받아와서, const getAlldata = Promise.all(currentQuery.map((v) => fakeFetch(v))); // promiseAll로 데이터를 한번에 받아온 뒤 getAlldata.then((r) => setData(r)); // data에 담아주고, 아래에서 이 data를 기반으로 렌더링한다. }, [searchParams]); return ( <Container> <Button data-query="isSoldOut" onClick={addQuery} isActive={searchParams.getAll('filter').includes('isSoldOut')} > 품절 상품 </Button> <Button data-query="isSale" onClick={addQuery} isActive={searchParams.getAll('filter').includes('isSale')} > 세일 상품 </Button> <Button data-query="isExclusive" onClick={addQuery} isActive={searchParams.getAll('filter').includes('isExclusive')} > 단독 상품 </Button> {data.map((v) => ( <div key={v}>{v}</div> ))} // 렌더링 되는 부분 </Container> ); };

그러면 위처럼 잘 작동한다. 그런데, 실제로 데이터를 받아오는 로직이라면 조금 조심해야 한다. promiseAll은 데이터를 받아오는 속도에 따라서 데이터의 순서가 바뀔 수 있기 때문이다. 또한, 데이터를 계속해서 받아올수도 있으므로 캐시처리가 중요하지 않을까? 싶은 생각이 든다.

목표는 이루었는가?

결국 이렇게 적용함으로써 얻고 싶은 목표는 다음과 같았다.

  • 새로고침 가능 여부
  • 뒤로가기의 원활한 기능

목표를 이루었는지 확인해보자.

잘된다!

그런데 이런 방법이 맞는가?

그런데, 위에서 적용했던 기능은 전역상태관리 라이브러리를 사용해서도 가능하다. 새로고침과, 뒤로가기의 사용자 편의를 위해 쿼리스트링은 입력해주되 상태는 따로 관리할 수도 있을 것이다. 두 기능모두 가능한데 어떤 방법이 좋을까? 일반적으로 하는 방법은 어떤 방법일까?

그러나 분명 쿼리스트링을 사용하는 방법도 장점이 있다고 느꼈다. 일단 상태를 만드는 부분이 많이 줄었다. 상태를 많이 만들지 않으니 상태에 대한 의존도를 신경쓸 부분이 줄어들었다. 또, 쿼리가 바뀌는 부분이 눈에 직관적으로 보여 구현할때 편했다. 또 개인적으로 쿼리가 필수적이라 생각하기도 하고 기왕 만들었으면 이용하는 것도 나쁘지 않겠는데? 라는 나이브한 생각도 들었다.

그런데, 전역상태관리 라이브러리는 다음과 같은 부분에 장점이 있을 것 같다. 일단 store에 관리하고 있는 데이터를 명확히 할 수 있을 것이다. 쿼리스트링을 이용하는 부분만 쿼리스트링으로 뺄수도 있겠지만, 이런 정보를 하나의 스토어에서 관리한다면 -만약 다른 데이터 뭉치가 존재하면?- 응집도가 좋지 않을까? 또 사실 프로젝트 하다보면 전역상태관리 라이브러리 하나쯤은 꼭 사용하게 되어있으니, 전역상태관리 라이브러리를 써도 좋을 것 같다.

Ref

https://reactrouter.com/docs/en/v6/hooks/use-search-params