끌어올리기로 형제 간 공유는 풀린다. 그런데 이 방식에는 한계가 있다. 값을 쓰는 컴포넌트가 부모에서 아주 멀리, 여러 층 아래 깊숙이 박혀 있을 때다. 상태는 위에 있고 그 값이 필요한 컴포넌트는 저 아래 있으면, 값을 props로 한 층 한 층 손수 내려보내야 한다. 그 사이에 낀 컴포넌트들은 자기는 그 값을 쓰지도 않으면서 그저 아래로 넘겨주는 배달부 노릇만 한다. 이 지겨운 배달을 없애주는 게 컨텍스트(context, 트리 중간을 건너뛰고 값을 공급하는 리액트의 장치)다.
먼저 prop drilling이 왜 괴로운지 봐야 한다. 로그인한 사용자 이름을 화면 깊은 곳의 메뉴에서 보여준다고 하자. 상태는 최상단 App에 있고, 그 이름이 필요한 UserMenu는 세 층 아래에 있다.
function App() {
const [user, setUser] = useState({ name: "철수" });
return <Layout user={user} />;
}
function Layout({ user }) {
return <Header user={user} />;
}
function Header({ user }) {
return <UserMenu user={user} />;
}
function UserMenu({ user }) {
return <span>{user.name}</span>;
}
실제로 user를 쓰는 건 맨 밑 UserMenu 하나뿐인데, Layout과 Header가 아무 상관도 없는 user를 받아서 그대로 아래로 넘기고 있다. 이걸 prop drilling(값을 여러 층 아래로 관통시켜 내려보내는 것)이라 부른다. 층이 세 개라 그나마 봐줄 만하지, 실무에선 예닐곱 층을 관통하기도 한다. 게다가 새 값 하나를 더 내려보내려면 중간 컴포넌트들의 props를 죄다 손봐야 하니, 고칠 곳이 줄줄이 딸려 나온다.
컨텍스트는 통로를 만들어 중간을 건너뛴다. 먼저 값이 흐를 통로를 하나 만든다. createContext가 그 통로다.
const UserContext = createContext(null);
이제 이 UserContext를 통해 값을 위에서 공급하고 아래에서 꺼내 쓸 수 있다. 핵심은 값이 props처럼 층층이 손으로 전달되는 게 아니라, 통로를 타고 중간을 통째로 건너뛰어 필요한 컴포넌트에 바로 닿는다는 점이다. 통로를 놓는 일과 값을 흘려보내는 일, 값을 받아 쓰는 일 셋으로 나뉜다.
Provider로 값을 흘려보낸다. 통로 위쪽 끝에서 값을 공급하는 게 Provider다. 값을 내려주고 싶은 범위를 Provider로 감싸고, value에 넘길 값을 얹는다.
function App() {
const [user, setUser] = useState({ name: "철수" });
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
);
}
달라진 걸 보라. Layout에 더 이상 user를 넘기지 않는다. Provider로 감싼 안쪽이라면 아무리 깊은 곳이든 이 user에 접근할 수 있으므로, 중간에 손으로 값을 이어줄 필요가 사라진 것이다. 상태 자체는 여전히 App이 useState로 쥐고 있다. 컨텍스트는 상태를 새로 만드는 게 아니라, 이미 있는 상태를 트리 아래로 배달하는 방식만 바꾼다.
useContext로 어느 깊이서든 바로 꺼낸다. 통로 아래쪽에서 값을 받는 건 useContext 훅이다. 값이 필요한 컴포넌트에서 이 훅을 부르면, 위쪽 Provider가 흘려보낸 값이 곧장 손에 들어온다.
function Layout() {
return <Header />;
}
function Header() {
return <UserMenu />;
}
function UserMenu() {
const user = useContext(UserContext);
return <span>{user.name}</span>;
}
Layout과 Header가 홀쭉해졌다. 이제 이 둘은 user라는 게 있는 줄도 모른다. 그냥 자식을 그릴 뿐이다. UserMenu만 useContext로 필요한 값을 직접 집어 온다. 배달부로 끌려다니던 중간 컴포넌트들이 자기 일에서 해방된 것이다. App에서 setUser로 값을 바꾸면, 그 값을 구독하는 UserMenu는 자동으로 다시 그려져 새 이름을 보여준다.
값만이 아니라 바꿀 함수도 함께 흘려보낼 수 있다. 지금까지는 깊은 자식이 값을 읽기만 했다. 그런데 저 아래 컴포넌트가 값을 바꿔야 할 때도 있다. 로그아웃 버튼이 대표적인데, 화면 깊은 곳에 있으면서 사용자 정보를 비워야 한다. 이럴 땐 Provider의 value에 값 하나만 얹지 말고, 값과 그 값을 바꾸는 함수를 객체로 묶어 함께 내려보내면 된다.
<UserContext.Provider value={{ user, setUser }}>
<Layout />
</UserContext.Provider>
그러면 아래쪽 어디서든 const { user, setUser } = useContext(UserContext)로 값과 변경 함수를 함께 꺼내, 그 자리에서 setUser(null)처럼 값을 바꿀 수 있다. 앞서 끌어올리기에서 값은 내려가고 변경 요청은 콜백으로 올라온다고 했는데, 컨텍스트에선 그 콜백마저 통로에 실어 아무 데서나 집어 쓰게 만드는 셈이다. 다만 이렇게 매 렌더마다 새 객체 { user, setUser }를 만들어 넘기면 참조가 계속 달라져 구독자들이 불필요하게 다시 그려질 수 있는데, 이 문제는 값이 자주 안 바뀌는 컨텍스트라면 대개 무시할 만하다.
여기서 멈춰야 한다. 컨텍스트는 남용하기 딱 좋은 도구다. prop drilling이 사라지는 게 후련하니까, 초보는 이제 모든 상태를 컨텍스트에 쓸어 담기 시작한다. 나도 그랬고, 그 대가를 톡톡히 치렀다. 컨텍스트에는 함정이 있다. Provider의 value가 바뀌면, 그 값을 useContext로 구독하는 컴포넌트가 전부 다시 그려진다. 자주 바뀌는 값을 컨텍스트에 올려두면, 값이 갱신될 때마다 트리의 넓은 영역이 통째로 리렌더되어 앱이 눈에 띄게 굼떠진다. 게다가 컨텍스트로 넘긴 값은 어느 컴포넌트가 쓰는지 props처럼 한눈에 드러나지 않아서, 나중에 코드를 읽을 때 데이터가 어디서 와서 어디로 가는지 추적하기가 더 어려워진다. 편해 보이는 만큼 흐름은 흐릿해진다.
그래서 컨텍스트는 조건을 갖춘 값에만 쓴다. 두 조건을 다 만족할 때가 적기다. 하나, 트리 곳곳의 멀리 떨어진 컴포넌트들이 널리 공유하는 값일 것. 둘, 자주 바뀌지 않는 값일 것. 로그인 사용자, 테마, 언어 설정 같은 것들이 정확히 여기 해당한다. 앱 전역에서 쓰이면서 좀처럼 안 바뀌니, 컨텍스트의 이상적인 손님이다. 반대로 값을 겨우 한두 층 아래로 내리는 정도라면 컨텍스트를 꺼낼 것 없이 그냥 props로 주는 게 더 명확하고, 초당 몇 번씩 바뀌는 값이라면 컨텍스트에 올리는 순간 성능이 무너진다. 순서를 기억하면 된다. 우선 로컬 상태로 두고, 형제끼리 나눠 쓰면 공통 부모로 끌어올리고, 그렇게 올린 값이 너무 깊고 넓게 퍼져 배달이 감당 안 될 때 비로소 컨텍스트를 꺼낸다. 컨텍스트는 상태관리의 시작이 아니라, 다른 방법이 버거워졌을 때 마지막으로 미는 문이다. 이 순서만 지키면 상태를 어디에 두고 어떻게 흐르게 할지, 그 큰 그림이 손에 잡힌다.