📕

1.3 Context API의 장점과 한계

1.3.1 React Context API의 장점

1) 전역적인 상태 관리

Context 객체로부터 Provider를 통해 제공된 값 모든 하위 컴포넌트에서 접근 가능하므로, 전역 상태를 효과적으로 관리하는 데 용이합니다. 즉, 여러 컴포넌트에서 공유해야 하는 데이터나 상태를 하나의 중앙 저장소에서 관리가 가능한 것입니다.
 
예를 들어 다국어 지원과 같은 서비스 제공에서의 Context는 애플리케이션에 대한 현재 언어 설정 및 번역 데이터를 저장하는 데 사용됩니다. 만약 사용자가 언어 설정을 변경하면 Context는 애플리케이션 전체에서 번역된 텍스트를 제공할 수 있습니다. 또한, 애플리케이션의 테마 및 스타일 관리 기능에서도 Context는 사용될 수 있습니다. 애플리케이션의 테마, 스타일 또는 사용자 정의 UI 설정을 Context에 저장하여 모든 컴포넌트에서 적용할 수 있습니다. 만약 사용자가 테마를 변경하면 Context를 통해 전체 애플리케이션의 스타일을 동적으로 업데이트 할 수 있습니다.
 

2) props의 전달 감소

Context를 사용하면 기존의 React의 props를 전달하는 방식에서 벗어나 컴포넌트 트리의 수많은 중간 컴포넌트들을 거치지 않고도 하위 컴포넌트로의 데이터 전달이 가능합니다. 이는 props를 전달하는 데 필요한 중간 단계를 제거하므로 코드의 가독성을 향상시킵니다. 특히 컴포넌트 계층 구조가 깊거나 중첩된 경우 유용하며, 각 컴포넌트가 필요한 데이터만 접근이 가능해 컴포넌트 간의 강력한 의존성을 줄일 수 있습니다. 여러 컴포넌트는 하나의 Context를 활용하여 코드 중복을 최소화하고 이는 모듈화된 형태로 작성할 수 있습니다.
 
예를 들어, Context를 통해 로그인 정보를 관리하는 컴포넌트나 테마 설정을 다루는 컴포넌트를 만들고, 이를 여러 프로젝트나 페이지에서 재사용이 가능합니다. 이로써 props를 전달하여 상태 관리하는 것에 비해 코드의 재사용성도 높아지고 개발 생산성도 향상합니다.
 

3) 효율적인 동적 데이터 처리

props나 useState로도 동적 데이터의 처리가 가능하지만, Context를 통해 더욱 효율적으로 동적 데이터 처리가 가능합니다. Context를 사용한 동적 데이터 처리는 애플리케이션에서 데이터를 동적으로 업데이트하고 공유하는 과정을 의미합니다.
 
이는 다음과 같은 순서로 처리합니다.
  1. 데이터 업데이트 : Context Provider에서 데이터를 업데이트하는 메서드를 호출하거나 상태를 변경합니다.
  1. 데이터 구독 : 관련 컴포넌트에서 Context Consumer를 사용하여 데이터를 구독합니다.
  1. 데이터 처리 : 구독한 데이터를 사용하여 동적 처리를 수행합니다.
  1. 렌더링 : 데이터가 업데이트되면 관련 컴포넌트가 다시 렌더링되어 변경된 데이터를 화면에 표시합니다.
위의 과정을 테마 변경 과정으로 예를 들겠습니다. 먼저 Context Provider에서 현재 테마 정보를 관리하고, 테마 변경의 메서드를 제공합니다. 테마 변경 관련 컴포넌트에서는 Context Consumer를 사용하여 현재 테마를 구독하고, 테마에 따라 UI 스타일을 동적으로 변경합니다. 만약 사용자가 테마를 변경하면, Provider에서는 테마를 업데이트하고 이 때문에 구독 중인 컴포넌트가 다시 렌더링 되어 변경된 테마가 반영됩니다. 이러한 과정을 통해 코드도 매우 간결하며 훨씬 효율적인 동적인 데이터의 처리가 가능합니다.
 

1.3.2 React Context API의 한계 및 제약사항

1) 코드의 복잡성, 높은 결합도

Context는 중첩해서 여러 개의 Context로 많은 데이터의 관리가 가능합니다. 하지만 Context API를 남용하면 애플리케이션은 복잡해질 수 있습니다. 과도한 중첩으로 코드의 가독성이 매우 떨어져 이해하기 어려운 문제점이 있으며, 여러 Context의 사용으로 컴포넌트간의 높은 결합도가 발생해 컴포넌트의 재사용성 감소와 어려운 유지보수라는 결과를 낳을 수 있습니다.
 
아래의 코드는 여러 개의 Context를 중첩해서 사용한 경우를 예시코드로 표현한 것입니다.
function App() { return ( <AProvider> <BProvider> <CProvider> <DProvider> <EProvider> <Component /> </EProvider> </DProvider> </CProvider> </BProvider> </AProvider> ); }
위 코드와 같이 중첩된 Context 구조는 기능 구현에 유용할 수 있지만 코드를 이해하고 유지보수 하는 데 어려움을 초래할 수 있습니다. 특히, 컴포넌트 계층 구조가 복잡하고 중첩된 Context가 많아질수록 코드의 가독성과 유지보수성이 저하될 수 있습니다.
 
따라서 여러 개의 Context를 한 컴포넌트에 중첩해 사용하는 경우 신중하게 고려해야 하고, 필요한 경우 컴포넌트 구조를 단순화하거나 관리하기 쉽도록 리팩토링 하는 것이 중요합니다. 코드의 구조가 복잡해지면 컴포넌트 간의 의존성과 데이터의 흐름을 이해하기 어렵기 때문입니다.
 

2) Re-rendering 문제

Context의 한계 중 하나는 컴포넌트의 리렌더링 동작과 관련이 있습니다. 이 한계점은 React의 업데이트 방식과 관련이 있으며, 이 방식을 이해하고 최적화하는 것이 중요합니다. 아래에서 리렌더링에 대한 자세한 설명을 제공하겠습니다.

(1) 리렌더링

React 컴포넌트는 상태(state) 또는 props의 변경으로 인해 리렌더링이 발생합니다. 이 때, 컴포넌트의 render 메서드가 호출되고 화면이 업데이트 됩니다.
Context를 사용한 데이터 전달은 컴포넌트 간에 데이터 의존성을 만들어 낼 수 있습니다. 즉, Context Provider에서 제공되는 데이터가 변경될 때 해당 Context를 구독하고 있는 모든 컴포넌트가 다시 렌더링 되는 것을 말합니다.
 

(2) 리렌더링의 원인

Context에서 제공되는 데이터가 변경되면, 그 데이터를 사용하는 컴포넌트들은 새로운 데이터를 반영하기 위해 리렌더링 됩니다. 이러한 리렌더링은 가장 상위에 위치한 Context Provider의 데이터가 변경될 때마다 발생합니다.
                     [그림] 리렌더링 되는 과정                     [그림] 리렌더링 되는 과정
[그림] 리렌더링 되는 과정
예를 들어, Context에 데이터가 저장되어 있고 어떤 컴포넌트가 Context를 구독 중이라고 가정해보겠습니다. Context에 저장된 데이터가 업데이트되면 그 데이터를 사용하는 컴포넌트는 리렌더링이 됩니다. 하지만 업데이트 된 데이터를 사용하는 컴포넌트가 렌더링하는 하위 컴포넌트는 그 Context를 구독하지도, Context 내의 데이터를 사용하지도 않아도 Context를 구독하는 컴포넌트의 하위 컴포넌트라는 이유로 리렌더링 됩니다.
 
자세한 예시코드와 그림으로 살펴보겠습니다.
import React, { createContext, useContext, useState } from "react"; // Context 생성 const UserContext = createContext(); // Context를 사용할 Provider 컴포넌트 function UserProvider({ children }) { const [userName, setUserName] = useState(""); // 유저 이름 상태 return ( <UserContext.Provider value={{ userName, setUserName }}> {children} </UserContext.Provider> ); } // 유저 이름을 입력하는 컴포넌트 function UserNameInput() { const { userName, setUserName } = useContext(UserContext); return ( <div> <input type="text" value={userName} onChange={(e) => setUserName(e.target.value)} /> </div> ); } // 유저 이름을 보여주는 컴포넌트 function UserNameDisplay() { const { userName } = useContext(UserContext); return ( <div> <p>유저 이름: {userName}</p> </div> ); } function App() { return ( <div> <h1>유저 정보 관리 예제</h1> <UserProvider> <UserNameInput /> <UserNameDisplay /> </UserProvider> </div> ); } export default App;
위 코드에서 UserProvider는 UserContext를 생성하고 유저 이름 상태를 관리하는 역할을 합니다. UserNameInput 컴포넌트는 유저 이름을 입력하고 UserNameDisplay 컴포넌트는 현재 유저 이름을 표시합니다.
                                  [그림] 예시코드에 대한 그림                                  [그림] 예시코드에 대한 그림
[그림] 예시코드에 대한 그림
사용자가 이름을 입력할 때마다 UserNameInput 컴포넌트는 리렌더링됩니다. 즉, UserNameInput 컴포넌트의 리렌더링은 UserProvider에서 상태를 변경할 때마다 발생합니다. 또한 UserProvider가 상태를 변경할 때마다 UserNameDisplay 컴포넌트도 함께 리렌더링되는 문제가 발생합니다.
 
위의 예시에서는 간단하게 유저 이름을 관리하고 표시하는 예시를 보여주었지만, 실제 애플리케이션에서는 이와 유사한 상황에서 발생할 수 있는 성능 문제를 고려해야 합니다.
 

(3) 성능 문제 최적화 기법

  • 각 데이터마다 Context 분리
    • 객체나 배열 같은 여러 항목의 데이터를 Context의 value로 관리하면 렌더링 문제가 빈번히 발생할 수 있습니다. 따라서 객체나 배열의 데이터를 각각 하나의 value로 하나의 Context에 저장하면 렌더링 문제를 해결할 수 있습니다. 하지만 Context를 분할해서 사용할 경우, 성능 개선에는 효과적이지만 Context가 많아져 결국 유지보수에 어려움이 생길 수 있습니다.
       
  • shouldComponentUpdate(클래스 컴포넌트), React.memo (함수 컴포넌트)
    • shouldComponentUpdate 메서드는 React 클래스 컴포넌트에서 사용되는 라이프사이클 메서드 중 하나로, 컴포넌트가 리렌더링될 때 리렌더링 여부를 결정하는 데 사용됩니다. ContextAPI의 성능 문제를 해결하기 위해 이 메서드를 활용하는 방법은 다음과 같습니다.
      먼저, Context를 사용하여 데이터를 구독하는 컴포넌트 중에서 리렌더링이 불필요한 컴포넌트를 식별합니다. 이는 성능 문제가 발생하는 지점을 파악하는 중요한 단계입니다.
      다음은 최적화의 대상으로 선택한 컴포넌트 내에서 shouldComponentUpdate 메서드를 구현합니다. 이 메서드는 현재의 props와 state를 비교해 리렌더링의 여부를 결정합니다.
       
      아래는 shouldComponentUpdate 메서드를 구현한 예시 코드 입니다.
      function shouldComponentUpdate(nextProps, nextState) { // 현재 상태(props, state)와 다음 상태(props, state)를 비교하여 리렌더링 여부를 결정 if (this.props.someData !== nextProps.someData) { // someData가 변경되었을 때에만 리렌더링 허용 return true; } // 다른 경우 리렌더링 방지 return false; }
       
      이제, 어떤 조건하에 리렌더링을 허용할 것인지 설정해야 합니다. 이 조건은 컴포넌트의 성능 최적화에 따라 달라질 수 있습니다. 예를 들어, 특정 데이터가 변경되었을 때만 리렌더링을 허용하는 등의 조건을 설정할 수 있습니다.
이렇게 shouldComponentUpdate 메서드를 구현한 후에는 성능을 모니터링하고 테스트하여 최적화가 제대로 동작하는지 확인해야 합니다. React의 개발자 도구나 다양한 성능 분석 도구를 사용하여 성능 이슈를 감지하고 개선할 수 있습니다.
 
shouldComponentUpdate가 클래스 컴포넌트를 위한 성능 최적화에 쓰이는 메소드 였다면 React.memo는 함수 컴포넌트에 대해 유사한 역할을 수행합니다.
import React, { memo } from "react"; function MyComponent(props) { // 컴포넌트 내용 } export default memo(MyComponent);
위의 코드와 같이 React.memo를 통해 성능 문제가 발생하는 함수 컴포넌트를 감싸주면 Context API를 활용한 컴포넌트의 성능 개선이 가능합니다.
 
  • useMemo
    • useMemo는 React 훅의 일종으로 리렌더링이 불필요한 상황에서 컴포넌트가 이전에 계산한 값을 재사용하는데, 이는 불필요한 계산과 렌더링을 방지하고 성능을 최적화할 수 있습니다.
아래의 코드는 useMemo를 사용해서 특정 값이 변경될 때만 새로 계산하여 렌더링하는 예시입니다.
import React, { createContext, useContext, useMemo } from "react"; // Context 생성 const MyContext = createContext(); // Provider 컴포넌트 function MyProvider({ children }) { // useMemo를 사용하여 값이 변경될 때만 새로 계산 const contextValue = useMemo(() => { return { data: "some data", // 다른 컨텍스트 값들... }; }, []); // 빈 배열을 전달하여 의존성을 없앰 return ( <MyContext.Provider value={contextValue}> {children} </MyContext.Provider> ); } // Consumer 컴포넌트 생략
위 코드를 살펴보면 useMemo를 사용하여 contextValue를 계산하고, 해당 값이 변경될 때만 새로운 객체를 생성합니다. 이렇게 하면 Provider가 리렌더링되어도 컨텍스트 값이 변경되지 않는 한, 하위 Consumer는 불필요한 리렌더링을 하지 않습니다. 이로써 ContextAPI를 사용할 때 성능 문제를 최소화할 수 있습니다.
 
하지만 모든 Context 값에 대해 useMemo를 사용하려고 하면, 컴포넌트가 리렌더링될 때마다 모든 Context 값이 메모이제이션되므로 메모리 사용량이 늘어날 수 있습니다. 따라서 필요한 곳에서만 useMemo를 사용하고, 그 외에는 일반적인 Context 사용이나 다른 상태 관리 라이브러리의 사용을 고려해야 합니다.
 
useMemo는 위의 React.memo와 이름이 유사한데, React.memo는 컴포넌트 자체를 대상으로 하며 컴포넌트의 렌더링 결과를 메모이제이션합니다. 반면 useMemo는 특정 값을 대상으로 하여 해당 값을 메모이제이션하여 재사용한다는 차이가 있습니다. 또한 React.memo는 불필요한 컴포넌트 리렌더링을 방지하기 위해 사용되며, 컴포넌트의 렌더링을 최적화합니다. useMemo는 값을 계산하고 재사용하기 위해 사용되며, 값을 계산하는 비용을 줄이고 성능을 향상시킵니다.
 
즉, Context 사용 시 렌더링 문제는 데이터의 변경 여부와 컴포넌트의 구조에 따라 발생하며 성능 문제의 예방을 위해 최적화 기법을 사용해야 합니다.
 
이와 같이 Context API를 사용할 때는 데이터의 변경 빈도와 영향을 고려해야 합니다. 자주 변경되는 데이터에 Context를 적용하면 오히려 불필요한 렌더링이 발생할 수 있으며, Context를 구독하는 컴포넌트가 많을 경우 성능에 영향을 미칠 수 있으므로 최적화를 고려해야 합니다.