안녕하세요! 프론트엔드 개발의 세계로 오신 것을 환영합니다. 오늘은 전역 상태 관리 라이브러리인 Zustand의 핵심 중 하나인 상태 업데이트(Updating state) 에 대해 공식 문서를 바탕으로 함께 공부해볼 거예요. 영어가 조금 부담스러우셨다니, 제가 이해하기 쏙쏙 편하게 구어체로 설명해 드리겠습니다. 자, 시작해볼까요?
Zustand로 상태를 업데이트하는 건 정말 간단해요! 제공된 set 함수를 새로운 상태값과 함께 호출하기만 하면 됩니다. 그러면 Zustand가 알아서 스토어에 있는 기존 상태와 얕은 병합(shallow merged) 을 해준답니다.
강사의 부연 설명 💡 > 여기서 '얕은 병합(shallow merge)'이란 1차원적인 속성들만 덮어씌워진다는 뜻이에요. 기존에
firstName,lastName,age상태가 있었는데set({ firstName: '홍' })만 호출하면,lastName과age는 날아가지 않고 그대로 유지되면서firstName만 쏙 바뀐다는 거죠. React의setState나useState객체 업데이트보다 훨씬 편하죠?
참고: 중첩된(nested) 상태를 다루는 방법은 다음 섹션에서 바로 설명해 드릴게요.
import { create } from 'zustand'
type State = {
firstName: string
lastName: string
}
type Action = {
updateFirstName: (firstName: State['firstName']) => void
updateLastName: (lastName: State['lastName']) => void
}
// 스토어 생성하기 (상태와 액션을 모두 포함합니다)
const usePersonStore = create<State & Action>((set) => ({
firstName: '',
lastName: '',
updateFirstName: (firstName) => set(() => ({ firstName: firstName })),
updateLastName: (lastName) => set(() => ({ lastName: lastName })),
}))
// 앱에서 사용할 때
function App() {
// 필요한 상태와 액션만 "선택(select)" 해서 가져옵니다.
// 이 경우엔 firstName 값과 updateFirstName 액션을 가져왔네요.
const firstName = usePersonStore((state) => state.firstName)
const updateFirstName = usePersonStore((state) => state.updateFirstName)
return (
<main>
<label>
First name
<input
// "firstName" 상태를 업데이트합니다.
onChange={(e) => updateFirstName(e.currentTarget.value)}
value={firstName}
/>
</label>
<p>
Hello, <strong>{firstName}!</strong>
</p>
</main>
)
}
만약 다음과 같이 아주 깊은 구조를 가진 상태 객체가 있다면 어떨까요?
type State = {
deep: {
nested: {
obj: { count: number }
}
}
}
이런 중첩된 상태를 업데이트하려면, 원본 데이터를 훼손하지 않는 불변성(immutability) 을 지키기 위해 어느 정도 수고가 필요합니다.
실무 꿀팁 🍯 > 자바스크립트에서 중첩된 객체를 다룰 때는 객체의 참조(reference)가 얽히지 않도록 조심해야 해요! 원본 객체의 값을 직접 수정해버리면(mutation), React나 Zustand는 값이 변했다는 걸 감지하지 못해서 화면이 리렌더링되지 않는 무서운 버그를 마주하게 될 수 있습니다.
React나 Redux에서 보통 하던 것과 비슷하게, 가장 보편적인 방법은 상태 객체의 각 깊이(level)별로 일일이 복사본을 만드는 거예요. 전개 연산자(spread operator, ...)를 사용해서, 기존 값들을 펼쳐놓고 새로운 상태값을 수동으로 병합하는 거죠. 아래 코드처럼요:
normalInc: () =>
set((state) => ({
deep: {
...state.deep,
nested: {
...state.deep.nested,
obj: {
...state.deep.nested.obj,
count: state.deep.nested.obj.count + 1
}
}
}
})),
어휴, 코드가 정말 길고 복잡하죠? 깊이가 깊어질수록 실수하기 딱 좋습니다. 여러분의 개발 라이프를 조금 더 편하게 만들어 줄 몇 가지 훌륭한 대안들을 함께 살펴보죠!
정말 많은 개발자들이 중첩된 값을 업데이트할 때 Immer라는 라이브러리를 애용합니다. Immer는 React, Redux는 물론이고 Zustand처럼 불변성을 지키면서 중첩된 상태를 업데이트해야 하는 모든 곳에서 빛을 발하죠!
Immer를 사용하면 깊게 중첩된 객체의 상태 업데이트 코드를 획기적으로 줄일 수 있답니다. 예시를 한번 볼까요?
immerInc: () =>
set(produce((state: State) => { ++state.deep.nested.obj.count })),
와! 엄청나게 줄어들었죠? 직접 값을 수정하는 것처럼 코드를 작성해도 Immer가 알아서 불변성을 유지하는 새로운 객체로 만들어주기 때문이에요.
강사의 강력 추천 👍 > 실무에서도 Zustand를 쓸 때, 상태가 2~3단계만 깊어져도 거의 무조건 Immer 미들웨어를 붙여서 사용합니다. 코드가 간결해지는 건 물론이고 읽기도 훨씬 편해져서 유지보수에 정말 큰 도움이 되거든요! 꼭 도입해 보시는 걸 추천합니다.
단, Zustand와 Immer를 함께 쓸 때 주의해야 할 몇 가지 사항(gotchas)이 있으니 꼭 한번 읽어보시길 바랄게요.
또 다른 선택지로 optics-ts라는 라이브러리를 활용하는 방법도 있습니다. 함수형 프로그래밍의 '렌즈(Lens)' 개념을 사용하는 건데요:
opticsInc: () =>
set(O.modify(O.optic<State>().path("deep.nested.obj.count"))((c) => c + 1)),
Immer와는 다르게, optics-ts는 프록시(proxies) 기술이나 직접 상태를 변경(mutation)하는 듯한 문법을 사용하지 않습니다. 순수하게 함수형 방식을 선호하신다면 좋은 선택이 될 수 있어요.
유명한 유틸리티 라이브러리인 Ramda를 사용할 수도 있답니다:
ramdaInc: () =>
set(R.modifyPath(["deep", "nested", "obj", "count"], (c) => c + 1)),
위에서 소개한 ramda와 optics-ts 두 가지 방법 모두 타입스크립트의 타입(types)을 완벽하게 지원하므로 안전하게 사용할 수 있습니다. 팀의 코딩 컨벤션이나 취향에 맞춰서 골라 쓰시면 됩니다.
지금까지 배운 내용들이 실제로 어떻게 동작하는지 아래 링크에서 직접 확인하고 코드를 만져보실 수 있습니다!
https://stackblitz.com/edit/vitejs-vite-j6bjdygu