---
name: Declarative Code
description: 선언적 코드 원칙. overlay-kit, TanStack Query, useSuspenseQuery, Suspense+ErrorBoundary, React Hook Form+Zod. 사내 프로덕션 앱의 실코드 예시 포함.
type: coding-standard
category: declarative
---

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

# 선언적 코드 (Declarative Code)

> 실코드에서 뽑은 예시다 (사내 프로덕션 앱).
> 명령형(어떻게 할지)이 아닌 선언형(무엇을 할지)으로 작성해
> 로딩·에러·상태 분기를 컴포넌트 밖으로 밀어낸다.

## P-006.1 불변 우선 — let/var 금지, 복잡 로직은 es-toolkit [필수, 기계 강제]

**`let`·`var`를 쓰지 않는다. `const`만.** 최신 JS/불변 스타일로, 재할당 대신 값 변환으로 표현한다. 반복·누적·그룹핑 같은 복잡한 로직은 손으로 `let` 루프를 짜지 말고 **`es-toolkit`**(이미 의존성)의 함수형 유틸로 선언형으로 쓴다.

```typescript
// ❌ 명령형 — let 누적 루프
let total = 0;
for (const item of items) total += item.price;

let grouped = {};
for (const u of users) {
  (grouped[u.team] ??= []).push(u);
}

// ✅ 선언형 — const + es-toolkit
import { sumBy, groupBy } from 'es-toolkit';
const total = sumBy(items, (i) => i.price);
const grouped = groupBy(users, (u) => u.team);
```

- **기계 강제**: `no-var`·`prefer-const`(oxlint 네이티브) + `let`/`var` 하드밴(code-smell.sh, PostToolUse hook). warn도 실패.
- 자주 쓰는 es-toolkit: `map/filter/reduce`, `sumBy/maxBy/minBy`, `groupBy/keyBy/countBy`, `uniq/uniqBy`, `sortBy/orderBy`, `chunk/partition`, `pick/omit`, `pipe/flow`, `debounce/throttle`.
- 정말 불가피한 재할당(예: 외부 라이브러리 imperative API)은 `// oxlint-disable-next-line -- <사유>` 로 사유를 남긴다.

## P-038 overlay-kit — 모달 생애주기를 함수 호출로 선언

`useState`로 모달 open/close를 관리하지 않는다.
`overlay.open`으로 모달의 생애주기를 호출 한 줄로 선언한다.

### P-038.1 Public API는 컴포넌트가 아니라 `openXxxDialog()` 함수다 [필수]

다이얼로그/모달 슬라이스가 밖으로 내보내는 것은 **트리거 함수**이지 컴포넌트가 아니다.

- 다이얼로그 컴포넌트(`XxxDialog`)는 **slice 내부에 감춘다** (`index.ts`로 export하지 않는다).
- `overlay.open(...)`을 감싼 **`openXxxDialog(args)` 함수를 다이얼로그와 같은 파일/슬라이스에 co-locate**하고, 그것만 public API로 export한다.
- 소비 측은 `openXxxDialog(...)`만 호출한다 — `isOpen`/`close` 상태 관리도, `overlay.open` 호출도 소비 측에 두지 않는다(인라인 정의 금지).
- **확인 창(예/아니오)은 이 패턴으로 새로 만들지 않는다** — 이 템플릿엔 이미 있다: `shared/ui`의 `openConfirmDialog`. 아래 예제는 폼·상세처럼 내용이 있는 다이얼로그를 만들 때의 모양이다 (code-smell #28).

```tsx
// ❌ 소비 측이 overlay.open을 직접 다루고, 컴포넌트를 import
import { DeletePostDialog } from './DeletePostDialog';
function openDeleteDialog(id: number) {
  // ← 소비 측에 인라인 정의 (금지)
  overlay.open(({ unmount }) => (
    <DeletePostDialog postId={id} close={unmount} isOpen />
  ));
}
<Button onClick={() => openDeleteDialog(post.id)}>삭제</Button>;

// ✅ 다이얼로그 파일이 open 함수를 export, 컴포넌트는 감춤
// delete-post-dialog.tsx
function DeletePostDialog({ postId, close }: Props) {
  /* ... Dialog ... */
} // 내부 전용
export function openDeletePostDialog(postId: number, postTitle: string) {
  overlay.open(({ unmount }) => (
    <DeletePostDialog
      postId={postId}
      postTitle={postTitle}
      isOpen
      close={unmount}
    />
  ));
}
// 소비 측 — 함수만 가져다 호출
import { openDeletePostDialog } from '@/.../delete-post-dialog';
<Button onClick={() => openDeletePostDialog(post.id, post.title)}>삭제</Button>;
```

결과값이 필요하면 `openXxxDialog`가 `overlay.openAsync`를 감싸 `Promise`를 반환한다(아래 참조).

```tsx
// ❌ 명령형 — 열기/닫기 상태를 컴포넌트가 직접 관리
const [isOpen, setIsOpen] = useState(false);
<Button onClick={() => setIsOpen(true)}>수정</Button>
<EditModal isOpen={isOpen} onClose={() => setIsOpen(false)} />

// ✅ 선언형 — overlay.open으로 생애주기 위임
export const openContractEditModal = ({ accountId, contract }) => {
  overlay.open(({ unmount }) => (
    <ContractEditModal
      isOpen
      close={unmount}
      accountId={accountId}
      contract={contract}
    />
  ));
};
```

모달 결과값이 필요하면 `overlay.openAsync`로 Promise로 받는다:

```tsx
const result = await overlay.openAsync(({ unmount, close }) => (
  <OfferModal
    isOpen
    close={unmount}
    onSubmit={async (values) => {
      try {
        const response = await mutateAsync(values);
        close(Promise.resolve(response)); // 성공 시 결과 반환
      } catch (error) {
        close(Promise.reject(error)); // 실패 시 에러 반환
      }
    }}
  />
));
```

---

## P-039 TanStack Query — queryOptions 팩토리로 쿼리 중앙 선언

queryKey·queryFn을 컴포넌트마다 인라인으로 쓰지 않는다.
`queryOptions` 팩토리로 한 곳에 선언하고 spread로 재사용한다.
mutation 성공 후 어떤 캐시를 무효화할지도 `onSuccess`에 선언한다.

```ts
export const statisticsQueries = {
  offersCount: (params?: GetOffersCountParams) =>
    queryOptions({
      queryKey: ['stats', 'job-offers', { ...params }],
      queryFn: () => getOffersCount(params),
    }),
  myProjectStatistics: () =>
    queryOptions({
      queryKey: ['stats', 'projects', 'my'],
      queryFn: getMyProjectStatistics,
    }),
};

export const offerMutations = {
  offer: () =>
    mutationOptions({
      mutationFn: (request) => offer(request.projectId, request.offerRequest),
      onSuccess: () => {
        // 성공 후 무효화할 캐시를 선언적으로 나열
        invalidateEachQueries([
          ['projects', 'inReview'],
          ['projects', 'inContact'],
          ['profile', 'detail'],
        ]);
      },
    }),
};
```

복수 쿼리 무효화는 `invalidateEachQueries` 유틸로 선언적으로 나열한다:

```ts
// 실물: src/shared/queryCache/index.ts (이 템플릿에 구현돼 있다 — import해서 쓴다)
export const invalidateEachQueries = (keys: unknown[][]) => {
  return queryClient.invalidateQueries({
    predicate: (query) =>
      keys.some((keyParts) =>
        keyParts.every((keyPart, index) => query.queryKey[index] === keyPart)
      ),
    type: 'all',
  });
};
```

---

## P-040 useSuspenseQuery — 로딩 상태 분기를 Suspense에 위임

`isLoading` 분기를 컴포넌트 내부에 쓰지 않는다.
`useSuspenseQuery`를 사용하면 데이터가 항상 존재하는 것으로 가정하고 렌더링 로직만 선언한다.
로딩·에러 처리는 상위 Suspense/ErrorBoundary가 담당한다.

```tsx
// ❌ 명령형 — isLoading 분기가 컴포넌트 안에 존재
const { data, isLoading } = useQuery(projectQueries.recommendedProfilesList(...));
if (isLoading) return <Spinner />;
return <ProfileList profiles={data} />;

// ✅ 선언형 — 데이터가 항상 있다고 선언, 로딩은 상위 Suspense가 처리
const { data: profiles } = useSuspenseQuery({
  ...projectQueries.recommendedProfilesList({
    projectId,
    params: controller.getObjectResult(),
  }),
  select: (data) => data.data,
});
return <ProfileList profiles={profiles} />;
```

---

## P-041 Suspense + ErrorBoundary — 로딩·에러 UI를 경계로 선언

로딩 UI와 에러 UI를 컴포넌트 내부가 아닌 상위 경계(boundary)에서 선언한다.
`AsyncSuspense` 공용 컴포넌트로 Suspense fallback과 ErrorBoundary fallback을 한 곳에 나란히 선언한다.

```tsx
<AsyncSuspense
  key={JSON.stringify(controller.getObjectResult())} // 필터 변경 시 경계 재설정
  fallback={<ProfileListSkeleton />} // 로딩 UI
  errorElement={
    // 에러 UI
    <EmptyResult
      title="오류가 발생했습니다"
      description="잠시 후 다시 시도해주세요"
    />
  }
>
  <ProfileListResult /> {/* 이 안에서는 isLoading/isError 분기 없음 */}
</AsyncSuspense>
```

앱 최상위에서는 `QueryErrorResetBoundary`와 조합해 재시도까지 선언한다:

```tsx
<QueryErrorResetBoundary>
  {({ reset }) => (
    <ErrorBoundary
      onReset={reset} // 재시도 시 React Query 에러 상태도 함께 리셋
      fallback={(props) => <ErrorScreen onRetry={props.reset} />}
    >
      {children}
    </ErrorBoundary>
  )}
</QueryErrorResetBoundary>
```

---

## P-042 React Hook Form + Zod — 폼 상태·검증·타입을 스키마 하나로 선언

타입 정의, 유효성 검사 규칙, 폼 상태 관리를 각각 분리하지 않는다.
Zod 스키마 하나로 선언하고 `zodResolver`로 연결하면 세 가지가 자동으로 처리된다.

```ts
import { z } from 'zod/v4';

export const accountFormSchema = z.object({
  company: z.object({
    id: z.number({
      error: (issue) =>
        issue.input === undefined
          ? '고객사를 선택해주세요'
          : '유효하지 않은 고객사입니다',
    }),
    name: z.string().min(1, { error: '고객사명을 입력해주세요' }),
  }),
  leadSource: z.enum(['BD', 'INBOUND']),
  tagIds: z.array(z.number()),
});

export type AccountFormValues = z.infer<typeof accountFormSchema>; // 타입 자동 추론
```

```tsx
const form = useForm<AccountFormValues>({
  mode: 'onChange',
  resolver: zodResolver(accountFormSchema), // 스키마가 곧 검증 규칙
});

const handleCreate = form.handleSubmit(async (values) => {
  // 이 안에 오면 이미 유효한 데이터 — 타입도 보장됨
  await mutateAsync({ companyId: values.company.id, ... });
});
```

필드 간 의존 검증은 `.refine()`으로 스키마에 선언해 컴포넌트에 if문이 침투하지 않게 한다:

```ts
export const validationSchema = z
  .object({
    nationalCode: z.string(),
    phone: z.string().min(1, { error: '휴대폰 번호를 입력하세요' }),
  })
  .refine(
    ({ phone, nationalCode }) => isValidPhoneNumber(`${nationalCode}${phone}`),
    { error: '휴대폰 번호를 확인 후 다시 입력하세요', path: ['phone'] }
  );
```

서브스키마는 변수로 추출해 레고 블록처럼 조합한다:

```ts
const salarySchema = z
  .string()
  .refine((v) => /^\d+$/.test(v), { error: '숫자만 입력하세요' })
  .refine((v) => parseInt(v) <= SALARY_LIMIT, {
    error: '최대 연봉을 초과했습니다',
  });

const profileSchema = z.object({
  salary: salarySchema, // 서브스키마 재사용
  phone: phoneSchema,
});
```
