Skip to main content

Command Palette

Search for a command to run...

[Series. 1] React 서브 컴포넌트와 Compound Component 패턴

더 다양한 방식의 리액트 컴포넌트 설계를 하기 위해 공부해봅니다.

Updated
7 min read
[Series. 1] React 서브 컴포넌트와 Compound Component 패턴


어제보다 더 나은 서비스를 만들어내는 사람이 되고자 노력하며, 내일의 나를 위해 기록합니다.

리액트를 활용하면서 재사용성이라는 말을 입에 붙이고 다녔으나 실상은 관성적인 태도로 일관된 개발을 하지 않았나 싶다. 그런 이유로 시간적 여유가 어느정도 생겼으니 조금 더 다양한 방법론을 공부하고 싶어졌다.

서브컴포넌트와 Compound Component를 이 시리즈의 첫 시작으로 잡은 이유는 비교적 단순한데, 과거 Ant Design이나 Bootstrap을 활용한 코드를 보았을 때 Menu.Item 같은 컴포넌트를 이해하지 못했었기 때문이다. 공부의 깊이가 얕았던 탓이겠지만 아무도 이게 Sub Component 라고 얘기해주지 않았다(당연함. 안물어봄 ㅎㅎ). 결국 키워드를 모르니 찾아보지 못하고 찾아보지 못하니 관성적으로 쓰던 관심을 안가지던 하게 됐다.

그런 지적 게으름 혹은 무지를 벗어나고 싶어서 하나씩 차근히 기록하고자 한다.

선수지식

우선 서브컴포넌트를 알기 위해서는 리액트의 컴포넌트가 어떠한 구조로 구성되어있는 지를 알아야했다. 다들 알다시피 자바스크립트는 많은 부분이 객체로 이루어져있고 함수 역시 객체다. 그렇다면 컴포넌트를 반환하는 함수 역시 객체 구조다. Menu.Item 같은 컴포넌트가 존재할 수 있는 이유가 여기서 밝혀진다.

리액트 컴포넌트의 기본적인 객체 구조

{
    $$typeof : Symbol("react.element"),
    key: "_key",
    props: {
        children: [내용],
        style:{}
    }
    type: tagname || 컴포넌트
    _owner : null,
    _store : {
        validated : boolean,
    }
    ref : null
}

위의 객체는 createElement 함수의 반환값을 까봤을 때 볼 수 있는 구조다. JSX구문에서 map을 통해 반복적으로 컴포넌트를 렌더링할 때 볼 수 있던 key 프로퍼티를 볼 수 있고, ref 프로퍼티도 볼 수 있다.

컴포넌트에 대한 정보가 어디에 담겨야할 지 파악하기 위해 여기서 주목할 부분은 props 객체와 type 이다. props에는 childrenstyle이 담기는데, 이 children에는 이 컴포넌트의 내용이 담긴다. type 프로퍼티에는 생성된 JSX의 HTML 태그가 문자열로 담기거나 참조한 컴포넌트 파일(혹은 컴포넌트 네임)의 이름과 length, arguments, caller 가 담기게 된다.

즉, 컴포넌트를 구성하는 대부분의 정보가 이 컴포넌트 객체에 담겨있다.

서브컴포넌트

이런 리액트 컴포넌트 구조를 활용해 서브컴포넌트 라는 하위 컴포넌트를 추가할 수 있다.

const MainComponent = ({children}) => {
    return (
        <div>
            <div>메인컴포넌트</div>
            {children}
        </div>
    )
}
MainComponent.SubComponent = () => <div>서브컴포넌트</div>

위 코드를 살펴보면 MainComponent는 우리가 잘 알고 있는 형태로 구성이 된다. 그리고 그 아래 MainComponent.SubComponent 로 MainComponent 객체에 프로퍼티를 추가하는 형태로 정의된 컴포넌트가 바로 서브 컴포넌트이다.

const App = () => {
    return (
        <MainComponent>
            <MainComponent.SubComponent/>
        </MainComponent>
    )
}

실제로 사용하게 되면 API 구조처럼 SubComponent를 불러올 수 있게된다.

그렇다면 서브컴포넌트가 추가된 컴포넌트의 객체구조는 어떨까?

{
  // 컴포넌트 함수 자체의 속성들
  $$typeof: Symbol('react.element'),
  name: "MainComponent",
  length: 0,
  // 직접 추가된 서브컴포넌트들
  SubComponent: [Function: SubComponent],
}

위와 같이 기본구조에 새로운 프로퍼티로 SubComponent가 추가된 것을 확일할 수 있고, SubComponent의 값도 동일한 컴포넌트 객체 구조를 가지게 된다.

이런 서브컴포넌트를 포함해서 createElement하게 되면 아래와 같이 반환된다.

{
  $$typeof: Symbol('react.element'),
  type: [Function: Any {  // 컴포넌트 함수도 당연히 객체. 내부에 여러 서브컴포넌트 함수를 가짐
    SubComponent1: [Function: SubComponent1],  // 서브컴포넌트1
    SubComponent2: [Function: SubComponent2]  // 서브컴포넌트2
  },
  key: null,
  ref: null,
  props: { children: [] },
  owner: null,
  store: { validated: false }
}

서브컴포넌트를 활용해 작성했던 코드를 렌더링 하면 아래와 같다.

서브컴포넌트의 장・단점

장점

  1. 사용자가 직관적으로 파악가능

  2. 명확한 관계성을 띄게됨

  3. 관련 컴포넌트가 하나의 파일/모듈에서 관리되기 때문에 응집도 향상

  4. 유지보수도 용이해짐

  5. API 디자인의 일관성

단점

  1. 한 파일/모듈에서 관리되기 때문에 복잡도가 증가함

  2. 각 서브 컴포넌트를 모듈화해서 활용 가능하지만 이 역시 과정이 번거로움(import 후 메인컴포넌트에 추가)

  3. 메인 컴포넌트가 모든 하위 컴포넌트를 포함하고 있기 때문에 번들 사이즈가 증가함 (번들러를 통해 사용되지 않은 서브컴포넌트는 트리쉐이킹이 되니 큰 문제는 아닐 수 있다.)

  4. (분리하지 않고) 한 파일/모듈에서 관리되면 상태관리가 어려움

  5. 각 서브컴포넌트 마다 필요한 props 등이 다를 경우 타입 복잡도가 증가함

  6. 메인컴포넌트 상태변화에 대해 서브컴포넌트도 영향을 받으므로 적절한 메모이제이션이 필요함


서브컴포넌트를 활용한 Compound Component 패턴

Compound Component는 Context API와 서브컴포넌트를 활용하여 부모컴포넌트와 자식컴포넌트들이 상태를 공유할 수 있게 해주는 디자인패턴이다.

서브컴포넌트를 활용하기 때문에 직관적인 API 디자인을 가지게 된다는 장점이 있고 특정 목적을 가진 컴포넌트를 모아서 관리할 수 있다는 장점이 있다. 거기다가 부모 컴포넌트에 상태 관리 로직을 숨겨 더욱 더 캡슐화된 구성이 가능하다.

이런 장점 때문에 Ant Design, React Bootstrap 같은 UI 라이브러리들이 이러한 패턴을 활용하고 있다.

// 부트스트랩에서 제공하는 예시 코드
// Compound Component를 활용한 UI다.
function BasicExample() {
  return (
    <Card style={{ width: '18rem' }}>
      <Card.Img variant="top" src="holder.js/100px180" />
      <Card.Body>
        <Card.Title>Card Title</Card.Title>
        <Card.Text>
          Some quick example text to build on the card title and make up the
          bulk of the card's content.
        </Card.Text>
        <Button variant="primary">Go somewhere</Button>
      </Card.Body>
    </Card>
  );
}

로직이 복잡해진다면 유지보수 시 피로도가 커질 수 있고 타입스크립트를 활용할 때 모든 Prop과 context에 대해 타입을 작성해야하며 지역적으로 활용되는 타입 정의이므로 전역 레벨이 아닌 컴포넌트 레벨에서 작성이 되게 되며 이로인해 복잡도가 증가한다는 단점도 가지고 있다.

구현해보기

메인컴포넌트

// 타입 정의
interface ComponentContextType {
  count: number;
  setCount: React.Dispatch<React.SetStateAction<number>>;
}


// 전역 컨텍스트 생성
const ComponentContext = createContext<ComponentContextType>({
  count: 0,
  setCount: () => {},
});

const MainComponent = ({ children }: { children: React.ReactNode }) => {
  const [count, setCount] = useState(0);

  return (
  // 전역 컨텍스트 프로바이더를 통해 상태 주입
    <ComponentContext.Provider value={{ count, setCount }}>
      <div
        style={{
          border: "1px solid white",
          padding: "20px",
        }}
      >
        <div style={{ fontSize: "24px", marginBottom: "20px" }}>
          메인컴포넌트
        </div>
        {children}
        <p>결과 : {count}</p>
      </div>
    </ComponentContext.Provider>
  );
};
  1. Context API를 활용해 서브컴포넌트에 상태를 주입할 수 있도록 해준다.

  2. 해당 UI에 필요한 공통 로직도 부모컴포넌트 기준으로 작성되고 서브컴포넌트는 이를 주입받는 형태로 구현된다.

서브컴포넌트

const SubComponent1 = () => {
  const { count, setCount } =
    useContext<ComponentContextType>(ComponentContext);

  return (
    <div style={{ backgroundColor: "black", padding: "10px 0 10px" }}>
      <div style={subComponentTitleStyle}>서브컴포넌트1</div>
      <button
        onClick={() => setCount(count + 1)}
        style={subComponentButtonStyle("blue")}
      >
        +1 클릭
      </button>
    </div>
  );
};

const SubComponent2 = () => {
  const { count, setCount } =
    useContext<ComponentContextType>(ComponentContext);

  return (
    <div style={{ backgroundColor: "black", padding: "10px 0 20px" }}>
      <div style={subComponentTitleStyle}>서브컴포넌트2</div>
      <button
        onClick={() => setCount(count - 1)}
        style={subComponentButtonStyle("red")}
      >
        -1 클릭
      </button>
    </div>
  );
};

// MainComponent에 주입 (선언 단계에서 처리해도 됨)
MainComponent.SubComponent1 = SubComponent1;
MainComponent.SubComponent2 = SubComponent2;
  1. Context API를 활용해 부모컴포넌트의 상태값을 전달받게 된다.

  2. 다른 서브 컴포넌트도 이 상태를 공유하므로 일관된 상태로 개발 가능하다.

사용

function App() {
  return (
  // 직관적이 API 디자인으로 부모컴포넌트와 관계를 명확하게 드러낸다.
    <MainComponent>
      <MainComponent.SubComponent1 />
      <MainComponent.SubComponent2 />
    </MainComponent>
  );
}

이런 서브컴포넌트를 활용한 패턴의 장점은 캡슐화 되고 네임스페이스 공유로 부모컴포넌트와의 관계가 명확하게 드러난다는 점이다.

구현 화면

[동영상 공유가 되지 않아 샌드박스 추가]

구조시각화

이 패턴에 대해서 구조적으로 시각화하면 아래와 같다.

전역상태로 부모컴포넌트가 감싸지고 부모컴포넌트 내에서 각종 공통로직들을 자식 컴포넌트에 주입하며, 자식 컴포넌트들은 개별적인 로직과 UI를 가질 수 있다.


Children Prop을 활용한 Wrapper 컴포넌트

💡
Compound Component의 절대적 대안적 패턴은 아니고 그저 Wrapper를 활용한 방법도 비슷하다고 처음에 느껴서 추가로 작성한다.

일반적으로 children props를 활용해 Wrapper 컴포넌트를 만들어 더 유연한 구조로 만드는 것도 생각이 가능하다. 하지만 이는 확실한 대안적 패턴이라고 할 수는 없고 각자의 장단점이 있다.

우선 Wrapper를 활용한 컴포넌트 설계의 경우 아래와 같은 장점이 있다.

  1. 더 유연한 구조를 만들 수 있다.

  2. 구현 방식 자체가 훨씬 간단하고 각 컴포넌트가 독립적이다.

하지만 Compound Component의 핵심적인 구조인 부모/자식 컴포넌트의 상태 공유가 부재하므로 완전한 대안이라고 보기에는 어렵다. 또한 서브컴포넌트를 활용한 방식이 아니기에 동일한 네임스페이스로 API 를 쓰는 듯한 사용을 기대할 수 없다는 점도 상당한 차이를 보인다.

아래의 예시를 보면 Wrapper 객체가 아닌 외부에서 상태를 주입하고 있는 것을 확인할 수 있는데 Context API를 활용할 수는 있겠으나 부모컴포넌트에 작성되어 서브컴포넌트들에 영향을 미치고 있던 Compound Component와는 전혀 다른 구현이다.

구현

Wrapper 컴포넌트

interface Props {
  children: React.ReactNode;
  count: number;
}

const Wrapper = ({ children, count }: Props) => {
  return (
    <div
      style={{
        border: "1px solid white",
        padding: "20px",
      }}
    >
      <div>메인 래퍼</div>
      {children}
      <p>결과 : {count}</p>
    </div>
  );
};

export default Wrapper;

Button 컴포넌트

// 증가버튼

import React from "react";

interface Props {
  count: number;
  setCount: React.Dispatch<React.SetStateAction<number>>;
}

const IncreaseCounterButton = ({ count, setCount }: Props) => {
  return (
    <div>
      <div>감소버튼</div>
      <button onClick={() => setCount(count - 1)}>-1 클릭</button>
    </div>
  );
};

export default IncreaseCounterButton;

// 감소버튼

import React from "react";

interface Props {
  count: number;
  setCount: React.Dispatch<React.SetStateAction<number>>;
}

const DecreaseCounterButton = ({ count, setCount }: Props) => {
  return (
    <div>
      <div>증가버튼</div>
      <button onClick={() => setCount(count + 1)}>+1 클릭</button>
    </div>
  );
};

export default DecreaseCounterButton;

사용

function App() {
  const [count, setCount] = useState(0);

  return (
    <Wrapper count={count}>
      <IncreaseCounterButton count={count} setCount={setCount} />
      <DecreaseCounterButton count={count} setCount={setCount} />
    </Wrapper>
  );
}

마치며…

앞서 말한 관성적으로 써오던 컴포넌트 설계는 당장의 구현에는 큰 문제가 없었다. 하지만 프로젝트 구조가 복잡해질 수록 더 다양한 설계에 대해서 공부하고 고민해야겠다는 생각을 하게 되었는데, 그 시작으로 서브컴포넌트와 그를 활용한 컴파운드 컴포넌트 패턴을 아티클로 작성했다.

원래는 검색하면 나오는 기술적인 얘기는 적지 말자는 주의였으나 글을 적으면서 정리되는 내용들이 있어 기술적인 내용도 작성해보려고 한다.

일단 당분간은 컴포넌트 설계에 집중해서 작성해보도록 하겠다.

Frontend Tech

Part 3 of 3

회고와 기술을 분리해 작성하고자 합니다. 리액트를 중심으로 Front-End 관련 기술 아티클을 작성합니다.

Start from the beginning

[Series. 3] 데코레이터 패턴과 프론트엔드 개발

웁니다