지나가던 개발(zigae)

React Context 중첩 된 컴포넌트 검사

2023년 5월 29일 • ☕️ 5 min read

react-logo

React Context API는 트리의 상위 부모 컴포넌트에서 하위 자식 컴포넌트로 prop을 전달해야 하는 prop 드릴링 문제를 해결하기 위해 만들어졌다. Context API가 없다면 여러 중간 컴포넌트를 통해 prop을 드릴링 하거나 HoC 또는 Hook을 사용해 글로벌 스토어에서 데이터를 읽어야 한다. 그러나 Context API를 다른 용도로 사용할 수도 있다.

본 포스팅에서는 Context API를 사용하여 컴포넌트가 DOM의 상위 어딘가에 특정 부모 컴포넌트가 있는지 감지하는 방법을 보여준다.

컴포넌트가 다른 인스턴스 안에 중첩되어 있는지 감지하기 위해 Context API를 사용해서 DOM 유효성 검사에 활용할 수 있다. 예를 들어 버튼은 상호작용 가능한 자식(예: 다른 버튼)을 가질 수 없지만 HTML, 브라우저 또는 React와 같은 프레임워크에는 개발자가 이 규칙을 위반하는 것을 방지하는 안전장치가 없다. Linter를 떠올릴 수 있지만 Linter는 단일 파일에 있는 경우 이러한 오류를 감지하지만 컴포넌트가 여러 파일에 걸쳐 중첩되면 Linter는 이러한 오류를 감지하지 못한 채 중첩된다.

React는 유효하지 않은 DOM 구조를 렌더링하려고 시도할 때 이미 내장된 콘솔 경고(validateDOMNesting)를 하고 있지만, 이러한 경고는 개발자의 실수를 막는 데 있어 거의 도움이 되지 않는다. 개발자가 개발을 하는 동안 개발자도구를 지속적으로 모니터링하지 않는 경우 쉽게 놓칠 수 있다. 모두 경험이 있을 것이다. 차라리 오류를 던지는 것이 낫다.

Context API를 사용한 중첩 컴포넌트 감지

알고리즘은 다음과 같다.

  1. Boolean 값을 저장하는 React Context를 생성
  2. 해당 컴포넌트(예: Button)가 Context 프로바이더를 렌더링
  3. 내부적으로는 동일한 컴포넌트가 자체 Context를 사용

한 번에 한 단계씩 진행하면서 이것이 어떻게 작동하는지 이해해 보자.

Context 생성

먼저 부울 값을 저장하는 간단한 React Context를 만든다.

import React from 'react';

const ButtonParentContext = React.createContext(false);

export const useButtonParent = () => React.useContext(ButtonParentContext);

편의를 위해 위 Context를 더 쉽게 사용할 수 있도록 커스텀 Hook을 정의하여 모든 곳에서 useContext(ButtonParentContext)를 작성할 필요 없이 useButtonParent를 호출할 수 있도록 했다.

Provider 렌더링

이제 Button 컴포넌트 내에서 이 Context 프로바이더를 렌더링하여 true 값을 전달한다.

const Button = (props) => {
  return <ButtonParentContext.Provider value={true}>
    <button {...props} />
  </ButtonParentContext.Provider>
}

지금까지는 특별한 작업을 하지 않았으며, Context API를 사용 해본 적이 있다면 익숙할 것이다. 하지만 곧 흥미로워질 것이다.

Provider에서 Context 사용

마지막으로 동일한 버튼 컴포넌트에서 자체 Context를 사용한다.

const Button = (props) => {
  const hasButtonParent = useButtonParent();

  return <ButtonParentContext.Provider value={true}>
    <button {...props} />
  </ButtonParentContext.Provider>
}

일반적으로 위 코드 예시에서 useContext 훅이 반환하는 Context 값(hasButtonParent)은 Context의 기본값(fasle)이 되는데, 이는 Context를 사용하는 컴포넌트가 그 자체로 Provider이고 트리에서 그 위에 Provider가 존재하지 않기 때문입니다. 예를 들어 중첩된 버튼이 없는 React DOM은 다음과 같이 보인다.

<html>
  <body>
    <main>
      <!-- 이 버튼은 상위에 ButtonParentContext.Provider 가 
      없으므로컨텍스트를 사용하면 false을 반환합니다. -->
      <Button></Button>
    </main>
  </body>
</html>

값을 정의하려면 Button 위에 ButtonParentContext에 대한 Provider가 있어야 한다. 그러나 Button은 그 자체로 Provider이다. 위 패턴을 유효하도록하는 경우는 Button이 다른 인스턴스 내에 중첩되어 있는 경우이다.(Button이 컨텍스트의 유일한 Provider가 되도록 코드를 올바르게 설정했다고 가정할 때):

<!-- 버튼 상위에 Context Provider가 존재하지 않음 -->
<Button>
  <span>
    <!-- 그렇지 않은 경우 -->
    <Button></Button>
  </span>
</Button>

위 트리의 내부 Button은 부모 Button에서 컨텍스트 값을 가져와서 다른 Button 안에 중첩되어 있음을 감지한다. 이제 컨텍스트 값이 true인지 확인하고, true라면 에러를 던지는 것만 남았다.

const Button = (props) => {
  // true이면 DOM 위에 다른 버튼이 있음
  const hasButtonParent = useButtonParent();

  // 잘못된 DOM이 있는 경우 에러를 던진다.
  if (hasButtonParent) {
    throw new Error(`Invalid DOM: 버튼은 버튼의 하위 컴포넌트가 될 수 없습니다.`)
  }

  return <ButtonParentContext.Provider value={true}>
    <button {...props} />
  </ButtonParentContext.Provider>
}

컴포넌트를 조건부 렌더링 하거나 로직 뒤에 렌더링 하는 경우 주의해야 한다. 예시의 경우 빌드 프로세스가 실패하지 않아 런타임 오류가 발생하지 않는다. 또는 아래와 같이 중첩될 수 있는 유효한 DOM 노드를 반환하여 오류를 보다 우아하게 처리할 수도 있지만, 원래 의도가 인터랙티브 버튼이었다면 이 방법은 의미가 없을 것이다.

const Button = (props) => {
  const hasButtonParent = useButtonParent();

  return <ButtonParentContext.Provider value={true}>
    {hasButtonParent ? <span {...props} /> : <button {...props} />}
  </ButtonParentContext.Provider>
}

재사용성 증대

지금까지는 다른 버튼에 중첩된 버튼의 유효성 검사만 수행했다. 하지만 예시를 확장하여 다른 타입의 부모-자식 관계 유효성을 검사할 수 있다. 먼저 컨텍스트의 이름을 일반화하기 위해 이름을 바꾸어 보자.

import { createContext, useContext } from 'react';

const InteractiveParentContext = createContext(false);

훅의 이름도 변경한다.

export const useInteractiveParent = () => useContext(InteractiveParentContext);

그리고 재사용 가능한 Provider를 export 하여 중첩된 컨텍스트 로직을 캡슐화한다.

export const InteractiveParentProvider = ({ children }) => {
  const hasInteractiveParent = useInteractiveParent();

  if (hasInteractiveParent) {
    throw new Error(`Invalid DOM: 상호작용 가능한 엘리먼트는 서로 중첩될 수 없습니다.`);
  }

  return <InteractiveParentContext.Provider value={true}>
    {children}
  </InteractiveParentContext.Provider>
}

이제 다른 컴포넌트에서 재사용할 수 있다.

마무리

필자가 위에서 보인 패턴을 반드시 DOM 유효성 검사에만 사용할 필요는 없다. 자식 컴포넌트에서 특정 이벤트를 통해 부모 컴포넌트를 제외해야 하는 경우 사용할 수 있다. 이번 포스팅의 중점은 React Context API를 사용하여 컴포넌트 인스턴스에 대한 참조를 저장할 수 있으며, 다른 컴포넌트에 중첩된 모든 컴포넌트는 부모 컴포넌트 인스턴스에 액세스할 수 있다는 것이다.

자칫 잘못 활용하면 Context API를 남용할 수 있으므로 주의해야 한다. 필자 역시 자주 사용하는 패턴은 아니다. 그러나 필자의 지인 중에 validateDOMNesting 경고를 수정하기 위해 시간을 소비하고 있는 개발자를 보았고, 더 나은 개발자 경험을 제공하는 데 도움을 줄 수 있는 다양한 Context API 활용법을 소개하고자 작성하였다.