MAKE IT SIMPLE
프론트엔드가 복잡해질수록 먼저 무너지는 것: 상태 관리 본문
프론트엔드 프로젝트가 일정 규모를 넘기 시작하면, 기능보다 먼저 문제가 되는 것은 상태(state)다. UI 버그, 불필요한 리렌더링, 어디서 바뀌는지 알 수 없는 값들. 대부분 “상태를 어디서, 어떻게 관리할지”에 대한 기준 부재에서 시작된다
이 글에서는 실제로 자주 마주치는 문제 상황을 기준으로
어떤 상태를 로컬로 두고, 언제 전역으로 끌어올리며, Context와 Recoil은 어디서 갈라지는지, URLSearchParams 은 언제 사용하면 좋은지를 단계적으로 정리한다.
문제 상황 1: “분명 값은 바뀌었는데 UI가 안 바뀐다”
증상
- API 응답은 정상
- 콘솔 로그에는 최신 값
- 그런데 화면은 이전 상태 그대로
원인 분석
React는 상태 변화 → 리렌더링이라는 단순한 규칙 위에서 동작한다. 문제는 다음 중 하나다.
- 상태가 불변성을 깨고 직접 수정됨
- 상태가 **잘못된 위치(너무 상위 or 너무 전역)**에 있음
- 파생 가능한 값을 중복 상태로 관리하고 있음
특히 주니어 단계에서 가장 흔한 패턴은
“일단 전역 상태로 올려두자”
하지만 모든 상태를 전역으로 두는 순간, 데이터 흐름은 흐려지고 디버깅 비용은 급격히 증가한다
해결 1: 상태를 먼저 분류한다 (관리 도구를 고르기 전에)
상태 관리 도구 선택보다 중요한 것은 상태의 성격 분리다.
실무 기준 상태 분류
- 로컬 상태: 단일 컴포넌트 UI 제어
- 전역 상태: 여러 컴포넌트에서 동시에 필요
- 서버 상태: API 기반, 캐싱/동기화 대상
- URL 상태: 라우팅과 직접 연결
- 폼 상태: 입력·검증 중심
이 기준만 명확해져도 “왜 여기서 이 상태를 쓰는가”가 설명 가능해진다
문제 상황 2: props drilling이 지옥이 되기 시작한다
증상
- 상위 → 하위 → 손자 컴포넌트까지 props 전달
- 중간 컴포넌트는 값도 안 쓰는데 전달만 함
- 구조 변경 시 연쇄 수정 발생
1차 해결: Context API
Context는 설정 비용이 거의 없고, 작은 범위의 상태 공유에는 여전히 유효하다
Context API가 적합한 경우
- 테마, 인증 정보
- 변경 빈도 낮음
- Provider 범위가 명확함
한계
- Context value 변경 시 구독 컴포넌트 전부 리렌더링
- 상태 의존성이 커질수록 추적 난이도 상승
이 지점에서 프로젝트는 보통 다음 단계로 넘어간다.
문제 상황 3: 전역 상태는 필요한데 Context로는 버겁다
증상
- Context가 점점 비대해짐
- selector 같은 파생 계산이 필요해짐
- 렌더링 최적화가 어려워짐
해결: Recoil 도입 포인트
Recoil은 전역 상태를 atom 단위로 쪼개고, 필요한 컴포넌트만 정확히 구독하게 만든다
Recoil이 적합한 경우
- 상태 변경 빈도가 높다
- 파생 상태가 많다
- 성능 최적화가 중요하다
- props / Context 계층을 줄이고 싶다
특히 selector는
“이 값은 저 값으로부터 계산된다”
라는 의존성을 코드 레벨에서 고정시킨다는 점에서 강력하다
문제 상황 4: 새로고침하면 상태가 날아간다
증상
- 검색어를 입력하고 페이지를 이동했는데 새로고침 시 초기화됨
- 페이지네이션 후 뒤로 가기를 누르면 페이지 정보가 유지되지 않음
- URL을 공유했지만 동일한 검색 결과가 재현되지 않음
이 상황에서 흔히 보이는 구현은 다음과 같다.
const [searchValue, setSearchValue] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [sort, setSort] = useState<"recent" | "favorite">("recent");
문제는 이 상태들이 모두 “쿼리로 표현되는 상태”라는 점이다.
즉, 컴포넌트 내부 상태(useState)로 관리하는 순간부터 구조적으로 한계를 갖게 된다
원인: 쿼리 상태를 컴포넌트 메모리에 두고 있다
useState로 관리된 상태는 다음 특성을 가진다.
- 페이지 내에서만 유효
- 새로고침 시 초기화
- 브라우저 히스토리와 무관
- URL 공유 불가능
검색어, 페이지, 정렬 조건은 본질적으로
“현재 화면을 설명하는 상태”
이며, 이 상태는 브라우저 주소창(URL)에 존재해야 일관성이 유지된다
해결: 검색 상태를 URL Query Parameter로 끌어올린다
핵심 전환
- ❌ 검색 상태를 useState로 관리
- ✅ 검색 상태를 **URL query string을 단일 진실 소스(Single Source of Truth)**로 사용
예시:
/items?page=3&category=health&sort=recent
이 URL 하나로 다음이 모두 가능해진다.
- 새로고침 후 동일한 상태 복원
- 뒤로 가기 / 앞으로 가기 동작 보장
- URL 공유 시 동일한 결과 재현
실무 패턴: 관련된 쿼리는 “하나의 훅”으로 관리한다
중요한 포인트는 쿼리마다 개별 훅을 만들지 않고, 하나의 통합 훅으로 관리했다는 점이다
이유
- 검색어 변경 → 페이지는 보통 1로 초기화됨
- 정렬 변경 → 같은 검색어라도 결과 의미가 달라짐
- 쿼리 간 의존성이 명확하게 존재
따라서 “독립 상태”가 아니라 하나의 검색 상태 묶음으로 다루는 것이 맞다.
Next.js App Router 기준 예시 구조
export function useProductQuery() {
const router = useRouter();
const params = useSearchParams();
const query = useMemo(() => {
return {
page: Number(params.get("page")) || 1,
search: params.get("search") || "",
sort: (params.get("sort") as "recent" | "favorite") || "recent",
};
}, [params]);
const updateQuery = useCallback(
(updates) => {
const newParams = new URLSearchParams(params.toString());
Object.entries(updates).forEach(([key, value]) => {
if (!value) newParams.delete(key);
else newParams.set(key, String(value));
});
router.push(`?${newParams.toString()}`);
},
[params, router]
);
return {
...query,
setPage: (page: number) => updateQuery({ page }),
setSearch: (search: string) => updateQuery({ search, page: 1 }),
setSort: (sort) => updateQuery({ sort }),
};
}
이 구조의 핵심은
- 컴포넌트는 상태를 “소유”하지 않는다
- URL을 읽어 UI를 그릴 뿐이다
- 상태 변경 = URL 변경 = 네비게이션
이렇게 기준으로 두고 판단해보면 좋다.
전역 상태를 써야 할때
- 새로고침 시 초기화
- 검색처럼 페이지 종속적인 상태에 비해 범위가 넓음
- 라우팅과 상태가 분리됨
URL 상태를 써야 할때
- 새로고침 안전
- 뒤로 가기 / 앞으로 가기 자연스러움
- 공유·북마크 가능
- 서버 컴포넌트 / SSR과도 궁합이 좋음
다음 질문에 2개 이상 YES라면 URL 상태가 맞다.
- 이 상태는 특정 페이지를 설명하는가?
- 사용자가 뒤로 가기를 기대하는가?
- URL 공유 시 동일한 화면이 나와야 하는가?
- 새로고침 이후에도 의미가 유지돼야 하는가?
검색, 필터, 정렬, 페이지네이션은 거의 항상 여기에 해당한다.
'React' 카테고리의 다른 글
| React로 동적 피벗 테이블 만들기: 트리 모델링부터 rowSpan까지 (0) | 2026.05.30 |
|---|---|
| SSE 기반 LLM 응답 스트리밍 구현기 (1) | 2026.05.19 |
| EventBus에 대해서 알아야 할것들 (feat. 잘쓰면 넘나 좋다) (5) | 2026.01.19 |
| Error: Image Optimization using Next.js' default loader is not compatible with next export. (0) | 2022.02.10 |