Frontend Conventions (TS/React)¶
Umbra 의
apps/webSPA 코드 작성 규칙. 스택(Bun + Vite + React 19 + TanStack Router/Query + shadcn/ui + Tailwind + React Hook Form + Zod) 전제의 패턴과 파일 구조를 강제한다.
Why conventions matter¶
React 생태계는 같은 문제에 여러 해법이 공존한다 (상태 관리, 폼, 라우팅, 스타일). Umbra 는 MVP 팀 규모가 작아 한 방법만 고수해야 context switch 비용이 작다. 이 문서는 "어떤 상황에 어떤 도구" 를 지정한다.
Stack summary¶
| Purpose | Tool | Rationale (ADR) |
|---|---|---|
| Runtime | Bun | ADR-0008 |
| Build | Vite | ADR-0008 |
| UI framework | React 19 | ADR-0008 |
| Router | TanStack Router (code-based) | ADR-0028 |
| Data fetching | TanStack Query | ADR-0028 |
| Component lib | shadcn/ui | ADR-0029 |
| Styling | Tailwind CSS | ADR-0029 |
| Forms | React Hook Form | — |
| Validation | Zod | — |
| Global state | Zustand (sparingly) | — |
| Icons | Lucide React | ADR-0029 |
| API types | openapi-typescript → api.gen.ts | ADR-0030 |
File organization¶
Directory layout¶
apps/web/src/
├─ features/ # 비즈니스 기능별 그룹
│ ├─ billing/
│ │ ├─ components/
│ │ │ ├─ SubscriptionCard.tsx
│ │ │ ├─ PlanChangeForm.tsx
│ │ │ └─ CardRegisterWidget.tsx
│ │ ├─ hooks/
│ │ │ ├─ useSubscription.ts
│ │ │ ├─ useStartSubscription.ts
│ │ │ └─ useCancelSubscription.ts
│ │ ├─ schemas/ # Zod schemas
│ │ │ └─ plan-change.schema.ts
│ │ └─ routes.ts # TanStack Router 등록
│ ├─ restore/
│ ├─ snapshot/
│ ├─ audit/
│ └─ settings/
│
├─ components/ # shadcn/ui 전용
│ └─ ui/
│ ├─ button.tsx
│ ├─ dialog.tsx
│ └─ ...
│
├─ shared/ # 재사용 가능한 공통 코드
│ ├─ api/ # fetch wrapper, query client
│ │ ├─ client.ts
│ │ ├─ query-keys.ts
│ │ └─ types.ts # common response types
│ ├─ components/ # 도메인 독립 컴포넌트
│ │ ├─ PageLayout.tsx
│ │ ├─ ErrorBoundary.tsx
│ │ └─ Loading.tsx
│ ├─ hooks/
│ │ ├─ useDiscordAuth.ts
│ │ └─ useCopyToClipboard.ts
│ ├─ lib/
│ │ ├─ format.ts # 날짜, 금액 포매팅
│ │ └─ utils.ts # cn, ...
│ └─ types/
│ └─ api.gen.ts # generated
│
├─ routes/ # TanStack Router root
│ ├─ __root.tsx
│ ├─ index.tsx # 홈
│ ├─ login.tsx
│ ├─ g/
│ │ └─ $guildId/
│ │ ├─ index.tsx
│ │ ├─ subscription.tsx
│ │ ├─ snapshots.tsx
│ │ └─ restore.tsx
│ └─ settings.tsx
│
├─ styles/
│ ├─ globals.css
│ └─ tailwind.css
│
├─ assets/ # static (svg, images)
├─ main.tsx
├─ App.tsx
└─ router.ts
features/ vs components/¶
components/ui/— shadcn 컴포넌트 전용. 수동 추가 금지.bunx shadcn@latest add button로만.features/*/components/— 비즈니스 로직이 섞인 컴포넌트 (도메인 지식 포함)shared/components/— 도메인 독립 공통 (PageLayout, ErrorBoundary)
섞이면 리팩토링 비용이 커진다. 새 컴포넌트는 반드시 한 곳에만.
Naming¶
Files¶
- 컴포넌트:
PascalCase.tsx(SubscriptionCard.tsx) - Hook:
camelCase.ts(useSubscription.ts) - Schema:
kebab-case.schema.ts(plan-change.schema.ts) - Util:
kebab-case.ts(format-currency.ts) - Type:
kebab-case.types.ts또는 inline
Identifiers¶
- Component:
PascalCase - Hook:
useXxx(camelCase) - Constant:
UPPER_SNAKE_CASE - Zod schema:
xxxSchema(e.g.planChangeSchema) - Zod type:
XxxInput또는XxxValues(e.g.PlanChangeValues)
Zod → TS type¶
import { z } from "zod"
export const planChangeSchema = z.object({
newPlanCode: z.enum(["FREE", "PRO", "ENTERPRISE"]),
})
export type PlanChangeValues = z.infer<typeof planChangeSchema>
TypeScript¶
Config¶
tsconfig.json 엄격 모드:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": true
}
}
any 금지¶
정말 필요하면 unknown + type guard:
// ❌
const data: any = await response.json()
// ✓
const data: unknown = await response.json()
if (isSubscription(data)) { ... }
Exception: generated API types (제어 불가), 3rd party 타입 불완전 케이스 (주석 + TODO 동반).
Prefer types over interfaces¶
type 기본, interface 는 declaration merging 필요 시만:
// ✓
type Subscription = {
id: string
status: "active" | "past_due" | "canceled"
}
// interface 는 특별한 이유 있을 때만
API types¶
apps/web/src/shared/types/api.gen.ts 는 swag → openapi-typescript 생성. 수정 금지.
import type { paths, components } from "@/shared/types/api.gen"
type SubscriptionResponse = components["schemas"]["SubscriptionResponse"]
type GetSubscription = paths["/api/v1/subscriptions/{id}"]["get"]
변경 흐름:
- 백엔드 수정 → swag annotation 업데이트
make openapi실행 →api.gen.ts재생성- 같은 PR 에 generated file 포함
Components¶
Function components only¶
// ✓
export function SubscriptionCard({ subscription }: Props) {
return <div>...</div>
}
// ❌ class component
Props typing¶
type Props = {
subscription: Subscription
onCancel?: () => void
}
export function SubscriptionCard({ subscription, onCancel }: Props) {
...
}
React.FC사용 지양 (Props 추론 이슈, children 타입 혼란)Props타입은 파일 내부 에서만 쓰는 경우 export 하지 않음
Composition over props explosion¶
// ❌ 많은 props
<Dialog
title="..."
description="..."
confirmLabel="..."
cancelLabel="..."
onConfirm={...}
onCancel={...}
/>
// ✓ shadcn pattern
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>...</DialogTitle>
<DialogDescription>...</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={...}>취소</Button>
<Button onClick={...}>확인</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Keep components small¶
한 컴포넌트 = 한 책임. 200줄 넘으면 분리:
- 로직은 custom hook 으로 추출
- 복잡한 조건부 렌더링은 sub-component 로
cn helper¶
Tailwind class 병합:
import { cn } from "@/shared/lib/utils"
<div className={cn("rounded-md p-4", isActive && "bg-primary", className)} />
Data fetching¶
TanStack Query only¶
다른 데이터 패칭 라이브러리 (SWR 등) 금지. raw fetch + useEffect 도 금지.
Query keys¶
중앙화된 factory:
// shared/api/query-keys.ts
export const queryKeys = {
subscriptions: {
all: ["subscriptions"] as const,
byId: (id: string) => [...queryKeys.subscriptions.all, id] as const,
byGuild: (guildId: string) => [...queryKeys.subscriptions.all, "guild", guildId] as const,
},
snapshots: {
all: ["snapshots"] as const,
byGuild: (guildId: string) => [...queryKeys.snapshots.all, "guild", guildId] as const,
byId: (id: string) => [...queryKeys.snapshots.all, id] as const,
},
// ...
} as const
이유: 캐시 invalidation 시 key 를 한 곳에서 관리.
Hooks pattern¶
// features/billing/hooks/useSubscription.ts
export function useSubscription(id: string) {
return useQuery({
queryKey: queryKeys.subscriptions.byId(id),
queryFn: () => apiClient.get(`/subscriptions/${id}`),
enabled: !!id,
})
}
// features/billing/hooks/useCancelSubscription.ts
export function useCancelSubscription() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => apiClient.post(`/subscriptions/${id}/cancel`),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: queryKeys.subscriptions.byId(id) })
},
})
}
API client¶
shared/api/client.ts 에 fetch wrapper:
class ApiClient {
async get<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
})
if (!res.ok) throw new ApiError(res.status, await res.text())
return res.json()
}
async post<T, B = unknown>(path: string, body?: B): Promise<T> { ... }
// ...
}
- axios 등 의존성 지양 (native fetch 충분)
credentials: include로 쿠키 세션 포함- 에러는
ApiError로 통일
Forms¶
React Hook Form + Zod¶
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
const form = useForm<PlanChangeValues>({
resolver: zodResolver(planChangeSchema),
defaultValues: { newPlanCode: "PRO" },
})
const onSubmit = async (values: PlanChangeValues) => {
await changePlan(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="newPlanCode"
render={({ field }) => (
<FormItem>
<FormLabel>플랜</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger>...</SelectTrigger>
<SelectContent>
<SelectItem value="FREE">Free</SelectItem>
<SelectItem value="PRO">Pro</SelectItem>
<SelectItem value="ENTERPRISE">Enterprise</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">변경</Button>
</form>
</Form>
)
Schema = Source of truth¶
API 와 다른 값이 와도 Zod schema 로 강제 검증. API 응답도 runtime validate 권장 (특히 critical 데이터).
Routing¶
TanStack Router (code-based)¶
라우트는 파일 기반 자동 생성이 아닌 code-based (ADR-0028).
// router.ts
import { createRouter, createRoute, createRootRoute } from "@tanstack/react-router"
const rootRoute = createRootRoute({ component: RootLayout })
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: HomePage,
})
const guildRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/g/$guildId",
component: GuildLayout,
})
const subscriptionRoute = createRoute({
getParentRoute: () => guildRoute,
path: "/subscription",
component: SubscriptionPage,
})
const routeTree = rootRoute.addChildren([
indexRoute,
guildRoute.addChildren([subscriptionRoute, ...]),
])
export const router = createRouter({ routeTree })
Loader + prefetch¶
TanStack Query 와 연계:
const subscriptionRoute = createRoute({
path: "/subscription",
loader: ({ context, params }) =>
context.queryClient.ensureQueryData({
queryKey: queryKeys.subscriptions.byGuild(params.guildId),
queryFn: () => ...,
}),
component: SubscriptionPage,
})
페이지 전환 전에 데이터 로딩 → UX 매끄러움.
Nav¶
import { Link, useNavigate } from "@tanstack/react-router"
<Link to="/g/$guildId/subscription" params={{ guildId }}>구독</Link>
const navigate = useNavigate()
navigate({ to: "/g/$guildId/snapshots", params: { guildId } })
State management¶
Local state first¶
useState, useReducer 충분한 경우 Zustand 도입 금지.
Zustand (sparingly)¶
진짜 전역 state 만:
- Sidebar open/close
- 현재 선택된 guild ID (URL 파라미터 외 추가 컨텍스트)
- Toast queue
// shared/store/sidebar.ts
import { create } from "zustand"
type SidebarState = {
isOpen: boolean
toggle: () => void
}
export const useSidebar = create<SidebarState>((set) => ({
isOpen: false,
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
}))
Server state 는 Query¶
사용자 데이터, API 응답은 전부 TanStack Query 에서. Zustand 에 server data 저장 금지.
Styling¶
Tailwind first¶
- shadcn/ui 가 제공하는 CSS 변수 사용 (light/dark theme)
- 직접 CSS 파일 작성 지양 (globals.css 만 예외)
tailwind.config.ts에 브랜드 color palette 정의
Umbra brand palette¶
// tailwind.config.ts
theme: {
extend: {
colors: {
umbra: {
shadow: "hsl(var(--umbra-shadow))",
glow: "hsl(var(--umbra-glow))",
accent: "hsl(var(--umbra-accent))",
},
},
},
}
CSS variable 은 globals.css:
:root {
--umbra-shadow: 240 10% 10%;
--umbra-glow: 280 80% 60%;
--umbra-accent: 280 50% 50%;
}
.dark {
...
}
Class ordering¶
Prettier prettier-plugin-tailwindcss 로 자동 정렬. 수동 순서 고민 금지.
Avoid inline style¶
Exception: 동적 값 (차트 크기 등).
Icons¶
Lucide React 만:
import { Shield, RefreshCw, Settings } from "lucide-react"
<Shield className="h-5 w-5 text-umbra-accent" />
- heroicons, font-awesome 등 금지
- custom SVG 는
assets/에 저장 후 React component 로
Error handling¶
Error Boundary¶
shared/components/ErrorBoundary.tsx 를 root layout 에서 사용:
Query error¶
const { data, error, isLoading } = useSubscription(id)
if (isLoading) return <Loading />
if (error) return <ErrorMessage error={error} />
return <SubscriptionCard subscription={data} />
Mutation error¶
const mutation = useCancelSubscription()
<Button
onClick={() => mutation.mutate(id, {
onError: (err) => toast.error("해지 실패: " + err.message),
onSuccess: () => toast.success("해지 예약 완료"),
})}
>
해지
</Button>
i18n (future)¶
MVP 는 한국어 only. Phase 2 에서 i18n 도입.
대비:
- 하드코딩된 문자열을 한 파일에 모으기 (
shared/lib/copy.ts) → Phase 2 에서 대체 쉬움 enlocale 은 Phase 2
Accessibility¶
기본 원칙¶
- shadcn/ui 는 radix-ui 기반 → 기본 a11y 준수
- 이미지는
alt필수 - 버튼은
aria-label(icon-only 시) - focus trap (dialog) → shadcn 이 자동 처리
Color contrast¶
- WCAG AA 이상 (4.5:1)
- Brand 팔레트 검증 완료
Keyboard¶
- 모든 interactive element 는 Tab 접근
- Shortcut 은
useHotkeys(Phase 2 에서 도입 가능)
Testing¶
Unit¶
- Vitest
- React Testing Library
- Mock: MSW (API mock)
import { describe, it, expect } from "vitest"
import { render, screen } from "@testing-library/react"
import { SubscriptionCard } from "./SubscriptionCard"
describe("SubscriptionCard", () => {
it("displays plan code", () => {
render(<SubscriptionCard subscription={mockSub} />)
expect(screen.getByText("PRO")).toBeInTheDocument()
})
})
E2E¶
- Playwright (Phase 2 에서 본격 도입)
- MVP 는 수동 QA
Performance¶
Bundle size¶
bun run analyze로 번들 분석 주기적- 큰 의존성 도입 시 번들 영향 체크
- Dynamic import 로 route-level code splitting (TanStack Router 지원)
Image¶
- SVG 선호 (icons, illustrations)
- 대형 이미지는 Vercel 의 Image optimization
Re-renders¶
React.memo는 측정 후 필요 시에만useMemo/useCallback남발 금지
Linting & formatting¶
Biome¶
Biome 를 단일 lint + format 도구로 사용한다. ESLint + Prettier 조합은 사용하지 않는다 (ADR-0032).
- 설정 파일:
apps/web/biome.json - 실행:
bun --cwd apps/web run lint(내부적으로biome check) - Tailwind class 정렬: Biome 의
useSortedClassesrule 활성화 (Prettier plugin 불필요) - IDE 통합: Biome VSCode/JetBrains 확장 권장
Do / Don't¶
Do¶
- ✅ strict TypeScript, no
any - ✅ shadcn/ui 만
components/ui/ - ✅ TanStack Query for server state
- ✅ Zustand for true global client state only
- ✅ React Hook Form + Zod
- ✅ TanStack Router code-based
- ✅ Tailwind + CSS variables
- ✅ Lucide icons only
- ✅ Native fetch wrapper
Don't¶
- ❌ axios, SWR, Redux, MobX, Jotai
- ❌ any-typing, interface over type by default
- ❌ Styled-components, Emotion, CSS modules
- ❌ Inline styles
- ❌ Raw fetch + useEffect
- ❌ Class components
- ❌ Page state via localStorage (세션 기반)
See also¶
project-structure.mdapi-conventions.md— API contractbackend-conventions.md— API 생성../adr/0008-frontend-bun-vite-react.md../adr/0028-tanstack-router-query.md../adr/0029-shadcn-ui.md../adr/0030-openapi-type-sync.md