한국어

복합 컴포넌트, 동적 컨텍스트, 복잡한 상태 관리를 위한 성능 최적화 기법 등 React Context API의 고급 패턴을 탐색합니다.

상태 관리를 위한 고급 React Context API 패턴

React Context API는 prop 드릴링 없이 애플리케이션 전체에서 상태를 공유할 수 있는 강력한 메커니즘을 제공합니다. 기본적인 사용법은 간단하지만, 그 잠재력을 최대한 활용하려면 복잡한 상태 관리 시나리오를 처리할 수 있는 고급 패턴을 이해해야 합니다. 이 글에서는 이러한 패턴 몇 가지를 살펴보고, 여러분의 React 개발 수준을 한 단계 높일 수 있는 실용적인 예제와 실행 가능한 인사이트를 제공합니다.

기본 Context API의 한계 이해하기

고급 패턴을 살펴보기 전에, 기본 Context API의 한계를 인지하는 것이 중요합니다. 간단하고 전역적으로 접근 가능한 상태에는 적합하지만, 상태가 자주 변경되는 복잡한 애플리케이션에서는 다루기 어렵고 비효율적일 수 있습니다. 컨텍스트를 사용하는 모든 컴포넌트는 컨텍스트 값이 변경될 때마다 다시 렌더링되는데, 이는 해당 컴포넌트가 업데이트된 특정 상태 조각에 의존하지 않더라도 마찬가지입니다. 이는 성능 병목 현상으로 이어질 수 있습니다.

패턴 1: 컨텍스트를 활용한 복합 컴포넌트(Compound Components)

복합 컴포넌트 패턴은 컨텍스트를 통해 암시적으로 상태와 로직을 공유하는 관련 컴포넌트 모음을 만들어 Context API를 향상시킵니다. 이 패턴은 재사용성을 높이고 소비자를 위한 API를 단순화합니다. 이를 통해 복잡한 로직을 간단한 구현으로 캡슐화할 수 있습니다.

예제: 탭 컴포넌트

탭 컴포넌트를 예로 들어 설명해 보겠습니다. 여러 계층을 통해 props를 전달하는 대신, Tab 컴포넌트들은 공유된 컨텍스트를 통해 암시적으로 통신합니다.

// TabContext.js
import React, { createContext, useContext, useState, ReactNode } from 'react';

interface TabContextType {
  activeTab: string;
  setActiveTab: (tab: string) => void;
}

const TabContext = createContext(undefined);

interface TabProviderProps {
  children: ReactNode;
  defaultTab: string;
}

export const TabProvider: React.FC = ({ children, defaultTab }) => {
  const [activeTab, setActiveTab] = useState(defaultTab);

  const value: TabContextType = {
    activeTab,
    setActiveTab,
  };

  return {children};
};

export const useTabContext = () => {
  const context = useContext(TabContext);
  if (!context) {
    throw new Error('useTabContext must be used within a TabProvider');
  }
  return context;
};

// TabList.js
import React, { ReactNode } from 'react';

interface TabListProps {
  children: ReactNode;
}

export const TabList: React.FC = ({ children }) => {
  return 
{children}
; }; // Tab.js import React, { ReactNode } from 'react'; import { useTabContext } from './TabContext'; interface TabProps { label: string; children: ReactNode; } export const Tab: React.FC = ({ label, children }) => { const { activeTab, setActiveTab } = useTabContext(); const isActive = activeTab === label; const handleClick = () => { setActiveTab(label); }; return ( ); }; // TabPanel.js import React, { ReactNode } from 'react'; import { useTabContext } from './TabContext'; interface TabPanelProps { label: string; children: ReactNode; } export const TabPanel: React.FC = ({ label, children }) => { const { activeTab } = useTabContext(); const isActive = activeTab === label; return ( ); };
// Usage
import { TabProvider, TabList, Tab, TabPanel } from './components/Tabs';

function App() {
  return (
    
      
        Tab 1
        Tab 2
        Tab 3
      
      Content for Tab 1
      Content for Tab 2
      Content for Tab 3
    
  );
}

export default App;

장점:

패턴 2: 동적 컨텍스트(Dynamic Contexts)

어떤 시나리오에서는 컴포넌트 트리의 위치나 다른 동적 요인에 따라 다른 컨텍스트 값이 필요할 수 있습니다. 동적 컨텍스트를 사용하면 특정 조건에 따라 달라지는 컨텍스트 값을 생성하고 제공할 수 있습니다.

예제: 동적 컨텍스트를 사용한 테마 적용

사용자의 선호도나 애플리케이션의 특정 섹션에 따라 다른 테마를 제공하려는 테마 시스템을 생각해 봅시다. 라이트 테마와 다크 테마로 간단한 예제를 만들 수 있습니다.

// ThemeContext.js
import React, { createContext, useContext, useState, ReactNode } from 'react';

interface Theme {
  background: string;
  color: string;
}

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

const defaultTheme: Theme = {
    background: 'white',
    color: 'black'
};

const darkTheme: Theme = {
    background: 'black',
    color: 'white'
};

const ThemeContext = createContext({
    theme: defaultTheme,
    toggleTheme: () => {}
});

interface ThemeProviderProps {
  children: ReactNode;
}

export const ThemeProvider: React.FC = ({ children }) => {
  const [isDarkTheme, setIsDarkTheme] = useState(false);
  const theme = isDarkTheme ? darkTheme : defaultTheme;

  const toggleTheme = () => {
    setIsDarkTheme(!isDarkTheme);
  };

  const value: ThemeContextType = {
    theme,
    toggleTheme,
  };

  return {children};
};

export const useTheme = () => {
  return useContext(ThemeContext);
};
// Usage
import { useTheme, ThemeProvider } from './ThemeContext';

function MyComponent() {
  const { theme, toggleTheme } = useTheme();

  return (
    

This is a themed component.

); } function App() { return ( ); } export default App;

이 예제에서 ThemeProviderisDarkTheme 상태에 따라 동적으로 테마를 결정합니다. useTheme 훅을 사용하는 컴포넌트들은 테마가 변경될 때 자동으로 다시 렌더링됩니다.

패턴 3: 복잡한 상태 관리를 위한 useReducer와 컨텍스트 결합

복잡한 상태 로직을 관리하기 위해 Context API와 useReducer를 결합하는 것은 훌륭한 접근 방식입니다. useReducer는 액션에 기반하여 상태를 업데이트하는 구조화된 방법을 제공하며, Context API를 사용하면 이 상태와 dispatch 함수를 애플리케이션 전체에서 공유할 수 있습니다.

예제: 간단한 할 일 목록(Todo List)

// TodoContext.js
import React, { createContext, useContext, useReducer, ReactNode } from 'react';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

interface TodoState {
  todos: Todo[];
}

type TodoAction = 
  | { type: 'ADD_TODO'; text: string } 
  | { type: 'TOGGLE_TODO'; id: number } 
  | { type: 'DELETE_TODO'; id: number };

interface TodoContextType {
  state: TodoState;
  dispatch: React.Dispatch;
}

const initialState: TodoState = {
  todos: [],
};

const todoReducer = (state: TodoState, action: TodoAction): TodoState => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, { id: Date.now(), text: action.text, completed: false }],
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
        ),
      };
    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.id),
      };
    default:
      return state;
  }
};

const TodoContext = createContext(undefined);

interface TodoProviderProps {
  children: ReactNode;
}

export const TodoProvider: React.FC = ({ children }) => {
  const [state, dispatch] = useReducer(todoReducer, initialState);

  const value: TodoContextType = {
    state,
    dispatch,
  };

  return {children};
};

export const useTodo = () => {
  const context = useContext(TodoContext);
  if (!context) {
    throw new Error('useTodo must be used within a TodoProvider');
  }
  return context;
};
// Usage
import { useTodo, TodoProvider } from './TodoContext';

function TodoList() {
  const { state, dispatch } = useTodo();

  return (
    
    {state.todos.map((todo) => (
  • {todo.text}
  • ))}
); } function AddTodo() { const { dispatch } = useTodo(); const [text, setText] = React.useState(''); const handleSubmit = (e) => { e.preventDefault(); dispatch({ type: 'ADD_TODO', text }); setText(''); }; return (
setText(e.target.value)} />
); } function App() { return ( ); } export default App;

이 패턴은 상태 관리 로직을 리듀서 내에 중앙 집중화하여, 로직을 추론하고 테스트하기 쉽게 만듭니다. 컴포넌트들은 상태를 직접 관리할 필요 없이 액션을 디스패치하여 상태를 업데이트할 수 있습니다.

패턴 4: useMemouseCallback을 이용한 컨텍스트 업데이트 최적화

앞서 언급했듯이, Context API의 핵심 성능 고려 사항 중 하나는 불필요한 리렌더링입니다. useMemouseCallback을 사용하면 컨텍스트 값의 필요한 부분만 업데이트되고 함수 참조가 안정적으로 유지되도록 보장하여 이러한 리렌더링을 방지할 수 있습니다.

예제: 테마 컨텍스트 최적화

// OptimizedThemeContext.js
import React, { createContext, useContext, useState, useMemo, useCallback, ReactNode } from 'react';

interface Theme {
  background: string;
  color: string;
}

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

const defaultTheme: Theme = {
    background: 'white',
    color: 'black'
};

const darkTheme: Theme = {
    background: 'black',
    color: 'white'
};

const ThemeContext = createContext({
    theme: defaultTheme,
    toggleTheme: () => {}
});

interface ThemeProviderProps {
  children: ReactNode;
}

export const ThemeProvider: React.FC = ({ children }) => {
  const [isDarkTheme, setIsDarkTheme] = useState(false);
  const theme = isDarkTheme ? darkTheme : defaultTheme;

  const toggleTheme = useCallback(() => {
    setIsDarkTheme(!isDarkTheme);
  }, [isDarkTheme]);

  const value: ThemeContextType = useMemo(() => ({
    theme,
    toggleTheme,
  }), [theme, toggleTheme]);

  return {children};
};

export const useTheme = () => {
  return useContext(ThemeContext);
};

설명:

useCallback이 없으면 toggleTheme 함수는 ThemeProvider가 렌더링될 때마다 재생성되어 value가 변경되고, 테마 자체가 변경되지 않았더라도 컨텍스트를 사용하는 모든 컴포넌트에서 리렌더링이 발생합니다. useMemo는 의존성(theme 또는 toggleTheme)이 변경될 때만 새로운 value가 생성되도록 보장합니다.

패턴 5: 컨텍스트 셀렉터(Context Selectors)

컨텍스트 셀렉터를 사용하면 컴포넌트가 컨텍스트 값의 특정 부분만 구독할 수 있습니다. 이는 컨텍스트의 다른 부분이 변경될 때 발생하는 불필요한 리렌더링을 방지합니다. `use-context-selector`와 같은 라이브러리나 직접 구현을 통해 이를 달성할 수 있습니다.

사용자 정의 컨텍스트 셀렉터 예제

// useCustomContextSelector.js
import { useContext, useState, useRef, useEffect } from 'react';

function useCustomContextSelector(
  context: React.Context,
  selector: (value: T) => S
): S {
  const value = useContext(context);
  const [selected, setSelected] = useState(() => selector(value));
  const latestSelector = useRef(selector);
  latestSelector.current = selector;

  useEffect(() => {
    let didUnmount = false;
    let lastSelected = selected;

    const subscription = () => {
      if (didUnmount) {
        return;
      }
      const nextSelected = latestSelector.current(value);
      if (!Object.is(lastSelected, nextSelected)) {
        lastSelected = nextSelected;
        setSelected(nextSelected);
      }
    };

    // You would typically subscribe to context changes here. Since this is a simplified
    // example, we'll just call subscription immediately to initialize.
    subscription();

    return () => {
      didUnmount = true;
      // Unsubscribe from context changes here, if applicable.
    };
  }, [value]); // Re-run effect whenever the context value changes

  return selected;
}

export default useCustomContextSelector;
// ThemeContext.js (Simplified for brevity)
import React, { createContext, useState, ReactNode } from 'react';

interface Theme {
  background: string;
  color: string;
}

interface ThemeContextType {
  theme: Theme;
  setTheme: (newTheme: Theme) => void; 
}

const ThemeContext = createContext(undefined);

interface ThemeProviderProps {
  children: ReactNode;
  initialTheme: Theme;
}

export const ThemeProvider: React.FC = ({ children, initialTheme }) => {
  const [theme, setTheme] = useState(initialTheme);

  const value: ThemeContextType = {
    theme,
    setTheme
  };

  return {children};
};

export const useThemeContext = () => {
    const context = React.useContext(ThemeContext);
    if (!context) {
        throw new Error("useThemeContext must be used within a ThemeProvider");
    }
    return context;
};

export default ThemeContext;
// Usage
import useCustomContextSelector from './useCustomContextSelector';
import ThemeContext, { ThemeProvider, useThemeContext } from './ThemeContext';

function BackgroundComponent() {
  const background = useCustomContextSelector(ThemeContext, (context) => context.theme.background);
  return 
Background
; } function ColorComponent() { const color = useCustomContextSelector(ThemeContext, (context) => context.theme.color); return
Color
; } function App() { const { theme, setTheme } = useThemeContext(); const toggleTheme = () => { setTheme({ background: theme.background === 'white' ? 'black' : 'white', color: theme.color === 'black' ? 'white' : 'black' }); }; return ( ); } export default App;

이 예제에서 BackgroundComponent는 테마의 background 속성이 변경될 때만 리렌더링되고, ColorComponentcolor 속성이 변경될 때만 리렌더링됩니다. 이는 전체 컨텍스트 값이 변경될 때 발생하는 불필요한 리렌더링을 방지합니다.

패턴 6: 상태와 액션 분리하기

규모가 큰 애플리케이션의 경우, 컨텍스트 값을 상태용과 액션(디스패치 함수)용의 두 가지 개별 컨텍스트로 분리하는 것을 고려해 보세요. 이는 코드 구성과 테스트 용이성을 향상시킬 수 있습니다.

상태와 액션 컨텍스트를 분리한 할 일 목록 예제

// TodoStateContext.js
import React, { createContext, useContext, useReducer, ReactNode } from 'react';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

interface TodoState {
  todos: Todo[];
}

const initialState: TodoState = {
  todos: [],
};

const TodoStateContext = createContext(initialState);

interface TodoStateProviderProps {
  children: ReactNode;
}

export const TodoStateProvider: React.FC = ({ children }) => {
  const [state] = useReducer(todoReducer, initialState);

  return {children};
};

export const useTodoState = () => {
  return useContext(TodoStateContext);
};

// TodoActionContext.js
import React, { createContext, useContext, Dispatch, ReactNode } from 'react';

type TodoAction = 
  | { type: 'ADD_TODO'; text: string } 
  | { type: 'TOGGLE_TODO'; id: number } 
  | { type: 'DELETE_TODO'; id: number };

const TodoActionContext = createContext | undefined>(undefined);

interface TodoActionProviderProps {
    children: ReactNode;
}

export const TodoActionProvider: React.FC = ({children}) => {
    const [, dispatch] = useReducer(todoReducer, initialState);

    return {children};
};


export const useTodoDispatch = () => {
  const dispatch = useContext(TodoActionContext);
  if (!dispatch) {
    throw new Error('useTodoDispatch must be used within a TodoActionProvider');
  }
  return dispatch;
};

// todoReducer.js
export const todoReducer = (state: TodoState, action: TodoAction): TodoState => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, { id: Date.now(), text: action.text, completed: false }],
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
        ),
      };
    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.id),
      };
    default:
      return state;
  }
};
// Usage
import { useTodoState, TodoStateProvider } from './TodoStateContext';
import { useTodoDispatch, TodoActionProvider } from './TodoActionContext';

function TodoList() {
  const state = useTodoState();

  return (
    
    {state.todos.map((todo) => (
  • {todo.text}
  • ))}
); } function TodoActions({ todo }) { const dispatch = useTodoDispatch(); return ( <> ); } function AddTodo() { const dispatch = useTodoDispatch(); const [text, setText] = React.useState(''); const handleSubmit = (e) => { e.preventDefault(); dispatch({ type: 'ADD_TODO', text }); setText(''); }; return (
setText(e.target.value)} />
); } function App() { return ( ); } export default App;

이러한 분리를 통해 컴포넌트는 필요한 컨텍스트만 구독하여 불필요한 리렌더링을 줄일 수 있습니다. 또한 리듀서와 각 컴포넌트를 개별적으로 단위 테스트하기가 더 쉬워집니다. 또한, 프로바이더를 감싸는 순서가 중요합니다. ActionProviderStateProvider를 감싸야 합니다.

모범 사례 및 고려 사항

결론

React Context API는 상태 관리를 위한 다재다능한 도구입니다. 이러한 고급 패턴을 이해하고 적용함으로써 복잡한 상태를 효과적으로 관리하고, 성능을 최적화하며, 더 유지보수하기 쉽고 확장 가능한 React 애플리케이션을 구축할 수 있습니다. 특정 요구에 맞는 올바른 패턴을 선택하고 컨텍스트 사용의 성능적 영향을 신중하게 고려하는 것을 잊지 마세요.

React가 발전함에 따라 Context API를 둘러싼 모범 사례도 발전할 것입니다. 새로운 기술과 라이브러리에 대한 정보를 계속 접하면 현대 웹 개발의 상태 관리 과제를 해결할 준비를 갖출 수 있습니다. 훨씬 더 세분화된 반응성을 위해 시그널(signals)과 함께 컨텍스트를 사용하는 것과 같은 새로운 패턴을 탐색하는 것을 고려해 보세요.