---
name: State Management & Data Fetching
description: 상태 관리 & 데이터 페칭 원칙. 쿼리 팩토리 중앙화, 파생 상태 render 계산, God Hook 금지, 콜백은 event handler.
type: coding-standard
category: state-data
---

> 출처: 팀 공통 원칙 문서 `principles/` 2026-07-03 발췌. 원칙 개정은 원본(팀 공통 문서) 먼저, 이 사본은 따라간다.

# 상태 관리 & 데이터 페칭

## P-011 쿼리 팩토리 중앙화

`useQuery({ queryKey, queryFn })`를 컴포넌트에 인라인으로 쓰지 않는다.
반드시 `entities/{domain}/queries.ts`에 `queryOptions` / `mutationOptions` 팩토리로 정의한다.

```typescript
// ❌ 인라인 — queryKey가 여러 곳에 흩어져 무효화가 어려움
const { data } = useQuery({
  queryKey: ['profile', id],
  queryFn: () => getProfile(id),
});

// ✅ 팩토리로 중앙화 — 사용처는 팩토리만 호출
const { data } = useQuery(profileQueries.detail(id));
```

## P-012 파생 상태는 render 중에 계산

`useEffect`로 상태를 동기화하는 것을 피한다. render 중에 파생 상태를 직접 계산한다.
effect 동기화는 불필요한 리렌더를 유발하고 타이밍 버그를 만든다.

```typescript
// ❌ effect로 동기화 — 렌더 1번 더 발생, 타이밍 이슈
const [isActive, setIsActive] = useState(false);
useEffect(() => {
  setIsActive(status === 'active');
}, [status]);

// ✅ render 중 직접 계산
const isActive = status === 'active';
```

## P-013 God Hook 금지

하나의 훅이 여러 관심사를 처리하지 않는다. 파라미터별, 관심사별로 분리한다.
God Hook은 관련 없는 상태 변경에도 모든 구독 컴포넌트가 리렌더된다.

```typescript
// ❌ God Hook — cardId만 쓰는 컴포넌트도 dateFrom 변경 시 리렌더
function usePageState() {
  const [query, setQuery] = useQueryParams({
    cardId: NumberParam,
    statementId: NumberParam,
    dateFrom: DateParam,
    dateTo: DateParam,
    statusList: ArrayParam,
  });
}

// ✅ 파라미터별 독립 Hook — 변경 영향 범위가 명확
function useCardIdQueryParam() {
  const [cardId, _setCardId] = useQueryParam('cardId', NumberParam);
  // React Compiler가 자동 메모이즈 → useCallback 불필요 (P-013.1)
  const setCardId = (id: number) => {
    _setCardId({ cardId: id }, 'replaceIn');
  };
  return [cardId ?? undefined, setCardId] as const;
}
```

## P-013.1 수동 메모이제이션 금지 — React Compiler [필수, 기계 강제]

이 프로젝트는 `babel-plugin-react-compiler`를 쓴다. **React Compiler가 컴포넌트·훅을 자동 메모이즈하므로 `useCallback`·`useMemo`·`memo`를 손으로 쓰지 않는다.** 수동 메모는 잡음이고 컴파일러 최적화와 충돌한다.

```typescript
// ❌ 수동 메모이제이션
const handle = useCallback(() => doThing(id), [id]);
const total = useMemo(() => items.reduce(sum, 0), [items]);
export default memo(MyComponent);

// ✅ 그냥 값·함수로 — 컴파일러가 알아서 안정화
const handle = () => doThing(id);
const total = items.reduce(sum, 0);
export default MyComponent;
```

- **기계 강제**: `.oxlintrc.json`의 `no-restricted-imports`가 `react`의 `useCallback`/`useMemo`/`memo` import를 차단한다. PostToolUse hook·code-smell·verify도 잡는다.
- 예외(측정된 실제 병목 등)는 거의 없다 — 필요하면 `// oxlint-disable-next-line -- <측정 근거>` 로 사유를 남긴다.

## P-014 콜백은 event handler에서 처리

interaction 로직은 effect가 아닌 event handler에서 처리한다.
effect는 외부 시스템 동기화에만 사용한다.

```typescript
// ❌ effect로 interaction 처리
useEffect(() => {
  if (isSubmitted) {
    sendAnalytics('form_submitted');
    router.push('/success');
  }
}, [isSubmitted]);

// ✅ event handler에서 직접 처리
const handleSubmit = async () => {
  await submitForm();
  sendAnalytics('form_submitted');
  router.push('/success');
};
```

## P-015 Zustand 읽기는 selector로, store getter(get())는 렌더에 쓰지 않는다

읽기는 selector(`useStore(s => s.x)`), 쓰기는 action(`set`)으로만 한다. store 상태는
read-only다. 이 규칙 자체가 OOP의 "필드 직접 노출 금지 + getter/setter 통로로만 접근"과
동치다 — selector가 곧 통제된 읽기 통로이고, 내부 상태 모양이 바뀌어도 selector만
고치면 된다.

store 안에 `get()`을 쓰는 getter 함수를 만들어 컴포넌트 렌더에서 호출하면 안 된다.
selector가 아니라 단순 함수 호출이라 **구독이 걸리지 않아** 값이 바뀌어도 리렌더되지
않는다(= 화면이 안 갱신되는 버그). store getter는 action 안이나 이벤트 핸들러처럼
"한 번 읽고 마는" 곳에서만 쓴다.

```typescript
// ❌ store 안 getter — 렌더에서 쓰면 구독 안 걸려 화면이 안 바뀜
const useTravelDateStore = create((set, get) => ({
  travelType: '',
  getTravelType: () => get().travelType, // 렌더용으로 쓰지 말 것
}));
const t = useTravelDateStore.getState().getTravelType(); // 리렌더 X

// ✅ 읽기는 selector — 그 값이 바뀔 때만 리렌더
const travelType = useTravelDateStore((s) => s.travelType);
```

파생값(유효성·포맷 등)이나 재사용되는 읽기는 store 밖 named selector 상수로 뺀다
(OOP getter의 함수형 버전 = 규칙을 한 곳에 모으는 SSOT). 단일 값을 한 곳에서만 읽으면
인라인 selector로 충분하고, 4개 이상을 한 번에 묶어 꺼낼 때만 `useShallow`를 쓴다.

```typescript
// ❌ 같은 파생 규칙이 화면마다 복붙됨
const isValid =
  Boolean(departureDate) && (travelType !== ROUND || Boolean(returnDate));

// ✅ named selector로 규칙을 store 옆에 모음 — 구독은 그대로 걸림
export const selectIsValidTrip = (s) =>
  Boolean(s.dates.departureDate) &&
  (s.travelType !== TRAVEL_TYPE.ROUND || Boolean(s.dates.returnDate));
const isValidTrip = useTravelDateStore(selectIsValidTrip);
```
