리엑트 디자인 패턴 - 컴포넌트 설계

dante Yoon·2021년 7월 5일
10
post-thumbnail

글을 시작하며

리엑트 디자인 패턴은 크게 훅 사용을 다루는 부분과 컴포넌트 설계를 다루는 부분, 두 가지로 나눌 수 있을 것 같습니다.

동일한 컴포넌트 요구사항이 주어지더라도 개발자마다 각기 다른 스타일로 컴포넌트를 작성합니다. 본인이 사용하는 컴포넌트에 대한 장점을 설명할 수는 있어도 이러한 패턴이 실제로 어떠한 명칭을 가지는지, 실제 어떤 모델에서 파생된 것인지 모르는 경우가 있습니다.

유명한 패턴들이 어떤 것들이 있는지, 나는 주로 어떤 패턴을 사용하는지 알고 있다면 코드리뷰나 다른 개발자와의 커뮤니케이션을 좀 더 원활히 할 수 있을 것 같습니다.

오늘은 유명한 컴포넌트 설계에 패턴에 어떤 것들이 있는지 톺아보도록 하려고 합니다.

다음 주제는 다루지 않습니다.

IOC (Inversion Of Control), Custom Hook, Performance

Container Presentational components


리덕스와 모벡스를 사용하는 앱의 경우 각각 ReduxProvider, MobxProvider로 비즈니스 로직이 작성된 스토어와 앱을 연결시켜 각 페이지에서 해당 스토어 값을 읽어옵니다.

앱은 여러 컴포넌트들이 겹쳐있는 구조로 구성이 되는데 이 때 비즈니스 로직과 통신하는 컴포넌트가 있는 반면 UI를 표현하는 역할을 하는 컴포넌트가 있습니다.

Container component 혹은 Container Page라고 하는 부분은 앱에서 중요한 비즈니스 로직과 통신하는 액션(dispatch)와 같은 역할을 맡아 UI 컴포넌트에게 뷰를 그리기 위한 데이터를 전달하는 역할을 합니다.

여기서 UI 컴포넌트는 Presentational component라고 불리며 재사용 할 수 있는 디자인 컴포넌트와 같은 것들이 이 범주에 속합니다. 디자인 시스템을 적용한 프로덕트의 경우 재사용 가능한 컴포넌트를 만들기 위해 비즈니스 로직, 심지어 lodash와 같은 외부 라이브러리의 사용 또한 엄격히 통제하여 재사용성을 극대화 시키는 경우도 있습니다.

render props pattern


공식 리엑트 문서에서 render props 패턴에 대한 자세한 설명을 찾아볼 수 있습니다.

컴포넌트의 render props에 함수를 전달해 어떻게 렌더링할지를 결정하는 패턴입니다.

예시로 앱에서 마우스가 움직일 때 커서를 따라 jpg가 이동하는 기능을 만들어본다면 이 때
MouseTracker 컴포넌트는 마우스 포인터의 좌표 값을 render props로 전달받는 자식 컴포넌트에게 전달해줍니다.

// MouseTracker.tsx

 import React from "react";
 const MouseTracker = ({render}) => {
   const [pointer, setPointer] = useState({x: 0, y: 0});
   
   const handleMousePointer = (event) => {
     setPointer({
       x: event.clientX,
       y: event.clientY,
     });
   }
   
   return(
     <div onMouseMove={handleMousePointer}>
       render(pointer)
     </div>
   )
 }

실제 MouseTracker가 어떻게 쓰이는지 살펴보면 아래와 같습니다.
render props로 강아지 이미지를 렌더링하는 컴포넌트가 전달된다고 합시다.
PuppyPointer 컴포넌트는 {x,y} 객체를 받아 본인의 위치를 설정합니다.

// App.tsx
import React from "react";
import MainApp from "./MainApp";
import PuppyPointer from "./PuppyPointer";

const App = () => {
  return(
    <>
      <MainApp/>
      <MouseTracker render={props => <PuppyPointer {...props} />}
    </>
  )
}

이 패턴의 이름은 render props 이지만 꼭 render이라는 값을 사용해야 하는 것은 아닙니다. 상황에 따라 children이 편할수도 있으며 자식 컴포넌트에 그대로 넣어줄 수 있습니다.

<Mouse children={mouse => (
  <p>The mouse position is {mouse.x}, {mouse.y}</p>
)}/>

앞선 예제와 같이 event 객체를 받아서 뷰를 그려주는 컴포넌트를 만들때도 해당 패턴이 유용하게 사용될 수 있지만,
탭, 토글과 같이 부모 컴포넌트의 상태 값에 따라 자식 컴포넌트의 화면 노출 여부가 결정되는 UI를 구성할때 더욱 유용하게 사용할 수 있습니다.

토글 컴포넌트에 이 패턴을 적용해본다면 아래와 같이 작성할 수 있습니다.

const Toggle = ({ children }) => {
  const [isOpen, setOpen] = React.useState(false);
  const handleToggle = () => {
    setOpen((openStatus) => !openStatus);
  };
  return children({ onToggle: handleToggle, isOpen });
};

<Toggle>
  {({ onToggle, isOpen }) => (
    <>
      <button onClick={onToggle}>click toggle</button>
      {isOpen ? <div>toggle opened</div> : <div>toggle closed</div>}
      </>
  )}
</Toggle>

<Toggle/> 내부에 원하는 렌더링 대상을 작성하기만 하면 해당 Toggle 컴포넌트는 다양한 상황에서 재사용될 수 있을 것입니다.

render props 패턴은 유용하지만 중첩된 컴포넌트를 사용할 경우 예상치 못한 prop drilling을 유발할 수 있습니다. 아래처럼 B컴포넌트에서 내부적으로 여러 단계의 중첩 계층을 형성하고 있다면 불필요한 Prop drilling으로 인해 복잡도가 올라갈 것입니다.

  <A> (a) => 
    <B a ={a} />
  </A>
      
const B = (a) => <C a={a}>

이러한 계층 구조에서는 ContextAPI를 이용해 컴포넌트를 구성하는 것이 더 용이할 수 있습니다.

compound components

방금 언급한 ContextAPI를 이용해 의존관계가 있는 부모-자식 컴포넌트의 state 연결을 용이하게 할 수 있습니다.

탭(Tab)은 대표적인 부모-자식 엘리먼트의 관계가 두터운 컴포넌트입니다.
Tab과 이를 둘러싸고 있는 Tabs 컴포넌트는 개별적으로는 동작을 하지 못하지만 서로 합쳐지면 본래의 역할을 잘 수행합니다.
Material-UI, BootStrap, Antd 와 같은 라이브러리를 사용해보신 분들은 얼마나 간편하게 탭 컴포넌트를 사용할 수 있는지 경험해보셨을 것입니다. 별 다른 추가 조작 없이도 <Tab>으로 래핑하기만 하면 됩니다.

<Tabs>
  <Tab>
    Lorem Ipsum is simply dummy text of the printing and typesetting
    industry. Lorem Ipsum has been the industry's standard dummy text ever
    since the 1500s, when an unknown printer took a galley of type 
  </Tab>
  <Tab>
    It is a long established fact that a reader will be distracted by the
    readable content of a page when looking at its layout. The point of
    using Lorem Ipsum is that it has a more-or-less normal distribution of
    letters, as opposed to using 'Content here, content here', 
  </Tab>
  <Tab>
    Contrary to popular belief, Lorem Ipsum is not simply random text. It
    has roots in a piece of classical Latin literature from 45 BC, making
    it over 2000 years old. Richard McClintock, a Latin professor at
    Hampden-Sydney College in Virginia, looked up one of the more obscure
    Latin words, consectetur,
  </Tab>
</Tabs>

마치 <select>, <option> 태그들과 같이, 사용하는 개발자는 내부적으로 어떻게 구현되어있는지 모르지만 내부적으로 암묵적인 State management가 이뤄지는 것을 추측할 수 있습니다.
이런 탭은 첫 번째로 React.Children과 React.CloneElement를 사용해서 구현할 수 있습니다.
개인적으로 복잡하지 않은 컴포넌트를 만들 때는 이 패턴을 사용하는 것을 좋아합니다.


export const Tab = ({ children, }) => {
  return <div>{children}</div>;
};

export const Tabs = ({ children }) => {
  const tabCounts = React.Children.count(children);
  const [index, setIndex] = React.useState(0);

  const handleClickTab = (idx) => {
    return () => setIndex(idx);
  };

  return (
    <section>
      <ul>
        {Array.from({ length: tabCounts }, (_, idx) => (
          <li onClick={handleClickTab(idx)}>`${idx + 1}`</li>
        ))}
      </ul>
      <article>
        {children &&
          React.Children.map(children, (child) =>
            React.cloneElement(child, { ...child.props })
          ).filter((_, idx) => idx === index)}
      </article>
    </section>
  );
};

탭의 계층 구조가 복잡해지고 여러 추가적인 기능을 요구받는다면 prop drilling이 발생하는데요,
보다 많은 요구사항에 유연하게 대처하기 위한 방법으로 React.Context를 이용해 볼 수 있습니다.


const TabContext = React.createContext();

const useTabContext = () => {
  const context = React.useContext(TabContext);
  if (!context) {
    throw "TabContext is not occured before useTabContext";
  }

  return context;
};

const { Provider: TabProvider } = TabContext;

export const TabController = ({ children }) => {
  const { tabIndex } = useTabContext();

  return (
    <div>
      <div>`current openedTab:${tabIndex + 1}nth`</div>
      {children}
    </div>
  );
};

export const TabBar = ({ idx }) => {
  const { setTabIndex } = useTabContext();
  const handleClickTab = () => () => {
    setTabIndex(idx);
  };
  return <div onClick={handleClickTab(idx)}>{idx + 1}</div>;
};

export const Tab = ({ children }) => {
  return <div>{children}</div>;
};
export const Tabs = ({ children }) => {
  const [tabIndex, setTabIndex] = React.useState(0);
  const providerValue = {
    tabIndex,
    setTabIndex
  };

  return (
    <TabProvider value={providerValue}>
      <TabController>
        {React.Children.toArray(
          Array.from({ length: React.Children.count(children) }, (_, idx) => (
            <TabBar idx={idx} />
          ))
        )}
      </TabController>
      {children[tabIndex]}
    </TabProvider>
  );
};

기존 탭과 다르게 현재 몇 번째 탭이 열렸는지 알려주는 TabController 컴포넌트 또한 추가했습니다. 필요한 기능이 추가적으로 생김에 따라 prop을 해당 컴포넌트에 전파해야 하는 것이 아닌, hook을 이용해 필요한 value만 가져다가 유연하게 사용할 수 있습니다.

props collection pattern


앞서 살펴보았던 render props pattern 와 유사한 패턴입니다.
다시 한번 토글 컴포넌트를 불러오자면 아래와 같습니다.
이때 <Toggle> 이 전달해주는 props를 child component의 적재적소에 개발자가 적절히 흩뿌려줘야 합니다.

1.웹 접근성과 관련된 attribute 중 aria-expanded 속성은 버튼, 인풋과 같은 태그에 고정적으로 입력해주어야 하는 값입니다. 이렇게 고정적인 props나

2.컴포넌트 사용에 있어 내부적으로 사용하는 props이나 실제 설계자가 아닌 사용자가 고려할 필요가 없는 props의 경우 하나의 그룹으로 묶어 자식 컴포넌트에 전달해야할 수 있습니다.


const Toggle = ({ children }) => {
  const [isOpen, setOpen] = React.useState(false);
  const commonAppliedProps = {
    "aria-expanded": isOpen
  };
  const handleToggle = () => {
    setOpen((openStatus) => !openStatus);
  };
  return children({
    onToggle: handleToggle,
    isOpen,
    togglerProps: commonAppliedProps
  });
};

commonAppliedProps라는 변수로 자식 컴포넌트의 속성으로 들어가야 할 값들을 그룹핑하여 togglerProps라는 값으로 전달해주고 있습니다.

실제 사용하는 측에서는 이 값을 spread operator로 전달해주기만 하면 됩니다.

<Toggle>
  {({ onToggle, isOpen, togglerProps }) => (
    <>
      <button onClick={onToggle} {...togglerProps}>
        click toggle
      </button>
      {isOpen ? <div>toggle opened</div> : <div>toggle closed</div>}
      </>
  )}
</Toggle>

속성 값이 제대로 적용된 것을 알 수 있습니다.

toggle component

글을 마치며

여러분은 어떠한 스타일로 컴포넌트 작성하는 것을 선호하시나요? 저는 이번 기회를 통해 어떤 스타일의 설계 패턴이 리엑트 세계에 존재하는지 알 수 있었습니다.
디자인 패턴은 클린코드 철학과 함께 유지보수하기 쉽고 장기적으로 높은 생산성을 갖는 코드를 만드는데 큰 도움을 준다고 생각합니다.
얼마 전 나온 리엑트 18버전, 넥스트 11 버전과 같이 빠른 속도로 발전하는 프론트엔드 생태계를 따라가는 것은 한가로이 앉아 레거시 코드를 읽고 반성하는 시간을 갖는 거과는 전혀 다른 방향에 있습니다.
하지만 최신 기술만큼이나 기존에 작성한 코드를 곱씹는 것에도 중요성을 느껴보며 각자 더 나은 형태의 패턴을 설계해보는 시간을 잠시 가져보는 것은 어떨까요?

reference:
1. https://reactjs.org/docs/render-props.html#using-props-other-than-render
2. https://kentcdodds.com/blog/advanced-react-component-patterns

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글