리액트를 처음 배울 때는 상태(state)를 컴포넌트 안에 두는 게 당연하게 느껴진다. 그런데 앱이 조금만 커지면 문제가 생긴다. 로그인한 사용자 정보를 헤더에서도 쓰고, 사이드바에서도 쓰고, 저 아래 댓글창에서도 쓴다. 이 값을 어디에 둘 것인가. 처음엔 별생각 없이 필요한 컴포넌트에 props로 넘기다가, 어느 순간 자기가 쓰지도 않는 값을 그냥 아래로 전달만 하는 컴포넌트가 수두룩해진다. 전역 스토어(store)라는 개념은 바로 이 지점에서 나온다.
상태를 위로 올리다 보면 지친다. 두 컴포넌트가 같은 값을 공유해야 하면, 리액트에서는 그 값을 둘의 공통 부모로 끌어올린다. 이걸 상태 끌어올리기(lifting state up)라고 부른다. 둘이 형제라면 부모 하나만 거치면 되니 견딜 만하다. 문제는 값을 쓰는 곳과 값을 가진 곳 사이가 멀 때다.
function App() {
const [user, setUser] = useState(null);
return <Layout user={user} />;
}
function Layout({ user }) {
return <Header user={user} />;
}
function Header({ user }) {
return <UserBadge user={user} />;
}
UserBadge 하나가 user를 쓰겠다는데, 중간의 Layout과 Header는 이 값에 아무 관심도 없으면서 손에서 손으로 넘겨주는 택배 기사 노릇만 한다. 이걸 props 내려꽂기(prop drilling)라고 한다. 단계가 서너 개면 그러려니 하는데, 나는 실무에서 여덟 단계를 타고 내려가는 props를 본 적 있다. 중간에 이름 하나 오타 내면 값이 undefined로 조용히 사라져서, 어느 층에서 끊겼는지 찾느라 반나절을 썼다.
전역 스토어는 상태를 컴포넌트 바깥에 둔다. 발상을 뒤집는 것이다. 값을 컴포넌트 트리 위에서 아래로 흘려보내는 대신, 트리 바깥의 한 곳에 상태를 모아두고 필요한 컴포넌트가 거기서 직접 꺼내 쓰게 한다. 그 한 곳이 스토어다. 스토어는 앱 전체가 함께 보는 단일 진열대(single source of truth)라고 생각하면 된다. 리액트에는 이걸 위한 기본 도구로 컨텍스트(Context)가 들어 있다.
const UserContext = createContext(null);
function App() {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
);
}
createContext로 값이 흐를 통로를 하나 만들고, Provider로 그 통로에 값을 실어 특정 영역을 감싼다. 이제 Provider가 감싼 범위 안에서는 어느 깊이에 있든 그 값을 바로 꺼낼 수 있다. Layout과 Header는 더 이상 user를 손에 쥐고 다닐 필요가 없다. 값이 진짜 필요한 UserBadge만 직접 손을 뻗으면 된다.
function UserBadge() {
const user = useContext(UserContext);
if (!user) return <span>손님</span>;
return <span>{user.name}님</span>;
}
useContext에 아까 만든 통로를 건네면, 가장 가까운 Provider가 실어둔 값이 그대로 나온다. props를 한 단계도 거치지 않았는데 값이 도착한다. 중간 컴포넌트들의 택배 기사 노릇이 통째로 사라진 것이다. 값이 바뀌면, 이를테면 로그인이 일어나 user가 채워지면, 그 값을 꺼내 쓰던 UserBadge만 알아서 다시 그려진다.
스토어에는 세 가지 요소가 있다. 도구가 컨텍스트든 나중에 배울 별도 라이브러리든, 전역 스토어라 부르는 것들은 대개 같은 뼈대를 공유한다. 첫째는 상태 그 자체, 즉 지금 담겨 있는 값이다. 둘째는 그 값을 읽는 통로, 즉 구독(subscribe)이다. 컴포넌트가 스토어의 값을 꺼내 쓰면, 그 값이 바뀔 때 자기도 다시 그려지겠다고 등록하는 셈이다. 셋째는 값을 바꾸는 통로, 즉 갱신(dispatch)이다. 읽기와 쓰기를 이렇게 갈라두면, 데이터가 어디서 와서 어디서 바뀌는지가 한눈에 정리된다.
읽기만 되는 스토어는 반쪽이다. 지금까지는 값을 꺼내 보기만 했다. 그런데 로그인 버튼을 눌러 user를 실제로 채우려면, 값을 바꾸는 통로도 스토어에 함께 실어야 한다. 컨텍스트의 value에 상태만이 아니라 상태를 바꾸는 함수까지 같이 담으면 된다.
function App() {
const [user, setUser] = useState(null);
const login = (name) => setUser({ name });
return (
<UserContext.Provider value={{ user, login }}>
<Layout />
</UserContext.Provider>
);
}
이제 value에는 현재 user와 login이라는 갱신 함수가 함께 들어 있다. 값을 바꾸고 싶은 컴포넌트는 이 통로에서 login만 꺼내 부르면 된다. 로그인 버튼도 어느 깊이에 있든 props 없이 바로 손을 뻗는다.
function LoginButton() {
const { login } = useContext(UserContext);
return <button onClick={() => login("김철수")}>로그인</button>;
}
버튼을 누르면 login이 setUser를 부르고, user가 채워지면서 이 값을 구독하던 UserBadge가 다시 그려져 이름이 뜬다. 읽는 쪽과 바꾸는 쪽이 서로 props로 이어져 있지 않은데도, 스토어를 사이에 두고 값이 오간다. 한 가지 주의할 점은, value에 { user, login }처럼 객체를 그대로 넣으면 렌더마다 새 객체가 만들어져 구독자가 매번 다시 그려질 수 있다는 것이다. 이 부담이 커지면 값과 갱신 함수를 각각 다른 컨텍스트로 쪼개는 방법을 쓴다.
전역이라고 다 스토어에 밀어넣지 마라. 스토어 맛을 보면 모든 상태를 여기 넣고 싶어진다. 나도 그랬다. 한 페이지에서만 쓰는 입력창 값, 모달이 열렸는지 여부까지 전부 전역으로 올렸다가 크게 데었다. 컨텍스트의 값이 바뀌면 그 값을 구독하는 컴포넌트가 전부 다시 그려진다. 사소한 입력 한 글자에 화면 절반이 리렌더되면서 앱이 눈에 띄게 굼떠졌다. 한 컴포넌트 안에서만 쓰는 값은 그냥 그 안의 useState로 두는 게 맞다. 여러 곳이 진짜로 함께 봐야 하는 값, 이를테면 로그인 정보나 테마 설정 같은 것만 스토어로 올린다. 전역 스토어는 강력한 만큼 남용하면 성능과 복잡도를 같이 갉아먹는다.
정리하면, 전역 스토어는 상태를 컴포넌트 트리 바깥으로 빼내 공유하는 방식이고, 그 덕에 props 내려꽂기에서 벗어난다. 대신 무엇을 올리고 무엇을 안 올릴지 선을 긋는 감각이 필요하다. 어디에 값을 둘 것인가라는 질문에 스스로 답할 수 있게 되면, 상태 관리의 절반은 이미 손에 들어온 것이다.