Frontend/Next

[Next.js] URL에 상태 저장하기 (useSearchParams, URLSearchParams)

zeo.y 2024. 5. 9. 19:42
반응형

개요

“URL 링크를 공유했을 때 같은 화면을 볼 수 있도록 해주세요.”

검색 폼이 있고 결과가 출력되는 화면에서 위와 같은 요구사항을 받았다.

 

처음에는 그냥 직접 검색하면 되는 거 아닌가..? 싶었는데 검색 조건이 매우 X2 많은 기획서를 보고 꼭 필요한 기능이라는 것을 깨달았다. 10개가 넘는 필터링 조건들을 보고 바로 납득했다. 😮

 

요구사항을 다시 보면,

URL 링크를 공유했을 때 같은 화면을 볼 수 있어야 한다.
= URL에 검색 조건(상태 정보)을 저장해야 한다.
= URL의 쿼리 스트링을 조작할 수 있어야 한다.

→ URL 쿼리스트링에 상태 정보를 넣고 뺄 수 있어야 한다는 결론이 나온다.

 

어찌 보면 당연한 기능인데, 왜인지 이전까지 생각해보지 못한 기능이었다. 아마도 리액트에 너무 익숙해져 있기 때문일 것이었다. 사실 React가 대세를 자리하기 전, 그러니까 SPA가 등장하기 전에는 URL에 query를 저장하는 것이 아주 흔한 형태였다. 링크를 통해 화면을 공유하는 것은 당연한 기능 중 하나였던 것이다.

 

물론 지금도 많이 쓰이지만..! 직접 다뤄본 적이 없어서 어색했던 위 요구사항을 Next.js (v14. app router) 기반의 프로젝트에서 처리했던 과정을 정리해 보자.

 

 

1. 쿼리스트링에 접근 (useSearchParams)

  • 우선 next에서 제공하는 기능이 있는지 찾아보자.
  • next/navigationuseSerchParams 훅에서 쿼리스트링을 읽을 수 있다.
  • 다만 read-only로 쓰기가 불가능하다는 특징이 있다.
  • 참고로 useRouter에서 query 옵션이 사라졌기 때문에 useSearchParams를 써야 한다.
  • 또 참고로 react-router의 useSearchParams는 읽기쓰기가 모두 가능하다. (근데 왜 next는 읽기 전용으로 만든 걸까 🤔)
import { useSearchParams } from 'next/navigation'
 
export default function SearchBar() {
  const searchParams = useSearchParams() 
  const search = searchParams.get('search')

  return <>Search: {search}</>
}

 

cf. 쿼리스트링이란

  • 검색 파라미터 (search parameters)라고도 불린다.
  • URL에서 ?다음에 나오는 key=value 형태의 문자열을 의미한다.
  • 아래 그림에서 parameters 부분을 의미한다.

https://developer.mozilla.org/ko/docs/Learn/Common_questions/Web_mechanics/What_is_a_URL

 

 

2. 쿼리스트링을 조작 (useCustomSearchParams)

  • next/navigationuseSearchParams는 읽기 전용이라 조작이 불가능하다.
  • 쿼리스트링 읽기/쓰기가 모두 가능한 커스텀 훅을 만들자.
  • 이를 위해서는 우선 Javascript에서 URL정보를 어떻게 가져오는지, 무엇을 통해 조작할 수 있는지 알아야 한다.

 

2-1. URL 객체

  • URL 객체는 URL의 구성요소를 읽고 쓸 수 있는 속성을 제공하는 인터페이스이다.
  • host, origin, pathname, search 등 URL의 정보들을 확인할 수 있다.

 

예시) new URL(”http://www.google.com/search?q=react”)

new URL 결과

 

search

  • search는 쿼리스트링 문자열을 반환한다.
  • 원하는 값이지만, 이 문자열에서 하나의 값을 가져오려면 파싱을 해야 하고, 상태 추가를 하려면 { key: value } 형태를 문자열로 바꿔서 붙여야 한다. (조작이 복잡하다는 뜻)
const search = new URL(document.location).search; 
console.log(search); // ?q=react

 

searchParams

  • 그래서 필요한 것이 searchParams이다.
  • searchParams는 쿼리스트링을 조작할 수 메서드를 가지고 있는 URLSearchParams 객체를 반환한다.
const params = new URL(document.location).searchParams;
console.log(params); // URLSearchParams {size: 1} 

 

 

2-2. URLSearchParams

  • URLSearchParams는 쿼리스트링의 get/set을 포함 다양한 유틸 메서드를 지원한다.
  • next/navigationuseSearchParams가 바로 URLSearchParams의 읽기 전용 버전이다.

 

get

  • get(key)로 값을 가져올 수 있다.
const params = new URL("<http://www.google.com/search?q=react>").searchParams;
const value = params.get("q");
console.log(value); //react

 

set

  • set(key, value)로 값을 설정할 수 있다.
console.log(params.get("test"); //null
params.set("test", "테스트");
console.log(params.get("test"); //테스트

 

cf. append

  • set과 비슷하게 값을 추가하는 동작이지만, append는 중복 key를 허용한다.
  • 필터링 시 중복되는 키가 있으면 혼란이 오기 때문에 set을 활용했다.
  • react-routeruseSearchParams는 append로 구현되어 있다.

 

2-3. useCustomSearchParams

  • 본론으로 돌아와, 위 내용을 바탕으로 커스텀 훅을 만들어보자.
  • 훅을 아래와 같은 방식으로 사용할 수 있도록 만들 것이다.
const { searchParams, setSearchParams } = useCustomSearchParams

setSearchParams({
	...searchParams,
	"key": "value",
})

 

 

useCustomSearchParams.tsx

  • 읽기 전용인 useSearchParams값(_searchParams)을 받아서 쓰기까지 가능한 URLSearchParams값(searchParams)을 생성한다.
  • setNewParams는 새로 넘어온 값을 받아 set 하고, 문자열을 반환한다. (값이 없는 경우에는 키 제거)
  • setSearchParamsrouter.push를 통해 URL을 업데이트한다.
"use client"

import { usePathname, useRouter, useSearchParams } from "next/navigation"

type NewParamsType = { [key: string]: string }

const useCustomSearchParams = () => {
  const router = useRouter()
  const pathname = usePathname()
  const _searchParams = useSearchParams()
  const searchParams = new URLSearchParams(_searchParams.toString())

  const setNewParams = (newParams: NewParamsType) => {
    for (const [key, value] of Object.entries(newParams)) {
      if (value) searchParams.set(key, value)
      else searchParams.delete(key)
    }
    return searchParams.toString()
  }

  const setSearchParams = (newParams: NewParamsType) => {
    return router.push(`${pathname}?${setNewParams(newParams)}`)
  }

  return { searchParams: Object.fromEntries(searchParams), setSearchParams }
}

export default useCustomSearchParams

 

 

3. 검색 폼에 적용

  • useCustomSearchParams 테스트를 해볼 컴포넌트를 만들자.
  • API는 https://restful-api.dev/ 를 활용했다.
  • 핸드폰 기종을 검색하고 그 결과를 보는 컴포넌트이다.

 

3-1. Search.tsx

  1. searchParams값으로 인풋 초기화 및 데이터 패칭
  2. searchParmas값이 변경될 때 setState로 입력 폼 동기화
  3. 검색 버튼 클릭 시 setSearchParams (→ handleSearch)
  4. 초기화 버튼 클릭 시 setState & setSearchParams (→ handleReset)
import { useGetSearchList } from "@api/search"
import useCustomSearchParams from "@hooks/useCustomSearchParams"
import React, { useState } from "react"

const INIT_NAME = ""

const Search = () => {
  const { searchParams, setSearchParams } = useCustomSearchParams()

  //1
  const { data, isLoading, isError } = useGetSearchList(searchParams.name)
  const [name, setName] = useState(searchParams.name || INIT_NAME)

  //3
  const handleSearch = () => {
    setSearchParams({
      name,
    })
  }

  //4
  const handleReset = () => {
    setName(INIT_NAME)
    setSearchParams({ name: INIT_NAME })
  }

  //2
  useEffect(() => {
	  setName(searchParams.name)
  }, [searchParams.name])

  if (isLoading) return <div>LOADING...</div>
  if (isError) return <div>ERROR...</div>
  return (
    <div>
      <div>
        <input
          placeholder="이름 검색"
          name="name"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />

        <button onClick={handleSearch}>검색</button>
        <button onClick={handleReset}>초기화</button>
      </div>
      <ul>
        {data?.map((d) => (
          <li key={d.id} style={{ padding: "4px" }}>
            {d.id}. {d.name}
          </li>
        ))}
      </ul>
    </div>
  )
}

export default Search

 

3-2. 데이터 흐름

  • 중요한 점은 SearchParams에서 Form으로 데이터가 흘러야 한다는 것이다.
  • 처음에는 FormSearchParams이 동기화되어야 한다는 것에 집중했고, 각각이 업데이트되었을 때 자동으로 다른 하나도 업데이트되도록 구현했다.
  • 하지만 그렇게 하면 데이터 흐름 사이에 사이클이 생겨 무한루프에 빠지는 문제가 있었다.
  • 그래서 데이터가 한 방향으로 흐르도록 설계해야 했다.

SearchParams에서 Form으로

 

 

cf. Redux의 Flux Structure와 유사한 느낌

Redux Flux Structure

 

 

3-3. 결과

  • 인풋 폼에 입력한 값이 URL의 쿼리파라미터로 저장되는 것을 볼 수 있다.
  • 새로운 탭을 열어 URL을 복붙 했을 때 동일한 결과를 볼 수 있다.
  • 새로고침 시에도 해당 결과가 남아있다.

 

 

4. useCustomSearchParams 디벨롭

  • 위에서 만들었던 useCustomSearchParams를 확장해 보자.
  • 현재 아쉬운 부분 두 가지가 있다.
    • 브라우저 히스토리를 유지하고 싶은 경우를 처리할 수 없음
    • setSearchParams의 이전 상태에 접근할 수 없음

 

브라우저 히스토리 옵션 추가

  • isReplace 옵션을 추가해 검색 조건들을 히스토리 스택에 쌓을지 말지 여부를 선택할 수 있도록 하자.
  • 추가로 필요에 따라 options 객체를 받아서 다양한 확장이 가능할 것이다.
const setSearchParams = (
  newParams: NewParamsType,
  isReplace?: boolean,
) => {
  if (isReplace) return router.replace(`${pathname}?${setNewParams(newParams)}`)
  return router.push(`${pathname}?${setNewParams(newParams)}`)
}

 

setState처럼 만들기

  • useStatesetState처럼 이전 상태에 접근할 수 있도록 하자.
  • newParams가 함수로 넘어오는 경우 함수의 반환값을, 그렇지 않은 경우 newParams 그대로 _newParams변수에 넣어서 분기 처리를 해준다.
const setSearchParams = (...) => {
  const _newParams =
    typeof newParams === "function" ? newParams(Object.fromEntries(searchParams)) : newParams

  return router.push(`${pathname}?${setNewParams(_newParams)}`)
}
setSearchParams((prev) => ({ ...prev })) //이런 식으로 사용

 

최종코드

"use client"

import { usePathname, useRouter, useSearchParams } from "next/navigation"

type NewParamsType = { [key: string]: string }

const useCustomSearchParams = () => {
  const router = useRouter()
  const pathname = usePathname()
  const _searchParams = useSearchParams()
  const searchParams = new URLSearchParams(_searchParams.toString())

  const setNewParams = (newParams: NewParamsType) => {
    for (const [key, value] of Object.entries(newParams)) {
      if (value) searchParams.set(key, value)
      else searchParams.delete(key)
    }
    return searchParams.toString()
  }

  const setSearchParams = (
    newParams: NewParamsType | ((prev: NewParamsType) => NewParamsType),
    isReplace?: boolean,
  ) => {
    const _newParams =
      typeof newParams === "function" ? newParams(Object.fromEntries(searchParams)) : newParams

    if (isReplace) return router.replace(`${pathname}?${setNewParams(_newParams)}`)
    return router.push(`${pathname}?${setNewParams(_newParams)}`)
  }

  return { searchParams: Object.fromEntries(searchParams), setSearchParams }
}

export default useCustomSearchParams

 

5. 결론

URL이야 말로 진정한 전역 공간 아닐까? 전역 상태 관리가 필요할 때 Redux, Recoil, Jotai 등의 라이브러리만 생각할 것이 아니라, 쿼리스트링도 하나의 선택지로 볼 수 있지 않을까? 싶은 생각이 들었다. 검색 필터링 로직에서 가장 많이 쓰이는 것 같지만, 다른 쓰임도 있는지 찾아보자!

 

 

Reference

반응형