예전 프로젝트에서 나는 전역 저장소 하나에 앱의 거의 모든 상태를 밀어넣은 적이 있다. 입력창 글자, 모달이 열렸는지, 마우스가 어디에 올라가 있는지, 심지어 어떤 탭이 눌렸는지까지 전부 전역이었다. 이유는 단순했다. 나중에 어디서 쓸지 모르니 일단 전역에 두면 편하다고 믿었다. 그 믿음의 청구서는 몇 달 뒤 리렌더 폭발과 도무지 못 고치는 결합이 되어 날아왔다. 돌아보면 그 저장소의 필드가 마흔 개를 넘었는데, 그중 실제로 여러 화면이 함께 봐야 했던 건 다섯 개 남짓이었다. 나머지 서른다섯 개는 전역에 있을 이유가 없는 값이었다.


리렌더가 폭발한다. 전역 상태가 바뀌면 그 저장소를 구독하는 컴포넌트가 다시 그려진다. 검색창에 글자 하나 칠 때마다 그 값을 전역에 저장했더니, 검색창과 아무 상관없는 사이드바, 헤더, 목록까지 매 타건마다 다시 그려졌다. 입력이 눈에 띄게 버벅였다. 원인을 찾는 데 하루를 꼬박 썼다.


// 나빴던 코드: 입력값을 전역에
const useStore = create(set => ({
searchText: '',
setSearchText: (t) => set({ searchText: t })
}))

function Header() {
const { searchText } = useStore() // 안 써도 구독이 걸린다
return <header>메뉴</header>
}


HeadersearchText를 화면에 쓰지도 않으면서 저장소를 구독하는 바람에 글자마다 다시 그려졌다. 전역 저장소는 구독을 잘게 나눠 주지 않으면 상관없는 컴포넌트까지 줄줄이 딸려 온다.


얼마나 심각한지는 리액트 개발자도구의 프로파일러(Profiler, 렌더 측정 도구)를 켜자 바로 드러났다. 검색어에 가나다 세 글자를 치는 동안 화면 전체가 서른 번 넘게 다시 그려지고 있었다. 그중 검색 결과와 관련된 렌더는 손에 꼽았고 나머지는 전부 헛돈 렌더였다. 사용자는 이유도 모른 채 느린 입력창만 탓하게 된다.


선택적 구독으로 급한 불은 끄지만. Zustand 같은 도구는 필요한 조각만 골라 구독하는 길을 열어 둔다. const text = useStore(s => s.searchText)처럼 값 하나만 집으면, 그 값이 바뀔 때만 리렌더된다. 이걸로 헛돈 렌더 상당수는 줄었다. 하지만 이건 증상을 눌러 준 것이지 원인을 없앤 게 아니다. 애초에 검색창 안에서만 쓰는 값이 왜 전역에 있어야 했느냐는 질문은 그대로 남는다. 구독을 잘게 쪼개 관리하는 수고 자체가, 처음부터 전역에 두지 않았다면 없었을 일이다.


결합도가 올라간다. 컴포넌트가 전역 저장소를 직접 꺼내 쓰면, 그 컴포넌트는 이제 저장소의 구조를 알아 버린다. store.user.profile.name 같은 경로를 여기저기서 직접 참조하기 시작하면, 저장소 모양을 조금만 바꿔도 수십 군데가 깨진다. 나는 user 객체의 필드 이름 하나를 바꾸려다 열일곱 개 파일을 고쳤다. 각각 독립적이어야 할 컴포넌트가 전역 저장소라는 한 덩어리에 전부 묶여 버린 것이다.


재사용도 막힌다. 잘 만든 버튼 컴포넌트를 다른 페이지에 가져다 쓰려 했더니, 특정 전역 저장소가 없으면 아예 동작하지 않았다. 버튼 하나가 앱 전체의 상태 구조에 인질로 잡혀 있었다. 결국 저장소 접근 코드를 들어내고 props로 값을 받도록 뜯어고쳤는데, 처음부터 그렇게 짰다면 하지 않아도 될 일이었다.


테스트가 지옥이 된다. 로컬 상태만 쓰는 컴포넌트는 props만 넣어 주면 테스트가 끝난다. 전역 저장소에 매달린 컴포넌트는 테스트할 때마다 저장소를 통째로 흉내 내야(mock, 가짜로 대체) 한다. 버튼 하나 눌리는지 확인하려고 저장소 초기값을 열 줄씩 세팅하고 Provider로 감싸는 준비 코드가, 정작 검증할 코드보다 길어졌다. 테스트가 귀찮아지면 사람은 테스트를 안 쓴다. 그렇게 품질이 조용히 무너진다. 반대로 로컬 상태로 짠 컴포넌트는 입력과 출력이 props로 훤히 드러나 있어서, 무엇을 넣으면 무엇이 나오는지 테스트가 곧 문서가 된다.


대부분은 로컬 상태로 충분하다. 위 삽질의 해법은 허무할 만큼 단순했다. 한 컴포넌트 안에서만 쓰는 값은 useState로 그 컴포넌트 안에 둔다. 검색창 입력값은 검색창의 로컬 상태로 되돌렸다. 그 순간 헤더와 사이드바의 리렌더가 멈췄고 입력 버벅임이 사라졌다.


// 되돌린 코드: 입력값은 로컬로
function SearchBox({ onSubmit }) {
const [text, setText] = useState('')
return (
<input
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && onSubmit(text)}
/>
)
}


여러 값이 함께 움직이고 갱신 규칙이 얽혀 있다면 useReducer로 묶는다. 그래도 여전히 그 컴포넌트 트리 안의 로컬 상태다. 전역으로 끌어올린 게 아니다.


전역 상태는 저절로 정리되지 않는다. 또 하나 물렸던 건 모달이었다. 모달 열림 여부를 전역에 뒀더니, 한 페이지에서 모달을 열고 다른 페이지로 이동해도 그 값이 true로 남아 있었다. 새 페이지에서 엉뚱한 모달이 떠 있는 유령 버그였다. 로컬 상태였다면 컴포넌트가 화면에서 사라질 때 상태도 같이 사라졌을 텐데, 전역에 올려 둔 탓에 스스로 정리되지 않았다. 상태의 수명이 그것을 쓰는 컴포넌트의 수명과 어긋나면 이런 유령이 생긴다. 로컬 상태는 컴포넌트와 생사를 같이하니 이 문제가 아예 없다.


상태는 쓰는 곳 가까이 둔다. 상태를 두는 위치의 기본 원칙은 콜로케이션(colocation, 근접 배치)이다. 그 상태를 실제로 읽고 쓰는 컴포넌트에, 또는 그들의 가장 가까운 공통 부모에 둔다. 두 형제 컴포넌트가 함께 쓰는 값이면 부모로 딱 한 칸만 끌어올린다(상태 끌어올리기, lifting state up). 앱 꼭대기 전역까지 올릴 이유가 없다. 정말 필요해지면 그때 올려도 늦지 않다. 미리 전역에 올려두는 건 쓰지도 않을 보험을 매달 붓는 짓이다.


정말 전역이어야 하는 것만 전역으로. 그럼 무엇이 진짜 전역감인가. 판별법은 간단하다. 화면 여러 군데가 동시에 봐야 하고, 한 곳에서 바꾸면 다른 곳도 즉시 따라 바뀌어야 하는 값. 로그인한 사용자 정보, 테마, 장바구니 정도다. 이런 건 손에 꼽을 만큼 적다. 나머지, 그러니까 앱 상태의 대부분은 특정 화면 조각에만 속한 로컬 값이다. 전역 저장소는 크면 클수록 좋은 물건이 아니라, 작을수록 건강한 물건이다.


모든 것을 전역에 두면 편해 보이지만, 그 편함은 빌린 돈이다. 리렌더 폭발, 결합도, 테스트 지옥이라는 이자가 반드시 따라온다. 상태는 기본을 로컬로 두고, 여러 곳이 진짜로 공유해야 할 때만 조심스럽게 위로 올린다. 올리는 것보다 내리는 게 훨씬 어렵다는 걸 몸으로 겪고 나면, 처음부터 낮게 두는 습관이 든다. 이 순서만 지켜도 상태관리의 절반은 이미 성공한 셈이다.