본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 10. 30. · 16 Views
Context API vs Redux 상태관리 완벽 비교
React 애플리케이션에서 상태관리를 할 때 Context API와 Redux 중 어떤 것을 선택해야 할까요? 각각의 특징, 장단점, 그리고 실무에서의 사용 사례를 초급 개발자도 이해할 수 있도록 쉽고 자세하게 비교 분석합니다.
목차
- Context API 기본 개념
- Redux 기본 개념
- Context API 실전 구현
- Redux 실전 구현
- 성능 비교
- 개발자 경험 비교
- 프로젝트 규모별 선택 기준
- Context API + useReducer
1. Context API 기본 개념
시작하며
여러분이 React 프로젝트를 진행하다가 props를 5단계, 6단계 깊이로 전달해본 적 있나요? 부모 컴포넌트에서 자식, 그 자식의 자식, 또 그 자식의 자식...
이렇게 계속 내려가다 보면 코드가 복잡해지고 관리하기 어려워집니다. 이런 문제를 "Prop Drilling"이라고 부르는데, 실제 개발 현장에서 정말 자주 발생합니다.
중간에 있는 컴포넌트들은 해당 데이터를 전혀 사용하지 않는데도 단지 전달만을 위해 props를 받아야 하죠. 코드 가독성도 떨어지고, 나중에 수정할 때도 여러 파일을 건드려야 하는 번거로움이 있습니다.
바로 이럴 때 필요한 것이 Context API입니다. React 16.3 버전부터 공식적으로 제공되는 이 기능을 사용하면, 컴포넌트 트리 전체에 데이터를 쉽게 공유할 수 있습니다.
개요
간단히 말해서, Context API는 React에서 기본으로 제공하는 전역 상태 관리 도구입니다. 실무에서 사용자 인증 정보, 테마 설정, 언어 설정 같은 데이터는 애플리케이션 전체에서 필요합니다.
이런 경우 매번 props로 전달하는 것은 비효율적이죠. Context API를 사용하면 이러한 "전역" 데이터를 중앙에서 관리하고, 필요한 컴포넌트에서 직접 꺼내 쓸 수 있습니다.
기존에는 props를 통해 데이터를 계층별로 전달했다면, 이제는 Context Provider로 데이터를 감싸고 필요한 곳에서 useContext Hook으로 바로 접근할 수 있습니다. Context API의 핵심 특징은 첫째, 별도의 라이브러리 설치가 필요 없다는 점입니다.
React에 내장되어 있어 즉시 사용할 수 있죠. 둘째, 사용법이 직관적이고 간단합니다.
셋째, 작은 규모의 프로젝트나 특정 영역의 상태 관리에 적합합니다. 이러한 특징들은 빠른 개발과 낮은 러닝 커브를 제공합니다.
코드 예제
// 1단계: Context 생성
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
// 2단계: Provider 컴포넌트 작성
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 3단계: Custom Hook으로 사용 편의성 제공
export const useTheme = () => useContext(ThemeContext);
설명
이것이 하는 일: Context API는 세 가지 핵심 요소로 구성됩니다. createContext로 컨텍스트를 생성하고, Provider로 데이터를 제공하며, useContext로 데이터를 소비합니다.
첫 번째로, createContext()를 호출하여 새로운 Context 객체를 만듭니다. 이것은 데이터를 담을 "컨테이너"를 준비하는 과정이라고 생각하면 됩니다.
이 컨테이너는 애플리케이션 전체에서 공유될 상태의 "틀"을 제공합니다. 두 번째로, ThemeProvider 컴포넌트 안에서 실제 상태(theme)와 상태를 변경하는 함수(toggleTheme)를 정의합니다.
useState Hook을 사용하여 컴포넌트 레벨의 상태를 만들고, 이를 Context.Provider의 value로 전달합니다. 여기서 중요한 점은 Provider로 감싼 모든 하위 컴포넌트가 이 value에 접근할 수 있다는 것입니다.
children props를 통해 하위 컴포넌트 트리 전체를 감싸는 구조입니다. 세 번째로, useTheme이라는 커스텀 Hook을 만들어 사용 편의성을 높입니다.
이렇게 하면 다른 컴포넌트에서 const { theme, toggleTheme } = useTheme()처럼 간단하게 테마 정보에 접근할 수 있습니다. 직접 useContext(ThemeContext)를 호출하는 것보다 훨씬 깔끔하고 에러도 줄어듭니다.
여러분이 이 코드를 사용하면 애플리케이션의 어느 깊이에 있는 컴포넌트든 상관없이 테마 정보에 접근하고 변경할 수 있습니다. Header 컴포넌트에서 테마를 변경하면, Footer나 Sidebar 같은 다른 컴포넌트에서도 즉시 변경된 테마가 반영됩니다.
props를 여러 단계로 전달할 필요가 없어 코드가 훨씬 간결해지고, 유지보수도 쉬워집니다.
실전 팁
💡 Context는 자주 변경되지 않는 데이터(테마, 언어, 사용자 정보)에 사용하세요. 매우 빈번하게 변경되는 데이터는 성능 이슈가 발생할 수 있습니다.
💡 Context를 여러 개 분리하여 사용하세요. 하나의 거대한 Context보다 테마용, 인증용, 설정용 등으로 나누면 불필요한 리렌더링을 방지할 수 있습니다.
💡 항상 커스텀 Hook으로 래핑하세요. useTheme, useAuth 같은 Hook을 만들면 코드가 깔끔해지고, Provider 밖에서 사용할 때 에러 처리도 쉽게 추가할 수 있습니다.
💡 defaultValue는 Provider 없이 테스트할 때 유용합니다. createContext(defaultValue)로 기본값을 설정하면 단위 테스트가 편해집니다.
💡 value 객체를 useMemo로 메모이제이션하세요. Provider의 value가 매번 새 객체로 생성되면 모든 하위 컴포넌트가 리렌더링됩니다.
2. Redux 기본 개념
시작하며
여러분이 대규모 React 프로젝트에서 상태 관리를 하다가 이런 고민을 해본 적 있나요? "지금 이 상태가 어디서 어떻게 변경된 거지?" 여러 컴포넌트에서 동시에 상태를 수정하다 보면 예측하기 어려운 버그가 발생하곤 합니다.
이런 문제는 특히 팀 프로젝트에서 심각합니다. 개발자 A가 수정한 상태가 개발자 B의 코드에 예상치 못한 영향을 미치고, 디버깅하는 데 몇 시간씩 걸리기도 하죠.
상태 변경의 흐름을 추적하기 어렵고, 시간 여행 디버깅 같은 고급 기능도 사용할 수 없습니다. 바로 이럴 때 필요한 것이 Redux입니다.
Redux는 "예측 가능한 상태 컨테이너"라는 철학으로 설계되어, 상태 변경을 체계적으로 관리하고 추적할 수 있게 해줍니다.
개요
간단히 말해서, Redux는 JavaScript 애플리케이션을 위한 예측 가능한 상태 관리 라이브러리입니다. Redux는 실무에서 복잡한 상태 로직, 여러 곳에서 사용되는 공유 상태, 시간에 따른 상태 변화 추적이 필요할 때 빛을 발합니다.
예를 들어, 전자상거래 사이트에서 장바구니, 사용자 정보, 주문 내역 등을 관리할 때 Redux를 사용하면 상태 변경이 명확하고 예측 가능해집니다. 기존에는 컴포넌트 내부에 흩어져 있던 상태와 로직을 "어디서든 접근 가능한" 중앙 저장소(Store)로 모았다면, Redux는 여기에 "엄격한 규칙"을 추가합니다.
상태는 오직 Action을 통해서만 변경되고, 변경 로직은 Reducer에만 작성됩니다. Redux의 핵심 특징은 첫째, 단일 스토어 원칙입니다.
애플리케이션의 모든 상태가 하나의 객체 트리에 저장됩니다. 둘째, 상태는 읽기 전용입니다.
직접 수정할 수 없고 Action을 dispatch해야만 변경됩니다. 셋째, 변경은 순수 함수(Reducer)로만 이루어집니다.
이러한 규칙들이 예측 가능성과 디버깅 용이성을 보장합니다.
코드 예제
// 1단계: Action 타입 정의
const INCREMENT = 'counter/increment';
const DECREMENT = 'counter/decrement';
// 2단계: Action 생성자 함수
const increment = (amount) => ({ type: INCREMENT, payload: amount });
const decrement = (amount) => ({ type: DECREMENT, payload: amount });
// 3단계: Reducer 함수 - 순수 함수로 상태 변경
const initialState = { count: 0 };
function counterReducer(state = initialState, action) {
switch (action.type) {
case INCREMENT:
return { ...state, count: state.count + action.payload };
case DECREMENT:
return { ...state, count: state.count - action.payload };
default:
return state;
}
}
// 4단계: Store 생성 (실제 앱에서는 루트 파일에서 한 번만)
import { createStore } from 'redux';
const store = createStore(counterReducer);
설명
이것이 하는 일: Redux는 단방향 데이터 흐름을 강제하는 상태 관리 시스템입니다. Action 발행 → Reducer 실행 → Store 업데이트 → UI 반영의 명확한 흐름을 따릅니다.
첫 번째로, Action 타입과 Action 생성자를 정의합니다. Action은 "무슨 일이 일어났는지"를 설명하는 평범한 JavaScript 객체입니다.
type 필드는 필수이고, payload 필드로 추가 데이터를 전달합니다. INCREMENT, DECREMENT처럼 문자열 상수로 타입을 정의하면 오타를 방지하고 자동완성도 활용할 수 있습니다.
Action 생성자 함수는 이러한 Action 객체를 쉽게 만들어주는 헬퍼 함수입니다. 두 번째로, Reducer 함수를 작성합니다.
Reducer는 "이전 상태"와 "Action"을 받아서 "새로운 상태"를 반환하는 순수 함수입니다. 여기서 중요한 점은 절대로 이전 상태를 직접 수정하지 않는다는 것입니다.
항상 새로운 객체를 만들어 반환해야 합니다. spread 연산자(...state)를 사용하는 이유가 바로 이것입니다.
switch 문으로 Action 타입에 따라 다른 로직을 실행하고, 처리할 수 없는 Action이 오면 기존 상태를 그대로 반환합니다. 세 번째로, createStore로 Redux Store를 생성합니다.
Store는 상태를 보관하는 저장소이며, getState()로 현재 상태를 읽고, dispatch(action)로 상태를 변경하며, subscribe(listener)로 변경을 감지할 수 있습니다. 실제 React 앱에서는 react-redux의 Provider로 Store를 주입하고, useSelector와 useDispatch Hook으로 접근합니다.
여러분이 이 패턴을 사용하면 상태 변경의 모든 이력을 추적할 수 있습니다. Redux DevTools를 설치하면 어떤 Action이 발행되었는지, 그 전후로 상태가 어떻게 변했는지 시각적으로 확인할 수 있죠.
심지어 특정 시점으로 돌아가는 "시간 여행 디버깅"도 가능합니다. 이는 복잡한 버그를 추적할 때 엄청난 생산성 향상을 가져다줍니다.
실전 팁
💡 Redux Toolkit(RTK)을 사용하세요. 공식 권장 방법으로, boilerplate 코드를 80% 이상 줄여주고 불변성 관리도 자동으로 해줍니다.
💡 Reducer는 순수 함수로 유지하세요. API 호출, 랜덤 값 생성, Date.now() 같은 부수 효과는 절대 Reducer에 넣으면 안 됩니다. 이런 것들은 미들웨어(Thunk, Saga)에서 처리하세요.
💡 Action 타입은 "도메인/이벤트" 형식으로 네이밍하세요. 'user/loginSuccess', 'cart/addItem'처럼 작성하면 코드베이스가 커져도 충돌을 피할 수 있습니다.
💡 Redux DevTools Extension을 반드시 설치하세요. 개발 생산성이 몇 배는 향상됩니다. Action 이력, 상태 diff, 시간 여행 디버깅을 모두 제공합니다.
💡 모든 상태를 Redux에 넣지 마세요. 폼 입력값, UI 토글 상태 같은 로컬 상태는 컴포넌트에 두는 게 더 간단합니다. "여러 곳에서 필요한가?"를 기준으로 판단하세요.
3. Context API 실전 구현
시작하며
여러분이 실제 프로젝트에서 Context API를 구현하려고 할 때, 간단한 예제는 많이 봤지만 "실무에서는 어떻게 구조화해야 하지?"라는 의문이 들지 않나요? 단순히 하나의 파일에 모든 로직을 넣으면 관리하기 어려워집니다.
이런 문제는 프로젝트가 성장하면서 더욱 심각해집니다. 여러 개의 Context를 관리해야 하고, 각 Context마다 복잡한 로직이 추가되면서 코드가 스파게티처럼 엉키게 되죠.
테스트 작성도 어려워지고, 새로운 팀원이 이해하기도 힘들어집니다. 바로 이럴 때 필요한 것이 체계적인 Context API 구조입니다.
Provider 컴포넌트 분리, 커스텀 Hook 패턴, 그리고 에러 처리까지 포함한 실전형 구현 방법을 알아보겠습니다.
개요
간단히 말해서, 실전 Context API 구현은 재사용성, 유지보수성, 테스트 가능성을 고려한 체계적인 코드 구조를 의미합니다. 실무에서는 단순히 Context를 만드는 것을 넘어, 여러 팀원이 함께 작업하고 장기간 유지보수할 수 있는 구조가 필요합니다.
예를 들어, 사용자 인증 Context를 만든다면 로그인, 로그아웃, 토큰 갱신, 권한 체크 등 다양한 기능이 포함됩니다. 이런 것들을 체계적으로 관리하지 않으면 금방 복잡해집니다.
기존의 튜토리얼 코드가 모든 로직을 한 곳에 몰아넣었다면, 실전 구조는 파일을 역할별로 분리하고, 타입 안정성을 확보하며, 에러 상황을 명확히 처리합니다. 실전 구현의 핵심 특징은 첫째, Context와 Provider를 별도 파일로 분리합니다.
둘째, 커스텀 Hook에서 Context가 올바르게 사용되는지 검증합니다. 셋째, TypeScript를 활용해 타입 안정성을 확보합니다.
이러한 구조는 버그를 사전에 방지하고 개발 경험을 크게 향상시킵니다.
코드 예제
// contexts/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// 초기 인증 상태 확인
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
// API 호출로 사용자 정보 검증
fetchUserProfile(token)
.then(setUser)
.finally(() => setLoading(false));
} else {
setLoading(false);
}
}, []);
const login = async (email, password) => {
const { token, user } = await loginAPI(email, password);
localStorage.setItem('token', token);
setUser(user);
};
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
const value = { user, loading, login, logout };
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// 커스텀 Hook에 에러 처리 추가
export function useAuth() {
const context = useContext(AuthContext);
if (context === null) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
설명
이것이 하는 일: 실전형 Context API 구현은 단순한 상태 공유를 넘어, 인증 로직, 로딩 상태, 에러 처리, 초기화 등 실제 애플리케이션에 필요한 모든 요소를 포함합니다. 첫 번째로, AuthContext를 생성할 때 초기값을 null로 설정합니다.
이는 나중에 커스텀 Hook에서 "Provider 밖에서 사용되었는지" 검증하는 데 활용됩니다. user 상태뿐만 아니라 loading 상태도 함께 관리하는 이유는, 초기 인증 확인 중에는 UI를 다르게 보여줘야 하기 때문입니다.
로딩 화면을 띄우지 않으면 사용자는 잠깐 로그아웃 상태로 보였다가 갑자기 로그인 상태로 바뀌는 깜빡임을 경험하게 됩니다. 두 번째로, useEffect로 컴포넌트가 마운트될 때 localStorage에서 토큰을 확인합니다.
토큰이 있으면 서버에 검증 요청을 보내 사용자 정보를 가져옵니다. 이 과정이 비동기이므로 loading 상태로 관리하고, finally 블록에서 반드시 loading을 false로 바꿔줍니다.
이렇게 하면 API 호출이 성공하든 실패하든 로딩 상태가 제대로 해제됩니다. fetchUserProfile과 loginAPI는 실제 프로젝트에서 axios나 fetch로 구현하는 API 함수입니다.
세 번째로, login과 logout 함수는 인증 관련 모든 작업을 캡슐화합니다. login은 API를 호출하고, 받은 토큰을 localStorage에 저장한 뒤, 사용자 정보를 상태에 설정합니다.
logout은 토큰을 삭제하고 사용자 정보를 null로 만듭니다. 이렇게 모든 인증 로직을 Provider 안에 모아두면, 컴포넌트에서는 단순히 login(), logout()을 호출하기만 하면 됩니다.
네 번째로, useAuth 커스텀 Hook에 에러 처리를 추가했습니다. context가 null이면 Provider 밖에서 사용된 것이므로 명확한 에러 메시지를 던집니다.
이렇게 하면 개발자가 실수로 Provider를 깜빡했을 때 "Cannot read property of undefined" 같은 모호한 에러 대신 정확한 원인을 바로 알 수 있습니다. 여러분이 이 구조를 사용하면 인증 관련 로직을 한 곳에서 관리하면서도, 애플리케이션 어디서든 useAuth()로 간편하게 접근할 수 있습니다.
loading 상태를 제공하므로 초기 로딩 화면도 쉽게 구현할 수 있고, 에러 처리 덕분에 디버깅도 훨씬 쉬워집니다. 테스트할 때도 AuthProvider로 감싸기만 하면 되므로 테스트 작성이 간단합니다.
실전 팁
💡 여러 Context를 조합할 때는 별도의 AppProviders 컴포넌트를 만드세요. <AuthProvider><ThemeProvider><LanguageProvider>... 이런 중첩을 한 곳에 모아두면 App.jsx가 깔끔해집니다.
💡 Context value는 useMemo로 메모이제이션하세요. const value = useMemo(() => ({ user, login, logout }), [user])처럼 작성하면 불필요한 리렌더링을 방지할 수 있습니다.
💡 로딩 상태와 에러 상태를 항상 함께 제공하세요. { data, loading, error } 패턴은 React Query에서도 사용하는 검증된 패턴입니다.
💡 Context를 여러 개 만들 때는 제너레이터 함수를 활용하세요. createContext 로직을 함수로 추출하면 보일러플레이트를 줄일 수 있습니다.
💡 민감한 데이터는 Context에 직접 저장하지 마세요. 토큰은 httpOnly 쿠키에 저장하고, Context에는 사용자 정보만 두는 것이 보안상 안전합니다.
4. Redux 실전 구현
시작하며
여러분이 Redux를 처음 배울 때 예제 코드들은 간단해 보였는데, 막상 실제 프로젝트에 적용하려니 "이걸 어떻게 구조화해야 하지?" 하는 막막함을 느낀 적 있나요? Action, Reducer, Store를 어떻게 파일로 나누고, 비동기 처리는 어떻게 하며, TypeScript는 어떻게 적용하는지 헷갈립니다.
이런 문제는 Redux의 보일러플레이트가 많다는 유명한 비판과도 연결됩니다. 전통적인 Redux 방식으로 하나의 기능을 추가하려면 Action 타입, Action 생성자, Reducer 케이스 등 여러 곳을 수정해야 하죠.
실수할 여지도 많고, 코드도 길어집니다. 바로 이럴 때 필요한 것이 Redux Toolkit(RTK)입니다.
Redux 팀이 공식적으로 권장하는 이 도구를 사용하면 보일러플레이트를 대폭 줄이고, 더 안전하고 생산적인 Redux 개발이 가능합니다.
개요
간단히 말해서, Redux Toolkit은 Redux를 쉽고 효율적으로 사용하기 위한 공식 도구 세트입니다. Redux Toolkit은 실무에서 필요한 거의 모든 기능을 내장하고 있습니다.
불변성 관리를 위한 Immer, 비동기 처리를 위한 createAsyncThunk, 개발자 도구 연동, 미들웨어 설정 등이 모두 자동으로 구성됩니다. 예를 들어, 쇼핑몰의 장바구니 기능을 만든다면 RTK를 사용하면 몇 줄의 코드로 완성할 수 있습니다.
기존의 전통적인 Redux가 Action 타입 상수, Action 생성자, Reducer switch문 등을 모두 수동으로 작성해야 했다면, RTK의 createSlice는 이 모든 것을 한 번에 자동 생성해줍니다. Redux Toolkit의 핵심 특징은 첫째, createSlice로 Action과 Reducer를 한 곳에 정의합니다.
둘째, Immer 라이브러리가 내장되어 있어 "불변성을 신경 쓰지 않고" 코드를 작성할 수 있습니다. 셋째, TypeScript 지원이 우수하여 타입 안정성을 쉽게 확보할 수 있습니다.
이러한 특징들은 개발 속도와 코드 품질을 동시에 향상시킵니다.
코드 예제
// features/cart/cartSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// 비동기 Thunk - API 호출
export const fetchCart = createAsyncThunk(
'cart/fetchCart',
async (userId) => {
const response = await fetch(`/api/cart/${userId}`);
return response.json();
}
);
// Slice 정의 - Reducer와 Action을 한 번에
const cartSlice = createSlice({
name: 'cart',
initialState: {
items: [],
loading: false,
error: null
},
reducers: {
// Immer 덕분에 "직접 수정"하는 것처럼 작성 가능
addItem: (state, action) => {
const existingItem = state.items.find(i => i.id === action.payload.id);
if (existingItem) {
existingItem.quantity += 1;
} else {
state.items.push({ ...action.payload, quantity: 1 });
}
},
removeItem: (state, action) => {
state.items = state.items.filter(i => i.id !== action.payload);
}
},
extraReducers: (builder) => {
// 비동기 액션의 상태별 처리
builder
.addCase(fetchCart.pending, (state) => {
state.loading = true;
})
.addCase(fetchCart.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchCart.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
}
});
export const { addItem, removeItem } = cartSlice.actions;
export default cartSlice.reducer;
설명
이것이 하는 일: Redux Toolkit은 전통적인 Redux의 복잡함을 제거하고, 실무에 바로 적용할 수 있는 간결하고 강력한 API를 제공합니다. 첫 번째로, createAsyncThunk로 비동기 로직을 정의합니다.
이것은 API 호출 같은 비동기 작업을 처리하는 Redux의 표준 방법입니다. 첫 번째 인자는 Action 타입 프리픽스이고, 두 번째 인자는 실제 비동기 함수입니다.
createAsyncThunk는 자동으로 세 가지 Action을 생성합니다: pending(시작), fulfilled(성공), rejected(실패). 이를 통해 로딩 상태와 에러 처리를 체계적으로 관리할 수 있습니다.
두 번째로, createSlice로 Redux의 핵심 로직을 정의합니다. name은 이 slice의 이름이고, initialState는 초기 상태입니다.
여기서는 장바구니 아이템, 로딩 상태, 에러 상태를 관리합니다. reducers 필드에는 동기적인 상태 변경 로직을 작성합니다.
주목할 점은 state.items.push(...)처럼 "직접 수정"하는 것처럼 보이는 코드가 가능하다는 것입니다. 이는 내부적으로 Immer가 불변성을 자동으로 처리해주기 때문입니다.
실제로는 새로운 상태 객체가 생성되지만, 개발자는 훨씬 직관적인 코드를 작성할 수 있습니다. 세 번째로, extraReducers에서 비동기 Action을 처리합니다.
builder 패턴을 사용하여 fetchCart의 세 가지 상태(pending, fulfilled, rejected)에 대한 Reducer를 등록합니다. pending일 때는 loading을 true로 설정하고, fulfilled일 때는 데이터를 상태에 저장하며, rejected일 때는 에러 메시지를 저장합니다.
이런 패턴은 모든 비동기 작업에 일관되게 적용할 수 있습니다. 네 번째로, slice에서 자동 생성된 Action 생성자와 Reducer를 export합니다.
cartSlice.actions에는 addItem, removeItem 같은 Action 생성자가 자동으로 만들어져 있고, cartSlice.reducer는 모든 케이스를 처리하는 완전한 Reducer입니다. 여러분이 이 패턴을 사용하면 장바구니 기능 전체를 하나의 파일에 응집도 높게 관리할 수 있습니다.
새로운 기능을 추가할 때도 이 파일만 수정하면 되고, 타입 안정성도 확보됩니다. React 컴포넌트에서는 useDispatch()로 addItem을 dispatch하고, useSelector()로 장바구니 상태를 읽어오기만 하면 됩니다.
전통적인 Redux 대비 코드량은 절반 이하로 줄어들면서도 기능은 더 강력해집니다.
실전 팁
💡 항상 Redux Toolkit을 사용하세요. 전통적인 Redux 방식은 레거시 프로젝트가 아니라면 사용할 이유가 없습니다. RTK가 공식 권장 방법입니다.
💡 비동기 로직은 createAsyncThunk를 사용하세요. Redux-Saga나 Redux-Observable보다 훨씬 간단하고, 대부분의 경우 충분합니다.
💡 Slice를 기능별로 나누세요. cart, user, products처럼 도메인별로 파일을 분리하면 관리가 쉽습니다. 하나의 거대한 Reducer보다 여러 개의 작은 Slice가 낫습니다.
💡 TypeScript를 사용한다면 RootState와 AppDispatch 타입을 미리 정의하세요. useSelector<RootState>, useDispatch<AppDispatch>처럼 타입을 지정하면 자동완성과 타입 체크가 완벽하게 작동합니다.
💡 RTK Query를 고려하세요. API 호출이 많은 프로젝트라면 createAsyncThunk보다 RTK Query가 더 강력합니다. 캐싱, 재요청, 낙관적 업데이트 등이 자동으로 처리됩니다.
5. 성능 비교
시작하며
여러분이 Context API와 Redux 중 하나를 선택할 때, 기능적인 차이만 보고 결정하면 나중에 성능 문제로 후회할 수 있습니다. 특히 리렌더링 최적화는 사용자 경험에 직접적인 영향을 미치는 중요한 요소입니다.
이런 성능 문제는 초기 개발 단계에서는 잘 드러나지 않습니다. 컴포넌트가 몇 개 없을 때는 상관없지만, 프로젝트가 커지고 상태가 복잡해지면서 불필요한 리렌더링이 누적되어 UI가 버벅거리기 시작합니다.
특히 모바일 환경에서는 더욱 심각하죠. 바로 이럴 때 필요한 것이 두 라이브러리의 성능 특성을 정확히 이해하는 것입니다.
각각의 리렌더링 메커니즘과 최적화 방법을 알아보겠습니다.
개요
간단히 말해서, Context API와 Redux는 리렌더링 최적화 방식에서 근본적인 차이가 있습니다. 실무에서 성능 문제가 가장 많이 발생하는 상황은 "전역 상태의 일부만 사용하는데 전체가 변경될 때마다 리렌더링되는" 경우입니다.
예를 들어, 사용자 프로필과 알림 개수를 함께 관리하는데, 알림 개수만 바뀌어도 프로필을 표시하는 컴포넌트까지 리렌더링되면 비효율적입니다. Context API는 Provider의 value가 변경되면 해당 Context를 사용하는 모든 컴포넌트가 리렌더링됩니다.
Redux는 구독한 상태의 특정 부분만 변경되었을 때만 리렌더링됩니다. 성능 비교의 핵심 포인트는 첫째, Context API는 기본적으로 리렌더링 범위가 넓습니다.
둘째, Redux는 selector를 통한 세밀한 구독이 가능합니다. 셋째, 둘 다 적절한 최적화 기법을 적용하면 충분히 좋은 성능을 낼 수 있습니다.
이러한 차이를 이해하면 상황에 맞는 최적화 전략을 수립할 수 있습니다.
코드 예제
// Context API - 최적화 전 (문제 있음)
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [notifications, setNotifications] = useState(0);
// 매번 새 객체 생성 - 모든 consumer 리렌더링!
const value = { user, setUser, notifications, setNotifications };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
// Context API - 최적화 후 (분리 전략)
const UserContext = createContext();
const NotificationContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
function NotificationProvider({ children }) {
const [count, setCount] = useState(0);
const value = useMemo(() => ({ count, setCount }), [count]);
return <NotificationContext.Provider value={value}>{children}</NotificationContext.Provider>;
}
// Redux - selector로 필요한 부분만 구독
function UserProfile() {
// user만 변경될 때만 리렌더링
const user = useSelector(state => state.user);
return <div>{user.name}</div>;
}
function NotificationBadge() {
// notifications만 변경될 때만 리렌더링
const count = useSelector(state => state.notifications.count);
return <span>{count}</span>;
}
설명
이것이 하는 일: 성능 최적화는 불필요한 리렌더링을 최소화하여 애플리케이션의 반응성을 높이는 작업입니다. 첫 번째 코드는 Context API의 전형적인 성능 문제를 보여줍니다.
AppProvider 안에서 user와 notifications를 함께 관리하면, 둘 중 하나만 바뀌어도 이 Context를 사용하는 모든 컴포넌트가 리렌더링됩니다. 더 심각한 문제는 value 객체가 매 렌더링마다 새로 생성된다는 점입니다.
JavaScript에서 객체는 참조 비교를 하므로, 내용이 같아도 새로운 객체면 다르다고 판단합니다. 이는 React.memo로 최적화한 하위 컴포넌트조차 무용지물로 만듭니다.
두 번째 코드는 Context를 분리하는 최적화 전략입니다. UserContext와 NotificationContext를 별도로 만들면, 사용자 정보만 필요한 컴포넌트는 UserContext만 구독하고, 알림 개수만 필요한 컴포넌트는 NotificationContext만 구독합니다.
이렇게 하면 user가 변경될 때 notifications를 사용하는 컴포넌트는 영향을 받지 않습니다. useMemo로 value를 감싸는 것도 중요합니다.
user가 실제로 변경되지 않으면 같은 객체 참조를 유지하므로 불필요한 리렌더링이 방지됩니다. 세 번째 코드는 Redux의 성능 최적화 메커니즘입니다.
useSelector는 매우 똑똑합니다. 반환값을 이전 값과 비교하여 실제로 변경되었을 때만 컴포넌트를 리렌더링합니다.
UserProfile 컴포넌트는 state.user만 선택하므로, notifications가 아무리 빈번하게 변경되어도 전혀 영향을 받지 않습니다. NotificationBadge도 마찬가지로 state.notifications.count만 보고 있으므로, user 정보가 변경되어도 리렌더링되지 않습니다.
이런 세밀한 구독 제어는 Redux의 큰 장점입니다. 여러분이 이러한 성능 특성을 이해하면 프로젝트에 맞는 전략을 선택할 수 있습니다.
Context API를 사용한다면 Context를 잘게 쪼개고 useMemo를 적극 활용하세요. Redux를 사용한다면 selector를 잘 설계하여 필요한 데이터만 정확히 선택하세요.
두 방법 모두 제대로 사용하면 충분히 좋은 성능을 낼 수 있지만, Redux가 기본적으로 더 세밀한 제어가 가능합니다.
실전 팁
💡 Context API 사용 시 여러 개의 작은 Context로 나누세요. 하나의 거대한 Context보다 여러 개의 작은 Context가 성능에 훨씬 유리합니다.
💡 Context value는 반드시 useMemo로 메모이제이션하세요. 이것만으로도 많은 불필요한 리렌더링을 방지할 수 있습니다.
💡 Redux selector는 가능한 한 원시 값(string, number, boolean)을 반환하도록 작성하세요. 객체나 배열을 반환하면 참조 비교 문제가 발생할 수 있습니다.
💡 Reselect 라이브러리로 메모이제이션된 selector를 만드세요. 복잡한 계산이 필요한 파생 상태를 다룰 때 성능이 크게 향상됩니다.
💡 React DevTools Profiler로 실제 리렌더링을 측정하세요. 추측하지 말고 측정하는 것이 최적화의 첫 단계입니다.
6. 개발자 경험 비교
시작하며
여러분이 코드를 작성할 때 단순히 "기능이 동작하는가"만 중요한 게 아니죠. 얼마나 쉽게 디버깅할 수 있는가, 팀원과 협업할 때 코드를 이해하기 쉬운가, 테스트 작성이 편한가 같은 개발자 경험(DX)도 매우 중요합니다.
이런 차이는 프로젝트 초기에는 잘 느껴지지 않지만, 시간이 지날수록 큰 영향을 미칩니다. 버그를 추적하는 데 몇 시간씩 걸리거나, 새로운 팀원이 코드베이스를 이해하는 데 몇 주가 걸린다면 생산성이 크게 떨어집니다.
바로 이럴 때 필요한 것이 개발 도구와 패턴의 성숙도를 비교하는 것입니다. Context API와 Redux는 개발자 경험 측면에서 뚜렷한 차이를 보입니다.
개요
간단히 말해서, Redux는 더 강력한 개발 도구와 명확한 패턴을 제공하고, Context API는 더 간단하고 직관적입니다. 실무에서 개발자 경험이 중요한 이유는 생산성과 직결되기 때문입니다.
예를 들어, 복잡한 버그가 발생했을 때 Redux DevTools로 모든 상태 변경 이력을 추적할 수 있다면 몇 분 만에 원인을 찾을 수 있습니다. 반면 Context API는 이런 도구가 없어 console.log를 찍어가며 수동으로 추적해야 합니다.
Context API는 React에 내장되어 있고 개념이 단순하여 학습 곡선이 낮습니다. Redux는 초기 학습 비용이 있지만, 익숙해지면 훨씬 강력하고 예측 가능한 개발 경험을 제공합니다.
개발자 경험 비교의 핵심 포인트는 첫째, 디버깅 도구의 차이입니다. Redux DevTools는 시간 여행, 액션 리플레이, 상태 diff 등 강력한 기능을 제공합니다.
둘째, 코드 구조의 명확성입니다. Redux는 강제된 패턴이 있어 팀 협업에 유리합니다.
셋째, 테스트 용이성입니다. Redux의 순수 함수 기반 구조는 테스트 작성을 쉽게 만듭니다.
코드 예제
// Context API - 디버깅 시나리오
function UserProfile() {
const { user, updateUser } = useAuth();
// 문제: user가 어디서 어떻게 변경되었는지 추적 어려움
// console.log를 직접 추가하거나 React DevTools로 수동 확인
useEffect(() => {
console.log('User changed:', user);
}, [user]);
return <div>{user.name}</div>;
}
// Redux - 디버깅 시나리오
function UserProfile() {
const user = useSelector(state => state.user);
const dispatch = useDispatch();
// Redux DevTools에서:
// 1. 어떤 액션이 발행되었는지 시간순 확인
// 2. 액션 전후의 상태 diff 시각화
// 3. 특정 시점으로 "시간 여행" 가능
// 4. 액션을 다시 발행하여 재현
return <div>{user.name}</div>;
}
// Context API - 테스트
test('updates user name', () => {
// Provider로 감싸야 하고, 모킹이 복잡
const wrapper = ({ children }) => (
<AuthProvider>{children}</AuthProvider>
);
const { result } = renderHook(() => useAuth(), { wrapper });
act(() => result.current.updateUser({ name: 'John' }));
expect(result.current.user.name).toBe('John');
});
// Redux - 테스트
test('updates user name', () => {
// Reducer는 순수 함수라 직접 테스트 가능
const state = { user: { name: 'Jane' } };
const action = { type: 'user/updateName', payload: 'John' };
const newState = userReducer(state, action);
expect(newState.user.name).toBe('John');
// 원본 상태가 변경되지 않았는지 확인
expect(state.user.name).toBe('Jane');
});
설명
이것이 하는 일: 개발자 경험 비교는 실제 개발 과정에서 마주치는 디버깅, 테스트, 협업 상황에서의 차이를 보여줍니다. 첫 번째 코드는 Context API의 디버깅 시나리오입니다.
user 상태가 예상치 못하게 변경되었을 때, 어디서 누가 updateUser를 호출했는지 추적하기 어렵습니다. useEffect에 console.log를 추가하거나, React DevTools의 Components 탭에서 수동으로 상태를 확인해야 합니다.
여러 컴포넌트에서 동시에 상태를 변경한다면 원인을 찾기가 더욱 어려워집니다. 특히 비동기 작업이 섞이면 문제의 원인을 파악하는 데 몇 시간씩 걸릴 수 있습니다.
두 번째 코드는 Redux의 디버깅 시나리오입니다. Redux DevTools를 설치하면 브라우저 개발자 도구에 새로운 탭이 추가됩니다.
여기서 애플리케이션 실행 중 발생한 모든 액션을 시간순으로 볼 수 있고, 각 액션이 상태를 어떻게 변경했는지 diff로 확인할 수 있습니다. 더 놀라운 것은 "시간 여행" 기능입니다.
특정 액션을 선택하면 그 시점의 상태로 UI가 즉시 업데이트됩니다. 버그가 발생한 시점으로 돌아가서 액션을 다시 발행해보고, 상태를 직접 수정해보면서 실험할 수 있습니다.
이는 디버깅 생산성을 몇 배나 향상시킵니다. 세 번째 코드는 Context API의 테스트입니다.
renderHook을 사용할 때 AuthProvider로 감싸야 하고, Provider 내부의 복잡한 로직(API 호출, useEffect 등)도 함께 실행됩니다. 이를 모킹하려면 추가 작업이 필요하고, 테스트가 "통합 테스트"에 가까워집니다.
단위 테스트처럼 빠르고 독립적으로 실행하기 어렵습니다. 네 번째 코드는 Redux의 테스트입니다.
Reducer는 "순수 함수"이므로 테스트가 매우 간단합니다. 입력(이전 상태 + 액션)을 주고 출력(새 상태)을 검증하기만 하면 됩니다.
API 호출도 없고, DOM도 필요 없으며, Provider도 필요 없습니다. 몇 밀리초 만에 수백 개의 테스트를 실행할 수 있습니다.
또한 불변성이 지켜지는지(원본 상태가 변경되지 않았는지) 검증하는 것도 쉽습니다. 여러분이 이러한 개발자 경험의 차이를 이해하면 프로젝트 특성에 맞는 선택을 할 수 있습니다.
작은 프로젝트나 프로토타입이라면 Context API의 간단함이 장점입니다. 하지만 장기 유지보수가 필요하고, 복잡한 상태 로직이 있으며, 팀 규모가 크다면 Redux의 강력한 도구와 명확한 패턴이 훨씬 유리합니다.
실전 팁
💡 Redux 사용 시 Redux DevTools Extension을 반드시 설치하세요. 이것 없이 Redux를 쓰는 것은 디버거 없이 코딩하는 것과 같습니다.
💡 Context API를 사용하더라도 상태 변경 로직을 useReducer로 관리하세요. 이렇게 하면 테스트가 쉬워지고, 로직이 명확해집니다.
💡 Redux의 액션 이름을 명확하게 작성하세요. "UPDATE"보다는 "user/profileUpdated", "cart/itemAdded"처럼 구체적으로 작성하면 DevTools에서 추적하기 쉽습니다.
💡 팀 컨벤션을 문서화하세요. Redux든 Context든, 팀 전체가 일관된 패턴을 따르면 협업 효율이 크게 향상됩니다.
💡 Storybook 같은 도구를 활용하세요. 컴포넌트를 독립적으로 개발하고 테스트할 수 있어, 상태 관리 방식과 무관하게 개발자 경험이 향상됩니다.
7. 프로젝트 규모별 선택 기준
시작하며
여러분이 새 프로젝트를 시작할 때 가장 고민되는 질문 중 하나가 바로 "Context API를 쓸까, Redux를 쓸까?"일 것입니다. 두 가지 모두 장단점이 있어서 선택하기 어렵죠.
이런 고민은 잘못된 선택이 나중에 큰 리팩토링으로 이어질 수 있기 때문에 중요합니다. 작은 프로젝트에 Redux를 도입하면 오버엔지니어링이 되고, 큰 프로젝트에 Context API만 쓰면 관리가 어려워집니다.
처음부터 제대로 선택하는 것이 중요합니다. 바로 이럴 때 필요한 것이 명확한 선택 기준입니다.
프로젝트 규모, 팀 구성, 복잡도 등 여러 요소를 고려한 의사결정 가이드를 제시하겠습니다.
개요
간단히 말해서, 프로젝트의 규모와 복잡도에 따라 적합한 상태 관리 솔루션이 다릅니다. 실무에서 이런 결정은 단순히 기술적인 측면만 고려하는 게 아닙니다.
팀의 기술 수준, 프로젝트 일정, 미래 확장 가능성, 유지보수 계획 등을 종합적으로 판단해야 합니다. 예를 들어, 스타트업에서 빠른 MVP 개발이 필요하다면 Context API가 적합하고, 대기업에서 장기 운영할 서비스라면 Redux가 적합합니다.
Context API는 설정이 간단하고 학습 비용이 낮아 작은 프로젝트에 적합합니다. Redux는 초기 설정 비용이 있지만 확장성과 유지보수성이 뛰어나 큰 프로젝트에 적합합니다.
선택 기준의 핵심 포인트는 첫째, 상태의 복잡도입니다. 간단한 전역 상태면 Context, 복잡한 로직과 파생 상태가 많으면 Redux입니다.
둘째, 팀 규모와 협업 필요성입니다. 혼자 또는 소규모면 Context, 여러 팀이 협업하면 Redux가 유리합니다.
셋째, 디버깅과 테스트의 중요도입니다. 프로토타입이면 Context, 프로덕션 서비스면 Redux가 적합합니다.
코드 예제
// 작은 프로젝트 (Context API 권장)
// - 상태: 테마, 언어, 간단한 사용자 정보
// - 팀: 1-3명
// - 특징: 빠른 개발, 간단한 로직
// contexts/AppContext.jsx
const ThemeContext = createContext();
const LanguageContext = createContext();
function App() {
return (
<ThemeProvider>
<LanguageProvider>
<Router>
<Routes />
</Router>
</LanguageProvider>
</ThemeProvider>
);
}
// 중간 규모 프로젝트 (Context + useReducer)
// - 상태: 위 + 폼 상태, 필터, 정렬
// - 팀: 3-7명
// - 특징: 구조화된 상태 로직 필요
const [state, dispatch] = useReducer(appReducer, initialState);
// 큰 프로젝트 (Redux Toolkit 권장)
// - 상태: 복잡한 비즈니스 로직, 다층 구조, 캐싱
// - 팀: 7명 이상, 여러 팀 협업
// - 특징: 명확한 패턴, 강력한 디버깅, 미들웨어 필요
// store/index.js
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './slices/userSlice';
import cartReducer from './slices/cartSlice';
import productsReducer from './slices/productsSlice';
const store = configureStore({
reducer: {
user: userReducer,
cart: cartReducer,
products: productsReducer
}
});
설명
이것이 하는 일: 프로젝트 특성에 맞는 상태 관리 솔루션을 선택하여 개발 효율과 유지보수성을 최적화합니다. 첫 번째 코드는 작은 프로젝트의 예시입니다.
개인 블로그, 랜딩 페이지, 소규모 대시보드 같은 경우가 여기 해당합니다. 관리해야 할 전역 상태가 테마와 언어 설정 정도라면 Context API로 충분합니다.
ThemeProvider와 LanguageProvider를 중첩하여 사용하고, 각 Context는 독립적으로 동작합니다. 설정 파일이나 복잡한 초기화가 필요 없어 프로젝트를 빠르게 시작할 수 있습니다.
팀이 1-3명 정도의 소규모이고, 개발 기간이 몇 주 이내라면 이 방식이 가장 효율적입니다. 두 번째는 중간 규모 프로젝트입니다.
전자상거래 사이트, 관리자 대시보드, SaaS 제품의 초기 버전 같은 경우가 해당합니다. 이 정도 규모가 되면 단순한 useState만으로는 상태 로직이 복잡해집니다.
여러 액션이 상태를 변경하고, 상태 간 의존성도 생기죠. 이럴 때 useReducer를 Context와 함께 사용하면 Redux와 비슷한 패턴을 얻으면서도 추가 라이브러리 없이 관리할 수 있습니다.
appReducer에 모든 상태 변경 로직을 모아두면 코드가 예측 가능해지고 테스트도 쉬워집니다. 팀이 3-7명 정도면 이 정도 구조로 충분히 협업할 수 있습니다.
세 번째는 큰 프로젝트의 예시입니다. 대규모 전자상거래 플랫폼, 엔터프라이즈 애플리케이션, 금융 서비스처럼 복잡한 비즈니스 로직과 많은 기능이 필요한 경우입니다.
사용자 관리, 장바구니, 제품 목록, 주문 내역, 결제 정보 등 여러 도메인의 상태를 관리해야 합니다. Redux Toolkit의 configureStore를 사용하면 이런 복잡한 상태를 체계적으로 관리할 수 있습니다.
각 도메인별로 slice를 만들어 독립적으로 개발하고 테스트할 수 있죠. 미들웨어로 비동기 작업, 로깅, 에러 처리를 추가할 수 있고, Redux DevTools로 모든 상태 변경을 추적할 수 있습니다.
7명 이상의 여러 팀이 협업할 때 이런 명확한 구조가 필수적입니다. 여러분이 프로젝트를 시작할 때 이런 기준을 참고하면 적절한 선택을 할 수 있습니다.
처음부터 과도하게 복잡한 구조를 만들 필요는 없지만, 미래 확장 가능성도 고려해야 합니다. 만약 작은 프로젝트로 시작했지만 빠르게 성장할 가능성이 있다면, 처음부터 Redux Toolkit을 선택하는 것도 좋은 전략입니다.
반대로 확실히 작은 규모로 유지될 프로젝트라면 Context API로 충분합니다.
실전 팁
💡 확신이 서지 않으면 Context API로 시작하세요. 나중에 Redux로 마이그레이션하는 것이 처음부터 Redux로 시작했다가 불필요했다고 느끼는 것보다 낫습니다.
💡 "글로벌 상태가 5개 이상, 파생 상태가 있음, 비동기 로직이 복잡함" 중 2개 이상 해당되면 Redux를 고려하세요.
💡 팀의 기술 수준을 고려하세요. Redux를 모르는 팀이라면 학습 시간을 확보하거나, Context API로 시작하는 것이 현실적일 수 있습니다.
💡 프로토타입과 프로덕션을 구분하세요. 프로토타입은 빠른 검증이 중요하므로 Context API, 프로덕션은 안정성이 중요하므로 Redux가 적합합니다.
💡 하이브리드 접근도 가능합니다. 주요 비즈니스 로직은 Redux, UI 상태는 Context API로 관리하는 식으로 섞어 쓸 수 있습니다.
8. Context API + useReducer
시작하며
여러분이 Context API를 쓰고 싶은데 Redux처럼 체계적인 상태 관리도 필요하다면 어떻게 해야 할까요? 외부 라이브러리를 추가하지 않으면서도 예측 가능한 상태 변경을 구현하고 싶은 상황이 있습니다.
이런 니즈는 실무에서 자주 발생합니다. Redux는 너무 무겁게 느껴지는데, 단순한 useState로는 복잡한 상태 로직을 관리하기 어려운 "중간 지점"에 있는 프로젝트들이 많죠.
여러 액션이 상태를 변경하고, 상태 업데이트가 서로 연관되어 있을 때 코드가 복잡해집니다. 바로 이럴 때 필요한 것이 Context API와 useReducer의 조합입니다.
이 패턴은 Redux의 핵심 아이디어를 가져오면서도 React 내장 기능만으로 구현할 수 있습니다.
개요
간단히 말해서, Context API + useReducer는 Redux와 비슷한 패턴을 추가 라이브러리 없이 구현하는 방법입니다. 실무에서 이 패턴은 중간 규모 프로젝트의 "스위트 스팟"입니다.
폼 관리, 다단계 위저드, 복잡한 필터 기능 같은 경우에 특히 유용합니다. 예를 들어, 제품 검색 페이지에서 카테고리, 가격 범위, 정렬 방식, 페이지네이션 등 여러 필터를 관리한다면 이 패턴이 딱 맞습니다.
useState로 각 필터를 따로 관리했다면, useReducer로 모든 필터를 하나의 상태 객체로 통합하고 액션으로 변경합니다. 이 패턴의 핵심 특징은 첫째, Redux의 액션-리듀서 패턴을 사용하여 상태 변경이 예측 가능합니다.
둘째, React 내장 기능만 사용하므로 번들 크기가 증가하지 않습니다. 셋째, 복잡한 상태 로직을 컴포넌트에서 분리하여 테스트가 쉬워집니다.
이러한 장점들은 Redux의 장점을 가져오면서도 설정의 복잡함은 피할 수 있게 해줍니다.
코드 예제
// reducer.js - 상태 로직 분리
const initialState = {
filters: {
category: 'all',
priceRange: [0, 1000],
sortBy: 'popular'
},
products: [],
loading: false,
error: null
};
function productReducer(state, action) {
switch (action.type) {
case 'SET_CATEGORY':
return {
...state,
filters: { ...state.filters, category: action.payload }
};
case 'SET_PRICE_RANGE':
return {
...state,
filters: { ...state.filters, priceRange: action.payload }
};
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { ...state, loading: false, products: action.payload };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
case 'RESET_FILTERS':
return { ...state, filters: initialState.filters };
default:
return state;
}
}
// ProductContext.jsx
import { createContext, useContext, useReducer } from 'react';
const ProductContext = createContext();
export function ProductProvider({ children }) {
const [state, dispatch] = useReducer(productReducer, initialState);
// 액션 생성자를 여기서 정의 (선택사항)
const actions = {
setCategory: (category) =>
dispatch({ type: 'SET_CATEGORY', payload: category }),
setPriceRange: (range) =>
dispatch({ type: 'SET_PRICE_RANGE', payload: range }),
resetFilters: () =>
dispatch({ type: 'RESET_FILTERS' })
};
return (
<ProductContext.Provider value={{ state, dispatch, actions }}>
{children}
</ProductContext.Provider>
);
}
export const useProduct = () => useContext(ProductContext);
설명
이것이 하는 일: Context API와 useReducer를 결합하면 Redux의 예측 가능성과 Context의 간결함을 동시에 얻을 수 있습니다. 첫 번째로, productReducer 함수를 별도 파일로 분리합니다.
이 Reducer는 Redux의 Reducer와 동일한 구조입니다. 이전 상태와 액션을 받아 새로운 상태를 반환하는 순수 함수죠.
initialState에 복잡한 중첩 구조의 상태를 정의합니다. filters 객체에 여러 필터 옵션을 담고, products 배열에 검색 결과를 저장하며, loading과 error로 비동기 상태를 관리합니다.
이렇게 관련된 상태를 하나의 객체로 묶으면 관리가 훨씬 쉬워집니다. 두 번째로, switch 문으로 각 액션 타입별 로직을 작성합니다.
SET_CATEGORY 액션이 오면 filters.category만 업데이트하고 나머지는 유지합니다. spread 연산자를 중첩해서 사용하는 이유는 불변성을 지키기 위함입니다.
FETCH_START, FETCH_SUCCESS, FETCH_ERROR는 API 호출의 세 가지 상태를 나타냅니다. 이런 패턴은 모든 비동기 작업에 일관되게 적용할 수 있어 코드베이스 전체의 일관성을 높입니다.
RESET_FILTERS는 필터를 초기 상태로 되돌리는 간단한 액션입니다. 세 번째로, ProductProvider 컴포넌트에서 useReducer를 호출합니다.
useReducer는 [state, dispatch]를 반환하는데, 이는 Redux의 store.getState()와 store.dispatch()와 유사합니다. state에는 현재 상태가 들어있고, dispatch는 액션을 발행하는 함수입니다.
이 둘을 Context.Provider의 value로 전달하면 하위 컴포넌트에서 접근할 수 있습니다. 네 번째로, 선택적으로 actions 객체를 만들어 액션 생성자를 제공합니다.
이렇게 하면 컴포넌트에서 actions.setCategory('electronics')처럼 간편하게 사용할 수 있습니다. 직접 dispatch({ type: 'SET_CATEGORY', payload: 'electronics' })를 호출하는 것보다 타입 안정성이 높고, 액션 타입 문자열을 외울 필요도 없습니다.
여러분이 이 패턴을 사용하면 복잡한 상태 로직을 체계적으로 관리하면서도 Redux의 무거움은 피할 수 있습니다. Reducer 함수는 순수 함수이므로 단위 테스트가 쉽고, 상태 변경 로직이 한 곳에 모여 있어 유지보수도 편합니다.
Redux DevTools 같은 고급 기능은 없지만, 중간 규모 프로젝트에서는 이 정도면 충분합니다. 나중에 프로젝트가 커지면 Redux로 마이그레이션하는 것도 어렵지 않습니다.
이미 액션-리듀서 패턴에 익숙하기 때문이죠.
실전 팁
💡 Reducer 함수를 별도 파일로 분리하세요. 테스트하기 쉽고, 여러 Provider에서 재사용할 수도 있습니다.
💡 액션 타입을 상수로 정의하세요. const SET_CATEGORY = 'SET_CATEGORY'처럼 작성하면 오타를 방지하고 자동완성을 활용할 수 있습니다.
💡 복잡한 비동기 로직은 useEffect나 커스텀 Hook으로 캡슐화하세요. Reducer는 동기적이고 순수해야 합니다.
💡 TypeScript를 사용한다면 액션 타입을 union type으로 정의하세요. 타입 안정성이 크게 향상됩니다.
💡 immer 라이브러리를 추가하면 불변성 관리가 훨씬 쉬워집니다. produce(state, draft => { draft.filters.category = action.payload })처럼 "직접 수정"하는 코드를 작성할 수 있습니다.
댓글 (0)
함께 보면 좋은 카드 뉴스
서비스 메시 완벽 가이드
마이크로서비스 간 통신을 안전하고 효율적으로 관리하는 서비스 메시의 핵심 개념부터 실전 도입까지, 초급 개발자를 위한 완벽한 입문서입니다. Istio와 Linkerd 비교, 사이드카 패턴, 실무 적용 노하우를 담았습니다.
EFK 스택 로깅 완벽 가이드
마이크로서비스 환경에서 로그를 효과적으로 수집하고 분석하는 EFK 스택(Elasticsearch, Fluentd, Kibana)의 핵심 개념과 실전 활용법을 초급 개발자도 쉽게 이해할 수 있도록 정리한 가이드입니다.
Grafana 대시보드 완벽 가이드
실시간 모니터링의 핵심, Grafana 대시보드를 처음부터 끝까지 배워봅니다. Prometheus 연동부터 알람 설정까지, 초급 개발자도 쉽게 따라할 수 있는 실전 가이드입니다.
분산 추적 완벽 가이드
마이크로서비스 환경에서 요청의 전체 흐름을 추적하는 분산 추적 시스템의 핵심 개념을 배웁니다. Trace, Span, Trace ID 전파, 샘플링 전략까지 실무에 필요한 모든 것을 다룹니다.
CloudFront CDN 완벽 가이드
AWS CloudFront를 활용한 콘텐츠 배포 최적화 방법을 실무 관점에서 다룹니다. 배포 생성부터 캐시 설정, HTTPS 적용까지 단계별로 알아봅니다.