---
name: Stack Conventions
description: 본 보일러플레이트의 기술 스택 선택 기준. shadcn/ui, Zustand, TanStack Query, React Hook Form+Zod, overlay-kit, React Router(Vite).
type: coding-standard
category: stack-conventions
---

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

# 기술 스택 선택 기준 (Stack Conventions)

> 적용 대상: 본 보일러플레이트. 디자인 시스템 없는 프로젝트에서 팀이 합의한 기본 스택이다.
> 각 항목은 "왜 이 스택인가"와 "무엇을 피해야 하는가"를 함께 명시한다.

## P-000 기술 스택 선택 기준

신규 프로젝트에서 아래 스택을 기본으로 사용한다.
스택을 교체하려면 팀 내 명시적 합의가 필요하다.

---

### UI 라이브러리: shadcn/ui

디자인 시스템이 없는 프로젝트의 기본값이다.
shadcn/ui는 컴포넌트 소스를 프로젝트 내부로 복사하는 방식이므로 완전한 커스터마이징이 가능하다.
Radix UI 기반 접근성(a11y)이 내장되어 있다.

```tsx
// ❌ Bad — 디자인 시스템 없이 MUI/Chakra 등 외부 패키지 의존
import { Button } from '@mui/material';

function BookingButton() {
  return <Button variant="contained">예매하기</Button>;
}

// ✅ Good — shadcn/ui 컴포넌트를 프로젝트 내 복사 후 사용
// shared/ui/button.tsx 가 프로젝트 내에 존재
import { Button } from '@/shared/ui/button';

function BookingButton() {
  return <Button variant="default">예매하기</Button>;
}
```

```tsx
// ❌ Bad — shadcn/ui 컴포넌트를 그대로 쓰지 않고 외부 div로 감싸 스타일 재정의
<div style={{ background: 'red' }}>
  <Button>예매하기</Button>
</div>;

// ✅ Good — className 또는 variants로 커스터마이징
import { cn } from '@/shared/lib/cn';

<Button className={cn('bg-red-500 hover:bg-red-600')}>예매하기</Button>;
```

---

### 상태 관리: Zustand

전역 클라이언트 상태(모달, 토스트, 인증 등)에만 사용한다.
서버에서 가져오는 데이터는 Zustand가 아닌 TanStack Query로 관리한다.

```tsx
// ❌ Bad — 서버 데이터를 Zustand에 저장
const useTrainStore = create<{ trains: Train[]; fetchTrains: () => void }>(
  (set) => ({
    trains: [],
    fetchTrains: async () => {
      const data = await api.getTrains();
      set({ trains: data });
    },
  })
);

// ✅ Good — 서버 데이터는 TanStack Query, UI 상태만 Zustand
const useAuthStore = create<{
  token: string | null;
  setToken: (t: string) => void;
}>((set) => ({
  token: null,
  setToken: (token) => set({ token }),
}));
```

```tsx
// ❌ Bad — 컴포넌트 로컬 상태로도 충분한 값을 Zustand에 올림
const useModalStore = create<{ isFilterOpen: boolean }>((set) => ({
  isFilterOpen: false,
  // 이 모달이 전역에서 열릴 일이 없다면 전역 상태 불필요
}));

// ✅ Good — 전역에서 열려야 하는 경우(토스트, 인증 만료 알림 등)만 Zustand 사용
const useToastStore = create<{
  message: string | null;
  show: (msg: string) => void;
  hide: () => void;
}>((set) => ({
  message: null,
  show: (message) => set({ message }),
  hide: () => set({ message: null }),
}));
```

---

### 데이터 페칭: TanStack Query

서버 상태 관리의 기본값이다.
`queryOptions` 팩토리 패턴으로 쿼리 키와 fetcher를 한 곳에서 정의한다. (P-039 참조)

```tsx
// ❌ Bad — useEffect + useState로 직접 fetch
function TrainList() {
  const [trains, setTrains] = useState<Train[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetchTrains().then((data) => {
      setTrains(data);
      setLoading(false);
    });
  }, []);

  if (loading) return <Spinner />;
  return (
    <ul>
      {trains.map((t) => (
        <li key={t.id}>{t.name}</li>
      ))}
    </ul>
  );
}

// ✅ Good — queryOptions 팩토리 + useSuspenseQuery
// queries/trainQueries.ts
export const trainQueryOptions = {
  list: () =>
    queryOptions({
      queryKey: ['trains'],
      queryFn: () => api.getTrains(),
    }),
};

// TrainList.tsx
function TrainList() {
  const { data: trains } = useSuspenseQuery(trainQueryOptions.list());
  return (
    <ul>
      {trains.map((t) => (
        <li key={t.id}>{t.name}</li>
      ))}
    </ul>
  );
}
```

```tsx
// ❌ Bad — 쿼리 키를 컴포넌트마다 하드코딩
useQuery({ queryKey: ['trains', 'list'], queryFn: fetchTrains });
// 다른 파일에서
useQuery({ queryKey: ['train', 'list'], queryFn: fetchTrains }); // 오타로 키 불일치

// ✅ Good — queryOptions 팩토리로 키 중앙화
export const trainQueryOptions = {
  list: () =>
    queryOptions({ queryKey: ['trains', 'list'], queryFn: fetchTrains }),
  detail: (id: string) =>
    queryOptions({ queryKey: ['trains', id], queryFn: () => fetchTrain(id) }),
};
```

---

### 폼: React Hook Form + Zod

폼 상태와 유효성 검사의 기본 조합이다. (P-042 참조)
Zod 스키마를 단일 진실 소스(single source of truth)로 삼아 타입과 유효성을 동시에 정의한다.

```tsx
// ❌ Bad — 폼 상태를 useState로 직접 관리하고 유효성 검사를 수동으로 작성
function BookingForm() {
  const [name, setName] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = () => {
    if (!name) {
      setError('이름을 입력해주세요');
      return;
    }
    submitBooking({ name });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      {error && <p>{error}</p>}
    </form>
  );
}

// ✅ Good — React Hook Form + Zod
const bookingSchema = z.object({
  name: z.string().min(1, '이름을 입력해주세요'),
  departureDate: z.string().min(1, '출발일을 선택해주세요'),
});

type BookingFormValues = z.infer<typeof bookingSchema>;

function BookingForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<BookingFormValues>({
    resolver: zodResolver(bookingSchema),
  });

  return (
    <form onSubmit={handleSubmit((data) => submitBooking(data))}>
      <input {...register('name')} />
      {errors.name && <p>{errors.name.message}</p>}
    </form>
  );
}
```

---

### 모달: overlay-kit

`useState`로 모달 열림/닫힘을 관리하지 않는다.
`overlay.open`으로 모달의 생애주기를 함수 호출 한 줄로 선언한다. (P-038 참조)

```tsx
// ❌ Bad — 모달 상태를 컴포넌트가 직접 관리
function SeatSelection() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <Button onClick={() => setIsOpen(true)}>좌석 선택</Button>
      <SeatMapModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
    </>
  );
}

// ✅ Good — overlay.open으로 생애주기 위임
import { overlay } from 'overlay-kit';

const openSeatMapModal = (trainId: string) => {
  overlay.open(({ unmount }) => (
    <SeatMapModal isOpen trainId={trainId} close={unmount} />
  ));
};

function SeatSelection({ trainId }: { trainId: string }) {
  return <Button onClick={() => openSeatMapModal(trainId)}>좌석 선택</Button>;
}
```

```tsx
// ❌ Bad — 모달 결과를 전역 상태로 전달
const useSeatStore = create<{ selectedSeat: Seat | null }>((set) => ({
  selectedSeat: null,
}));

// ✅ Good — overlay.openAsync로 Promise 반환
const selectedSeat = await overlay.openAsync<Seat>(({ unmount, close }) => (
  <SeatMapModal
    isOpen
    trainId={trainId}
    close={unmount}
    onSelect={(seat) => close(seat)}
  />
));
```

---

### 테이블: @tanstack/react-table

데이터 테이블은 **`@tanstack/react-table`(headless) + shadcn `Table` UI** 조합으로 만든다. 정렬·필터·페이지네이션·행 선택 로직을 손으로 구현하지 않는다.

```tsx
// ❌ Bad — 정렬·페이지네이션을 useState로 손수 관리
const [sorted, setSorted] = useState(rows.sort(...));

// ✅ Good — react-table 모델에 위임, UI는 shadcn Table
import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table';

const columns: ColumnDef<Post>[] = [
  { accessorKey: 'title', header: '제목' },
  { accessorKey: 'userId', header: '작성자' },
];

const table = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
});
// table.getHeaderGroups() / table.getRowModel().rows 를 shadcn <Table>에 렌더
```

- 컬럼 정의는 `ColumnDef<T>[]`로 선언형. 셀 커스텀은 `cell: ({ row }) => ...`.
- 서버 페이지네이션이면 `manualPagination`/`manualSorting` + TanStack Query 연동.

---

### 라우팅: React Router (Vite CSR)

본 보일러플레이트는 **Vite + React Router**다. 라우트는 `src/app/router.tsx`의 `createBrowserRouter`에 명시 등록하고, 페이지는 `src/pages/<route>/`에 둔다(공식 Vite FSD). 배치·의존 규칙은 `fsd-router` 스킬을 따른다. 서버 렌더링은 없다 — 전부 브라우저에서 실행된다.

```tsx
// ❌ Bad — 페이지 컴포넌트 마운트 후 useEffect로 데이터 페칭 (waterfall)
function TrainSearchPage() {
  const [trains, setTrains] = useState([]);
  useEffect(() => {
    fetchTrains().then(setTrains);
  }, []);
  return <TrainList trains={trains} />;
}

// ✅ Good — 상위에서 Suspense, 컴포넌트가 useSuspenseQuery (선언형 데이터 진입)
// pages/train-search/ui/TrainSearchPage.tsx
function TrainSearchPage() {
  const [searchParams] = useSearchParams(); // react-router
  return (
    <Suspense fallback={<TrainListLoading />}>
      <SuspenseQuery {...trainQueries.search(searchParams.get('date') ?? '')}>
        {({ data: trains }) => <TrainList trains={trains} />}
      </SuspenseQuery>
    </Suspense>
  );
}
```
