React 상태 관리를 단순하게: 서버 상태와 클라이언트 상태를 분리하고 몇 가지 규칙을 따라 복잡성이 커지는 초기 신호를 포착하세요.

상태(state)는 앱이 실행되는 동안 변할 수 있는 모든 데이터입니다. 보이는 것(모달이 열려 있음), 편집 중인 것(폼 초안), 페치한 데이터(프로젝트 목록)가 모두 여기에 포함됩니다. 문제는 이 모든 것이 ‘상태’라고 불리지만 동작 방식이 매우 다르다는 점입니다.
대부분의 지저분한 앱은 같은 방식으로 무너집니다: 너무 많은 상태 유형이 같은 장소에 섞입니다. 컴포넌트가 서버 데이터, UI 플래그, 폼 드래프트, 도출 값들을 모두 들고 있다가 이펙트로 정렬을 맞추려고 합니다. 곧 "이 값은 어디서 오는가?" 또는 "무엇이 이 값을 업데이트하는가?" 같은 간단한 질문에 여러 파일을 뒤져야만 답을 찾을 수 있게 됩니다.
생성된 React 앱은 초기 동작하는 버전을 그대로 받아들이기 쉬워 이 문제가 더 빨리 발생합니다. 새 화면을 추가하고 패턴을 복사하고 버그를 다른 useEffect로 패치하면 이제 두 개의 진실의 근원이 생깁니다. 생성기나 팀이 중간에 방향을 바꾸면(여긴 로컬 상태, 저긴 글로벌 스토어) 코드베이스는 하나를 구축하기보다 패턴을 모으게 됩니다.
목표는 지루한 것입니다: 상태 종류를 줄이고 찾아볼 곳을 줄이는 것. 서버 데이터의 명확한 저장소 하나와 UI 전용 상태의 명확한 저장소 하나가 있으면 버그는 작아지고 변경이 위험하게 느껴지지 않습니다.
"지루하게 유지"한다는 것은 몇 가지 규칙을 지킨다는 뜻입니다:
구체적 예: 사용자 목록이 백엔드에서 온다면 이를 서버 상태로 취급하고 사용되는 곳에서 페치하세요. selectedUserId가 세부 패널을 구동하기 위해서만 존재한다면 해당 패널 근처에 작은 UI 상태로 유지하세요. 이 둘을 섞는 것이 복잡성이 시작되는 방식입니다.
대부분의 React 상태 문제는 한 가지 혼동에서 시작합니다: 서버 데이터를 UI 상태처럼 다루는 것. 초기에 분리하면 앱이 커져도 상태 관리는 침착함을 유지합니다.
서버 상태는 백엔드 소유입니다: 사용자, 주문, 작업, 권한, 가격, 기능 플래그 등. 앱이 아무 것도 하지 않아도(다른 탭에서 업데이트되거나, 관리자가 편집하거나, 작업이 실행되거나, 데이터가 만료되는 등) 변경될 수 있습니다. 공유되고 변경 가능하므로 페칭, 캐싱, 재페치, 에러 처리가 필요합니다.
클라이언트 상태는 지금 이 순간 UI만 신경 쓰는 것들입니다: 어떤 모달이 열려 있는지, 어떤 탭이 선택되었는지, 필터 토글, 정렬 순서, 접힌 사이드바, 저장 전의 검색 쿼리 드래프트 등. 탭을 닫으면 잃어도 괜찮다면 클라이언트 상태일 가능성이 큽니다.
간단한 테스트는: "페이지를 새로고침하고 서버에서 이것을 다시 구성할 수 있는가?"
도출된 상태(derived state)도 있습니다. 이는 애초에 추가 상태를 만들지 않게 해 줍니다. 다른 값들로 계산할 수 있는 값이므로 저장하지 않습니다. 필터된 목록, 합계, isFormValid, "빈 상태 표시" 등이 여기에 보통 속합니다.
예: 프로젝트 목록을 페치하면(서버 상태), 선택된 필터와 "새 프로젝트" 대화창 열림 플래그는 클라이언트 상태입니다. 필터링 후 보이는 목록은 도출된 상태입니다. 보이는 목록을 별도로 저장하면 동기화가 흐트러지고 "왜 갱신이 안 되지?" 같은 버그를 쫓게 됩니다.
이 분리는 Koder.ai 같은 도구가 화면을 빠르게 생성할 때 도움이 됩니다: 백엔드 데이터는 한 페칭 레이어에 두고, UI 선택은 컴포넌트 근처에 두고, 계산된 값은 저장하지 마세요.
상태는 한 데이터에 두 명의 소유자가 생길 때 고통스러워집니다. 가장 빠른 단순화 방법은 누가 무엇을 소유하는지 결정하고 그걸 지키는 것입니다.
예: 사용자 목록을 페치하고 하나를 선택해 세부 정보를 보여주면, 흔한 실수는 선택된 전체 사용자 객체를 상태에 저장하는 것입니다. 대신 selectedUserId를 저장하세요. 목록은 서버 캐시에 두고, 상세 뷰는 ID로 유저를 조회합니다. 그러면 재페치가 있어도 추가 동기화 코드 없이 UI가 업데이트됩니다.
생성된 React 앱에서는 서버 데이터를 중복하는 "도움되는" 생성 상태를 받아들이기 쉽습니다. fetch -> setState -> edit -> refetch 같은 코드가 보이면 멈춰서 생각하세요. 브라우저에 두 번째 데이터베이스를 만드는 신호일 때가 많습니다.
서버 상태는 백엔드에 있는 모든 것: 목록, 상세 페이지, 검색 결과, 권한, 카운트 등입니다. 지루한 접근법은 하나의 도구를 골라 계속 사용하는 것입니다. 많은 React 앱에서 TanStack Query면 충분합니다.
목표는 간단합니다: 컴포넌트가 데이터를 요청하고 로딩/에러 상태를 보여주며 내부에서 몇 번의 페치가 일어나는지는 신경 쓰지 않는 것. 생성된 앱에서는 사소한 불일치가 새 화면이 추가되면서 빠르게 곱해지기 때문에 이것이 중요합니다.
쿼리 키를 네이밍 시스템으로 다루세요, 뒷전에 두지 마세요. 일관되게 유지하세요: 안정적인 배열 키, 결과를 바꾸는 입력(필터, 페이지, 정렬)만 포함, 많은 일회성 키보다 예측 가능한 몇 가지 형태를 선호하세요. 많은 팀은 작은 헬퍼에 키 빌딩을 넣어 모든 화면이 같은 규칙을 사용하게 합니다.
쓰기 작업에는 명시적인 성공 처리를 가진 뮤테이션을 사용하세요. 뮤테이션은 두 가지 질문에 답해야 합니다: 무엇이 변했는가, 그리고 UI는 다음에 무엇을 해야 하는가?
예: 새 작업을 생성할 때 성공 시 작업 목록 쿼리를 무효화하여 한 번 다시 로드하게 하거나 캐시를 타깃으로 업데이트하여 새 작업을 캐시 목록에 추가하세요. 피처별로 한 가지 접근법을 골라 일관되게 유지하세요.
여러 곳에 "안전하게" 하려고 refetch 호출을 추가하고 싶은 유혹이 들면 한 가지 지루한 방법을 고르세요:
클라이언트 상태는 브라우저가 소유한 것들입니다: 사이드바 열림 플래그, 선택된 행, 필터 텍스트, 저장 전 드래프트 등. 사용되는 곳 가까이에 두면 보통 관리가 쉽습니다.
작게 시작하세요: 가장 가까운 컴포넌트에서 useState를 사용하세요. Koder.ai로 화면을 생성할 때 모든 것을 "혹시 몰라" 글로벌 스토어로 밀어 넣고 싶어질 수 있습니다. 그렇게 하면 아무도 이해하지 못하는 스토어가 생깁니다.
상태를 위로 올릴 때는 공유 문제가 무엇인지 이름을 붙일 수 있을 때만 올리세요.
예: 세부 패널이 있는 테이블은 selectedRowId를 테이블 컴포넌트에 둘 수 있습니다. 페이지의 다른 툴바도 필요하면 페이지 컴포넌트로 올리세요. 별도의 라우트(예: 대량 편집)가 필요하면 그때 작은 스토어를 고려하세요.
스토어를 사용할 경우 하나의 역할에 집중되게 유지하세요. 스토어에는 "무엇(what)"을 저장하고, "결과(results)"(정렬된 목록 등)처럼 도출 가능한 것은 저장하지 마세요.
스토어가 커지기 시작하면 스스로 물어보세요: 이게 아직 하나의 기능인가? 솔직한 답이 "어느 정도"라면 다음 기능이 그것을 건들기 전에 지금 나누세요.
폼 버그는 보통 사용자가 타이핑하는 것, 서버에 저장된 것, 그리고 UI가 보여주는 것 세 가지를 섞어 쓸 때 발생합니다.
지루한 상태 관리를 위해 폼은 제출할 때까지 클라이언트 상태로 다루세요. 서버 데이터는 마지막 저장된 버전이고 폼은 드래프트입니다. 서버 객체를 제자리에서 수정하지 마세요. 값을 드래프트 상태로 복사하고 사용자가 자유롭게 바꾸게 한 뒤 제출하고 성공 시 재페치(또는 캐시 업데이트)하세요.
사용자가 떠날 때 무엇을 유지할지 초기에 결정하세요. 이 한 가지 선택이 많은 놀라운 버그를 예방합니다. 예를 들어 인라인 편집 모드나 열린 드롭다운은 보통 리셋되어야 하고, 긴 위자드 드래프트나 전송되지 않은 메시지 드래프트는 유지될 수 있습니다. 새로고침 간 지속은 사용자가 분명히 기대할 때만(예: 체크아웃 폼) 하세요.
검증 규칙은 한 곳에 모으세요. 입력, 제출 핸들러, 헬퍼에 규칙을 흩어놓으면 일치하지 않는 에러가 생깁니다. 하나의 스키마(또는 하나의 validate() 함수)를 선호하고 UI는 언제 에러를 보여줄지(변경 시, 블러 시, 제출 시)를 결정하게 하세요.
예: Koder.ai로 Edit Profile 화면을 생성하면 저장된 프로필을 서버 상태로 로드하고, 폼 필드에 대한 드래프트 상태를 만드세요. 드래프트와 저장된 값을 비교해 "저장되지 않은 변경"을 보여주고, 사용자가 취소하면 드래프트를 버리고 서버 버전을 보여주며, 저장하면 드래프트를 제출한 뒤 서버 응답으로 저장된 버전을 대체하세요.
생성된 React 앱이 성장하면서 동일한 데이터가 세 곳에 중복되는 경우가 흔합니다: 컴포넌트 상태, 글로벌 스토어, 그리고 캐시. 보통 해결책은 새 라이브러리가 아니라 각 상태의 하나의 집을 선택하는 것입니다.
대부분의 앱에서 효과적인 정리 흐름:
users + filter로 계산할 수 있다면 filteredUsers 같은 상태를 제거. 중복된 selectedUser 객체 대신 selectedUserId를 선호.예: Koder.ai가 생성한 CRUD 앱은 보통 useEffect로 페치한 뒤 동일한 목록을 글로벌 스토어에 복사하는 식으로 시작합니다. 서버 상태를 중앙화하면 목록은 하나의 쿼리에서 오고 "새로고침"은 수동 동기화가 아니라 무효화가 됩니다.
네이밍은 일관되고 지루하게 유지하세요:
users.list, users.detail(id)ui.isCreateModalOpen, filters.userSearchopenCreateModal(), setUserSearch(value)users.create, users.update, users.delete목표는 각 항목에 대해 한 출처의 진실이 있고 서버 상태와 클라이언트 상태 사이에 명확한 경계가 있는 것입니다.
상태 문제는 작게 시작해 어느 날 필드를 변경하면 UI의 세 부분이 서로 다른 "진짜" 값을 가지는 상황으로 번집니다.
가장 명확한 경고 신호는 데이터 중복입니다: 동일한 사용자나 장바구니가 컴포넌트, 글로벌 스토어, 요청 캐시에 존재합니다. 각 복사본이 다른 시간에 업데이트되고 이를 같게 유지하려고 더 많은 코드를 추가하게 됩니다.
또 다른 신호는 동기화 코드입니다: 상태를 앞뒤로 밀어넣는 이펙트들. "쿼리 데이터가 바뀌면 스토어 업데이트"와 "스토어가 바뀌면 재페치" 같은 패턴은 엣지 케이스에서 오래된 값이나 루프를 유발할 수 있습니다.
몇 가지 빠른 적신호:
needsRefresh, didInit, isSaving 같은 공유 플래그를 추가함.예: Koder.ai로 생성한 대시보드에 Edit Profile 모달을 추가하면 프로필 데이터가 쿼리 캐시에 저장되고 글로벌 스토어에 복사되고 로컬 폼 상태에 중복되면 이제 세 곳의 진실의 근원이 생깁니다. 백그라운드 재페치나 낙관적 업데이트를 처음 추가할 때 불일치가 드러납니다.
이 신호들이 보이면 지루한 선택지는 각 데이터의 단일 소유자를 정하고 미러를 삭제하는 것입니다.
"혹시 몰라" 저장하는 것은 상태를 고통스럽게 만드는 가장 빠른 방법 중 하나입니다, 특히 생성된 앱에서 그렇습니다.
API 응답을 글로벌 스토어로 복사하는 것은 흔한 함정입니다. 데이터가 서버에서 온다면(목록, 상세, 사용자 프로필) 기본적으로 클라이언트 스토어로 복사하지 마세요. 서버 데이터의 한 집(보통 쿼리 캐시)을 선택하세요. 클라이언트 스토어는 서버가 모르는 UI 전용 값에 사용하세요.
도출된 값을 저장하는 것도 함정입니다. 카운트, 필터된 목록, 합계, canSubmit, isEmpty는 보통 입력 값으로부터 계산되어야 합니다. 성능 문제가 실제로 발생하면 그때 메모이제이션을 고려하고, 결과를 바로 저장하는 것으로 시작하지 마세요.
모든 것을 담는 거대한 메가스토어(auth, modals, toasts, filters, drafts, onboarding flags)는 쓰레기 저장소가 됩니다. 기능 경계에 따라 분리하세요. 상태가 하나의 화면에서만 사용된다면 로컬로 유지하세요.
Context는 안정적인 값(테마, 현재 사용자 ID, 로케일)에 좋습니다. 빠르게 변하는 값에는 광범위한 재렌더링을 초래할 수 있습니다. Context는 연결용으로 사용하고 자주 변하는 UI 값은 컴포넌트 상태(또는 작은 스토어)를 사용하세요.
마지막으로 일관성 없는 네이밍을 피하세요. 거의 중복인 쿼리 키와 스토어 필드는 미묘한 중복을 만듭니다. 간단한 표준을 골라 따르세요.
"한 가지 상태만 더" 추가하고 싶을 때 소유권 체크를 빠르게 하세요.
첫째, 서버 페칭과 캐싱이 한 곳에서 일어나는지(한 쿼리 도구, 한 세트의 쿼리 키)가 있는지 가리킬 수 있나요? 동일한 데이터가 여러 컴포넌트에서 페치되고 스토어에 복사된다면 이미 이자를 내고 있는 셈입니다.
둘째, 이 값이 한 화면 내부에서만 필요한가요(예: "필터 패널 열림")? 그렇다면 전역으로 만들지 마세요.
셋째, 객체를 중복하지 않고 ID를 저장할 수 있나요? selectedUserId를 저장하고 캐시나 목록에서 사용자를 읽으세요.
넷째, 도출 가능한가요? 기존 상태에서 계산할 수 있다면 저장하지 마세요.
마지막으로 1분 추적 테스트를 해보세요. 동료가 "이 값은 어디서 오나요?(prop, 로컬 상태, 서버 캐시, URL, 스토어)"에 대해 1분 이내에 답하지 못하면 더 상태를 추가하기 전에 소유권을 고치세요.
프롬프트로 생성된 관리자 앱(예: Koder.ai에서 생성된 것)을 떠올려보세요. 고객 목록, 고객 상세 페이지, 편집 폼의 세 가지 화면이 있습니다.
상태는 명확한 집이 있을 때 침착함을 유지합니다:
목록과 상세 페이지는 쿼리 캐시에서 서버 상태를 읽습니다. 저장할 때 고객을 글로벌 스토어에 다시 저장하지 않습니다. 뮤테이션을 전송한 뒤 캐시를 갱신하거나 업데이트하도록 두세요.
편집 화면에서는 폼 드래프트를 로컬에 두세요. 페치한 고객에서 초기화하되 사용자가 타이핑을 시작하면 별개로 취급하세요. 이렇게 하면 상세 뷰가 안전하게 재페치되어 반만 저장된 변경으로 덮어쓰는 일을 피할 수 있습니다.
낙관적 UI는 팀이 종종 모든 것을 복제하게 만드는 곳입니다. 대부분의 경우 그럴 필요가 없습니다.
사용자가 저장을 누르면 캐시된 고객 레코드와 해당 목록 항목만 업데이트하고 요청이 실패하면 롤백하세요. 폼 드래프트는 저장이 성공할 때까지 유지하세요. 실패하면 에러를 보여주고 사용자가 재시도할 수 있도록 드래프트를 유지하세요.
예: 대량 편집을 추가하고 선택된 행들이 필요하다고 합시다. 새 스토어를 만들기 전에 묻습니다: 이 상태가 네비게이션과 새로고침을 견뎌야 하나?
생성된 화면은 빠르게 늘어날 수 있고, 각 새 화면이 자체 상태 결정을 가져오면 문제가 됩니다.
리포에 짧은 팀 노트를 적어두세요: 무엇이 서버 상태로 간주되는지, 무엇이 클라이언트 상태인지, 각 항목을 어떤 도구가 소유하는지. 사람들이 실제로 따를 수 있을 만큼 짧게 유지하세요.
작은 PR 습관을 추가하세요: 새로운 상태를 추가할 때마다 서버인지 클라이언트인지 라벨을 붙이세요. 서버 상태라면 "어디서 로드되는가, 어떻게 캐시되며 무엇이 무효화하는가?"를 묻고, 클라이언트 상태라면 "누가 소유하고 언제 리셋되는가?"를 물으세요.
Koder.ai(koder.ai)를 사용 중이라면 Planning Mode는 새 화면을 생성하기 전에 소유권에 대해 합의하는 데 도움이 됩니다. 스냅샷과 롤백을 통해 상태 변경이 꼬였을 때 안전하게 실험할 수 있습니다.
한 가지 기능(예: 프로필 편집)을 골라 규칙을 끝까지 적용하고 그 예제를 모두가 복사하게 하세요.
모든 상태를 서버, 클라이언트(UI), 또는 도출된(derived) 상태로 라벨링하는 것부터 시작하세요.
isValid).라벨을 붙였으면 각 항목에 명확한 단일 소유자(쿼리 캐시, 로컬 컴포넌트 상태, URL, 혹은 작은 스토어)가 있는지 확인하세요.
빠른 테스트: “페이지를 새로고침하고 서버에서 이것을 다시 구성할 수 있는가?”
예: 프로젝트 목록은 서버 상태이고, 선택된 행 ID는 클라이언트 상태입니다.
왜냐하면 진실의 근원(source of truth)이 두 개가 되기 때문입니다.
users를 페치한 뒤 useState나 글로벌 스토어에 복사하면 다음 상황에서 동기화를 유지해야 합니다:
기본 규칙: , 로컬 상태는 UI 전용 고민이나 드래프트에만 사용하세요.
오직 정말로 싸게 계산할 수 없을 때만 도출된 값을 저장하세요.
보통은 기존 입력에서 계산합니다:
visibleUsers = users.filter(...)total = items.reduce(...)canSubmit = isValid && !isSaving성능 문제가 실제로 측정될 때 나 다른 최적화 기법을 고려하고, 바로 결과를 저장하는 패턴은 피하세요.
기본적으로 서버-상태 도구(많은 React 앱에서는 TanStack Query)를 사용해 컴포넌트가 데이터를 “요청”하고 로딩/에러 상태를 처리하도록 하세요.
실용적인 기본:
여기저기에 를 뿌려 "안전하게" 만드는 것을 피하세요.
실제 공유 필요를 명명할 수 있을 때까지 로컬으로 유지하세요.
승격 규칙:
이렇게 하면 글로벌 스토어가 잡동사니 저장소가 되는 것을 막을 수 있습니다.
ID와 작은 플래그를 저장하세요, 전체 서버 객체를 저장하지 마세요.
예:
selectedUserIdselectedUser (복사된 객체)렌더링 시 캐시된 목록/상세 쿼리에서 ID로 객체를 조회하세요. 이렇게 하면 백그라운드 재페치와 업데이트가 추가 동기화 코드 없이도 올바르게 동작합니다.
폼은 제출할 때까지 **드래프트(클라이언트 상태)**로 다루세요.
실용적 패턴:
이렇게 하면 서버 데이터를 "제자리에서" 수정하는 실수를 피하고 재페치와 충돌하는 일을 줄일 수 있습니다.
일반적인 경고 신호들:
needsRefresh, didInit, isSaving 같은 공유 플래그가 계속 쌓임.보통 해결책은 새로운 라이브러리가 아니라 미러를 삭제하고 각 값에 단일 소유자를 정하는 것입니다.
생성된 화면은 빠르게 서로 다른 상태 패턴으로 흩어질 수 있습니다. 간단한 안전장치는 소유권 표준화입니다:
Koder.ai를 사용한다면 Planning Mode로 소유권을 결정하고 스냅샷/롤백을 통해 상태 변경 실험을 안전하게 되돌릴 수 있습니다.
useMemorefetch()