Tech

React 합성 컴포넌트로 재사용성 극대화하기

ENTER TECH 2022. 9. 26. 17:00

 

 

 

프론트엔드 개발을 하다 보면 빠짐없이 등장하는 주제 중 하나는 ‘어떤 기준으로 컴포넌트를 나눌 것인가’라고 생각합니다. 마찬가지로 카카오페이지 웹 파트도 이 주제로 여러 가지 시행착오를 겪어 왔습니다. 현재는 저희 FE개발팀의 Harry가 소개해 주신 Atomic Design Pattern을 적용해 UI 컴포넌트를 나누는 기준으로 활용하고 있습니다. (https://fe-developers.kakaoent.com/2022/220505-how-page-part-use-atomic-design-system/)

 

개인적으로 이 디자인 패턴의 가장 큰 장점을 꼽아보자면 컴포넌트별 역할이 잘 분리되고 높은 재사용성을 가진다는 것입니다. 재사용성이 뛰어난 컴포넌트들을 미리 잘 준비해 놓으면 추후 화면 전체를 개발할 때 속도 향상에 큰 도움이 되는 것을 체감하고 있습니다. 그래서 현재 프로젝트의 UI 컴포넌트를 개발할 때는 재사용성에 공을 상당히 많이 들이고 있습니다.

 

그렇게 컴포넌트들을 만들던 도중 하루는 Dialog Modal 구현이 필요하다는 디자인 팀의 요구사항이 들어왔습니다. 첨부된 이미지처럼 제목, 확인 버튼이 있는 안내 메시지였습니다. 이 컴포넌트도 역시 재사용성이 높게 만들고 싶어 졌습니다. 어떻게 구현해볼 수 있을까요?

 

 

단순한 Dialog Modal

Step 1. 최초 구현

최초 요구사항은 무척 간단했습니다. 단순히 화면에 안내 메시지를 보여주면서 언제든 사용자가 닫을 수 있는 Dialog가 필요했습니다. 재사용성을 고려해 이런 컴포넌트를 만드는 것은 크게 난해한 일은 아닙니다. 아래 코드처럼 해당 컴포넌트를 그리는 데 필요한 모든 정보를 prop으로 받아서 컴포넌트를 구현한다면 어디서든 텍스트와 동작을 정의해 편하게 사용할 수 있을 것입니다. 실제로 최초에는 이 방향으로 Organism Atomic Component로 제작되어 사용되었습니다.

interface Props {
    isOpen: boolean;
    title: string;
    buttonLabel: string;
    onClickButton: (e:MouseEvent) => void;
}

function Dialog({ isOpen, title, buttonLabel, onClickButton }: Props){
    if (!isOpen){
        return null;
    }
    return React.createPortal(
        <div>
            <span>{title}</span>
            <button onClick={onClickButton}>{buttonLabel}</button>
        </div>
    ,document.body)
}

위와 같은 방향으로 코드를 작성한다면 Dialog가 필요한 곳에서 상황에 맞는 prop을 넘겨줌으로써 다양한 Dialog를 그릴 수 있게 됩니다.

 

Step 2. 추가 요구사항

하지만 개발이 계속됨에 따라 조금 더 다양한 Dialog 형태가 생기기 시작했습니다. Dialog에 체크박스나 버튼이 더 생기기도 하고, 제목 외에도 주석이나 설명 등 부가적인 요소가 많이 들어오기 시작했습니다.

다양한 주석이 추가된 Dialog
중간에 버튼이 추가된 Dialog

 

만약 위처럼 2가지의 요구사항을 추가로 개발할 일이 생겼다고 가정해 보겠습니다. 사실 이 요구사항들을 Step 1에서 구현한 Dialog에 추가하기도 어려운 작업은 아닙니다. 관련된 Props를 추가하고 추가된 기능에 대해 처리를 해 주는 것으로 위에서 구현한 Dialog를 보완할 수 있습니다.

interface Props {
    isOpen: boolean;
    title: string;
    buttonLabel: string;
    onClickButton: (e: MouseEvent) => void;
    isChecked?: boolean;    
    checkBoxLabel?: string;   
    onClickCheckBox? : (e: MouseEvent) => void;   
    descriptionList?: string[]
}

function Dialog({ 
        isOpen, 
        title, 
        buttonLabel, 
        onClickButton, 
        isChecked, 
        checkBoxLabel, 
        onClickCheckBox, 
        descriptionList 
    }: Props){
     if (!isOpen){
        return null;
    }
    return React.createPortal(
        <div>
            <span>{title}</span>
            {descriptionList && descriptionList.map(desc => <span key={desc}>{desc}</span>)}
            {checkBoxLabel && <div>
                <input checked={isChecked} onClick={onClickCheckBox} type="checkbox" id="dialog_checkbox">
                <label for="dialog_checkbox">{checkBoxLabel}</label>
            </div>}
            <button onClick={onClickButton}>{buttonLabel}</button>
        </div>
    ,document.body)
}

코드가 조금 복잡해지긴 했지만 이런 식으로 컴포넌트를 수정하면 재사용성을 유지할 수 있습니다. 하지만 이쯤에서 마음 한편에 불안감이 들기 시작합니다. ‘과연 앞으로 얼마나 많은 요구사항이 추가될까?’. 버튼 한 개가 추가된 것만으로 3개의 props가 늘어났습니다. 요구사항이 늘어날수록 이 컴포넌트의 props는 끝을 볼 줄 모르고 늘어날 것입니다.

 

또, prop을 받아 처리하는 컴포넌트의 특성상 각 컴포넌트의 위치는 구현 시점에 이미 결정됩니다. 특정 상황에서는 checkbox가 description 위쪽에 보여야 한다고 가정해 볼까요? 위 코드는 이미 checkBox가 description 아래에 보이도록 확정이 된 상황이기 때문에 컴포넌트에 추가 처리를 해야 합니다. 이런 단순한 상황별 분기에 따라 컴포넌트는 또 prop을 추가해야 하고, 구현 시점에 모든 상황을 고려해서 구현을 해야만 합니다. 경험상 컴포넌트에 이런 히스토리가 쌓이고 구현이 추가될 때마다 기존에 해당 컴포넌트를 사용하던 곳에서 사이드 이펙트가 발생할 우려도 커지게 되고, 유지 보수도 어려워지게 됩니다. ( 아마 스토리북을 참고하면서 여러 케이스를 테스트하면서 사용해야만 하는 상황이 올 것 같네요. )

 

이쯤 되니 재사용성이 높은 컴포넌트를 만들겠다는 계획은 실패한 것으로 보입니다. 분명히 개선이 필요한 컴포넌트가 되어버렸고, 어떻게 개선해야 할지 고민에 잠기게 되었습니다.

 

Step 3. 합성 컴포넌트의 도입

합성 컴포넌트 패턴은 하나의 컴포넌트를 여러 가지 집합체로 분리한 뒤, 분리된 각 컴포넌트를 사용하는 쪽에서 조합해 사용하는 컴포넌트 패턴을 의미합니다. 간단한 예시로 html의 select를 볼 수 있는데, select는 <select>와 <option> 태그의 조합으로 이루어집니다. <select>와 <option>은 각각 독립적으로는 큰 의미가 없지만 사용하는 곳에서 이를 조합해 사용함으로써 화면에 의미 있는 요소가 됩니다. (https://developer.mozilla.org/ko/docs/Web/HTML/Element/select)

<select>
  <option value="1">Option 1</option>
  <option value="2">Option 2</option>
</select>

이처럼 사용하는 곳에서 컴포넌트의 조합을 활용할 수 있다면 높은 재사용성을 만족하면서 다양한 상황에 사용할 수 있다는 생각이 들어 도입해 보게 되었습니다. 어떤 순서로 어떻게 작업을 하게 되었는지 간략하게 설명해 보겠습니다.

 

3-1. 서브 컴포넌트 구현

html의 <option>태그에 해당하는 서브 컴포넌트를 구현합니다. Dialog를 구성하는 컴포넌트들이지만 각각 별개로는 큰 의미를 갖지 못하는 요소들을 서브 컴포넌트로 칭하게 되었습니다. Title을 표현할 <DialogTitle>, ButtonLabel을 표현할 <DlalogLabelButton> 등 Dialog의 구성요소가 될 수 있는 것들을 모두 서브 컴포넌트로 만들었습니다.

interface DialogTitleProps {
    children?: ReactNode;
}
function DialogTitle({children}: DialogTitleProps){
    return <div css={/*DialogTitle 스타일*/}>{children}</div>
}

interface DialogLabelButtonProps {
    children?: ReactNode;
    onClick?: (e: MouseEvent) => void;
}
function DialogLabelButton({children}: DialogLabelButtonProps){
    return <div css={/*DialogLabelButton 스타일*/}>{children}</div>
}

// ... 기타 Dialog 서브 컴포넌트

3-2. 메인 컴포넌트 구현

html의 <select>태그에 해당하는 메인 컴포넌트를 구현합니다. 서브 컴포넌트들을 묶어서 화면에 적절하게 보이도록 하는 Wrapper 성격의 컴포넌트입니다. 또, Dialog를 최종적으로 Dom에 렌더링 하는 역할을 수행합니다.

interface DialogMainProps {
    children?: ReactNode;
    isOpen: boolean;
}
function DialogMain({children, isOpen}: DialogMainProps){
    if(!isOpen) {
        return null;
    }
    return createPortal(<div>{children}</div>, document.body)
}

위처럼 작성하면 children으로 들어오는 서브 컴포넌트들은 순서에 따라 위에서 아래로 배치될 것입니다. 하지만 Dialog의 요소들을 살펴보면 일부 컴포넌트들은 단순히 위에서 아래로 흐르지 않고 특정한 곳에 있는 것을 알 수 있습니다. 예를 들면 LabelButton은 항상 Dialog 하단에 붙어있어야 하고, Dimmed가 있다면 Dimmed는 Dialog Stack 아래에 위치해야 하겠죠. 그래서 컴포넌트를 분류해 어느 정도 서브 컴포넌트의 위치를 잡아줄 수 있도록 구현했습니다.

const DialogLabelButtonType = (<DialogLabelButton />).type;
function getDialogLabelButtons(children: ReactNode) {
  const childrenArray = Children.toArray(children);
  return childrenArray
    .filter(
      child => isValidElement(child) && child.type === DialogLabelButtonType,
    )
    .slice(0, 2);
}

interface DialogMainProps {
    children?: ReactNode;
    isOpen: boolean;
}

function DialogMain({children, isOpen}: DialogMainProps){
    if(!isOpen) {
        return null;
    }
    const dialogContents = getDialogContents(children);
    const dialogLabelButtons = getDialogLabelButtons(children);
    const dialogDimmed = getDialogDimmed(children);
    
    return createPortal(
        <div>
            <div>{getDialogDimmed(children)}</div>
            {dialogContents && (
                <div>{dialogContents}</div>
            )}
            {dialogLabelButtons && (
                <div>{dialogLabelButtons}</div>
            )}
        </div>,
    document.body)
}

3-3. 메인 & 서브 컴포넌트를 묶어서 export

이렇게 구현된 컴포넌트들을 묶어서 export 해줍니다. 이렇게 해 주면 사용하는 곳에서 각각의 컴포넌트가 Dialog의 서브 컴포넌트임을 조금 더 확실하게 알 수 있어 가독성에 도움을 줄 수 있다고 생각해 작업했습니다.

// export
export const Dialog = Object.assign(DialogMain, {
  Dimmed: DialogDimmed,
  Title: DialogTitle,
  Subtitle: DialogSubtitle,
  Description: DialogDescription,
  Comment: DialogComment,
  CheckButton: DialogCheckButton,
  CheckBox: DialogCheckBox,
  TextButton: DialogTextButton,
  Button: DialogButton,
  LabelButton: DialogLabelButton,
  Divider: DialogDivider,
});

// Usage
<Dialog>
    <Dialog.Title>제목</Dialog.Title>
</Dialog>

3-4. 완성된 합성 컴포넌트를 사용해 화면 구현

이제 완성된 합성 컴포넌트를 사용해 어디서든 요구사항을 만족할 수 있게 되었습니다. 아래 사진처럼 조금 더 복잡한 Dialog를 구현할 일이 생기더라도, 얼마든지 서브 컴포넌트의 조합으로 손쉽게 만들 수 있습니다.

 

조금 복잡해진 Dialog

그럼 처음에 구현했던 prop 방식과 한번 비교해 볼까요? 먼저 prop을 사용한 방식입니다. 관련된 prop을 만들고 필요한 상태를 넘겨주는 방식으로 구현할 수 있을 것 같습니다. 코드를 정리해서 checkBoxList를 변수에 할당해 사용하면 코드를 조금 더 깔끔하게 만들 수 있겠지만 한눈에 이 Dialog에 checkBox가 어떻게 나열될지 알기 어렵고, 이미 구현된 대로만 사용해야 하므로 checkBox 중간에 안내 문구가 추가된다거나 했을 때 대응하기 어렵습니다.

<Dialog
    dimmed
    title="타이틀"
    checkBoxList={[
        {
            title: '버튼명',
            isChecked: true,
            hasArrowButton: true,
        },
          {
            title: '버튼명',
            isChecked: false,
            hasArrowButton: true,
        },
          {
            title: '버튼명',
            isChecked: false,
            hasArrowButton: true,
        },
          {
            title: '버튼명',
            isChecked: false,
            hasArrowButton: true,
        },
          {
            title: '버튼명',
            isChecked: false,
            hasArrowButton: true,
        },
    ]}
    labelButtonList={[
        { 
            title: '버튼레이블',
        }
    ]}
/>

그럼 합성 컴포넌트 방식은 어떨까요? 훨씬 직관적이고 Dialog가 어떻게 생겼을지 추측하기 쉽습니다. 또한 CheckBox 중간에 어떤 Dialog 요소가 추가된다고 하더라도, 서브 컴포넌트만 중간에 끼워 넣으면 요구사항을 바로 반영할 수 있습니다.

// 합성 컴포넌트 방식. 훨씬 직관적이고 상황별로 유연하게 대처할 수 있습니다.
<Dialog>
  <Dialog.Dimmed />
  <Dialog.Title>타이틀</Dialog.Title>
  <Dialog.CheckBox isChecked hasArrowButton>
    버튼명
  </Dialog.CheckBox>
  <Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
  <Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
  {/* 혹시 여기에 무언가 설명이 들어가야 한다면 아래처럼 추가만 하면 됩니다. 더이상 이미 구현된 Dialog를 수정할 필요는 없습니다.
    <Dialog.Description>설명</Dialog.Description> 
  */}
  <Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
  <Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
  <Dialog.LabelButton>버튼레이블</Dialog.LabelButton>
</Dialog>

 

Step 4. 결론

생각했던 것보다 아주 만족스러운 합성 컴포넌트가 만들어졌습니다. 재사용성도 뛰어나고 유연성도 높은 컴포넌트가 된 것입니다. 물론 합성 컴포넌트가 모든 의문의 해결점일 수는 없습니다. 일반적인 상황에서는 prop을 사용한 방식으로도 충분히 직관적으로 개발이 가능하고 스토리북에서 테스트하기도 훨씬 용이합니다.

 

하지만, 요구사항이 복잡하고 조금 더 다양한 상황을 고려해야 할 때, 재사용성을 만족시키기에 합성 컴포넌트 패턴은 분명히 아주 좋은 대안이 될 것이라고 확신합니다. 저와 비슷한 고민을 하시는 분들이 있다면 이번에 합성 컴포넌트 패턴을 도입해서 재사용성을 극대화하는 경험을 해 보시는 것도 좋을 것 같습니다.

Reference

 

 

 

 

 

 

더 많은 FE 지식을 나누고 싶다면?! 카카오엔터테인먼트 FE 기술블로그 [바로가기]