콘텐츠로 이동

Frontend Conventions (TS/React)

Umbra 의 apps/web SPA 코드 작성 규칙. 스택(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"]

변경 흐름:

  1. 백엔드 수정 → swag annotation 업데이트
  2. make openapi 실행 → api.gen.ts 재생성
  3. 같은 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 매끄러움.

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

// ❌
<div style={{ marginTop: 8 }} />

// ✓
<div className="mt-2" />

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 에서 사용:

<ErrorBoundary fallback={<ErrorScreen />}>
  <Outlet />
</ErrorBoundary>

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 에서 대체 쉬움
  • en locale 은 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 의 useSortedClasses rule 활성화 (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.md
  • api-conventions.md — API contract
  • backend-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