[React] 공식문서 톺아보기 6

Part 6. Context API와 컴포넌트 합성, 그리고 ThreadLocal과의 비교

React의 단방향 데이터 흐름을 보다 보니 상위 컴포넌트에서 하위 컴포넌트로 데이터를 전달할 때 깊이가 깊어질수록 코드가 복잡해지는게 보인다.

데이터를 사용하지 않는 중간 컴포넌트들이 오로지 하위로 값을 넘겨주기 위해 Props를 받아야 하는 구조. 뭔가 비효율적인게 아닌가 싶어지는 중에 Context API 챕터를 읽게 됐다.

1. Context API: 트리 레벨에서의 데이터 공유

[질문: 중간 컴포넌트를 건너뛰고 데이터를 전달할 수는 없는가?]

Page -> Section -> Heading 와 같이 컴포넌트 계층이 깊어질 때, Heading에서만 필요한 level 정보를 중간에 있는 Section이 굳이 Props로 받아야 한다.

단순히 값을 전달하는 역할만 수행하는 중간 컴포넌트들이 불필요하게 데이터에 의존하게 되고, 구조를 변경할 때마다 여러 파일을 수정해야 하는 문제가 보인다.

[답변: 트리 구조를 통한 데이터 공급과 구독]

React의 Context API를 사용하면 명시적으로 Props를 전달하지 않고도 컴포넌트 트리 전체에 데이터를 제공할 수 있다.

createContext로 생성된 Context 객체는 ProviderConsumer(또는 useContext)로 나뉜다.

상위 컴포넌트에서 Provider를 통해 값을 정의하면, 하위 트리에 있는 모든 컴포넌트는 깊이에 상관없이 useContext를 통해 해당 값에 직접 접근할 수 있다.

이를 통해 중간 컴포넌트는 데이터 전달 로직에서 완전히 제외될 수 있다.

2. Context의 중첩과 값의 결정

[질문: Context를 써도 값을 매번 지정해줘야 하는 번거로움]

차례로 불필요 props들을 걷어내는 과정 중에, Context를 도입했음에도 <Section level={1}> 안에 <Section level={2}>를 작성하는 것처럼 계층 구조를 파악해 값을 일일이 명시해야 하는 시점을 만났다. 단계는 줄었지만 여전히 근본적인 해결이 아닌 것 같다.

컴포넌트가 중첩될 때 자동으로 상위 값을 기반으로 자신의 값을 결정할 수는 없을까?

[답변: 가장 가까운 Provider의 값을 읽는 원리]

Context Provider는 중첩해서 사용할 수 있고, React는 값을 찾을 때 트리 상에서 “가장 가까운 상위 Provider”의 값을 우선적으로 채택한다. (이 값을 못찾으면 createContext의 디폴트를 채택한다.)

이를 이용해 Section 컴포넌트 내부에서 상위 Context의 값을 읽어오고(level), 그 값에 1을 더한 새로운 값을 하위 트리에 제공(Provider value={level + 1})하는 패턴을 구현할 수 있다.

이렇게 하면 외부에서 값을 주입하지 않아도 컴포넌트의 깊이(Depth)에 따라 동적으로 값이 결정되는 유연한 구조를 만들 수 있다.

3. Context의 대안: 컴포넌트 합성

3. Context의 대안: 컴포넌트 합성하기

[질문: Context를 사용하는 대신 컴포넌트의 독립성을 포기해야 하나?]

Context를 사용하면 해당 컴포넌트는 반드시 Provider 내부에서만 정상적으로 작동하게 된다.

예를 들어 Avatar 컴포넌트가 내부적으로 UserContext를 구독(useContext)하게 만들면, 이 컴포넌트는 이제 Context 없이는 사용할 수 없는 종속적인 컴포넌트가 되어버린다.

데이터 전달이 편해지는 대신 해당 컴포넌트를 특정 환경에 묶어버리는(결합도를 높이는) 것이 과연 좋은 설계일까 하는 생각이 들었다.

[답변: 데이터 전달의 책임을 부모에게 위임하자.]

공식 문서에서는 이를 해결하기 위해 “컴포넌트 합성”을 제안한다. 여기서도 제어의 역전(IOC)가 등장한다.

(스프링컨테이너가 생성자를 통해 빈을 주입해주듯, 여기서는 상위 컴포넌트가 props를 통해 UI 컴포넌트를 주입해 주는 걸로 이해했다.)

데이터를 하위로 내려보내는 대신, 데이터를 가진 상위 컴포넌트가 하위 컴포넌트를 완성해서 전달하는 방식이다.

[Before: Prop Drilling 발생]

중간 컴포넌트인 Layoutuser 데이터가 전혀 필요 없지만, 단지 Header에게 전달하기 위해 props를 받아야 한다.

<Layout user={user} />

function Layout({ user }) {
  // Layout은 user를 몰라도 되는데, 억지로 떠안고 있다.
  return (
    <div className="layout">
      <Header user={user} />
      <Main />
    </div>
  );
}

[After: 컴포넌트 합성 적용]

상위 컴포넌트에서 <Header user={user} />를 미리 만들어서 Layoutchildren으로 전달한다.

// App에서 미리 조립해서 넘긴다.
<Layout>
  <Header user={user} />
</Layout>

function Layout({ children }) {
  // Layout은 이제 user가 뭔지 몰라도 된다. 그저 구멍(slot)만 뚫어주면 된다.
  return (
    <div className="layout">
      {children}
      <Main />
    </div>
  );
}

이렇게 하면 Layout이나 Header는 자신이 어디에 있는지, 데이터가 어디서 왔는지 알 필요가 없다. 컴포넌트는 여전히 독립적이며, 데이터 전달 경로도 획기적으로 줄어든다. Context 없이도 Prop Drilling 문제를 깔끔하게 해결하는 방법이다.

4. React Context와 Java ThreadLocal의 비교

[질문: 백엔드의 ThreadLocal과 유사한 개념인가?]

첫인상은 Java의 ThreadLocal과 비슷하단 느낌을 받았다. 메서드 파라미터로 계속 넘기지 않고, 필요할 때 전역적으로 값을 꺼내 쓴다는 점에서 같은 목적을 가진 것 때문인 것 같다.

그래서 비교해보았다.

[답변: 스코프(Scope)의 차이와 반응성(Reactivity)]

두 가지 모두 매개변수 전달의 번거로움을 해결하기 위해, 명시적인 전달 없이 Context을 공유한다는 목적이 동일하다.

차이점은 ThreadLocal은 스레드라는 실행 흐름에 값이 바인딩되는 반면, Context는 컴포넌트 트리라는 UI 구조에 값이 바인딩된다.

가장 결정적인 차이는 반응성이다. ThreadLocal은 단순 값 저장소라면, React Context는 값이 변경되면 해당 Context를 구독하고 있는 모든 하위 컴포넌트를 감지하여 리렌더링을 발생시킨다. 즉, Context는 데이터 공유를 넘어 상태 변경에 따른 UI 업데이트를 처리하는 메커니즘으로 보면 될 것 같다.


© 2022. All rights reserved.

Powered by Hydejack v9.2.1