Communitygithub.com

프론트엔드 개발 패턴 툴킷

React, Next.js, 상태 관리, 성능 최적화 및 UI 모범 사례를 위한 프론트엔드 개발 패턴입니다. React 또는 Next.js 컴포넌트, 상태 또는 렌더링 성능을 구축하거나 검토할 때 사용하세요.

프론트엔드 개발 패턴 툴킷란 무엇인가요?

이 스킬은 React, Next.js 및 현대적인 프론트엔드 애플리케이션 개발을 위한 포괄적인 패턴 라이브러리입니다. 컴포넌트 구성(Component Composition), 복합 컴포넌트(Compound Components), 렌더 프롭스 패턴(Render Props)과 같은 고급 React 패턴을 제시하며, useReducer와 Context를 활용한 상태 관리, SWR/React Query와 같은 데이터 페칭 전략을 포함합니다. 성능 최적화를 위해 메모이제이션(React.memo, useMemo, useCallback), 코드 분할(React.lazy + Suspense), 가상화(@tanstack/react-virtual) 기법을 다루고, 폼 처리(유효성 검사, Zod), 에러 바운더리, 애니메이션(Framer Motion), 접근성(키보드 네비게이션, ARIA)까지 광범위한 주제를 아우릅니다. 각 패턴은 실제 예제 코드와 함께 설명되어 있어, 개발자가 즉시 프로젝트에 적용하거나 코드 리뷰 시 참고할 수 있습니다. Claude Code, Cursor, Codex를 포함한 모든 주요 AI 에이전트 플랫폼과 호환됩니다.

지원 대상~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/junksidetm/darkside-toolkit/tree/HEAD/skills/frontend-patterns

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

프론트엔드 개발 패턴

React, Next.js 및 성능이 뛰어난 사용자 인터페이스를 위한 최신 프론트엔드 패턴입니다.

활성화 시점

  • React 컴포넌트 구축 (컴포지션, props, 렌더링)
  • 상태 관리 (useState, useReducer, Zustand, Context)
  • 데이터 페칭 구현 (SWR, React Query, 서버 컴포넌트)
  • 성능 최적화 (메모이제이션, 가상화, 코드 분할)
  • 폼 작업 (유효성 검사, 제어 입력, Zod 스키마)
  • 클라이언트 사이드 라우팅 및 네비게이션 처리
  • 접근 가능하고 반응형인 UI 패턴 구축

컴포넌트 패턴

상속보다 컴포지션

// GOOD: 컴포넌트 컴포지션
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 TabList({ children }: { children: React.ReactNode }) {
  return <div className="tab-list">{children}</div>
}

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>
  )
}

// 사용법
<Tabs defaultTab="overview">
  <TabList>
    <Tab id="overview">개요</Tab>
    <Tab id="details">상세</Tab>
  </TabList>
</Tabs>

렌더 프롭스 패턴

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)

  // 호출자가 인라인 함수와 객체 리터럴을 전달하더라도 refetch가 참조적으로
  // 안정적으로 유지되도록 최신 fetcher/options를 ref에 보관합니다.
  // 이것 없이는 모든 렌더링에서 새로운 refetch가 생성되고, 아래의 effect가
  // 각 상태 업데이트 후 다시 실행되어 무한 페칭 루프가 발생합니다.
  const fetcherRef = useRef(fetcher)
  const optionsRef = useRef(options)
  useEffect(() => {
    fetcherRef.current = fetcher
    optionsRef.current = options
  })

  const refetch = useCallback(async () => {
    setLoading(true)
    setError(null)

    try {
      const result = await fetcherRef.current()
      setData(result)
      optionsRef.current?.onSuccess?.(result)
    } catch (err) {
      const error = err as Error
      setError(error)
      optionsRef.current?.onError?.(error)
    } finally {
      setLoading(false)
    }
  }, [])

  const enabled = options?.enabled !== false

  useEffect(() => {
    if (enabled) {
      refetch()
    }
  }, [key, enabled, refetch])

  return { data, error, loading, refetch }
}

// 사용법
const { data: markets, loading, error, refetch } = useQuery(
  'markets',
  () => fetch('/api/markets').then(r => r.json()),
  {
    onSuccess: data => console.log('가져옴', data.length, '개의 마켓'),
    onError: err => console.error('실패:', err)
  }
)

디바운스 훅

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>
  )
}

export function useMarkets() {
  const context = useContext(MarketContext)
  if (!context) throw new Error('useMarkets는 MarketProvider 내에서 사용되어야 합니다')
  return context
}

성능 최적화

메모이제이션

// GOOD: 값비싼 연산에 useMemo 사용
// 정렬 전 복사 - Array.prototype.sort는 원본을 변경합니다
const sortedMarkets = useMemo(() => {
  return [...markets].sort((a, b) => b.volume - a.volume)
}, [markets])

// GOOD: 자식 컴포넌트에 전달되는 함수에 useCallback 사용
const handleSearch = useCallback((query: string) => {
  setSearchQuery(query)
}, [])

// GOOD: 순수 컴포넌트에 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'

// GOOD: 무거운 컴포넌트를 지연 로드
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 = '이름은 필수 입력 사항입니다'
    } else if (formData.name.length > 200) {
      newErrors.name = '이름은 200자 이하여야 합니다'
    }

    if (!formData.description.trim()) {
      newErrors.description = '설명은 필수 입력 사항입니다'
    }

    if (!formData.endDate) {
      newErrors.endDate = '종료일은 필수 입력 사항입니다'
    }

    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 }))}
        placeholder="마켓 이름"
      />
      {errors.name && <span className="error">{errors.name}</span>}

      {/* 기타 필드 */}

      <button type="submit">마켓 생성</button>
    </form>
  )
}

에러 바운더리 패턴

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 }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('에러 바운더리에서 포착:', error, errorInfo)
  }

  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
  }
}

// 사용법
<ErrorBoundary>
  <App />
</ErrorBoundary>

애니메이션 패턴

Framer Motion 애니메이션

import { motion, AnimatePresence } from 'framer-motion'

// GOOD: 목록 애니메이션
export function AnimatedMarketList({ markets }: { markets: Market[] }) {
  return (
    <AnimatePresence>
      {markets.map(market => (
        <motion.div
          key={market.id}
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -20 }}
          transition={{ duration: 0.3 }}
        >
          <MarketCard market={market} />
        </motion.div>
      ))}
    </AnimatePresence>
  )
}

// GOOD: 모달 애니메이션
export function Modal({ isOpen, onClose, children }: ModalProps) {
  return (
    <AnimatePresence>
      {isOpen && (
        <>
          <motion.div
            className="modal-overlay"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            onClick={onClose}
          />
          <motion.div
            className="modal-content"
            initial={{ opacity: 0, scale: 0.9, y: 20 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.9, y: 20 }}
          >
            {children}
          </motion.div>
        </>
      )}
    </AnimatePresence>
  )
}

접근성 패턴

키보드 네비게이션

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':
        e.preventDefault()
        setIsOpen(false)
        break
    }
  }

  return (
    <div onKeyDown={handleKeyDown} role="combobox" aria-expanded={isOpen}>
      <button onClick={() => setIsOpen(!isOpen)}>
        {options[activeIndex]?.label || '선택하세요'}
      </button>
      {isOpen && (
        <ul role="listbox">
          {options.map((option, index) => (
            <li
              key={option.value}
              role="option"
              aria-selected={index === activeIndex}
              className={index === activeIndex ? 'active' : ''}
              onClick={() => {
                onSelect(option)
                setIsOpen(false)
              }}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

ARIA 라이브 리전

export function LiveRegion({ message, type }: {
  message: string
  type: 'polite' | 'assertive'
}) {
  return (
    <div
      role="status"
      aria-live={type}
      aria-atomic="true"
      className="sr-only"
    >
      {message}
    </div>
  )
}

테스팅 패턴

React Testing Library

import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

// GOOD: 사용자 중심 테스트
test('사용자가 마켓 이름을 입력할 수 있다', async () => {
  const user = userEvent.setup()
  
  render(<CreateMarketForm />)
  
  const input = screen.getByPlaceholderText('마켓 이름')
  await user.type(input, '새로운 마켓')
  
  expect(input).toHaveValue('새로운 마켓')
})

test('유효성 검사 오류가 표시된다', async () => {
  const user = userEvent.setup()
  
  render(<CreateMarketForm />)
  
  await user.click(screen.getByText('마켓 생성'))
  
  expect(screen.getByText('이름은 필수 입력 사항입니다')).toBeInTheDocument()
})

Individual skills in this repo

This repo contains 3 individual skills — each has its own dedicated page.

관련 스킬