React 핵심 패턴
React 애플리케이션의 복잡도는 대부분 ‘상태’에서 온다. 상태를 어디에 두고 어떻게 갱신하느냐가 구조를 결정한다. 상태의 종류를 구분하는 것에서 출발해, 렌더링 최적화와 재사용 가능한 컴포넌트 설계까지 실무 관점으로 정리한다.
클라이언트 상태 vs 서버 상태
상태 관리의 첫 단추는 ‘상태의 출처’를 구분하는 것이다. 흔히 하나의 상태 관리 도구로 모든 상태를 다루려다 복잡해지는데, 상태는 성격에 따라 크게 두 가지로 나뉜다.
클라이언트 상태
모달 열림 여부, 탭 선택, 폼 입력값, 다크 모드 등 브라우저 안에서만 존재하고 서버와 동기화할 필요가 없는 상태다. 동기적으로 즉시 갱신된다.
서버 상태
API로 가져온 데이터처럼 원본이 서버에 있는 상태다. 비동기로 받아오고, 캐싱과 재검증(refetch)이 필요하며, 여러 컴포넌트가 동일 데이터를 공유한다.
| 구분 | 클라이언트 상태 | 서버 상태 |
|---|---|---|
| 원본 위치 | 브라우저 메모리 | 원격 서버 (DB) |
| 갱신 방식 | 동기적, 즉시 | 비동기, 네트워크 경유 |
| 핵심 관심사 | UI 상태 일관성 | 캐싱 · 동기화 · 무효화 |
| 추천 도구 | Zustand, useState | TanStack Query, SWR |
핵심 원칙
서버 상태를 전역 스토어(Zustand/Redux)에 직접 복사해 두지 않는다. 서버 상태는 전용 데이터 페칭 라이브러리가 캐싱과 동기화를 담당하게 하고, 전역 스토어는 순수한 클라이언트 상태만 다루도록 역할을 분리하는 것이 유지보수의 핵심이다.
Zustand로 전역 상태 관리
Zustand는 보일러플레이트가 거의 없는 경량 전역 상태 라이브러리다. Context의 불필요한 리렌더링 문제를 셀렉터(selector) 기반 구독으로 해결한다.
import { create } from 'zustand'
interface UIState {
sidebarOpen: boolean
theme: 'light' | 'dark'
toggleSidebar: () => void
setTheme: (theme: 'light' | 'dark') => void
}
export const useUIStore = create<UIState>((set) => ({
sidebarOpen: false,
theme: 'light',
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setTheme: (theme) => set({ theme }),
}))컴포넌트에서는 필요한 값만 셀렉터로 구독한다. 이렇게 하면 구독하지 않은 다른 상태가 바뀌어도 해당 컴포넌트는 리렌더링되지 않는다.
// ✅ 좋은 예: 필요한 값만 선택 → theme 변경 시 리렌더 안 됨
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const toggleSidebar = useUIStore((s) => s.toggleSidebar)
// ❌ 나쁜 예: 스토어 전체 구독 → 무관한 상태 변경에도 리렌더
const { sidebarOpen, toggleSidebar } = useUIStore()persist 미들웨어
persist로 감싸면 상태가 localStorage에 자동 저장된다. 다크 모드, 최근 본 항목처럼 새로고침에도 유지돼야 하는 상태에 적합하다.
action 분리
상태를 바꾸는 로직(action)을 스토어 안에 함께 정의하면, 컴포넌트는 의도만 호출하고 변경 방법은 스토어가 캡슐화한다.
TanStack Query로 서버 상태 관리
서버 상태는 useEffect + useState 조합으로 직접 다루면 로딩/에러/캐시/중복 요청을 모두 수동으로 처리해야 한다. TanStack Query는 이 모든 것을 선언적으로 해결한다.
import { useQuery } from '@tanstack/react-query'
function useUser(userId: string) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
staleTime: 60 * 1000, // 1분간 fresh 상태 유지
})
}
// 컴포넌트에서
const { data, isLoading, isError } = useUser(userId)queryKey가 캐시의 주소다
queryKey가 같으면 동일 캐시를 공유한다. 여러 컴포넌트가 같은 키로 요청해도 네트워크 호출은 한 번만 발생하며, 결과는 모두에게 공유된다.
무효화(invalidation)
데이터를 변경(mutation)한 뒤 queryClient.invalidateQueries()로 관련 쿼리를 무효화하면, 화면이 항상 최신 서버 상태를 반영하도록 자동 재요청된다.
렌더링 최적화 전략
최적화는 항상 측정 이후에 한다. 무분별한 memo 남발은 오히려 메모리와 비교 비용을 늘린다. 아래는 ‘실제 병목이 확인됐을 때’ 쓰는 도구들이다.
| 도구 | 역할 | 적합한 상황 |
|---|---|---|
| React.memo | props가 동일하면 리렌더 생략 | 부모가 자주 리렌더되는 무거운 자식 |
| useMemo | 비싼 계산 결과 캐싱 | 큰 배열 정렬·필터 등 연산 비용이 큰 값 |
| useCallback | 함수 참조 안정화 | memo된 자식에 콜백을 props로 전달할 때 |
| Suspense / lazy | 코드 스플리팅과 비동기 경계 | 초기 번들이 큰 페이지·라우트 분할 |
흔한 함정
- 인라인 객체/함수 props: 부모가 렌더될 때마다 새 참조가 생겨
memo가 무력화된다. 객체는useMemo, 함수는useCallback으로 참조를 고정한다. - 거대한 Context: 자주 바뀌는 값과 거의 안 바뀌는 값을 한 Context에 담으면, 작은 변경에도 모든 소비자가 리렌더된다. Context를 분리하거나 셀렉터 기반 스토어를 쓴다.
- key로 index 사용: 리스트 순서가 바뀌면 잘못된 재사용으로 상태가 어긋난다. 안정적인 고유 id를 key로 사용한다.
컴포넌트 설계 원칙
재사용 가능한 컴포넌트의 핵심은 ‘관심사 분리’다. 데이터를 가져오고 가공하는 로직과, 그것을 화면에 그리는 로직을 나누면 테스트와 재사용이 쉬워진다.
Container / Presentational
데이터 페칭·상태 보유는 컨테이너가, 받은 props를 그리는 일은 프레젠테이셔널 컴포넌트가 맡는다. 프레젠테이셔널 컴포넌트는 순수해서 Storybook과 단위 테스트에 유리하다.
커스텀 훅으로 로직 추출
여러 컴포넌트가 같은 로직(데이터 페칭, 폼 검증 등)을 공유하면 커스텀 훅으로 뽑아낸다. UI와 로직이 분리돼 컴포넌트가 얇아진다.
훅 추출 기준
- 같은 상태/이펙트 로직이 2곳 이상에서 반복될 때
- 컴포넌트의
useEffect·useState가 많아져 JSX보다 로직이 더 길어질 때 - 로직 자체에 의미 있는 이름을 붙일 수 있을 때 (예: useDebounce)
결국 좋은 React 코드는 ‘상태를 올바른 곳에 두는 것’에서 시작한다.
상태의 출처를 구분하고, 측정한 뒤 최적화하고, 관심사를 분리하라.