상태가 뭔지 잡았으면 다음 질문은 자연스럽게 이거다. 이 상태를 대체 어디에 둬야 하나. 리액트에서 상태를 두는 위치는 크게 둘로 갈린다. 한 컴포넌트 안에 두는 로컬 상태(local state, 그 컴포넌트만 쓰는 값)와, 앱 곳곳에서 나눠 쓰는 전역 상태(global state, 여러 컴포넌트가 공유하는 값)다. 이 둘을 구분 못 하면 두 방향으로 다 고생한다. 전역에 둬야 할 걸 로컬에 두면 값을 나눠 쓰려고 props를 온 사방에 이어 붙이게 되고, 로컬이면 될 걸 전역에 두면 앱이 쓸데없이 무거워지고 어디서 값이 바뀌는지 추적이 안 된다. 그래서 어느 쪽에 둘지를 정하는 감각이 상태관리의 절반이다.


로컬 상태는 그 컴포넌트 혼자 쓰는 값이다. 바깥의 누구도 알 필요가 없고, 그 컴포넌트가 사라지면 같이 사라져도 되는 값. 이런 건 그냥 컴포넌트 안에 useState로 두면 된다. 아코디언이 펼쳐졌는지, 입력창에 뭘 쳤는지, 마우스가 위에 올라왔는지 같은 것들이다.


function Accordion() {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>제목</button>
{open && <p>펼쳐진 내용</p>}
</div>
);
}


openAccordion 바깥의 그 누구도 궁금해하지 않는다. 헤더도, 옆 사이드바도, 다른 페이지도 이 아코디언이 열렸는지 알 이유가 없다. 이렇게 관심의 범위가 컴포넌트 하나 안에서 닫히는 값은 로컬 상태의 전형이다. 실무 상태의 대부분은 사실 여기에 속한다. 처음부터 겁먹고 거창하게 갈 필요가 없다는 뜻이다.


전역 상태는 서로 멀리 떨어진 컴포넌트들이 같이 봐야 하는 값이다. 대표적인 게 로그인한 사용자 정보다. 헤더의 프로필 아이콘도 이 값을 알아야 하고, 마이페이지도, 댓글 작성창도, 심지어 화면 맨 밑 푸터의 로그아웃 버튼도 이 값을 안다. 이 컴포넌트들은 화면 트리에서 서로 한참 떨어져 있다. 장바구니 개수도 마찬가지다. 헤더 우측의 장바구니 배지와, 전혀 다른 위치에 있는 상품 상세 페이지의 담기 버튼이 같은 개수를 봐야 한다.


// 헤더 안쪽
function CartBadge() {
// cartCount를 알아야 한다
}

// 완전히 다른 곳
function ProductPage() {
// 여기서 담으면 cartCount가 올라야 한다
}


이 둘을 props로 이으려면, 두 컴포넌트의 공통 조상까지 값을 올렸다가 다시 양쪽으로 줄줄이 내려보내야 한다. 문제는 이 공통 조상이 앱의 거의 최상단이라는 데 있다. 헤더와 상품 페이지의 공통 조상은 사실상 앱 뿌리에 가깝고, 그러면 뿌리에서부터 양쪽 말단까지 값이 관통해 내려가야 한다. 중간에 낀 컴포넌트 열몇 개가 자기는 쓰지도 않는 cartCount를 그저 아래로 전달만 하게 된다. 이런 게 반복되면 값 하나 추가할 때마다 중간 컴포넌트들의 props 목록이 지저분하게 부푼다. 이럴 때가 전역 상태를 꺼낼 자리다.


판단 기준은 딱 하나다. 이 값을 누가 쓰나. 로컬이냐 전역이냐를 고민할 때 화려한 이론은 필요 없다. 그 값을 읽거나 바꾸는 컴포넌트가 누구누구인지만 세어보면 된다. 한 컴포넌트, 혹은 그 컴포넌트와 바로 아래 자식 몇 개 정도가 전부라면 로컬이다. 트리 여기저기 흩어진 컴포넌트들이 봐야 하고, 그 사이에 관련 없는 층이 여럿 껴 있다면 전역 후보다. 핵심은 값이 관심받는 범위다. 범위가 좁으면 좁은 데 두고, 넓으면 넓은 데 둔다. 이 질문만 습관처럼 던져도 대부분 답이 나온다. 애매한 중간 지대도 있다. 검색창과 바로 옆 결과 목록처럼 두세 컴포넌트가 나눠 쓰는 정도라면 앱 전체의 전역까지 갈 것 없이, 그 둘의 공통 부모에만 두면 된다. 전역이란 게 무조건 앱 꼭대기를 뜻하는 건 아니고, 값을 쓰는 무리의 딱 필요한 높이를 말하는 거다.


일단 로컬로 시작해라. 전역은 최후의 수단이다. 초보 때 나는 반대로 했다. 어차피 나중에 여기저기서 쓸지도 모르니 미리 전역에 몰아두자는 심보였다. 그 결과가 어땠냐면, 입력창 글자 하나 칠 때마다 앱 전체가 다시 그려졌다. 전역 상태는 그걸 구독하는 모든 컴포넌트를 리렌더시키는데, 상관없는 컴포넌트까지 전부 구독자로 묶여 있었으니 당연했다. 게다가 값이 이상해졌을 때 대체 어느 컴포넌트가 바꾼 건지 찾을 수가 없었다. 전 세계가 접근 가능한 변수는 전 세계가 용의자다. 그때 배운 게, 상태는 가능한 한 좁게 두고 정말 공유가 필요해질 때에만 넓히라는 것이다. 넓히는 건 나중에 언제든 되지만, 넓혀 놓은 걸 다시 좁히는 건 훨씬 귀찮다.


로컬이던 값이 전역이 되는 순간이 온다. 상태의 위치는 처음에 정하면 끝나는 게 아니라, 요구사항이 자라면서 옮겨간다. 다크모드를 예로 들면, 처음엔 설정 페이지에만 토글이 있으니 그 페이지의 로컬 상태로 충분하다.


function SettingsPage() {
const [dark, setDark] = useState(false);
// 이 페이지 안에서만 쓴다
}


그런데 어느 날 헤더도, 사이드바도, 본문도 다 이 테마를 따라야 한다는 요구가 붙는다. 그 순간 dark는 한 페이지의 로컬 상태로는 감당이 안 되고, 앱 전체가 공유하는 전역 상태로 승격돼야 한다. 이때 값을 위로 올리게 되는데, 어디까지 얼마나 올릴지, 어떤 도구로 나눠줄지가 바로 상태 끌어올리기와 컨텍스트 같은 기법들이 다루는 주제다. 중요한 건 순서다. 로컬로 두고 쓰다가 공유가 진짜로 필요해졌을 때 올린다. 미래를 지레짐작해 처음부터 전역으로 박지 않는다. 이 순서만 지켜도 상태 구조가 훨씬 깔끔하게 유지된다.


전역 상태에도 결이 있다. 전역이라고 다 같은 무게가 아니다. 로그인 사용자나 테마처럼 한번 정해지면 좀처럼 안 바뀌는 값은 전역에 둬도 부담이 적다. 어차피 자주 안 바뀌니 그걸 구독하는 컴포넌트들이 리렌더될 일도 드물다. 반대로 입력창 글자나 마우스 위치처럼 초당 몇 번씩 바뀌는 값을 전역에 올리면, 그 값이 바뀔 때마다 구독자 전원이 다시 그려져 앱이 버벅인다. 그래서 전역에 올릴 후보를 고를 때는 이렇게도 물어야 한다. 이 값이 얼마나 자주 바뀌나. 넓게 공유되면서 자주 안 바뀌는 값이 전역의 이상적인 손님이고, 자주 바뀌는 값은 되도록 좁게 가둬 두는 게 성능에도 이롭다. 공유 범위와 변경 빈도, 이 두 축으로 값을 저울질하면 위치 판단이 한결 또렷해진다.


정리하면 로컬이 기본값, 전역은 예외다. 로컬 상태는 useState로 컴포넌트 안에 두고, 그 값에 관심 있는 컴포넌트가 늘어나 props로 잇기가 버거워질 때 비로소 전역으로 끌어올린다. 어느 쪽에 둘지 헷갈리면 값을 쓰는 컴포넌트를 세어보고, 애매하면 일단 좁게 둔 다음 필요할 때 넓힌다. 이 원칙 하나로 상태를 어디에 둘지의 팔 할은 정리된다. 처음부터 완벽한 위치를 맞히려고 애쓸 필요도 없다. 로컬로 시작해 두면 나중에 요구가 바뀌어도 그때 올리면 그만이라, 되돌리기 어려운 실수를 애초에 안 하게 되는 구조다. 남은 문제는, 공유가 필요해졌을 때 값을 정확히 어느 높이까지 올리고 어떻게 나눠줄 것이냐다.