on my way

한 입 크기로 잘라먹는 리액트 9장: 컴포넌트 트리에 데이터 공급 본문

Computer Science/React

한 입 크기로 잘라먹는 리액트 9장: 컴포넌트 트리에 데이터 공급

wingbeat 2024. 7. 16. 15:38

9장: 컴포넌트 트리에 데이터 공급하기

이 장에서 주목할 키워드

  • Context
  • Props Drilling
  • Context.Provider
  • 리팩터링
  • useContext
  • 구조 재설계와 Context 분리

이 장의 학습 목표

  • Context가 무엇인지 알아봅니다.
  • Context로 [할 일 관리] 앱을 리팩토링합니다.

 

Context란 무엇인가요?

Context는 리액트 컴포넌트 트리 전체에 데이터를 공급하는 기능입니다.

이를 통해 Props Drilling 문제를 해결할 수 있습니다.

 

Props Drilling 문제란?

Props Drilling은 부모 컴포넌트에서 자식 컴포넌트로 데이터를 전달할 때, 중간에 위치한 모든 컴포넌트에 일일이 Props를 전달해야 하는 문제를 말합니다.

ㄴ이렇게 되면 코드가 복잡해지고 유지보수가 어려워집니다.

 

Context를 사용하는 이유

Context를 사용하면 단계마다 일일이 Props를 전달하지 않고도 컴포넌트 트리 전역에 데이터를 공급할 수 있습니다. 이를 통해 Props Drilling 문제를 해결할 수 있습니다.


Context를 사용하는 것과 일반적으로 Props를 사용하는 것에는 여러 차이가 있습니다. 아래에서 두 접근법의 차이를 상세히 설명하겠습니다.

일반적인 Props 사용

장점

  1. 단순성: Props는 리액트 컴포넌트에서 기본적으로 제공하는 데이터 전달 방법으로, 별도의 설정이 필요하지 않습니다.
  2. 명시적 데이터 흐름: Props를 통해 데이터가 부모에서 자식으로 명시적으로 전달되므로, 데이터의 흐름을 쉽게 추적할 수 있습니다.

단점

  1. Props Drilling: 데이터가 필요한 컴포넌트까지 도달하기 위해 중간 컴포넌트들이 불필요하게 Props를 전달해야 합니다. 이 과정은 특히 컴포넌트 트리가 깊거나 넓을 때 번거롭고 코드가 복잡해집니다.
  2. 유지보수 어려움: Props를 전달하는 컴포넌트가 많아지면, 데이터 전달 구조를 파악하고 유지보수하는 것이 어렵습니다.

Context 사용

장점

  1. Props Drilling 방지: Context를 사용하면 중간 컴포넌트를 거치지 않고도 데이터를 여러 컴포넌트에 전달할 수 있어 Props Drilling 문제를 해결할 수 있습니다.
  2. 글로벌 데이터 관리: 애플리케이션의 여러 컴포넌트에서 공통적으로 사용되는 데이터를 중앙에서 관리하고 공급할 수 있습니다.
  3. 유지보수 용이성: 데이터 공급과 소비를 명확하게 분리할 수 있어 코드가 깔끔해지고 유지보수가 용이합니다.

단점

  1. 복잡성 증가: Context를 설정하고 사용하려면 추가적인 코드와 구조가 필요합니다. 작은 프로젝트에서는 오히려 불필요한 복잡성을 초래할 수 있습니다.
  2. 디버깅 어려움: Context로 데이터를 공급받는 컴포넌트는 어디서 데이터가 오는지 명확하지 않을 수 있어 디버깅이 어려워질 수 있습니다.

예제 비교

Props를 통한 데이터 전달

function App() {
  const [data, setData] = useState("Hello World");
  return <Parent data={data} />;
}

function Parent({ data }) {
  return <Child data={data} />;
}

function Child({ data }) {
  return <div>{data}</div>;
}

이 예제에서는 data를 Child 컴포넌트까지 전달하기 위해 Parent 컴포넌트를 거쳐야 합니다.

 

Context를 통한 데이터 전달

const MyContext = React.createContext();

function App() {
  const [data, setData] = useState("Hello World");
  return (
    <MyContext.Provider value={data}>
      <Parent />
    </MyContext.Provider>
  );
}

function Parent() {
  return <Child />;
}

function Child() {
  const data = useContext(MyContext);
  return <div>{data}</div>;
}

이 예제에서는 MyContext.Provider를 사용해 data를 제공하므로, Child 컴포넌트는 useContext(MyContext)를 사용해 직접 data를 받을 수 있습니다.

Parent 컴포넌트는 data를 전달할 필요가 없습니다.

 

결론

Context는 특히 컴포넌트 트리가 깊거나 넓을 때, 데이터가 여러 컴포넌트에서 필요할 때 유용합니다.

Props를 통한 데이터 전달은 간단한 경우에 적합하며, Context를 사용하면 Props Drilling 문제를 해결할 수 있지만, 코드의 복잡도가 증가할 수 있습니다.

상황에 따라 적절한 방법을 선택하는 것이 중요합니다.

 


Context 만들기

Context를 만들려면 React.createContext를 사용합니다.

import React from 'react';
const MyContext = React.createContext(defaultValue);

 

Context에 데이터 공급하기

Context.Provider를 사용해 Context에 데이터를 공급합니다.

import React from 'react';

const MyContext = React.createContext();

function App() {
  const data = 'data';
  return (
    <div>
      <Header />
      <MyContext.Provider value={data}>
        <Body />
      </MyContext.Provider>
    </div>
  );
}

export default App;

 

Context 데이터 사용하기

useContext를 사용해 Context 데이터를 불러옵니다.

 

import React, { useContext } from 'react';
const MyContext = React.createContext();

function App() { /*...*/ }

function Main() {
  const data = useContext(MyContext);
  return <div>{data}</div>;
}

 

[할 일 관리] 앱 리팩토링하기

리액트 앱에서 데이터 전달과 최적화를 더 효과적으로 하기 위해 Context와 최적화 기법을 사용합니다.

이를 통해 Props Drilling 문제를 해결하고, 불필요한 리렌더링을 방지합니다.

아래에서 단계별로 자세히 설명하겠습니다.

TodoContext 만들기

먼저 App.js에서 TodoContext를 생성하여 앱 내에서 공통 데이터를 전달할 수 있도록 합니다.

import React, { createContext, useReducer, useRef, useCallback } from 'react';

const TodoContext = createContext();  // TodoContext 생성

function App() {
  const initialTodos = [];
  const [todo, dispatch] = useReducer(reducer, initialTodos);  // useReducer 사용
  const nextId = useRef(1);

  const onCreate = useCallback(content => {
    dispatch({ type: "ADD_TODO", payload: { id: nextId.current++, content, isDone: false } });
  }, []);

  const onUpdate = useCallback(id => {
    dispatch({ type: "TOGGLE_TODO", payload: id });
  }, []);

  const onDelete = useCallback(id => {
    dispatch({ type: "DELETE_TODO", payload: id });
  }, []);

  return (
    <div className="App">
      <Header />
      <TodoContext.Provider value={{ todo, onCreate, onUpdate, onDelete }}>  // Context 제공
        <TodoEditor />
        <TodoList />
      </TodoContext.Provider>
    </div>
  );
}

export default App;

여기서 createContext를 사용해 TodoContext를 생성하고, useReducer로 상태 관리 로직을 처리합니다.

onCreate, onUpdate, onDelete 함수들은 useCallback을 사용해 메모이제이션합니다.

이렇게 하면 컴포넌트가 리렌더링되더라도 함수가 재생성되지 않습니다.

 

TodoList에서 Context 데이터 사용하기

이제 TodoList 컴포넌트에서 useContext를 사용하여 TodoContext로부터 데이터를 가져옵니다.

 

TodoList.js

import React, { useContext } from 'react';
import { TodoContext } from './App';
import TodoItem from './TodoItem';

const TodoList = () => {
  const { todo, onUpdate, onDelete } = useContext(TodoContext);  // Context 데이터 사용

  return (
    <div className="TodoList">
      {todo.map(item => (
        <TodoItem key={item.id} {...item} onUpdate={onUpdate} onDelete={onDelete} />
      ))}
    </div>
  );
};

export default TodoList;

useContext(TodoContext)를 사용하여 TodoContext에서 데이터를 가져옵니다.

이렇게 하면 TodoList 컴포넌트는 필요한 데이터를 직접 가져올 수 있어 Props Drilling 문제를 해결할 수 있습니다.

 

 

 

 

  • 초기 구조:
    • App 컴포넌트가 상태 todo와 여러 함수들 (onCreate, onUpdate, onDelete)를 관리합니다.
    • TodoContext.Provider가 App의 하위 컴포넌트들에게 상태와 함수를 제공합니다.
    • TodoEditor, TodoList 및 TodoItem 컴포넌트들은 모두 TodoContext.Provider로부터 데이터를 받습니다.
  • State 업데이트 시 문제:
    • App 컴포넌트의 상태가 변경되면 TodoContext.Provider의 값이 변경되어 하위 모든 컴포넌트들이 리렌더링됩니다.
    • TodoEditor, TodoList, TodoItem 컴포넌트들이 모두 리렌더링됩니다. 이는 불필요한 리렌더링을 유발할 수 있습니다.

 

 

  • Context 분리:
    • TodoStateContext와 TodoDispatchContext로 Context를 나눕니다.
    • TodoStateContext.Provider는 todo 상태만 제공합니다.
    • TodoDispatchContext.Provider는 onCreate, onUpdate, onDelete 함수를 제공합니다.
  • 재설계 후 구조:
    • App 컴포넌트가 TodoStateContext.Provider와 TodoDispatchContext.Provider를 통해 상태와 함수를 각각 나누어 제공합니다.
    • TodoEditor와 TodoList는 필요한 데이터만을 각각의 Context로부터 받습니다.
    • TodoItem 컴포넌트는 TodoDispatchContext로부터 필요한 함수만을 받습니다.

 

 

 

  • 서로 다른 Context로 분리된 구조:
    • todo 상태가 업데이트될 때, TodoStateContext.Provider 하위 컴포넌트들만 리렌더링됩니다.
    • dispatch 함수들 (onCreate, onUpdate, onDelete)이 변경될 때, TodoDispatchContext.Provider 하위 컴포넌트들만 리렌더링됩니다.
    • 서로 다른 Context로 분리되어, 불필요한 리렌더링이 발생하지 않습니다.
  • 효과적인 리렌더링 관리:
    • TodoEditor는 onCreate 함수만 사용하므로, TodoDispatchContext에 의존합니다.
    • TodoList는 todo 상태와 onUpdate, onDelete 함수를 사용하므로, 두 Context에 모두 의존하지만, 리렌더링은 개별적으로 관리됩니다.
    • TodoItem은 todo와 onUpdate, onDelete를 사용하므로, 관련된 Context에만 영향을 받습니다.

그림 9-13: State 업데이트 후 컴포넌트 트리 구조 확인하기

  • 구조: 모든 데이터를 하나의 TodoContext.Provider에서 공급합니다.
  • State 업데이트: App 컴포넌트의 state가 업데이트되면 TodoContext.Provider의 value가 변경되어 하위의 모든 컴포넌트(TodoEditor, TodoList, TodoItem)가 리렌더링됩니다.
  • 문제점: todo가 변경될 때 TodoEditor, TodoList, TodoItem 모두가 불필요하게 리렌더링됩니다.

그림 9-14: 구조를 재설계하여 Context를 나누기

  • 구조: 두 개의 컨텍스트(TodoStateContext, TodoDispatchContext)로 분리하여 공급합니다.
  • State 업데이트: todo state는 TodoStateContext.Provider를 통해 공급되고, dispatch 함수들은 TodoDispatchContext.Provider를 통해 공급됩니다.
  • 개선점: todo가 변경될 때 TodoDispatchContext.Provider 하위의 컴포넌트들은 영향을 받지 않습니다.

그림 9-15: State 변수 todo를 업데이트해도 서로 영향받지 않도록 설계

  • 구조: 그림 9-14와 동일하게 두 개의 컨텍스트(TodoStateContext, TodoDispatchContext)로 분리하여 공급합니다.
  • 상세한 개선점: TodoStateContext.Provider와 TodoDispatchContext.Provider를 명확하게 구분하여, todo와 dispatch 함수가 독립적으로 관리될 수 있도록 설계되어 있습니다.
  • 결과: todo state가 업데이트되어도 TodoStateContext.Provider 하위의 컴포넌트들만 리렌더링되고, dispatch 함수들이 포함된 TodoDispatchContext.Provider 하위의 컴포넌트들은 불필요하게 리렌더링되지 않습니다.

요약

  • 그림 9-13은 하나의 TodoContext.Provider에서 모든 데이터를 공급하여, state 업데이트 시 모든 하위 컴포넌트가 리렌더링되는 문제를 보여줍니다.
  • 그림 9-14는 TodoStateContext와 TodoDispatchContext로 분리하여, state와 dispatch 함수가 독립적으로 관리되도록 설계하였습니다.
  • 그림 9-15는 그림 9-14의 구조를 명확하게 보여주며, todo state 업데이트 시 영향을 받는 컴포넌트들을 구체적으로 설명합니다.

결론:

  • Context를 사용해 컴포넌트 트리 구조를 리팩토링하면, 상태와 함수의 업데이트 시 불필요한 리렌더링을 방지할 수 있습니다.
  • Context를 적절히 분리해 사용함으로써, 각 컴포넌트가 필요한 데이터만을 받도록 최적화할 수 있습니다. 이는 성능 향상과 유지보수성을 높이는 데 기여합니다.

 

 

최적화하기

불필요한 컴포넌트 리렌더링 방지하기

React.memo를 사용해 불필요한 리렌더링을 방지합니다.

Header.js

import React from "react";

const Header = () => {
  console.log("Header 업데이트");
  return <h1>오늘은 {new Date().toLocaleDateString()}입니다</h1>;
};

export default React.memo(Header);  // React.memo 사용

React.memo는 컴포넌트를 메모이제이션하여, 전달된 props가 변경되지 않으면 리렌더링되지 않도록 합니다.

Header 컴포넌트는 props가 변경되지 않으면 리렌더링되지 않습니다.

 

함수 재생성 방지하기

useCallback을 사용해 컴포넌트가 리렌더링될 때 함수가 다시 생성되지 않도록 합니다.

 

App.js (최적화된 버전)

import { useCallback, useReducer, createContext, useRef } from "react";

const TodoContext = createContext();

const reducer = (state, action) => {
  switch (action.type) {
    case "ADD_TODO":
      return [...state, action.payload];
    case "TOGGLE_TODO":
      return state.map(todo => 
        todo.id === action.payload ? { ...todo, isDone: !todo.isDone } : todo
      );
    case "DELETE_TODO":
      return state.filter(todo => todo.id !== action.payload);
    default:
      return state;
  }
};

const App = () => {
  const [todo, dispatch] = useReducer(reducer, []);
  const nextId = useRef(1);

  const addTodo = useCallback(content => {
    dispatch({ type: "ADD_TODO", payload: { id: nextId.current++, content, isDone: false } });
  }, []);

  const toggleTodo = useCallback(id => {
    dispatch({ type: "TOGGLE_TODO", payload: id });
  }, []);

  const deleteTodo = useCallback(id => {
    dispatch({ type: "DELETE_TODO", payload: id });
  }, []);

  return (
    <TodoContext.Provider value={{ todo, addTodo, toggleTodo, deleteTodo }}>
      <Header />
      <TodoList />
    </TodoContext.Provider>
  );
};

export default App;

여기서 useCallback을 사용하여 addTodo, toggleTodo, deleteTodo 함수들이 컴포넌트가 리렌더링되더라도 동일한 참조를 유지하도록 합니다.

이렇게 하면 함수가 재생성되지 않아 불필요한 리렌더링을 방지할 수 있습니다.

 

결론

  • Context 사용: Props Drilling 문제를 해결하여, 컴포넌트 트리 깊이가 깊어져도 데이터를 쉽게 전달할 수 있습니다.
  • React.memo: 불필요한 컴포넌트 리렌더링을 방지하여 성능을 최적화할 수 있습니다.
  • useCallback: 컴포넌트가 리렌더링될 때 함수가 재생성되지 않도록 하여 성능을 최적화할 수 있습니다.

이러한 최적화 기법을 통해 보다 효율적이고 유지보수하기 쉬운 리액트 앱을 작성할 수 있습니다.

 


useCallback, useReducer, Provider, Context, 그리고 dispatch는 모두 리액트에서 상태 관리와 성능 최적화를 위해 사용하는 기능들입니다.

이들은 서로 유기적으로 작용하여 복잡한 상태 관리와 최적화를 구현합니다.

각각의 역할과 이들 간의 관계를 자세히 설명해드리겠습니다.

 

관계와 각 기능의 역할

1. useReducer

useReducer는 리액트에서 복잡한 상태 관리를 위해 사용하는 훅입니다.

useState의 대체제로, 상태 업데이트 로직을 외부의 리듀서 함수로 분리하여 관리합니다.

  • 상태 및 리듀서 정의: useReducer는 현재 상태와 dispatch 함수를 반환합니다. 상태 업데이트는 리듀서 함수에서 정의합니다.
  • 구문 및 예시:
const [state, dispatch] = useReducer(reducer, initialState);

 

const reducer = (state, action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, action.payload];
    case 'TOGGLE_TODO':
      return state.map(todo => todo.id === action.payload ? { ...todo, isDone: !todo.isDone } : todo);
    case 'DELETE_TODO':
      return state.filter(todo => todo.id !== action.payload);
    default:
      return state;
  }
};

const [todos, dispatch] = useReducer(reducer, []);

 

2. useCallback

useCallback은 함수 컴포넌트가 리렌더링될 때 함수들이 재생성되지 않도록 메모이제이션하는 훅입니다.

주로 useReducer나 useState와 함께 사용되어, 상태 변경 시 불필요한 함수 재생성을 방지합니다.

  • 구문 및 예시:
const memoizedCallback = useCallback(() => {
  // callback logic here
}, [dependencies]);
const addTodo = useCallback(content => {
  dispatch({ type: 'ADD_TODO', payload: { id: Date.now(), content, isDone: false } });
}, [dispatch]);

 

3. Context 및 Provider

Context는 컴포넌트 트리 전체에 데이터를 공급할 수 있는 방법을 제공합니다.

Provider는 Context 객체를 통해 데이터를 제공하는 역할을 합니다.

 

  • Context 생성:
const TodoContext = createContext();
  • Provider 사용:
return (
  <TodoContext.Provider value={{ todos, addTodo, toggleTodo, deleteTodo }}>
    {children}
  </TodoContext.Provider>
);

 

4. dispatch

dispatch는 useReducer 훅에서 반환되는 함수로, 상태를 업데이트하기 위해 액션을 리듀서 함수로 보내는 역할을 합니다. 액션 객체에는 type과 payload가 포함됩니다.

  • 액션 디스패치
dispatch({ type: 'ADD_TODO', payload: newTodo });

 

 

"Payload"

는 컴퓨터 공학에서 여러 가지 맥락에서 사용되는 용어로, 주로 데이터 전송이나 함수 호출 시 전달되는 실제 데이터 내용을 의미합니다.

일반적으로, 네트워크 패킷이나 함수의 매개변수에서 "payload"는 실제로 전송되거나 처리되는 핵심 데이터를 가리킵니다.

예시로 설명하기

네트워크 통신에서의 Payload

네트워크 패킷은 여러 부분으로 구성됩니다. 패킷의 헤더는 전송 경로와 같은 제어 정보를 포함하고, 패킷의 본문인 "payload"는 실제 전송하려는 데이터를 포함합니다.

[HEADER] | [PAYLOAD]
  • HEADER: 출발지 주소, 목적지 주소, 프로토콜 정보 등
  • PAYLOAD: 전송하려는 실제 데이터 (예: 메시지 내용, 파일 데이터)

 

2. 함수 호출에서의 Payload

JavaScript와 같은 프로그래밍 언어에서, 함수가 호출될 때 매개변수로 전달되는 데이터가 바로 "payload"입니다.

 

다음은 JavaScript에서 dispatch 함수에 전달되는 action 객체를 예로 들겠습니다.

const action = {
  type: 'ADD_TODO',
  payload: {
    id: 1,
    content: 'Learn React',
    isDone: false
  }
};

dispatch(action);

여기서 action 객체는 type과 payload를 포함합니다.

  • type: 액션의 종류를 나타냅니다. 여기서는 'ADD_TODO'입니다.
  • payload: 액션과 함께 전달되는 실제 데이터입니다. 여기서는 새로운 할 일 항목의 세부 정보를 포함합니다.

 

Redux에서의 Payload

Redux와 같은 상태 관리 라이브러리에서는 액션 객체에 payload를 포함시켜 상태를 변경하는데 필요한 데이터를 전달합니다.

const addTodoAction = {
  type: 'ADD_TODO',
  payload: {
    id: 1,
    content: 'Learn Redux',
    isDone: false
  }
};

dispatch(addTodoAction);

여기서 addTodoAction은 type과 payload를 포함하는 액션 객체입니다.

type은 액션의 종류를 나타내고, payload는 상태를 변경하는 데 필요한 데이터를 담고 있습니다.

 

요약

  • Payload는 데이터 전송이나 함수 호출 시 전달되는 실제 데이터 내용을 의미합니다.
  • 네트워크 통신에서는 패킷의 본문 데이터를 의미하고, 함수 호출에서는 매개변수로 전달되는 데이터를 의미합니다.
  • Redux와 같은 상태 관리 라이브러리에서는 상태를 변경하는 데 필요한 데이터를 액션 객체의 payload 속성에 담아 전달합니다.