리액트를 배우는 초반엔 props(부모가 자식에게 넘기는 값)로 데이터를 내려주는 게 세상 편하다. 부모가 값을 넘기고 자식이 받아서 그리고, 끝. 그런데 화면이 조금만 깊어지면 이 방식이 슬슬 사람을 괴롭힌다. 로그인한 사용자 이름 하나를 맨 아래 버튼까지 전달하려고, 중간에 낀 컴포넌트 대여섯 개가 자기는 쓰지도 않는 값을 손에서 손으로 넘기고만 있는 꼴을 보게 된다. 나도 처음엔 이게 정상인 줄 알고 꾸역꾸역 넘겼는데, 어느 순간 컴포넌트 시그니처가 죄다 user로 도배된 걸 보고 뭔가 잘못됐다는 걸 깨달았다.


props drilling(프롭 내려꽂기). 안 쓰는 값을 억지로 릴레이시키는 삽질이다. 말로 설명하면 와닿지 않으니 코드로 보자. 사용자 이름을 최상단 App에서 만들어서, 아무 상관도 없는 PageToolbar를 거쳐 맨 밑 UserBadge까지 내려보내는 상황이다.


function App() {
const user = { name: "김프론트" };
return <Page user={user} />;
}

function Page({ user }) {
return <Toolbar user={user} />;
}

function Toolbar({ user }) {
return <UserBadge user={user} />;
}

function UserBadge({ user }) {
return <span>{user.name}님</span>;
}


화면에는 김프론트님이 잘 뜬다. 문제는 PageToolbar다. 이 둘은 user를 화면에 쓰지도 않으면서, 그저 아래로 넘기기 위해 받아들고 있다. 한 단계면 참겠는데, 실무 화면은 열 단계도 우습다. 나중에 user 옆에 theme, lang 하나만 더 붙어도 중간 컴포넌트를 전부 다시 열어 인자를 추가해야 한다. 이건 유지보수가 아니라 두더지 잡기다.


createContext. 값을 담아둘 통을 하나 만든다. 리액트는 이 릴레이를 건너뛰라고 Context라는 걸 준다. 트리 어딘가 위에 값을 넣어두면, 아래 어느 깊이에서든 중간을 거치지 않고 곧장 꺼내 쓴다. 먼저 통부터 만든다.


import { createContext } from "react";

const UserContext = createContext(null);


createContext(null)null은 아무도 값을 안 넣었을 때 나오는 기본값이다. 이 기본값은 나중에 함정이 되니 기억해두자.


Provider. 이 통을 트리 윗부분에 씌운다. 통을 만들었으면, 값을 실어서 자식들을 감싼다. UserContext.Provider로 감싼 안쪽은 전부 이 값을 꺼낼 수 있게 된다.


function App() {
const user = { name: "김프론트" };
return (
<UserContext.Provider value={user}>
<Page />
</UserContext.Provider>
);
}

function Page() {
return <Toolbar />;
}

function Toolbar() {
return <UserBadge />;
}


PageToolbar에서 user가 통째로 사라진 걸 보라. 이제 이 둘은 자기가 안 쓰는 값을 나를 이유가 없다. 그냥 자식을 그릴 뿐이다.


useContext. 필요한 곳에서 통을 열어 값을 꺼낸다. 맨 밑 UserBadgeuseContext에 아까 만든 통을 넣어 값을 바로 받는다.


import { useContext } from "react";

function UserBadge() {
const user = useContext(UserContext);
return <span>{user.name}님</span>;
}


결과는 앞과 똑같이 김프론트님이다. 화면은 그대로인데 중간 다리들이 홀가분해졌다. UserBadge가 아무리 깊은 곳에 있어도, Provider 안쪽이기만 하면 한 줄로 값을 집어 든다. 이게 전역 상태의 감각이다. 값을 위에 한 번 두고, 필요한 데서 직접 꺼낸다.


바꾸는 함수까지 같이 넣으면 진짜 전역 상태가 된다. 지금까지는 읽기만 했는데, 값을 바꾸고 싶으면 useState의 상태와 세터를 같이 통에 실으면 된다. 테마를 껐다 켜는 예로 보자.


import { createContext, useState, useContext } from "react";

const ThemeContext = createContext(null);

function App() {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<ThemeButton />
</ThemeContext.Provider>
);
}


이번엔 value에 값 하나가 아니라 { theme, setTheme } 객체를 넣었다. 이러면 아래에서 현재 값도 읽고 바꾸는 함수도 같이 받는다.


function ThemeButton() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
지금 테마: {theme}
</button>
);
}


버튼을 누르면 setTheme이 불리고, Providervalue가 바뀌고, 그 값을 꺼내 쓰는 컴포넌트가 다시 그려진다. 화면 글자는 지금 테마: light에서 지금 테마: dark로 토글된다. 상태 하나를 여러 컴포넌트가 공유하면서 같이 반응하는 것, 이게 전역 상태를 쓰는 이유다.


Provider로 안 감싸면 기본값이 조용히 나온다. 아까 미뤄둔 함정이다. useContext를 부른 컴포넌트가 Provider 바깥에 있으면, 값 대신 createContext에 넣었던 기본값이 나온다. 나는 이걸 null로 두고 Provider 씌우는 걸 깜빡했다가, user.name에서 null의 name을 읽을 수 없다며 터지는 걸 한참 들여다봤다. 에러가 Context를 가리키질 않으니 원인 찾기가 더 고약했다. 기본값을 의미 있는 값으로 두거나, Provider로 확실히 감쌌는지부터 확인하는 습관이 필요하다.


매번 useContext를 부르기 귀찮으면 훅으로 감싼다. 실무에선 컴포넌트마다 useContext(ThemeContext)를 반복해 적는 대신, 이걸 한 겹 감싼 작은 훅을 만들어 쓴다.


function useTheme() {
return useContext(ThemeContext);
}

// 쓰는 쪽
const { theme, setTheme } = useTheme();


별것 아닌 것 같아도, 나중에 기본값 검사나 Provider 누락 에러 처리를 이 한 군데에 몰아넣을 수 있어서 두고두고 편하다. 값을 꺼내는 통로가 하나로 정해지는 셈이다.


남용하면 리렌더로 되갚는다. 편하다고 온갖 값을 Context 하나에 때려 넣으면 대가가 있다. Providervalue가 바뀔 때마다, 그 값을 구독하는 컴포넌트가 전부 다시 그려진다. 1초에 몇 번씩 바뀌는 값까지 여기 넣으면 화면 전체가 쉴 새 없이 리렌더된다. Context는 자주 안 바뀌는 값, 이를테면 로그인 정보나 테마, 언어 설정에 어울린다. 복잡하고 자주 바뀌는 상태를 본격적으로 다루는 도구는 따로 있고, 그건 이걸 손에 익힌 다음에 봐도 늦지 않다. 우선은 props를 몇 단계씩 내려꽂고 있다는 신호가 보이면 Context를 떠올리는 것, 거기서부터 시작하면 된다.