1. 컴파운드 컴포넌트 (Compound Component) 패턴

컴파운드 컴포넌트 패턴은, 여러개의 컴포넌트를 "복합적으로" 사용 하는 것을 의미하는데요. 주로 하나의 부모 컴포넌트와 여러개의 자식 컴포넌트로 이뤄지며, 자식 컴포넌트는 부모 컴포넌트 없이는 작동하지 않습니다.

실제로 어떻게 이뤄졌는지, 한번 코드를 확인해봅시다.

import React, { Component } from 'react';
import Tabs from './Tabs';
class App extends Component {
  render() {
    return (
      <Tabs>
        <Tabs.Tab name="tab1" text="첫번째 탭">
          안녕하세요! 이건 첫번째 탭이죠.
        </Tabs.Tab>
        <Tabs.Tab name="tab2" text="둘째 탭">
          이것은 두번째 탭이에요
        </Tabs.Tab>
        <Tabs.Tab name="tab3" text="세번째 탭">
          이건 세번째네요
        </Tabs.Tab>
        <Tabs.Tab name="tab4" text="네번째 탭">
          사실 네번째 탭이 마지막입니다.
        </Tabs.Tab>
      </Tabs>
    );
  }
}

export default App;

위 코드의 결과물은 다음과 같이 생겼습니다:

Edit Compound Component

여기서 Tab 컴포넌트는 Tabs 컴포넌트 없이는 쓸모가 없는 컴포넌트입니다. 즉, Tab 컴포넌트에 종속적입니다. Tab 은 언제나 Tabs 내부에서 사용 될 것이기에 Tab 를 Tabs 의 namespaced 컴포넌트로 설정해주었습니다.

Namespaced 컴포넌트

Namespaced 컴포넌트는 A.B 이런식으로 사용되는 컴포넌트입니다. namespaced 컴포넌트를 만드는 방식은 여러방법이 있는데 한번 예제코드를 통해 학습해봅시다.

static 값으로 설정하기

import React, { Component } from 'react';

class Tabs extends Component {
  static Tab = () => {}

  /* 클래스형태로도 설정 가능
  static Tab = class Tab extends Component {
    // ...
  }
  */
  render() {
    // ...
  }
}

export default Tabs;

이렇게 static 키워드를 사용하여 설정해줄수도있고 아니면 class 밖에서 설정하는 방법도 있습니다.

class 밖에서 설정하기

import React, { Component } from 'react';

class Tabs extends Component {
  render() {
    // ...
  }
}

Tabs.Tab = () => {};

/* 이것도 가능
Tab = class Tab extends Component {
  // ...
}
*/

export default Tabs;

여러분이 편한 방식, 아무거나 사용하면 됩니다. 내부적으로 사용 할 컴포넌트에서 라이프사이클 메소드를 필요로 한다면 class 형태로 작성하시면 되고, 그렇지 않다면 함수형으로 작성하시면 됩니다.

React.Children 과 cloneElement

컴파운드 컴포넌트의 핵심은 부모 컴포넌트가 자식 컴포넌트한테 특정 props 를 직접 주입해준다는 것 입니다. 위 예제에서 사용된 App 컴포넌트의 render 를 확인해보면,

      <Tabs>
        <Tabs.Tab name="tab1" text="첫번째 탭">
          안녕하세요! 이건 첫번째 탭이죠.
        </Tabs.Tab>
        <Tabs.Tab name="tab2" text="둘째 탭">
          이것은 두번째 탭이에요
        </Tabs.Tab>
        <Tabs.Tab name="tab3" text="세번째 탭">
          이건 세번째네요
        </Tabs.Tab>
        <Tabs.Tab name="tab4" text="네번째 탭">
          사실 네번째 탭이 마지막입니다.
        </Tabs.Tab>
      </Tabs>

위와 같은 형식으로, Tabs.Tab name 과 text, 그리고 그 안의 내용을 정의해주었지만, 현재 어떤 탭이 선택되었는지에 대한 정보는 전달해주지 않고 있습니다. 그러면, Tabs.Tab 은 현재 어떤 탭이 선택된건지 어떻게 알 수가 있을까요?

바로, Tabs 컴포넌트 의 render 함수에서 자신의 children 에 추가 값을 전달해주고 있기 때문에 알 수 있습니다:

{React.Children.map(this.props.children, tab => {
  return React.cloneElement(tab, {
    currentTab
  });
})}

이 과정에서 React.Children 이 사용됩니다. 우리는 기존에 렌더링하는 children 들에서 렌더링하는 엘리먼트를 cloneElement 를 통하여 복사하고, 우리가 추가적으로 넣어주고 싶은 props 를 전달해주고 있습니다.

결국, App에서 렌더링했던 Tabs.Tab 은

<Tabs.Tab name="tab1" text="첫번째 탭">
  안녕하세요! 이건 첫번째 탭이죠.
</Tabs.Tab>

이지만 실제로 렌더링될때에는

<Tabs.Tab name="tab1" text="첫번째 탭" currentTab={currentTab}>
  안녕하세요! 이건 첫번째 탭이죠.
</Tabs.Tab>

형태가 되는 것 입니다.

this.props.children 은 리액트 엘리먼트의 배열로서 다음과 같은 형태를 지니고 있습니다.

우리는 위에 보이는 각 엘리먼트들을 복사해서 사용하고 있는 것인데요, 왜 그냥 this.props.children.map 을 사용하지는 않는지 궁금하신 분들도 있을겁니다.

this.props.children.map 을 써도, 작동 할 수도 있습니다. 하지만, 만약에 children 에 Tab 이 하나라면, 해당 children 은 배열형태로 받아오지 않게 되고, 만약에 children 이 없다면 값이 undefined 로 전달되기 때문에 .map 을 사용 할 수 없습니다.

React.Children 은 이와 관계 없이 제대로 처리를

만약 분기를 직접 태워서, undefined/null 일 경우, 배열이 아니라 하나의 엘리먼트일 경우, 그리고 배열일 경우를 하나하나 처리해준다면 React.Children 을 사용할 필요 없습니다.

실습! 단계별 회원가입 흐름 구현하기

이번에는 create-react-app 으로 프로젝트를 생성해서 에디터로 직접 작업하겠습니다.

물론, 원하신다면 Codesandbox 에서 작업하셔도 무방합니다.

Edit 리액트 시작하기

먼저 프로젝트를 생성하세요.

$ npx create-react-app steps-tutorial

그 다음, Steps 컴포넌트와 Step 컴포넌트의 틀을 Steps.js 에 작성하세요.

src/Steps.js

import React, { Component } from "react";

const Step = ({ children, index, currentIndex }) => {
  // index 가 일치할때만 보입니다.
  if (index !== currentIndex) return null;
  return children;
};

class Steps extends Component {
  static Step = Step; // Namespaced 컴포넌트 생성
  render() {
    return (
      <div>
        <h1>회원가입 창</h1>
        <p>못생겼지만, 회원가입 창입니다.</p>
        <hr />
        <section>{this.props.children}</section>
        <hr />
        <button>이전</button>
        <button>다음</button>
      </div>
    );
  }
}

export default Steps;

그리고, App 에서 Steps 컴포넌트와 그 안에 Steps.Step 을 렌더링하겠습니다:

import React from 'react';
import Steps from './Steps';

const App = () => {
  return (
    <Steps>
      <Steps.Step>
        <h2>약관 동의</h2>
        <p>여기선 약관 동의를 받고</p>
      </Steps.Step>
      <Steps.Step>
        <h2>가입 양식</h2>
        <p>아이디, 이메일, 기본적인 정보들을 받고</p>
      </Steps.Step>
      <Steps.Step>
        <h2>추가 정보</h2>
        <p>필수가 아닌 정보들을 더 받고</p>
      </Steps.Step>
      <Steps.Step>
        <h2>환영합니다!</h2>
        <p>뭐 이런 흐름으로 하면 된다는 것이죠!</p>
      </Steps.Step>
    </Steps>
  );
};

export default App;

Edit steps-tutorial

지금은 모든 단계가 다 나타날 것입니다.

이제 내부 로직들을 구현해볼까요?

Steps 컴포넌트를 다음과 같이 수정해보세요

src/Steps.js

import React, { Component } from 'react';

const Step = ({ children, index, currentIndex }) => {
  // index 가 일치할때만 보입니다.
  if (index !== currentIndex) return null;
  return children;
};

class Steps extends Component {
  static Step = Step;
  state = {
    currentIndex: 0
  };
  handleNext = () => {
    this.setState({
      currentIndex: this.state.currentIndex + 1
    });
  };
  handlePrevious = () => {
    this.setState({
      currentIndex: this.state.currentIndex - 1
    });
  };
  render() {
    const { currentIndex } = this.state;
    return (
      <div>
        <h1>회원가입 창</h1>
        <p>못생겼지만, 회원가입 창입니다.</p>
        <hr />
        <section>{this.props.children}</section>
        <hr />
        <button disabled={currentIndex === 0} onClick={this.handlePrevious}>
          이전
        </button>
        <button
          disabled={
            /* this.props.children && 을 해주는 이유는, 
              해당 값이 존재 할 때만 length 를 확인하게 하기 위함 입니다.*/
            currentIndex === (this.props.children && this.props.children.length - 1)
          }
          onClick={this.handleNext}
        >
          다음
        </button>
      </div>
    );
  }
}

export default Steps;

Edit steps-tutorial

버튼이 눌려짐에 따라 currentIndex 상태 값을 +1 혹은 -1 하도록 처리를 했고, 버튼에 현재 currentIndex 값에 따라 비활성화를 하는 속성을 설정해주었습니다. 여기서 disabled 는 HTML button의 기본 기능입니다.

이제, React.Children 과 cloneElement 를 사용해서 자식 컴포넌트들에게 props 를 넣어줘봅시다!

src/Steps.js

기존에 {this.props.children} 을 렌더링하던 부분을 다음과 같이 구현해보세요:

import React, { Component } from 'react';

const Step = ({ children, index, currentIndex }) => {
  // index 가 일치할때만 보입니다.
  if (index !== currentIndex) return null;
  return children;
};

class Steps extends Component {
  static Step = Step;
  state = {
    currentIndex: 0
  };
  handleNext = () => {
    this.setState({
      currentIndex: this.state.currentIndex + 1
    });
  };
  handlePrevious = () => {
    this.setState({
      currentIndex: this.state.currentIndex - 1
    });
  };
  render() {
    const { currentIndex } = this.state;
    return (
      <div>
        <h1>회원가입 창</h1>
        <p>못생겼지만, 회원가입 창입니다.</p>
        <hr />
        <section>
          {/* 콜백 함수에서 두번째 파라미터는 순서를 가르키는 index 값입니다. 
          0, 1, 2, 3 이런 값들을 우리가 따로 지정하지 않아도 
          자동으로 children 순서대로 넣어줍니다. */
          React.Children.map(this.props.children, (step, i) =>
            React.cloneElement(step, {
              index: i,
              currentIndex
            })
          )}
        </section>
        <hr />
        <button disabled={currentIndex === 0} onClick={this.handlePrevious}>
          이전
        </button>
        <button
          disabled={
            /* this.props.children && 을 해주는 이유는, 
              해당 값이 존재 할 때만 length 를 확인하게 하기 위함 입니다.*/
            currentIndex ===
            (this.props.children && this.props.children.length - 1)
          }
          onClick={this.handleNext}
        >
          다음
        </button>
      </div>
    );
  }
}

export default Steps;

Edit steps-tutorial

구현이 완료되었습니다!

정리

이러한 구조를 모르더라도, 우리가 원하는 기능은 충분히 구현 할 수 있을겁니다. 하지만, 컴파운드 컴포넌트에 익숙해지면 컴포넌트를 사용하는 쪽에서 더 높은 가독성을 보이면서 코드를 작성 할 수 있게됩니다.

results matching ""

    No results matching ""