전역 상태 50개를 0개로


랜딩페이지 개발을 마칠 무렵 다음 업무를 받았습니다. 3년 전에 만들어진 레거시 서비스를 랜딩페이지와 같은 기술 스택(Next.js)으로 옮기고, 두 코드베이스를 하나로 합치는 일이었습니다.

이관할 목록을 훑다가 전역 상태에서 멈췄습니다. 상태를 저장하는 Recoil atom이 50개, 그 값으로 계산하는 selector가 9개 있었고, 이를 직접 읽는 파일이 78개였습니다.

이 글은 그 50개를 어디로 옮겼는지 정리한 기록입니다.

멈춰버린 Recoil

마이그레이션을 위해 레거시 코드의 파일 구조를 분석하던 중 Recoil 코드가 보였습니다. Recoil은 2023년부터 릴리스가 멈췄고 2025년에는 저장소가 아카이브되어, 새 스택의 React 19 대응을 기대할 수 없는 상태였습니다. 옮겨야 한다는 것은 분명했고, 어디로 옮길지만 정하면 됐습니다.

분석하다 보니 흥미로운 점도 하나 발견했습니다. atom key 중복 경고를 끄려고 만든 configs/recoil.ts라는 파일이 있었는데, 어디에서도 import되지 않아 실제로는 한 번도 적용된 적이 없는 죽은 파일이었습니다. 그만큼 오래 손대는 사람이 없던 코드였습니다.

옮길 곳은 Zustand로 정했다

atom을 옮길 곳으로는 Zustand를 골랐습니다. 제가 자주 사용해 익숙한 라이브러리였고, Recoil과 달리 유지보수가 진행 중이었습니다. Context로 대체하는 방법도 있었지만, 값 하나가 바뀔 때 consumer 전체가 리렌더되는 구조라 이 규모에는 맞지 않다고 봤습니다.

구조적인 이유도 있었습니다. 레거시는 RecoilRoot를 앱 최상단에 두고 있었습니다. 이 구조를 그대로 가져오면 트리 꼭대기에 클라이언트 경계와 하이드레이션 지점이 생기고, 상태를 쓰지 않는 페이지에도 라이브러리 코드가 실립니다. Zustand는 store가 React 트리 밖 모듈에 있기 때문에 Provider가 필요 없고, store를 실제로 쓰는 컴포넌트만 클라이언트 경계가 되면 됩니다.

계획은 단순했습니다. atom 50개를 Zustand store로 옮기면 끝. 그렇게 생각하고 작업을 시작했습니다.

옮기려고 보니 대부분 견적 폼이었다

작업을 시작하자마자 계획이 꼬였습니다. atom 50개 중 44개가 견적 문의 페이지의 입력값이었고, selector 9개도 전부 폼의 계산이었습니다. 전역 상태 코드의 9할이 한 페이지에서 사용하는 입력값이었던 겁니다.

우선 그 44개의 atom을 하나의 store로 옮겼습니다. Zustand는 파생값을 별도 selector로 두지 않고 함수로 계산하면 되니, atom과 selector로 나뉘어 있던 구조도 store 하나로 단순해졌습니다.

다만 뼈대만 옮겼습니다. 폼의 입력값과 검증은 언젠가 폼 라이브러리가 맡는 게 맞다고 봤지만, 견적 페이지의 실제 이관은 한참 뒤 일정이었고 Recoil을 걷어내는 일이 먼저였기 때문입니다. 폼을 이관할 때 걷어낼 수 있도록 검증 로직 없이 최소 구조로만 세워두었습니다.

남은 6개는 store에 넣을 것이 아니었다

50개의 atom 중 44개를 store로 옮기고 나니 6개가 남았습니다. 그런데 이 6개는 살펴볼수록 store에 둘 값이 아니었습니다. Zustand가 부족해서가 아니라, 각자 더 맞는 자리가 따로 있었기 때문입니다.

레거시 atom 하던 일 옮긴 자리
sidebar 사이드바 열림 컴포넌트 로컬 상태
headerMenu 헤더 메뉴 선택 컴포넌트 로컬 상태
schedule(국가) 일정 목록의 국가 URL 경로 세그먼트
schedule(페이지) 일정 목록의 페이지 URL 쿼리 ?page=
lang 현재 언어 next-intl (URL)
device PC/모바일 판별 MUI breakpoint

가장 기억에 남는 것은 페이지 번호였습니다. 레거시 코드에는 "2페이지 이상에서 게시글 조회 후 뒤로가기 시 1페이지로 돌아가는 것 방지"라는 주석이 남아 있었습니다. 페이지 번호를 전역 상태로 둔 이유였고, 실제로 있는 문제를 푼 코드였던 겁니다. 다만 그 과정에서 같은 페이지 번호가 전역 atom, URL, history.state 세 곳에 중복 보관되고 있었습니다. 페이지 번호를 ?page= 쿼리 하나로 옮기면 뒤로가기는 브라우저 히스토리가 기억합니다. 주석이 걱정한 시나리오를 직접 재현해, 상세에서 목록으로 돌아갈 때 보던 페이지가 유지되는 것도 확인했습니다. 같은 문제를 상태 없이 풀면서, 특정 페이지를 가리키는 공유 가능한 URL까지 생겼습니다.

lang과 device에는 값을 채워 넣는 전용 컴포넌트까지 딸려 있었습니다. LanguageDetect와 DeviceTypeDetect라는 컴포넌트가 브라우저를 관찰해 atom에 값을 넣고, 화면들이 그 atom을 읽는 구조였습니다. 그런데 합쳐질 랜딩페이지에서는 이미 next-intl로 언어를 URL에서 결정하고 있었고, PC/모바일 분기는 MUI breakpoint로 충분했습니다. store로 옮기면 같은 값을 두 곳에서 따로 관리하게 되니, 랜딩페이지의 방식을 따르고 atom은 제거했습니다.

sidebar와 headerMenu는 헤더 안에서만 쓰는 값이었습니다. 사이드바가 열렸는지, 어떤 메뉴가 선택됐는지를 서비스 전체가 알 필요는 없었습니다. 헤더 컴포넌트의 useState 두 개로 충분했습니다.

이렇게 6개가 각자 자리를 찾아가고 나니, 전역 상태에는 44개를 합쳐 둔 견적 폼 store 하나만 남았습니다.

마지막 하나도 store가 아니었다

시간이 지나 견적 페이지를 이관할 차례가 왔습니다. 세워둔 뼈대에 화면을 연결하면 되는 시점이었습니다.

그런데 연결하려고 보니 만만치 않은 비용이 보였습니다. store를 그대로 쓰면 레거시가 화면 여섯 곳에 흩어두었던 수동 검증 36곳을 다시 구현해야 했습니다. store에 검증만 붙이는 방법도 있지만, 오류 표시와 입력 관리까지 폼이 하는 일을 결국 하나씩 다시 만들게 됩니다. 그리고 더 근본적인 문제가 있었습니다. 한 화면에서만 쓰는 폼 상태를 전역 store에 두는 것은, 앞서 6개를 store에 넣지 않으며 세운 기준을 제 손으로 어기는 일이었습니다.

그래서 미뤄두었던 계획대로 React Hook Form(RHF)과 Zod를 도입했습니다. 입력값과 오류는 RHF가 화면 안에서 관리하고, 36곳에 흩어져 있던 검증은 Zod 스키마 하나로 모였습니다. selector 9개가 하던 계산도 폼 값과 스키마가 대신하게 됐습니다. 뼈대만 세워둔 덕에 store를 지우는 비용은 크지 않았습니다. store가 사라지자 Zustand를 쓰는 곳이 한 곳도 남지 않았고, 의존성 자체를 제거했습니다. 내부 구조가 완전히 달라진 만큼, 이관 검증은 코드 대조가 아니라 화면과 동작을 레거시와 직접 대조하는 방식으로 진행했습니다.

atom 50개를 옮기려고 도입한 라이브러리가, 옮기고 나니 함께 사라진 겁니다.

결론

전역 상태 50개는 이렇게 0개가 됐습니다. 44개는 폼 라이브러리가, 6개는 로컬 상태, URL, 프레임워크가 가져갔습니다. 상태가 사라진 것이 아니라 각자 맞는 자리를 찾아갔고, 그러고 나니 전역 store라는 계층 자체가 필요 없어졌습니다. 전역 상태의 9할이 한 페이지의 폼이었던 이 서비스라서 가능한 결과이기도 합니다. 전역 상태가 정말 필요한 값이 생기면, 그때 Zustand를 다시 꺼내면 됩니다.

레거시 코드를 분석하며, 마이그레이션 계획을 세우고, 검증하고, 학습하면서 느낀 점은 “재미있다”였습니다. 학생 때는 할 수 없었던 경험이기도 하고 무엇보다 남이 작성한 코드를 내 코드로 바꾸는 작업이 재미로 다가왔기 때문입니다. 긴 글 읽어주셔서 감사합니다.

← 글 목록으로