배경
NMS의 그룹·사업장 권한 화면은 두 회사 리스트(전체 ↔ 선택) 이동, 담당자 무한 스크롤, 회사가 빠질 때 함께 빠져야 하는 제외 사용자, 그리고 적용 버튼 직렬화까지 동시에 다뤄야 합니다. 기존에는 다수의 useState로 흩어져 있어 회사 제거 시 제외 사용자가 누락되는 버그가 반복적으로 발생했고, 각 화면에서 수동으로 누락 케이스를 가드하는 코드가 누적되고 있었습니다.
문제는 “버그가 자주 난다”가 아니라 “한 변이가 다른 변이에 의존하는데 호출부가 그걸 매번 기억해야 한다”는 구조였습니다.
접근
9개 액션의 판별 유니온 타입을 정의한 useReducer 기반 커스텀 훅으로 모든 상태 변이를 한 곳에 모았습니다. 변경이 없는 경우 동일 객체를 반환해 불필요한 렌더를 차단하고, 회사 제거 액션 안에 “현재 선택 회사도 함께 정리”하는 파생 효과를 같은 트랜잭션에 묶었습니다.
같은 변경이 두 곳에서 일어나야 한다면 한 액션에서 둘 다 처리합니다. 호출부가 잊을 수 있는 일은 호출부에 맡기지 않습니다.
별도 마스터 데이터 맵은 원본 · 문자열 · 숫자 세 키로 동시에 인덱싱해 백엔드 타입 드리프트(같은 ID가 string으로 오기도 number로 오기도 하는 상황)를 흡수했습니다. UI 컴포넌트마다 타입 검사를 두지 않고, 인덱싱 시점에 세 키를 모두 등록해두면 호출부는 항상 안전합니다.
캐시 tier는 회사 데이터(staleTime 5분 / gcTime 10분)와 마스터 데이터(staleTime 10분 / gcTime 30분)를 분리해 변경 빈도와 보존 가치에 맞췄습니다 — 자주 바뀌는 데이터와 거의 안 바뀌는 데이터를 같은 cache 정책으로 다루지 않도록.
결과
- 9개 액션 판별 유니온으로 상태 변경 진입점 통일.
- 무변경 액션은 동일 객체 반환 → 참조 동등성 유지 → 하위 컴포넌트 메모이제이션 안전.
- 회사 제거 시 종속 제외 사용자 정리를 같은 트랜잭션에서 처리.
- 마스터 데이터 ID는 세 키 동시 인덱싱 — 백엔드 타입 드리프트 흡수.
- 캐시 tier 2단 (5분/10분 vs 10분/30분) — 변경 빈도별 분리.
배운 점
복합 상태가 다수의 useState로 흩어져 있으면 한 변이가 다른 변이에 의존하는 케이스를 호출부에 맡기게 됩니다. 호출부가 매번 같은 가드를 작성해야 한다는 건 결국 변경 책임이 누락되는 자리가 생긴다는 뜻입니다. 판별 유니온 + reducer는 그런 케이스를 변이의 정의에 흡수하는 구조였습니다.
또 한 가지: 백엔드 타입 드리프트(string vs number id) 같은 우리가 통제할 수 없는 노이즈는 진입점에서 흡수하는 게 가장 깨끗했습니다. UI 컴포넌트마다 타입 검사를 두지 않고, 인덱싱 시점에 세 가지 키를 모두 등록해두면 호출부는 항상 안전합니다.