상태 관리 관점에서 이해하는 React

React를 처음 배울 때 컴포넌트, JSX, props 같은 개념에 집중하다 보면, React의 본질을 놓치기 쉽습니다. React의 핵심은 “상태가 바뀌면 UI가 자동으로 업데이트된다”는 아주 간단한 아이디어입니다.

이 글에서는 상태 관리 관점에서 React를 처음부터 다시 이해해봅니다. “왜 React가 이런 방식으로 설계되었을까?”, “상태는 어떻게 UI와 연결될까?”, “상태가 복잡해지면 어떻게 관리할까?” 같은 질문들에 답하면서, React의 철학을 깊이 이해하게 될 것입니다.

먼저, 기초부터 이해하기

React를 이해하려면 먼저 “상태란 무엇인가”부터 생각해봐야 합니다.

상태(State)란 무엇인가?

상태는 시간에 따라 변할 수 있는 데이터입니다. 웹 애플리케이션에서 사용자가 입력한 텍스트, 토글 버튼의 켜짐/꺼짐, API에서 받아온 사용자 목록 등이 모두 상태입니다.

// 상태의 예시들
const isLoggedIn = true;                    // 로그인 상태
const username = "김철수";                  // 사용자 이름
const cartItems = [{ id: 1, name: "" }]; // 장바구니 아이템
const isLoading = false;                    // 로딩 상태

React 이전의 세계

React가 나오기 전에는 상태가 바뀌면 개발자가 직접 DOM을 조작해야 했습니다. 예를 들어, 좋아요 버튼을 만든다면 이런 코드를 작성했을 겁니다.

// ❌ jQuery 시절의 코드
let likeCount = 0;

$('#like-button').click(function() {
  likeCount++;
  $('#like-count').text(likeCount);  // DOM을 직접 업데이트

  if (likeCount > 0) {
    $('#like-button').addClass('liked');  // 스타일 변경
  }
});

문제는 애플리케이션이 복잡해질수록 이런 명령형 코드가 관리하기 어려워진다는 점입니다. 상태가 여러 곳에서 변경되고, 그때마다 DOM을 직접 업데이트해야 하니까요.

React의 선언적 접근

React는 이 문제를 “상태가 변하면 UI를 자동으로 다시 그린다”는 선언적 방식으로 해결했습니다.

// ✅ React 방식
function LikeButton() {
  const [likeCount, setLikeCount] = useState(0);

  // UI는 상태에 따라 자동으로 결정됨
  return (
    <button
      className={likeCount > 0 ? 'liked' : ''}
      onClick={() => setLikeCount(likeCount + 1)}
    >
      좋아요 {likeCount}
    </button>
  );
}

이 코드에서 중요한 점은 DOM을 직접 조작하지 않는다는 것입니다. likeCount라는 상태만 변경하면, React가 알아서 UI를 업데이트합니다.

이것이 React의 핵심 철학입니다. “UI는 상태의 함수다” (UI = f(state))

상태의 종류와 범위

상태는 어디에 두느냐에 따라 성격이 달라집니다. React에서는 상태를 크게 네 가지로 나눌 수 있습니다.

1. 로컬 상태 (Local State)

하나의 컴포넌트 안에서만 사용되는 상태입니다. 다른 컴포넌트에는 영향을 주지 않습니다.

function SearchBox() {
  // 이 상태는 SearchBox 컴포넌트 안에서만 사용됨
  const [query, setQuery] = useState('');

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="검색어를 입력하세요"
    />
  );
}

언제 사용할까?

  • 폼 입력값
  • 모달의 열림/닫힘 상태
  • 탭의 활성 상태
  • 컴포넌트 내부의 임시 데이터

2. 공유 상태 (Shared State)

여러 컴포넌트가 함께 사용하는 상태입니다. 상태를 공통 부모로 올려서(lifting state up) 관리합니다.

function ParentComponent() {
  // 두 자식 컴포넌트가 공유하는 상태
  const [selectedCategory, setSelectedCategory] = useState('all');

  return (
    <div>
      <CategoryFilter
        selected={selectedCategory}
        onSelect={setSelectedCategory}
      />
      <ProductList
        category={selectedCategory}
      />
    </div>
  );
}

언제 사용할까?

  • 부모-자식 간에 공유되는 데이터
  • 형제 컴포넌트 간 통신이 필요한 경우
  • 관련된 컴포넌트들이 가까이 있을 때

3. 전역 상태 (Global State)

애플리케이션 전체에서 접근 가능한 상태입니다. Context API나 Redux 같은 상태 관리 라이브러리를 사용합니다.

// Context API를 사용한 전역 상태
const UserContext = createContext();

function App() {
  const [user, setUser] = useState(null);

  return (
    <UserContext.Provider value=>
      <Header />
      <MainContent />
      <Footer />
    </UserContext.Provider>
  );
}

// 깊이 중첩된 컴포넌트에서도 접근 가능
function UserProfile() {
  const { user } = useContext(UserContext);
  return <div>{user?.name}</div>;
}

언제 사용할까?

  • 사용자 인증 정보
  • 테마 설정 (다크모드/라이트모드)
  • 언어 설정
  • 애플리케이션 전체에서 필요한 설정값

4. 서버 상태 (Server State)

서버에서 받아온 데이터를 다루는 상태입니다. React Query나 SWR 같은 라이브러리를 사용하면 효과적으로 관리할 수 있습니다.

// React Query를 사용한 서버 상태 관리
function UserList() {
  const { data: users, isLoading, error } = useQuery('users', fetchUsers);

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

서버 상태의 특징:

  • 비동기적으로 로드됨
  • 캐싱과 동기화가 필요함
  • 로딩, 성공, 에러 상태를 함께 관리해야 함
  • 여러 컴포넌트에서 같은 데이터를 공유할 수 있음

상태는 어떻게 UI를 업데이트할까?

React의 작동 원리를 이해하면 상태 관리를 더 효과적으로 할 수 있습니다.

렌더링 사이클

React에서 상태가 변경되면 다음 과정을 거칩니다.

1. 상태 변경 (setState 호출)
     ↓
2. 리렌더링 예약 (React가 스케줄링)
     ↓
3. 컴포넌트 함수 재실행
     ↓
4. Virtual DOM 생성
     ↓
5. 이전 Virtual DOM과 비교 (Reconciliation)
     ↓
6. 변경된 부분만 실제 DOM에 반영 (Commit)

실제 예시로 보면 이해하기 쉽습니다.

function Counter() {
  const [count, setCount] = useState(0);

  console.log('Counter 컴포넌트 렌더링:', count);

  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        증가
      </button>
    </div>
  );
}

// 버튼을 클릭할 때마다.
// 1. setCount가 호출됨
// 2. React가 컴포넌트를 다시 실행
// 3. 콘솔에 "Counter 컴포넌트 렌더링: 1" 출력
// 4. 변경된 텍스트만 DOM에 반영

배치 업데이트 (Batching)

React는 성능을 위해 여러 상태 업데이트를 묶어서 한 번에 처리합니다.

function MultipleUpdates() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  console.log('렌더링됨');

  function handleClick() {
    setCount(count + 1);  // 첫 번째 상태 변경
    setFlag(!flag);       // 두 번째 상태 변경
    // React는 이 두 변경을 묶어서 한 번만 렌더링함
  }

  return (
    <div>
      <p>카운트: {count}</p>
      <p>플래그: {flag ? '켜짐' : '꺼짐'}</p>
      <button onClick={handleClick}>업데이트</button>
    </div>
  );
}

// 버튼 클릭 시 "렌더링됨"이 한 번만 출력됨

React 18부터는 비동기 함수 안에서도 자동으로 배치 처리됩니다.

// React 18 이후
async function handleClick() {
  await fetch('/api/data');
  setCount(count + 1);
  setFlag(!flag);
  // 이것도 배치 처리됨!
}

불변성(Immutability)의 중요성

React는 상태가 변경되었는지 확인할 때 참조(reference)를 비교합니다. 그래서 상태 객체를 직접 수정하면 안 되고, 새로운 객체를 만들어야 합니다.

// ❌ 잘못된 방법: 직접 수정
function TodoList() {
  const [todos, setTodos] = useState([]);

  function addTodo(text) {
    todos.push({ id: Date.now(), text });  // 이렇게 하면 안 됨!
    setTodos(todos);  // React가 변경을 감지하지 못함
  }
}

// ✅ 올바른 방법: 새 배열 생성
function TodoList() {
  const [todos, setTodos] = useState([]);

  function addTodo(text) {
    setTodos([...todos, { id: Date.now(), text }]);  // 새 배열 생성
  }

  function removeTodo(id) {
    setTodos(todos.filter(todo => todo.id !== id));  // 필터링된 새 배열
  }

  function updateTodo(id, newText) {
    setTodos(todos.map(todo =>
      todo.id === id
        ? { ...todo, text: newText }  // 새 객체 생성
        : todo
    ));
  }
}

왜 불변성이 중요할까?

  1. 변경 감지: React가 상태 변경을 정확하게 감지할 수 있음
  2. 성능 최적화: 참조만 비교하면 되므로 빠름
  3. 예측 가능성: 이전 상태가 보존되어 디버깅이 쉬움
  4. 시간 여행 디버깅: Redux DevTools 같은 도구 사용 가능

상태 관리 패턴과 전략

애플리케이션이 커질수록 상태 관리가 복잡해집니다. 여기서는 실전에서 자주 사용하는 패턴들을 알아봅니다.

1. 상태 끌어올리기 (Lifting State Up)

여러 컴포넌트가 같은 데이터를 공유해야 할 때 사용하는 가장 기본적인 패턴입니다.

// ❌ 각 컴포넌트가 독립적인 상태를 가짐
function SearchBox() {
  const [query, setQuery] = useState('');
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

function SearchResults() {
  const [query, setQuery] = useState('');  // 중복된 상태!
  const results = searchProducts(query);
  return <div>{results.map(...)}</div>;
}

// ✅ 상태를 부모로 올림
function SearchPage() {
  const [query, setQuery] = useState('');  // 공통 부모에서 관리

  return (
    <div>
      <SearchBox value={query} onChange={setQuery} />
      <SearchResults query={query} />
    </div>
  );
}

function SearchBox({ value, onChange }) {
  return (
    <input
      value={value}
      onChange={e => onChange(e.target.value)}
    />
  );
}

function SearchResults({ query }) {
  const results = searchProducts(query);
  return <div>{results.map(...)}</div>;
}

2. 상태 그룹화하기

관련된 상태들은 하나로 묶어서 관리하면 편리합니다.

// ❌ 개별 상태로 관리
function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  // 상태 업데이트가 번거로움
  async function handleSubmit() {
    setIsLoading(true);
    setError(null);
    try {
      await login(email, password);
    } catch (err) {
      setError(err.message);
    } finally {
      setIsLoading(false);
    }
  }
}

// ✅ 관련 상태를 객체로 그룹화
function LoginForm() {
  const [formData, setFormData] = useState({
    email: '',
    password: ''
  });

  const [submitState, setSubmitState] = useState({
    isLoading: false,
    error: null
  });

  function updateFormData(field, value) {
    setFormData(prev => ({ ...prev, [field]: value }));
  }

  async function handleSubmit() {
    setSubmitState({ isLoading: true, error: null });

    try {
      await login(formData.email, formData.password);
    } catch (err) {
      setSubmitState({ isLoading: false, error: err.message });
    }
  }
}

3. useReducer로 복잡한 상태 관리하기

상태 업데이트 로직이 복잡하거나 여러 상태가 연관되어 있을 때는 useReducer를 사용합니다.

// ✅ useReducer로 장바구니 관리
const cartReducer = (state, action) => {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload],
        totalPrice: state.totalPrice + action.payload.price
      };

    case 'REMOVE_ITEM':
      const item = state.items.find(i => i.id === action.payload);
      return {
        ...state,
        items: state.items.filter(i => i.id !== action.payload),
        totalPrice: state.totalPrice - item.price
      };

    case 'CLEAR_CART':
      return {
        items: [],
        totalPrice: 0
      };

    default:
      return state;
  }
};

function ShoppingCart() {
  const [cart, dispatch] = useReducer(cartReducer, {
    items: [],
    totalPrice: 0
  });

  return (
    <div>
      <p> 금액: {cart.totalPrice}</p>
      <button onClick={() => dispatch({ type: 'CLEAR_CART' })}>
        장바구니 비우기
      </button>
    </div>
  );
}

useReducer를 사용하면 좋은 경우:

  • 여러 상태가 함께 업데이트되어야 할 때
  • 상태 업데이트 로직이 복잡할 때
  • 상태 변경 로직을 컴포넌트 밖으로 분리하고 싶을 때
  • 상태 업데이트를 테스트하고 싶을 때

4. Context API로 깊은 컴포넌트에 상태 전달하기

props drilling(여러 단계를 거쳐 props를 전달하는 것)을 피하고 싶을 때 Context를 사용합니다.

// ❌ Props Drilling
function App() {
  const [theme, setTheme] = useState('light');
  return <Layout theme={theme} setTheme={setTheme} />;
}

function Layout({ theme, setTheme }) {
  return <Sidebar theme={theme} setTheme={setTheme} />;
}

function Sidebar({ theme, setTheme }) {
  return <ThemeToggle theme={theme} setTheme={setTheme} />;
}

function ThemeToggle({ theme, setTheme }) {
  return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
    테마 변경
  </button>;
}

// ✅ Context API 사용
const ThemeContext = createContext();

function App() {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value=>
      <Layout />
    </ThemeContext.Provider>
  );
}

function Layout() {
  return <Sidebar />;  // props 전달 불필요
}

function Sidebar() {
  return <ThemeToggle />;  // props 전달 불필요
}

function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);  // 직접 접근

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      테마 변경
    </button>
  );
}

Context 사용 시 주의사항: Context value가 변경되면 해당 Context를 사용하는 모든 컴포넌트가 리렌더링됩니다. 성능 문제를 피하려면 Context를 분리하세요.

// ❌ 하나의 Context에 모든 것을 넣음
const AppContext = createContext();

function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [language, setLanguage] = useState('ko');

  // theme만 변경해도 user나 language를 사용하는 컴포넌트도 리렌더링됨
  return (
    <AppContext.Provider value=>
      <Layout />
    </AppContext.Provider>
  );
}

// ✅ Context를 목적별로 분리
const UserContext = createContext();
const ThemeContext = createContext();
const LanguageContext = createContext();

function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [language, setLanguage] = useState('ko');

  return (
    <UserContext.Provider value=>
      <ThemeContext.Provider value=>
        <LanguageContext.Provider value=>
          <Layout />
        </LanguageContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

상태 관리 라이브러리는 언제 필요할까?

React만으로도 상태 관리를 충분히 할 수 있습니다. 하지만 애플리케이션이 커지면 상태 관리 라이브러리가 도움이 됩니다.

라이브러리가 필요한 시점

다음 중 하나라도 해당된다면 라이브러리 도입을 고려해보세요:

  1. Context 중첩이 심해질 때
    // Context Hell
    <UserContext.Provider>
      <ThemeContext.Provider>
        <LanguageContext.Provider>
          <NotificationContext.Provider>
            <FeatureFlagContext.Provider>
              <App />
            </FeatureFlagContext.Provider>
          </NotificationContext.Provider>
        </LanguageContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
    
  2. 동일한 데이터를 여러 곳에서 사용할 때
    • 사용자 정보, 설정값 등을 많은 컴포넌트에서 접근
    • 데이터 캐싱과 동기화가 필요
  3. 복잡한 상태 업데이트 로직
    • 여러 액션이 복잡하게 얽혀 있음
    • 상태 변경 이력을 추적해야 함
  4. 성능 최적화가 중요할 때
    • 불필요한 리렌더링을 세밀하게 제어해야 함
    • 선택적 구독(selective subscription)이 필요

주요 상태 관리 라이브러리

1. Zustand (추천: 간단하고 가벼움)

import { create } from 'zustand';

// 스토어 생성
const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));

// 컴포넌트에서 사용
function Counter() {
  const { count, increment, decrement } = useStore();

  return (
    <div>
      <p>{count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}

Zustand의 장점:

  • 보일러플레이트가 적음
  • Provider가 필요 없음
  • TypeScript 지원이 우수함
  • 작은 번들 크기

2. Redux Toolkit (추천: 대규모 애플리케이션)

import { configureStore, createSlice } from '@reduxjs/toolkit';

// Slice 생성
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => {
      state.value += 1;  // Immer 덕분에 직접 수정 가능
    },
    decrement: (state) => {
      state.value -= 1;
    },
  },
});

// 스토어 생성
const store = configureStore({
  reducer: {
    counter: counterSlice.reducer,
  },
});

// 컴포넌트에서 사용
function Counter() {
  const count = useSelector((state) => state.counter.value);
  const dispatch = useDispatch();

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => dispatch(counterSlice.actions.increment())}>+</button>
    </div>
  );
}

Redux Toolkit의 장점:

  • 검증된 생태계 (미들웨어, DevTools 등)
  • 대규모 팀 프로젝트에 적합
  • 명확한 데이터 흐름
  • 시간 여행 디버깅

3. React Query / TanStack Query (추천: 서버 상태)

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// 데이터 가져오기
function UserList() {
  const { data: users, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

// 데이터 수정하기
function AddUser() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: addUser,
    onSuccess: () => {
      // 캐시 무효화하여 자동으로 다시 가져오기
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });

  return (
    <button onClick={() => mutation.mutate({ name: '새 사용자' })}>
      사용자 추가
    </button>
  );
}

React Query의 장점:

  • 서버 상태 관리에 특화됨
  • 자동 캐싱, 리패칭, 동기화
  • 로딩/에러 상태 자동 관리
  • 낙관적 업데이트(Optimistic Updates) 지원

어떤 라이브러리를 선택할까?

프로젝트 규모와 요구사항에 따라 선택하세요:

상황 추천 솔루션
소규모 프로젝트, 간단한 전역 상태 Context API + useState
중규모 프로젝트, 깔끔한 전역 상태 Zustand
대규모 프로젝트, 복잡한 비즈니스 로직 Redux Toolkit
서버 데이터 중심 애플리케이션 React Query + Zustand/Context
실시간 협업 애플리케이션 Recoil 또는 Jotai

함정과 주의사항

상태 관리에서 자주 하는 실수들과 해결 방법을 알아봅니다.

1. 과도한 상태 만들기

모든 것을 상태로 만들 필요는 없습니다. 계산으로 구할 수 있다면 상태가 아닌 변수로 충분합니다.

// ❌ 불필요한 상태
function ProductList({ products }) {
  const [filteredProducts, setFilteredProducts] = useState([]);
  const [category, setCategory] = useState('all');

  // products나 category가 변경될 때마다 수동으로 업데이트해야 함
  useEffect(() => {
    if (category === 'all') {
      setFilteredProducts(products);
    } else {
      setFilteredProducts(products.filter(p => p.category === category));
    }
  }, [products, category]);

  return <div>{filteredProducts.map(...)}</div>;
}

// ✅ 계산된 값 사용
function ProductList({ products }) {
  const [category, setCategory] = useState('all');

  // 렌더링할 때마다 계산 (충분히 빠름)
  const filteredProducts = category === 'all'
    ? products
    : products.filter(p => p.category === category);

  return <div>{filteredProducts.map(product => ...)}</div>;
}

2. 상태 업데이트 타이밍 오해

상태 업데이트는 비동기적입니다. 즉시 반영되지 않습니다.

// ❌ 잘못된 이해
function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    console.log(count);  // 여전히 이전 값이 출력됨!
    setCount(count + 1);  // count는 여전히 0이므로 0 + 1 = 1
  }

  // 버튼을 클릭해도 count는 1만 증가함
  return <button onClick={handleClick}>{count}</button>;
}

// ✅ 함수형 업데이트 사용
function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(prev => prev + 1);  // 이전 값을 받아서 업데이트
    setCount(prev => prev + 1);  // 이것도 이전 값 기준으로 업데이트
  }

  // 버튼을 클릭하면 count가 2씩 증가함
  return <button onClick={handleClick}>{count}</button>;
}

3. 객체 상태의 부분 업데이트

객체 상태를 업데이트할 때는 전체 객체를 새로 만들어야 합니다.

// ❌ 일부만 업데이트하면 나머지가 사라짐
function UserProfile() {
  const [user, setUser] = useState({
    name: '김철수',
    email: 'kim@example.com',
    age: 30
  });

  function updateEmail(newEmail) {
    setUser({ email: newEmail });  // name과 age가 사라짐!
  }
}

// ✅ 스프레드 연산자로 기존 값 유지
function UserProfile() {
  const [user, setUser] = useState({
    name: '김철수',
    email: 'kim@example.com',
    age: 30
  });

  function updateEmail(newEmail) {
    setUser({ ...user, email: newEmail });  // 기존 값 유지하면서 email만 변경
  }

  // 또는 함수형 업데이트
  function updateAge(newAge) {
    setUser(prev => ({ ...prev, age: newAge }));
  }
}

4. useEffect 의존성 배열 누락

상태를 사용하는 effect는 반드시 의존성 배열에 포함해야 합니다.

// ❌ 의존성 배열 누락
function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetchResults(query).then(setResults);
  }, []);  // query가 변경되어도 다시 실행되지 않음!
}

// ✅ 의존성 명시
function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    fetchResults(query).then(setResults);
  }, [query]);  // query가 변경될 때마다 실행
}

5. Context를 너무 많이 사용하기

Context는 편리하지만 성능 문제를 일으킬 수 있습니다.

// ❌ 모든 상태를 Context로
const GlobalContext = createContext();

function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [count, setCount] = useState(0);
  const [items, setItems] = useState([]);

  // value가 변경될 때마다 모든 Consumer가 리렌더링됨
  return (
    <GlobalContext.Provider value=>
      <Layout />
    </GlobalContext.Provider>
  );
}

// ✅ 필요한 상태만 Context로, 나머지는 로컬 상태로
const UserContext = createContext();
const ThemeContext = createContext();

function App() {
  const [user, setUser] = useState(null);      // 전역 필요
  const [theme, setTheme] = useState('light'); // 전역 필요

  return (
    <UserContext.Provider value=>
      <ThemeContext.Provider value=>
        <Layout />
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

function Layout() {
  const [count, setCount] = useState(0);   // 로컬 상태로 충분
  const [items, setItems] = useState([]);  // 로컬 상태로 충분

  return <div>...</div>;
}

실전 예제: 할 일 관리 앱

지금까지 배운 내용을 종합하여 간단한 할 일 관리 앱을 만들어봅니다.

요구사항 분석

먼저 어떤 상태가 필요한지 파악합니다.

  • ✅ 할 일 목록 (배열)
  • ✅ 필터 (전체/완료/미완료)
  • ✅ 입력 중인 텍스트

구현 코드

import { useState, useReducer } from 'react';

// 할 일 관리 리듀서
function todosReducer(state, action) {
  switch (action.type) {
    case 'ADD':
      return [
        ...state,
        { id: Date.now(), text: action.text, completed: false }
      ];

    case 'TOGGLE':
      return state.map(todo =>
        todo.id === action.id
          ? { ...todo, completed: !todo.completed }
          : todo
      );

    case 'DELETE':
      return state.filter(todo => todo.id !== action.id);

    case 'EDIT':
      return state.map(todo =>
        todo.id === action.id
          ? { ...todo, text: action.text }
          : todo
      );

    default:
      return state;
  }
}

function TodoApp() {
  // 상태 정의
  const [todos, dispatch] = useReducer(todosReducer, []);
  const [filter, setFilter] = useState('all');
  const [inputText, setInputText] = useState('');

  // 할 일 추가
  function handleAddTodo(e) {
    e.preventDefault();
    if (inputText.trim()) {
      dispatch({ type: 'ADD', text: inputText });
      setInputText('');
    }
  }

  // 필터링된 할 일 계산
  const filteredTodos = todos.filter(todo => {
    if (filter === 'completed') return todo.completed;
    if (filter === 'active') return !todo.completed;
    return true;
  });

  // 통계 계산
  const stats = {
    total: todos.length,
    completed: todos.filter(t => t.completed).length,
    active: todos.filter(t => !t.completed).length,
  };

  return (
    <div className="todo-app">
      <h1>  관리</h1>

      {/* 입력 폼 */}
      <form onSubmit={handleAddTodo}>
        <input
          type="text"
          value={inputText}
          onChange={(e) => setInputText(e.target.value)}
          placeholder="할 일을 입력하세요"
        />
        <button type="submit">추가</button>
      </form>

      {/* 통계 */}
      <div className="stats">
        <span>전체: {stats.total}</span>
        <span>완료: {stats.completed}</span>
        <span>미완료: {stats.active}</span>
      </div>

      {/* 필터 버튼 */}
      <div className="filters">
        <button
          className={filter === 'all' ? 'active' : ''}
          onClick={() => setFilter('all')}
        >
          전체
        </button>
        <button
          className={filter === 'active' ? 'active' : ''}
          onClick={() => setFilter('active')}
        >
          미완료
        </button>
        <button
          className={filter === 'completed' ? 'active' : ''}
          onClick={() => setFilter('completed')}
        >
          완료
        </button>
      </div>

      {/* 할 일 목록 */}
      <ul className="todo-list">
        {filteredTodos.map(todo => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={() => dispatch({ type: 'TOGGLE', id: todo.id })}
            onDelete={() => dispatch({ type: 'DELETE', id: todo.id })}
            onEdit={(text) => dispatch({ type: 'EDIT', id: todo.id, text })}
          />
        ))}
      </ul>

      {filteredTodos.length === 0 && (
        <p className="empty-message">
          {filter === 'all'
            ? '할 일을 추가해보세요'
            : `${filter === 'completed' ? '완료된' : '미완료'} 할 일이 없습니다`}
        </p>
      )}
    </div>
  );
}

// 할 일 아이템 컴포넌트
function TodoItem({ todo, onToggle, onDelete, onEdit }) {
  const [isEditing, setIsEditing] = useState(false);
  const [editText, setEditText] = useState(todo.text);

  function handleEdit() {
    if (editText.trim()) {
      onEdit(editText);
      setIsEditing(false);
    }
  }

  if (isEditing) {
    return (
      <li className="todo-item editing">
        <input
          type="text"
          value={editText}
          onChange={(e) => setEditText(e.target.value)}
          onBlur={handleEdit}
          onKeyPress={(e) => e.key === 'Enter' && handleEdit()}
          autoFocus
        />
      </li>
    );
  }

  return (
    <li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={onToggle}
      />
      <span onDoubleClick={() => setIsEditing(true)}>
        {todo.text}
      </span>
      <button onClick={onDelete}>삭제</button>
    </li>
  );
}

export default TodoApp;

코드 분석

이 예제에서 주목할 점:

  1. 상태 계층 구조
    • 전역 수준: 할 일 목록, 필터
    • 컴포넌트 수준: 입력 텍스트, 편집 상태
  2. useReducer 사용
    • 복잡한 상태 업데이트 로직을 리듀서로 분리
    • 여러 타입의 액션을 명확하게 정의
  3. 계산된 값
    • filteredTodosstats는 상태가 아닌 계산된 값
    • 렌더링할 때마다 계산하지만 성능상 문제 없음
  4. 컴포넌트 분리
    • TodoItem을 별도 컴포넌트로 분리하여 재사용성 향상
    • 각 아이템의 편집 상태는 로컬 상태로 관리

개선 방안

실전에서는 다음을 추가로 고려할 수 있습니다.

// 1. 로컬 스토리지에 저장
useEffect(() => {
  localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);

// 2. 서버와 동기화 (React Query 사용)
const { data: todos, mutate } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
});

const addTodoMutation = useMutation({
  mutationFn: addTodoToServer,
  onSuccess: () => {
    mutate();  // 할 일 목록 다시 가져오기
  },
});

// 3. 낙관적 업데이트
const toggleTodoMutation = useMutation({
  mutationFn: toggleTodoOnServer,
  onMutate: async (todoId) => {
    // 즉시 UI 업데이트
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previousTodos = queryClient.getQueryData(['todos']);

    queryClient.setQueryData(['todos'], old =>
      old.map(todo =>
        todo.id === todoId
          ? { ...todo, completed: !todo.completed }
          : todo
      )
    );

    return { previousTodos };
  },
  onError: (err, variables, context) => {
    // 실패 시 롤백
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
});

성능 최적화 전략

상태가 변경될 때마다 컴포넌트가 리렌더링되는 것은 React의 작동 방식입니다. 하지만 애플리케이션이 커지면서 불필요한 리렌더링이 쌓이면 성능 문제가 발생할 수 있습니다. 실제로 React DevTools Profiler로 측정해보면 의외로 많은 컴포넌트가 불필요하게 렌더링되고 있다는 것을 발견하게 됩니다. 여기서는 상태 관리와 관련된 성능 최적화 방법을 알아봅니다.

1. React.memo로 컴포넌트 메모이제이션

Props가 변경되지 않으면 리렌더링을 건너뜁니다.

// ❌ 매번 리렌더링됨
function TodoItem({ todo, onToggle }) {
  console.log('TodoItem 렌더링:', todo.id);
  return (
    <li>
      <input type="checkbox" checked={todo.completed} onChange={onToggle} />
      {todo.text}
    </li>
  );
}

function TodoList() {
  const [count, setCount] = useState(0);
  const [todos, setTodos] = useState([...]);

  // count가 변경되면 모든 TodoItem이 리렌더링됨
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>카운트: {count}</button>
      <ul>
        {todos.map(todo => (
          <TodoItem key={todo.id} todo={todo} onToggle={...} />
        ))}
      </ul>
    </div>
  );
}

// ✅ React.memo로 최적화
const TodoItem = React.memo(function TodoItem({ todo, onToggle }) {
  console.log('TodoItem 렌더링:', todo.id);
  return (
    <li>
      <input type="checkbox" checked={todo.completed} onChange={onToggle} />
      {todo.text}
    </li>
  );
});

// 이제 count가 변경되어도 TodoItem은 리렌더링되지 않음

2. useCallback으로 함수 메모이제이션

함수를 props로 전달할 때는 useCallback을 사용하세요.

// ❌ 매번 새 함수가 생성됨
function TodoList() {
  const [todos, setTodos] = useState([...]);

  // 렌더링할 때마다 새 함수가 생성되어 TodoItem이 리렌더링됨
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={() => {
            setTodos(todos.map(t =>
              t.id === todo.id ? { ...t, completed: !t.completed } : t
            ));
          }}
        />
      ))}
    </ul>
  );
}

// ✅ useCallback으로 함수 재사용
function TodoList() {
  const [todos, setTodos] = useState([...]);

  const handleToggle = useCallback((todoId) => {
    setTodos(prev => prev.map(t =>
      t.id === todoId ? { ...t, completed: !t.completed } : t
    ));
  }, []);  // 의존성이 없으므로 한 번만 생성됨

  return (
    <ul>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={handleToggle}
        />
      ))}
    </ul>
  );
}

const TodoItem = React.memo(function TodoItem({ todo, onToggle }) {
  return (
    <li>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => onToggle(todo.id)}
      />
      {todo.text}
    </li>
  );
});

3. useMemo로 계산 결과 캐싱

비용이 큰 계산은 useMemo로 캐싱하세요.

function TodoStats({ todos }) {
  // ❌ 렌더링할 때마다 계산
  const stats = {
    total: todos.length,
    completed: todos.filter(t => t.completed).length,
    active: todos.filter(t => !t.completed).length,
    completionRate: (todos.filter(t => t.completed).length / todos.length * 100).toFixed(1),
  };

  return <div>{JSON.stringify(stats)}</div>;
}

// ✅ useMemo로 캐싱
function TodoStats({ todos }) {
  const stats = useMemo(() => {
    const completed = todos.filter(t => t.completed).length;
    return {
      total: todos.length,
      completed,
      active: todos.length - completed,
      completionRate: (completed / todos.length * 100).toFixed(1),
    };
  }, [todos]);  // todos가 변경될 때만 재계산

  return <div>{JSON.stringify(stats)}</div>;
}

주의: useMemo를 남용하지 마세요. 간단한 계산은 그냥 하는 게 더 빠를 수 있습니다.

// ❌ 불필요한 useMemo
const sum = useMemo(() => a + b, [a, b]);  // 그냥 a + b가 더 빠름

// ✅ 복잡한 계산에만 사용
const sortedData = useMemo(
  () => data.sort((a, b) => a.value - b.value),
  [data]
);

4. 상태를 분리하여 리렌더링 범위 줄이기

자주 변경되는 상태와 그렇지 않은 상태를 분리하세요.

// ❌ 하나의 컴포넌트에 모든 상태
function Dashboard() {
  const [time, setTime] = useState(new Date());
  const [userData, setUserData] = useState(null);
  const [settings, setSettings] = useState({});

  useEffect(() => {
    const timer = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(timer);
  }, []);

  // time이 매초 변경되어 전체 Dashboard가 리렌더링됨
  return (
    <div>
      <Clock time={time} />
      <UserProfile data={userData} />
      <Settings config={settings} />
    </div>
  );
}

// ✅ 자주 변경되는 상태를 별도 컴포넌트로 분리
function Dashboard() {
  const [userData, setUserData] = useState(null);
  const [settings, setSettings] = useState({});

  // time은 Clock 컴포넌트 내부에서만 관리
  return (
    <div>
      <Clock />
      <UserProfile data={userData} />
      <Settings config={settings} />
    </div>
  );
}

function Clock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const timer = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(timer);
  }, []);

  // 이제 Clock만 매초 리렌더링됨
  return <div>{time.toLocaleTimeString()}</div>;
}

테스팅 전략

상태 관리 로직은 테스트하기 좋은 구조로 만들 수 있습니다.

1. 리듀서 테스트

리듀서는 순수 함수이므로 테스트하기 쉽습니다.

// todosReducer.test.js
import { todosReducer } from './todosReducer';

describe('todosReducer', () => {
  it('할 일을 추가해야 함', () => {
    const initialState = [];
    const action = { type: 'ADD', text: '새 할 일' };

    const newState = todosReducer(initialState, action);

    expect(newState).toHaveLength(1);
    expect(newState[0].text).toBe('새 할 일');
    expect(newState[0].completed).toBe(false);
  });

  it('할 일을 토글해야 함', () => {
    const initialState = [
      { id: 1, text: '테스트', completed: false }
    ];
    const action = { type: 'TOGGLE', id: 1 };

    const newState = todosReducer(initialState, action);

    expect(newState[0].completed).toBe(true);
  });

  it('원본 상태를 변경하지 않아야 함', () => {
    const initialState = [{ id: 1, text: '테스트', completed: false }];
    const action = { type: 'TOGGLE', id: 1 };

    todosReducer(initialState, action);

    // 원본이 변경되지 않았는지 확인
    expect(initialState[0].completed).toBe(false);
  });
});

2. 커스텀 훅 테스트

@testing-library/react-hooks를 사용하여 커스텀 훅을 테스트할 수 있습니다.

// useTodos.js
import { useReducer, useCallback } from 'react';

export function useTodos() {
  const [todos, dispatch] = useReducer(todosReducer, []);

  const addTodo = useCallback((text) => {
    dispatch({ type: 'ADD', text });
  }, []);

  const toggleTodo = useCallback((id) => {
    dispatch({ type: 'TOGGLE', id });
  }, []);

  return { todos, addTodo, toggleTodo };
}

// useTodos.test.js
import { renderHook, act } from '@testing-library/react';
import { useTodos } from './useTodos';

describe('useTodos', () => {
  it('할 일을 추가하고 토글할 수 있어야 함', () => {
    const { result } = renderHook(() => useTodos());

    // 할 일 추가
    act(() => {
      result.current.addTodo('새 할 일');
    });

    expect(result.current.todos).toHaveLength(1);
    expect(result.current.todos[0].text).toBe('새 할 일');

    // 토글
    act(() => {
      result.current.toggleTodo(result.current.todos[0].id);
    });

    expect(result.current.todos[0].completed).toBe(true);
  });
});

3. 통합 테스트

실제 컴포넌트와 함께 상태 관리를 테스트합니다.

// TodoApp.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import { TodoApp } from './TodoApp';

describe('TodoApp', () => {
  it('할 일을 추가하고 완료 처리할 수 있어야 함', async () => {
    render(<TodoApp />);

    // 할 일 입력
    const input = screen.getByPlaceholderText('할 일을 입력하세요');
    fireEvent.change(input, { target: { value: '테스트 할 일' } });

    // 추가 버튼 클릭
    const addButton = screen.getByText('추가');
    fireEvent.click(addButton);

    // 할 일이 목록에 나타나는지 확인
    expect(screen.getByText('테스트 할 일')).toBeInTheDocument();

    // 체크박스 클릭하여 완료 처리
    const checkbox = screen.getByRole('checkbox');
    fireEvent.click(checkbox);

    // 완료 상태가 반영되는지 확인
    expect(checkbox).toBeChecked();
  });

  it('필터링이 작동해야 함', () => {
    render(<TodoApp />);

    // 할 일 2개 추가 (하나는 완료)
    // ...

    // "완료" 필터 클릭
    fireEvent.click(screen.getByText('완료'));

    // 완료된 할 일만 표시되는지 확인
    expect(screen.queryByText('미완료 할 일')).not.toBeInTheDocument();
    expect(screen.getByText('완료 할 일')).toBeInTheDocument();
  });
});

마치며

React의 상태 관리는 단순해 보이지만, 깊이 이해하면 복잡한 애플리케이션도 효과적으로 구축할 수 있습니다.

핵심 정리

  1. UI는 상태의 함수다: React의 선언적 특성을 이해하세요
  2. 상태는 필요한 곳에만: 과도한 전역 상태를 피하세요
  3. 불변성을 지켜라: 상태 객체를 직접 수정하지 마세요
  4. 계산할 수 있다면 상태가 아니다: 불필요한 상태를 만들지 마세요
  5. 적절한 도구 선택: 프로젝트 규모에 맞는 상태 관리 방법을 사용하세요

학습 로드맵

상태 관리를 완전히 이해하려면 다음 순서로 학습하는 것을 추천합니다.

  1. 기초: useStateuseEffect로 로컬 상태 관리
  2. 중급: useReducer와 Context API로 복잡한 상태 다루기
  3. 고급: 상태 관리 라이브러리 (Zustand, Redux Toolkit)
  4. 전문가: 서버 상태 관리 (React Query) + 성능 최적화

다음 단계

  • React 공식 문서의 Thinking in React 읽어보기
  • 작은 프로젝트를 만들면서 상태 관리 패턴 직접 적용해보기
  • Redux Toolkit이나 Zustand 같은 라이브러리 실습하기
  • React Query로 서버 상태 관리 경험해보기

상태 관리는 React 개발의 핵심입니다. 이 글이 React를 상태 관리 관점에서 깊이 이해하는 데 도움이 되었기를 바랍니다.

참고 자료

댓글