안녕하세요! 프론트엔드 개발을 함께 공부할 강사입니다. Zustand 공식 문서를 통해 상태 관리를 깊이 있게 학습하시려는 모습이 정말 멋집니다!
공식 문서가 영어로 되어 있어서 조금 막막하셨죠? 걱정 마세요. 제가 원본 내용 하나도 빠짐없이, 이해하기 쉬운 구어체로 싹 번역해 드릴게요. 거기에 현업에서 실제로 어떻게 쓰이는지, 왜 이렇게 코드를 작성하는지에 대한 제 경험과 부연 설명, 그리고 실무 꿀팁까지 듬뿍 담아보았습니다.
자, 그럼 즐겁게 시작해 볼까요?
💡 강사의 부연 설명:
우리가 웹 애플리케이션을 만들다 보면, 사용자가 현재 보고 있는 화면의 상태(예: 검색 필터, 선택된 탭, 모달 창 열림 여부 등)를 다른 사람에게 링크로 공유하고 싶을 때가 있습니다. 이럴 때 Zustand의 상태를 URL에 묶어두면(Connect), 링크만 복사해서 전달해도 똑같은 상태의 화면을 띄워줄 수 있어요. 이 문서에서는 URL의 해시(Hash,#뒤의 값)와 쿼리 파라미터(Query Parameters,?뒤의 값)를 활용하는 두 가지 방법을 설명하고 있습니다.
만약 여러분이 Zustand 스토어의 상태를 URL 해시(hash)에 연결하고 싶다면, 여러분만의 고유한 해시 스토리지(hash storage)를 직접 만들어서 사용할 수 있어요.
💡 강사의 부연 설명:
해시(Hash)는 URL에서#기호 뒤에 오는 부분을 말해요 (예:https://example.com/#/my-state). 원래는 HTML 문서 내의 특정 ID 위치로 스크롤을 이동시킬 때 쓰였지만, SPA(Single Page Application) 환경에서는 브라우저가 서버로 추가 요청을 보내지 않는다는 특성 때문에 상태를 저장하거나 라우팅을 할 때 아주 유용하게 쓰인답니다.
아래 코드는 location.hash를 이용해 상태를 읽고 쓰는 커스텀 스토리지를 만드는 방법입니다.
import { create } from 'zustand'
import { persist, StateStorage, createJSONStorage } from 'zustand/middleware'
const hashStorage: StateStorage = {
getItem: (key): string => {
const searchParams = new URLSearchParams(location.hash.slice(1))
const storedValue = searchParams.get(key) ?? ''
return JSON.parse(storedValue)
},
setItem: (key, newValue): void => {
const searchParams = new URLSearchParams(location.hash.slice(1))
searchParams.set(key, JSON.stringify(newValue))
location.hash = searchParams.toString()
},
removeItem: (key): void => {
const searchParams = new URLSearchParams(location.hash.slice(1))
searchParams.delete(key)
location.hash = searchParams.toString()
},
}
export const useBoundStore = create()(
persist(
(set, get) => ({
fishes: 0,
addAFish: () => set({ fishes: get().fishes + 1 }),
}),
{
name: 'food-storage', // 고유한 이름
storage: createJSONStorage(() => hashStorage),
},
),
)
👨💻 강사의 실무 팁:
여기서persist미들웨어가 정말 강력한 역할을 합니다! 기본적으로persist는localStorage나sessionStorage에 데이터를 저장하지만, 위 코드처럼createJSONStorage를 통해 우리가 직접 만든hashStorage를 주입해주면 URL 해시가 곧 데이터베이스처럼 작동하게 됩니다. 상태가 변경될 때마다setItem이 알아서 호출되어 URL을 업데이트해 주니 개발자는 그저 상태만 바꾸면 되는 거죠. 참 쉽죠?
때로는 특정 조건에서만 상태를 URL과 연결하고 싶을 때가 있습니다. 이번 예제에서는 URL 쿼리 파라미터(Query Parameters)를 사용하면서, 이를 localstorage 같은 다른 영구 저장소(persistence implementation)와 동기화된 상태로 유지하는 방법을 보여드릴게요.
만약 URL 파라미터가 항상 채워지도록 만들고 싶다면, 아래 코드에서 getUrlSearch()를 확인하는 조건문을 제거해버리면 됩니다.
아래에 구현된 코드는 관련된 상태가 변경될 때마다 페이지 새로고침 없이 현재 URL을 그 자리에서 즉시 업데이트합니다.
💡 강사의 부연 설명:
쿼리 파라미터는 URL에서?뒤에 오는key=value형태의 문자열입니다 (예:?fishes=5&bears=2). 보통 검색 조건이나 필터링 값을 저장할 때 해시보다 쿼리 파라미터를 더 많이 사용합니다. 백엔드 서버에서도 이 값을 쉽게 읽을 수 있기 때문이죠.
import { create } from 'zustand'
import { persist, StateStorage, createJSONStorage } from 'zustand/middleware'
const getUrlSearch = () => {
return window.location.search.slice(1)
}
const persistentStorage: StateStorage = {
getItem: (key): string => {
// 먼저 URL을 확인합니다.
if (getUrlSearch()) {
const searchParams = new URLSearchParams(getUrlSearch())
const storedValue = searchParams.get(key)
return JSON.parse(storedValue as string)
} else {
// URL에 값이 없다면, localstorage나 다른 대체 저장소에서 불러와야 합니다.
return JSON.parse(localStorage.getItem(key) as string)
}
},
setItem: (key, newValue): void => {
// 쿼리 파라미터가 존재하는지 먼저 확인합니다. 항상 URL을 설정하고 싶다면 이 조건문을 지우면 됩니다.
if (getUrlSearch()) {
const searchParams = new URLSearchParams(getUrlSearch())
searchParams.set(key, JSON.stringify(newValue))
window.history.replaceState(null, '', `?${searchParams.toString()}`)
}
localStorage.setItem(key, JSON.stringify(newValue))
},
removeItem: (key): void => {
const searchParams = new URLSearchParams(getUrlSearch())
searchParams.delete(key)
window.location.search = searchParams.toString()
},
}
type LocalAndUrlStore = {
typesOfFish: string[]
addTypeOfFish: (fishType: string) => void
numberOfBears: number
setNumberOfBears: (newNumber: number) => void
}
const storageOptions = {
name: 'fishAndBearsStore',
storage: createJSONStorage<LocalAndUrlStore>(() => persistentStorage),
}
const useLocalAndUrlStore = create()(
persist<LocalAndUrlStore>(
(set) => ({
typesOfFish: [],
addTypeOfFish: (fishType) =>
set((state) => ({ typesOfFish: [...state.typesOfFish, fishType] })),
numberOfBears: 0,
setNumberOfBears: (numberOfBears) => set(() => ({ numberOfBears })),
}),
storageOptions,
),
)
export default useLocalAndUrlStore
👨💻 강사의 실무 팁:
이 코드는 정말 실무에서 많이 쓰이는 패턴입니다!getItem로직을 보면 URL 쿼리 스트링을 먼저 검사하고, 없으면localStorage로 폴백(Fallback)하죠? 이렇게 하면 사용자가 필터가 적용된 URL 링크를 타고 들어오면 그 링크의 상태를 최우선으로 보여주고, 그냥 메인 페이지로 들어오면 이전에 자기가 설정해둔 로컬 스토리지의 환경을 그대로 복원해줄 수 있습니다.또한,
window.history.replaceState를 사용한 부분도 눈여겨보세요. 만약pushState를 썼다면 상태가 바뀔 때마다 뒤로 가기 히스토리에 엄청난 쓰레기(?) 데이터가 쌓였을 텐데,replaceState를 써서 현재 히스토리를 덮어씌움으로써 깔끔한 UX를 제공하고 있습니다.
컴포넌트 내에서 공유할 수 있는 URL을 직접 생성해야 할 때는, 아래처럼 buildShareableUrl 함수를 만들어서 호출할 수 있습니다.
const buildURLSuffix = (params, version = 0) => {
const searchParams = new URLSearchParams()
const zustandStoreParams = {
state: {
typesOfFish: params.typesOfFish,
numberOfBears: params.numberOfBears,
},
version: version, // Zustand가 상태를 세팅하는 방식에 포함되어 있기 때문에 버전 정보가 여기에 들어갑니다.
}
// URL 파라미터의 key 이름은 위 storageOptions에서 지정했던 스토어의 name과 일치해야 합니다.
searchParams.set('fishAndBearsStore', JSON.stringify(zustandStoreParams))
return searchParams.toString()
}
export const buildShareableUrl = (params, version) => {
return `${window.location.origin}?${buildURLSuffix(params, version)}`
}
이렇게 생성된 URL은 대략 아래와 같은 모습일 거예요 (가독성을 위해 인코딩은 하지 않은 원본 상태로 보여드립니다):
https://localhost/search?fishAndBearsStore={"state":{"typesOfFish":["tilapia","salmon"],"numberOfBears":15},"version":0}}
💡 강사의 부연 설명:
실제 브라우저 주소창에서는 괄호나 따옴표가%7B,%22같은 문자로 인코딩되어 다소 지저분해 보일 수 있습니다. 하지만 브라우저나 자바스크립트는 저걸 정상적인 JSON 문자열로 아주 잘 해석해 내니 안심하셔도 됩니다! 공유 버튼을 눌렀을 때 클립보드에 저런 형태의 URL이 복사되도록 만들면 아주 훌륭한 공유 기능이 완성되는 거죠.
백문이 불여일견이죠! 브라우저 상에서 코드가 어떻게 돌아가는지 직접 확인해 보세요.
다음
React 18 이전 버전에서 React 이벤트 핸들러 외부에서 액션 호출하기
어떠셨나요? 생각보다 원리가 간단하죠? 상태를 로컬이나 세션 스토리지에만 저장하지 않고 URL과 동기화하는 기법은 사용자 경험(UX)을 크게 끌어올려 주는 고급 프론트엔드 기술입니다. 꼭 한 번 직접 타이핑해 보시면서 원리를 손에 익혀보시길 바랄게요.
더 궁금한 부분이 있거나, 여러분의 프로젝트에 직접 적용해 볼 때 막히는 부분이 있다면 언제든 저에게 질문해 주세요. 제가 도와드릴게요!