스킬
프론트엔드 패턴

프론트엔드 패턴

다운로드 후 ~/.claude/skills/ 폴더에 복사하여 사용하세요

React, Next.js, 그리고 성능 좋은 사용자 인터페이스를 위한 현대적인 프론트엔드 패턴입니다.

컴포넌트 패턴

상속보다 합성

// 좋음: 컴포넌트 합성
interface CardProps {
  children: React.ReactNode
  variant?: 'default' | 'outlined'
}
 
export function Card({ children, variant = 'default' }: CardProps) {
  return <div className={`card card-${variant}`}>{children}</div>
}
 
export function CardHeader({ children }: { children: React.ReactNode }) {
  return <div className="card-header">{children}</div>
}
 
export function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="card-body">{children}</div>
}
 
// 사용 예
<Card>
  <CardHeader>제목</CardHeader>
  <CardBody>내용</CardBody>
</Card>

복합 컴포넌트

interface TabsContextValue {
  activeTab: string
  setActiveTab: (tab: string) => void
}
 
const TabsContext = createContext<TabsContextValue | undefined>(undefined)
 
export function Tabs({ children, defaultTab }: {
  children: React.ReactNode
  defaultTab: string
}) {
  const [activeTab, setActiveTab] = useState(defaultTab)
 
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      {children}
    </TabsContext.Provider>
  )
}
 
export function Tab({ id, children }: { id: string, children: React.ReactNode }) {
  const context = useContext(TabsContext)
  if (!context) throw new Error('Tab은 Tabs 내에서 사용해야 합니다')
 
  return (
    <button
      className={context.activeTab === id ? 'active' : ''}
      onClick={() => context.setActiveTab(id)}
    >
      {children}
    </button>
  )
}

Render Props 패턴

interface DataLoaderProps<T> {
  url: string
  children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode
}
 
export function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)
 
  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false))
  }, [url])
 
  return <>{children(data, loading, error)}</>
}
 
// 사용 예
<DataLoader<Market[]> url="/api/markets">
  {(markets, loading, error) => {
    if (loading) return <Spinner />
    if (error) return <Error error={error} />
    return <MarketList markets={markets!} />
  }}
</DataLoader>

커스텀 훅 패턴

상태 관리 훅

export function useToggle(initialValue = false): [boolean, () => void] {
  const [value, setValue] = useState(initialValue)
 
  const toggle = useCallback(() => {
    setValue(v => !v)
  }, [])
 
  return [value, toggle]
}
 
// 사용 예
const [isOpen, toggleOpen] = useToggle()

비동기 데이터 페칭 훅

interface UseQueryOptions<T> {
  onSuccess?: (data: T) => void
  onError?: (error: Error) => void
  enabled?: boolean
}
 
export function useQuery<T>(
  key: string,
  fetcher: () => Promise<T>,
  options?: UseQueryOptions<T>
) {
  const [data, setData] = useState<T | null>(null)
  const [error, setError] = useState<Error | null>(null)
  const [loading, setLoading] = useState(false)
 
  const refetch = useCallback(async () => {
    setLoading(true)
    setError(null)
 
    try {
      const result = await fetcher()
      setData(result)
      options?.onSuccess?.(result)
    } catch (err) {
      const error = err as Error
      setError(error)
      options?.onError?.(error)
    } finally {
      setLoading(false)
    }
  }, [fetcher, options])
 
  useEffect(() => {
    if (options?.enabled !== false) {
      refetch()
    }
  }, [key, refetch, options?.enabled])
 
  return { data, error, loading, refetch }
}

디바운스 훅

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)
 
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)
 
    return () => clearTimeout(handler)
  }, [value, delay])
 
  return debouncedValue
}
 
// 사용 예
const [searchQuery, setSearchQuery] = useState('')
const debouncedQuery = useDebounce(searchQuery, 500)
 
useEffect(() => {
  if (debouncedQuery) {
    performSearch(debouncedQuery)
  }
}, [debouncedQuery])

상태 관리 패턴

Context + Reducer 패턴

interface State {
  markets: Market[]
  selectedMarket: Market | null
  loading: boolean
}
 
type Action =
  | { type: 'SET_MARKETS'; payload: Market[] }
  | { type: 'SELECT_MARKET'; payload: Market }
  | { type: 'SET_LOADING'; payload: boolean }
 
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'SET_MARKETS':
      return { ...state, markets: action.payload }
    case 'SELECT_MARKET':
      return { ...state, selectedMarket: action.payload }
    case 'SET_LOADING':
      return { ...state, loading: action.payload }
    default:
      return state
  }
}
 
const MarketContext = createContext<{
  state: State
  dispatch: Dispatch<Action>
} | undefined>(undefined)
 
export function MarketProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(reducer, {
    markets: [],
    selectedMarket: null,
    loading: false
  })
 
  return (
    <MarketContext.Provider value={{ state, dispatch }}>
      {children}
    </MarketContext.Provider>
  )
}

성능 최적화

메모이제이션

// useMemo로 비용이 많이 드는 계산 메모이제이션
const sortedMarkets = useMemo(() => {
  return markets.sort((a, b) => b.volume - a.volume)
}, [markets])
 
// useCallback으로 자식에게 전달되는 함수 메모이제이션
const handleSearch = useCallback((query: string) => {
  setSearchQuery(query)
}, [])
 
// React.memo로 순수 컴포넌트 메모이제이션
export const MarketCard = React.memo<MarketCardProps>(({ market }) => {
  return (
    <div className="market-card">
      <h3>{market.name}</h3>
      <p>{market.description}</p>
    </div>
  )
})

코드 스플리팅 & 지연 로딩

import { lazy, Suspense } from 'react'
 
// 무거운 컴포넌트 지연 로딩
const HeavyChart = lazy(() => import('./HeavyChart'))
const ThreeJsBackground = lazy(() => import('./ThreeJsBackground'))
 
export function Dashboard() {
  return (
    <div>
      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart data={data} />
      </Suspense>
 
      <Suspense fallback={null}>
        <ThreeJsBackground />
      </Suspense>
    </div>
  )
}

긴 목록 가상화

import { useVirtualizer } from '@tanstack/react-virtual'
 
export function VirtualMarketList({ markets }: { markets: Market[] }) {
  const parentRef = useRef<HTMLDivElement>(null)
 
  const virtualizer = useVirtualizer({
    count: markets.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,  // 예상 행 높이
    overscan: 5  // 추가로 렌더링할 항목 수
  })
 
  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.index}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`
            }}
          >
            <MarketCard market={markets[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

폼 처리 패턴

검증이 포함된 제어 컴포넌트 폼

interface FormData {
  name: string
  description: string
  endDate: string
}
 
interface FormErrors {
  name?: string
  description?: string
  endDate?: string
}
 
export function CreateMarketForm() {
  const [formData, setFormData] = useState<FormData>({
    name: '',
    description: '',
    endDate: ''
  })
 
  const [errors, setErrors] = useState<FormErrors>({})
 
  const validate = (): boolean => {
    const newErrors: FormErrors = {}
 
    if (!formData.name.trim()) {
      newErrors.name = '이름은 필수입니다'
    }
 
    if (!formData.description.trim()) {
      newErrors.description = '설명은 필수입니다'
    }
 
    setErrors(newErrors)
    return Object.keys(newErrors).length === 0
  }
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
 
    if (!validate()) return
 
    try {
      await createMarket(formData)
    } catch (error) {
      // 오류 처리
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={formData.name}
        onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
      />
      {errors.name && <span className="error">{errors.name}</span>}
      <button type="submit">마켓 생성</button>
    </form>
  )
}

Error Boundary 패턴

interface ErrorBoundaryState {
  hasError: boolean
  error: Error | null
}
 
export class ErrorBoundary extends React.Component<
  { children: React.ReactNode },
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = {
    hasError: false,
    error: null
  }
 
  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error }
  }
 
  render() {
    if (this.state.hasError) {
      return (
        <div className="error-fallback">
          <h2>문제가 발생했습니다</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false })}>
            다시 시도
          </button>
        </div>
      )
    }
 
    return this.props.children
  }
}

접근성 패턴

키보드 내비게이션

export function Dropdown({ options, onSelect }: DropdownProps) {
  const [isOpen, setIsOpen] = useState(false)
  const [activeIndex, setActiveIndex] = useState(0)
 
  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setActiveIndex(i => Math.min(i + 1, options.length - 1))
        break
      case 'ArrowUp':
        e.preventDefault()
        setActiveIndex(i => Math.max(i - 1, 0))
        break
      case 'Enter':
        e.preventDefault()
        onSelect(options[activeIndex])
        setIsOpen(false)
        break
      case 'Escape':
        setIsOpen(false)
        break
    }
  }
 
  return (
    <div
      role="combobox"
      aria-expanded={isOpen}
      aria-haspopup="listbox"
      onKeyDown={handleKeyDown}
    >
      {/* 드롭다운 구현 */}
    </div>
  )
}

현대적인 프론트엔드 패턴은 유지보수 가능하고 성능 좋은 사용자 인터페이스를 가능하게 합니다. 프로젝트 복잡도에 맞는 패턴을 선택하세요.