앱은 여러 진실의 출처, 비동기 데이터, UI 상호작용, 성능 트레이드오프를 동시에 다루기 때문에 상태 관리가 어렵습니다. 버그를 줄이는 패턴을 배우세요.

프론트엔드 앱에서 **상태(state)**는 단순히 UI가 의존하고 시간이 지나며 변할 수 있는 데이터입니다.
상태가 바뀌면 화면은 그에 맞춰 업데이트되어야 합니다. 화면이 업데이트되지 않거나 일관되지 않게 업데이트되거나 옛 값과 새 값이 섞여 보인다면 즉시 “상태 문제”를 느끼게 됩니다 — 버튼이 비활성화된 채로 남아 있거나, 합계가 맞지 않거나, 사용자가 방금 한 작업을 반영하지 않는 뷰가 그런 예입니다.
상태는 작은 상호작용에서부터 큰 기능까지 여러 곳에 나타납니다. 예를 들면:
이들 중 일부는 “임시적”인 반면(예: 선택된 탭), 다른 일부는 “중요해 보이는”(예: 장바구니) 데이터입니다. 모두 현재 UI가 렌더링할 때 영향을 주기 때문에 상태입니다.
일반 변수는 그 변수가 존재하는 범위(scope)에서만 의미가 있습니다. 하지만 상태에는 규칙이 있습니다:
상태 관리의 진짜 목표는 데이터를 저장하는 것이 아니라, 업데이트를 예측 가능하게 만들어 UI가 일관성 있게 유지되도록 하는 것입니다. “무엇이, 언제, 왜 바뀌었나?”에 답할 수 있으면 상태는 관리 가능해집니다. 답할 수 없으면 단순한 기능도 놀라운 버그로 바뀝니다.
프로젝트 초기엔 상태가 거의 지루할 정도로 간단하게 느껴집니다. 컴포넌트 하나, 입력 하나, 명확한 업데이트. 사용자가 필드에 타이핑하고 그 값을 저장하면 UI가 리렌더됩니다. 모든 것이 보이고 즉각적이며 한 곳에 머뭅니다.
예를 들어 입력값을 미리보기로 보여주는 단일 텍스트 입력을 생각해보세요:
이 경우 상태는 본질적으로: 시간에 따라 변하는 변수입니다. 어디에 저장되고 어디서 업데이트되는지 가리킬 수 있다면 끝입니다.
로컬 상태가 잘 작동하는 이유는 사고 모델이 코드 구조와 일치하기 때문입니다:
React 같은 프레임워크를 써도 아키텍처를 깊게 고민할 필요가 없습니다. 기본값들이 충분히 작동합니다.
앱이 “위젯이 있는 페이지”에서 “제품”이 되자마자 상태는 한곳에 머무르지 않습니다.
같은 데이터 조각이 이제 다음과 같은 곳들에서 필요할 수 있습니다:
프로필 이름은 헤더에 표시되고 설정 페이지에서 편집되고 빠른 로딩을 위해 캐시되며 환영 메시지 개인화에 사용될 수 있습니다. 갑자기 질문은 “이 값을 어디에 두어야 모든 곳에서 올바르게 유지되나?”가 됩니다.
상태 복잡성은 기능이 늘어남에 따라 점진적으로 증가하지 않고 점프합니다.
같은 데이터를 읽는 두 번째 장소를 추가하는 것은 “두 배로 어렵다”가 아니라 협조 문제를 도입합니다: 뷰들을 일관되게 유지하기, 오래된 값 방지, 누가 무엇을 업데이트할지 결정하기, 타이밍 처리 등. 몇 개의 공유 상태와 비동기 작업이 결합되면 각 기능은 여전히 단순해 보이더라도 전반적 동작을 이해하기 어려워질 수 있습니다.
같은 “사실”이 여러 곳에 저장되면 상태는 고통스러워집니다. 각 복사본이 어긋날 수 있고, 이제 UI끼리 서로 다투는 상황이 됩니다.
대부분의 앱은 다음과 같은 여러 저장소를 갖게 됩니다:
이들 각각은 일부 상태에 대해 유효한 소유자일 수 있습니다. 문제는 이들이 같은 상태를 모두 소유하려 들 때 시작됩니다.
흔한 패턴: 서버 데이터를 가져와서 “편집하려고” 로컬 상태로 복사합니다. 예: 사용자 프로필을 로드한 뒤 formState = userFromApi로 설정합니다. 이후 서버가 재패치되거나 다른 탭이 레코드를 업데이트하면 캐시와 폼이 서로 다른 버전을 가지게 됩니다.
복제는 또한 “도움이 되는” 변환을 통해 몰래 들어옵니다: items와 itemsCount를 둘 다 저장하거나 selectedId와 selectedItem을 함께 저장하는 식입니다.
여러 진실의 출처가 있으면 버그는 보통 이렇게 들립니다:
각 상태 조각에 대해 하나의 소유자를 선택하세요 — 업데이트가 이루어지는 곳 — 그리고 나머지는 **투영(projection)**으로 취급하세요(읽기 전용, 파생값, 혹은 한 방향으로 동기화되는 것). 소유자를 가리킬 수 없다면 아마 같은 진실을 두 번 저장하고 있는 것입니다.
많은 프론트엔드 상태는 동기적이기 때문에 단순해 보입니다: 사용자가 클릭하고 값을 설정하면 UI가 업데이트됩니다. 사이드 이펙트는 그 깔끔한 단계별 이야기를 깨뜨립니다.
사이드 이펙트는 컴포넌트의 순수한 “데이터 기반 렌더” 모델 밖으로 나가는 모든 동작입니다:
각각은 나중에 실행될 수 있고, 실패할 수 있고, 여러 번 실행될 수 있습니다.
비동기 업데이트는 시간이라는 변수를 도입합니다. 더 이상 “무엇이 일어났나”가 아니라 “무엇이 아직 일어나고 있을 수 있나”를 생각해야 합니다. 두 요청이 겹치고 느린 응답이 더 빠른 응답보다 늦게 도착할 수 있으며, 컴포넌트가 언마운트된 후에도 콜백이 상태를 변경하려 할 수 있습니다.
그게 버그가 보통 다음처럼 보이는 이유입니다:
isLoading 같은 불리언을 여기저기 뿌리는 대신, 비동기 작업을 작은 상태 머신으로 취급하세요:
데이터와 상태를 함께 추적하고, 요청 ID나 쿼리 키 같은 식별자를 유지해 늦은 응답을 무시할 수 있게 하세요. 그러면 “지금 UI가 무엇을 보여줘야 하는가?”라는 질문이 추측이 아니라 명확한 결정이 됩니다.
많은 상태 문제가 단순한 착각에서 시작됩니다: “사용자가 지금 하는 일”을 “백엔드가 진실로 말하는 것”과 동일하게 취급하는 실수입니다. 둘 다 시간이 지남에 따라 변할 수 있지만 서로 다른 규칙을 따릅니다.
UI 상태는 임시적이고 상호작용 중심입니다. 사용자가 이 순간에 기대하는 화면을 렌더링하기 위해 존재합니다.
예: 모달 열림/닫힘, 활성 필터, 검색 입력 초안, hover/focus, 선택된 탭, 페이지네이션 UI(현재 페이지, 페이지 크기, 스크롤 위치).
이 상태는 보통 페이지나 컴포넌트 트리에 국한됩니다. 내비게이션 시 리셋되는 것이 괜찮습니다.
서버 상태는 API에서 오는 데이터입니다: 사용자 프로필, 제품 목록, 권한, 알림, 저장된 설정. 이는 “원격 진실”이며 당신의 UI와 무관하게 바뀔 수 있습니다(다른 사람이 편집하거나 서버가 재계산하거나 백그라운드 작업이 업데이트할 수 있음).
원격이기 때문에 로딩/에러 상태, 캐시 타임스탬프, 재시도, 무효화 같은 메타데이터도 필요합니다.
UI 초안을 서버 데이터 안에 저장하면 재패치가 로컬 편집을 덮어써 버릴 수 있습니다. 서버 응답을 UI 상태에 저장하되 캐싱 규칙이 없으면 오래된 데이터, 중복 페치, 불일치한 화면과 싸워야 합니다.
흔한 실패 모드: 사용자가 폼을 편집하는 중에 백그라운드 재패치가 완료되어 들어오는 응답이 초안을 덮어써 버리는 경우입니다.
서버 상태는 페치/캐시/무효화/포커스 시 재패치 같은 캐싱 패턴으로 관리하세요. 공유되고 비동기적이라고 생각하세요.
UI 상태는 로컬 컴포넌트 상태나 진짜로 공유해야 하는 UI 관심사에 대한 컨텍스트로 관리하세요. 초안은 서버에 의도적으로 “저장”할 때까지 분리해 두세요.
파생 상태는 다른 상태에서 계산할 수 있는 값입니다: 카트 총합(line items에서 계산), 원본 리스트 + 검색 쿼리에서의 필터된 리스트, 필드 값과 검증 규칙에서의 canSubmit 플래그 등.
이들 값을 저장해 두면 편리해 보이지만(“total도 상태로 두자”), 입력이 여러 곳에서 바뀌는 순간 드리프트 위험이 생깁니다: 저장된 total이 아이템과 맞지 않거나 필터된 리스트가 현재 쿼리를 반영하지 않거나 제출 버튼이 오류 수정 후에도 비활성화된 상태로 남는 문제 등.
더 안전한 패턴은 최소한의 소스 오브 트루스를 저장하고 나머지는 읽을 때 계산하는 것입니다. React에서는 간단한 함수나 메모이제이션으로 충분할 수 있습니다.
const items = useCartItems();
const total = items.reduce((sum, item) => sum + item.price * item.qty, 0);
const filtered = products.filter(p => p.name.includes(query));
대규모 앱에서는 “셀렉터”(또는 계산된 게터)가 이 아이디어를 공식화합니다: 한 곳에서 total, filteredProducts, visibleTodos를 유도하는 방법을 정의하고 모든 컴포넌트가 같은 로직을 사용하도록 합니다.
렌더마다 계산해도 보통 괜찮습니다. 실제 비용이 측정된 경우(비싼 변환, 거대한 리스트, 많은 컴포넌트에서 공유되는 파생 값 등)에만 캐시하세요. 메모이제이션(useMemo, 셀렉터 메모이제이션)을 사용해 캐시 키가 실제 입력들을 반영하도록 하세요 — 그렇지 않으면 다시 드리프트 문제로 돌아갑니다, 단지 성능 마스크를 쓴 형태일 뿐입니다.
상태는 누가 소유하는지 불분명할 때 고통스러워집니다.
상태의 소유자는 해당 값을 업데이트할 권한이 있는 앱의 위치입니다. 다른 UI 부분은 읽을 수 있지만(프롭, 컨텍스트, 셀렉터 등 통해), 직접 변경해서는 안 됩니다.
명확한 소유권은 두 가지 질문에 답합니다:
경계가 흐려지면 충돌하는 업데이트, “왜 이게 바뀌었지?” 같은 순간, 재사용하기 어려운 컴포넌트가 생깁니다.
상태를 글로벌 스토어(또는 최상위 컨텍스트)에 넣으면 깔끔해 보입니다: 어디서든 접근할 수 있고 prop drilling을 피합니다. 대가로 의도치 않은 결합이 생깁니다 — 관련 없는 화면들이 같은 값에 의존하게 되고 작은 변경이 앱 전반에 영향을 미칩니다.
글로벌 상태는 현재 유저 세션, 앱 전역 기능 플래그, 공유 알림 큐처럼 진짜로 횡단적인 것들에 적합합니다.
일반적인 패턴은 로컬로 시작해서 두 형제 컴포넌트가 조정해야 할 때만 가장 가까운 공통 부모로 상태를 올리는 것입니다.
한 컴포넌트만 필요하면 그 안에 두세요. 여러 컴포넌트가 필요하면 가장 작은 공유 소유자에게 올리세요. 여러 먼 영역이 필요하면 그때 글로벌을 고려하세요.
상태를 사용하는 곳 가까이에 두세요. 공유가 필요할 때만 범위를 넓히세요.
이렇게 하면 컴포넌트 이해가 쉬워지고 우발적인 의존성이 줄며 리팩토링이 덜 무서울 것입니다.
프론트엔드 앱은 “단일 스레드”처럼 느껴지지만 사용자 입력, 타이머, 애니메이션, 네트워크 요청은 모두 독립적으로 실행됩니다. 즉 여러 업데이트가 동시에 진행될 수 있고 시작한 순서대로 끝나지 않을 수 있습니다.
흔한 충돌 예: UI의 두 부분이 같은 상태를 업데이트합니다.
query를 업데이트합니다.query를 업데이트합니다.각각은 옳지만 함께하면 타이밍에 따라 서로를 덮어쓸 수 있습니다. 더 나쁜 경우는 새로운 필터를 표시하는 동안 이전 쿼리의 결과를 보여줄 수 있습니다.
레이스 컨디션은 요청 A를 보내고 곧바로 요청 B를 보냈는데 요청 A가 나중에 응답하는 상황에서 발생합니다.
예: 사용자가 “c”, “ca”, “cat”을 타이핑합니다. “c” 요청이 느리고 “cat” 요청이 빠르면 UI는 잠깐 “cat” 결과를 보여주다가 늦게 도착한 오래된 “c” 결과로 덮어써질 수 있습니다.
모든 것이 “작동했다”처럼 보이지만 순서가 잘못된 것입니다.
일반적으로 다음 전략 중 하나를 원합니다:
간단한 요청 ID 예시:
let latestRequestId = 0;
async function fetchResults(query) {
const requestId = ++latestRequestId;
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
if (requestId !== latestRequestId) return; // stale response
setResults(data);
}
낙관적 업데이트는 UI를 즉각적으로 느끼게 합니다: 서버 확인 전에 화면을 업데이트합니다. 하지만 동시성은 가정들을 깨뜨릴 수 있습니다:
안전하게 낙관성을 유지하려면 명확한 조정 규칙이 필요합니다: 보류 중인 작업을 추적하고, 서버 응답을 순서대로 적용하며, 롤백해야 하면 알려진 체크포인트로 롤백하세요(“지금 UI가 어떻게 보이는가”에 근거하지 마세요).
상태 업데이트는 “공짜”가 아닙니다. 상태가 변경되면 앱은 어떤 화면 부분이 영향을 받는지 판단하고 새 현실을 반영하기 위한 작업을 수행해야 합니다: 값 재계산, UI 리렌더, 포맷팅 로직 재실행, 때로는 재패치나 재검증까지. 이 연쇄 반응이 불필요하게 크면 사용자는 지연, 끊김, 혹은 버튼이 반응하기 전에 “생각하는” 것처럼 느낍니다.
단일 토글 하나가 많은 추가 작업을 유발할 수 있습니다:
결과는 기술적인 것을 넘어서 경험적입니다: 타이핑이 지연되고 애니메이션이 끊기며 인터페이스가 날카로운 반응성을 잃습니다.
가장 흔한 원인 중 하나는 너무 광범위한 상태입니다: 관련 없는 정보를 많이 담은 “큰 버킷” 객체. 어떤 필드를 업데이트하면 전체 버킷이 새 것으로 보이므로 더 많은 UI가 깨어납니다.
또 다른 함정은 계산된 값을 상태에 저장하고 수동으로 업데이트하는 것입니다. 이는 동기화를 유지하기 위해 추가 업데이트(그리고 추가 UI 작업)를 초래합니다.
상태를 더 작은 조각으로 분리하세요. 관련 없는 관심사를 분리해 검색 입력 변경이 전체 페이지 결과를 새로고치지 않게 하세요.
데이터 정규화(Normalize): 같은 아이템을 여러 곳에 저장하지 말고 한 곳에 저장한 뒤 참조하세요. 반복된 업데이트를 줄이고 하나의 편집으로 많은 복사본을 다시 씁는 “변경 폭주(change storm)”를 방지합니다.
파생 값을 메모이제이션하세요. 필터된 결과 같은 값은 입력이 실제로 바뀔 때만 재계산되도록 캐시하세요.
성능을 고려한 좋은 상태 관리는 주로 포함 범위의 최소화입니다: 업데이트는 가능한 한 작은 영역에만 영향을 미치고 비싼 작업은 진짜 필요할 때만 실행되도록 하세요. 그러면 사용자는 프레임워크를 느끼지 못하고 인터페이스를 신뢰하게 됩니다.
상태 버그는 개인적인 문제처럼 느껴집니다: UI가 “틀렸다”지만 가장 단순한 질문—누가 이 값을 언제 변경했나?—에 답하지 못합니다. 값이 바뀌면 타임라인이 필요합니다, 직감이 아니라.
가장 빠른 명료성 경로는 예측 가능한 업데이트 흐름입니다. 리듀서, 이벤트, 스토어를 사용하든 목표는:
setShippingMethod('express')처럼, updateStuff같은 모호한 이름 금지)명확한 액션 로깅은 디버깅을 “화면을 바라보기”에서 “영수증을 따라가기”로 바꿉니다. 간단한 콘솔 로그(액션 이름 + 주요 필드)도 무언가가 어떻게 일어났는지 재구성하려 애쓰는 것보다 낫습니다.
모든 리렌더를 테스트하려 들지 마세요. 대신 순수 로직처럼 행동해야 하는 부분을 테스트하세요:
이 조합은 “산수 버그”와 실제 배선 문제 둘 다 잡아냅니다.
비동기 문제는 틈새에 숨어 있습니다. 타임라인을 가시화하는 최소한의 메타데이터를 추가하세요:
그러면 늦은 응답이 새로운 것을 덮어썼을 때 즉시 증명할 수 있고 자신 있게 고칠 수 있습니다.
상태 도구를 고를 때는 라이브러리 비교를 시작하기 전에 설계 결정을 산출물로 취급하면 더 쉽습니다. 무엇이 완전히 로컬인지, 무엇을 공유해야 하는지, 무엇이 실제로 서버 데이터인지(가져와서 동기화할 데이터인지)를 먼저 정의하세요.
실용적으로 결정하려면 몇 가지 제약을 보세요:
“우리는 X를 전역적으로 쓴다”로 시작하면 잘못된 곳에 잘못된 것을 저장하게 됩니다. 먼저 소유권으로 시작하세요: 누가 이 값을 업데이트하고 누가 읽는가, 변경 시 무엇이 발생해야 하는가.
많은 앱은 API 데이터엔 서버 상태 라이브러리, 클라이언트 전용 UI엔 소규모 UI 상태 솔루션 조합으로 잘 운영됩니다. 목표는 명확성: 각 상태 유형은 가장 이해하기 쉬운 곳에 놓습니다.
상태 경계와 비동기 흐름을 실험할 때 Koder.ai는 반복(try it, observe it, refine it) 속도를 올려줄 수 있습니다. 에이전트 기반 워크플로로 React 프론트엔드(및 Go + PostgreSQL 백엔드)를 생성하므로 로컬 vs 글로벌, 서버 캐시 vs UI 초안 같은 소유권 모델을 빠르게 프로토타이핑하고 예측 가능한 버전을 선택할 수 있습니다.
실험에 도움이 되는 두 가지 실용적 기능: 계획 모드(Planning Mode)(구현 전에 상태 모델을 개략화)와 스냅샷 + 롤백(파생 상태 제거나 요청 ID 도입 같은 리팩터를 안전하게 테스트)를 통해 작동 중인 기준선을 잃지 않고 시도해볼 수 있습니다.
상태는 디자인 문제로 다루면 더 쉬워집니다: 누가 소유하는지, 그것이 무엇을 나타내는지, 어떻게 변하는지 결정하세요. 컴포넌트가 “수수께끼 같다”고 느껴질 때 이 체크리스트를 사용하세요.
물어보세요: 앱의 어느 부분이 이 데이터를 책임지는가? 상태를 사용되는 곳 가까이에 두고 여러 부분이 진짜로 조정해야 할 때만 끌어올리세요.
다른 상태에서 계산할 수 있으면 저장하지 마세요.
items, filterText).visibleItems)은 렌더 시나 메모이제이션으로 계산.비동기 작업은 직접 모델링하면 더 명확합니다:
status: 'idle' | 'loading' | 'success' | 'error' + data와 error.isLoading, isFetching, isSaving, hasLoaded, …) 대신 하나의 상태(status).“이 값이 어떻게 이 상태가 되었나?” 버그가 줄고, 변경에 다섯 파일을 건드릴 필요가 없어지며, 진실이 어디에 있는지 가리킬 수 있는 정신 모델을 목표로 하세요: 여기가 진실이 존재하는 곳이다.