이전에 구현했던 SNS 플랫폼 기반 익명/기명 롤링페이퍼 형식의 "대박 사건"을 리팩토링 하면서 아키텍처에 대한 아쉬움을 개선했던 경험을 작성한다.

우선 내가 맡았던 기능중 편지 작성, 이전 편지 수정, 댓글, 등 다양한 부분에서 Textarea가 활용되고 있다. 빠르게 페이지를 구현하느라 다양한 부분에서 동일하게 사용되고 있는 Textarea를 공통 컴포넌트로 분리 할 생각을 하지 못했다. 그러다 보니, 코드의 중복이 잦았고, 불필요한 코드들이 이곳 저곳에서 동일하게 작성되어 있었다.

또한, 다른 분이 구현해주셨던 공통 컴포넌트인 Input 컴포넌트를 이곳 저곳에서 사용하는 것을 보며 나름의 문제점을 찾았는데, 하나의 Input이라는 공통 컴포넌트 안에 자식으로 들어가는 UI를 고정시켜버려 사용하는 입장에서 확장성이 떨어진다고 느꼈다.

위 문제점을 정리해보면

  1. 중복되는 컴포넌트
  2. 고정된 UI로 인한 닫힌 확장성

이 부분을 좀 더 사용하는 입장에서 편리하고, 코드를 읽는 사람으로 하여금 좀 더 가독성있게 구현해보자.

우선 내가 수정 할 컴포넌트는 다음 이미지와 같다.

모두 같은 Textarea를 사용하고 있고 제목 밑의 underLine과 제목, 내용을 가지고 있고 첫번째 이미지만 버튼 두개를 추가적으로 더 가지고있다.

그렇다면 설계단계에서 무엇을 고민해볼 수 있을까

  • 일단 이 여러군데에서 공통적으로 사용되는 Textareacommon 폴더로 분리하여 공통 컴포넌트로 구현할 수 있다.

  • 어떤 페이지에서는 버튼 아이콘이 추가되어 사용되고, 어떤 페이지에서는 버튼 아이콘이 사용되지 않고 있고, 제목 없이 내용만 적을 수 있는 Textarea가 사용될 수도 있다.

    • 위 같은 상황에서는 공통 컴포넌트에서 모든 자식 컴포넌트(UI)를 고정시켜둔채 사용할 수 없게 되는데 어떻게 할 수 있을까?

위와 같은 고민을 했다면 Textarea 관련 여러 컴포넌트를 따로 분리해놓고 내가 사용하고 싶을 때 가져다가 붙혀넣는 식으로는 구현해 볼 수 없을까? 라고 생각 할 수 있다.

📌 이때 사용할 수 있는 패턴이 Compound Component Pattern(합성 컴포넌트 패턴)이다.

합성 컴포넌트란 무엇일까?

"합성 컴포넌트란 소프트웨어 개발에서 재사용이 가능한 구성 요소를 만들기 위해 여러 개의 다른 컴포넌트를 조합하는 디자인 패턴. 복합적인 기능을 가진 큰 컴포넌트를 작은 단위의 컴포넌트들로 구성함으로써 코드의 유지보수성확장성을 높일 수 있다."

쉽게 말하면 자동차 "튜닝과 비슷"한 것 같다. 기본적으로 자동차라는 틀이 있을 때 우리는 미리 만들어 둔 타이어 휠을 좀 더 멋있는 휠로 갈아 끼울 수도 있고, 자동차 로고를 없애버릴 수도 있고, 소리가 크게나는 마후라를 달 수도 있다. 또는 자동차 규격에만 맞는다면 세상에는 없던 튜닝 부품을 만들어서 달 수도 있다.

이처럼 합성 컴포넌트도 하나의 틀을 두고 우리가 튜닝하고 싶은대로 끼워 맞추는 것이라고 보면 좋을 것 같다.

최초 코드

좀 부끄럽지만... 프로젝트 마감기한이 얼마 남지 않아 빠르게 구현하느라 공통 컴포넌트로 뺄 생각도 못하고 각각의 페이지에서 따로 구현했었다(그럴싸하게 써 놨지만 쉽게 표현하면 그냥 노가다다 ㅋㅋㅋ 머리가 멍청하면 몸이 고생한다는 아주 좋은 예시).

‼️ 아래 코드 자세히 안봐도 됩니다!! 그냥 노가다의 흔적일 뿐 어떻게 구현했나 안보셔도 됩니다 그냥 슥- 지나가세요!!

/** 편지 작성 페이지의 편지지 컴포넌트 */
function Letter({ darkMode, register, userName }: letterProps) {
  return (
    <Style.LetterContainer darkMode={darkMode}>
      <Style.LetterTitle
        darkMode={darkMode}
        value={
          userName ? (userName === '익명' ? undefined : userName) : undefined
        }
        placeholder={
          userName
            ? userName === '익명'
              ? '작성자명을 입력해주세요(최대 15자)'
              : ''
            : '작성자명을 입력해주세요(최대 15자)'
        }
        maxLength={15}
        {...register('letterTitle', {
          required: '작성자명은 반드시 입력해야합니다.'
        })}
      />
      <Style.TitleUnderLine />
      <Style.LetterContent
        darkMode={darkMode}
        placeholder="내용을 입력하세요"
        {...register('letterComment', {
          required: '편지 내용은 반드시 입력해야합니다.'
        })}
      />
    </Style.LetterContainer>
  );
}

export default Letter;
/** 받은 편지(작성자라면 수정 또는 삭제 가능) 컴포넌트 */
function PrePost({ userName, darkMode, postId, postDetail }: PrePostProps) {
  /** 이전 코드 많이 생략 */
  return (
    <>
      <Style.PrePostAndCommentContainer>
        <Style.PrePostContainer darkMode={darkMode}>
          <Style.PrePostInnerTitle darkMode={darkMode}>
            {postDetail && JSON.parse(postDetail.title).title}
          </Style.PrePostInnerTitle>
          <Style.PrePostUnnerline />
          {postState ? (
            <Style.PrePostEditContent
              darkMode={darkMode}
              defaultValue={postDetail && JSON.parse(postDetail.title).content}
              {...register('prePostContent', {
                required: '편지 내용은 반드시 입력해야 합니다.'
              })}
            />
          ) : (
            <Style.PrePostContent darkMode={darkMode}>
              {postDetail && JSON.parse(postDetail.title).content}
            </Style.PrePostContent>
          )}
          {postState ? (
            <Style.CompleteImg
              src={completeIcon}
              onClick={handleSubmit(onSubmit)}
            />
          ) : (
            <Style.EditImg
              src={editIcon}
              onClick={() => {
                if (userName === '익명') {
                  toast.error('익명 회원은 편지를 수정 할 수 없습니다.');
                  return;
                }
                handlePostToggleClick();
              }}
            />
          )}
          <Style.DeleteImg
            src={deleteIcon}
            onClick={handleDeletePostClick}
          />
        </Style.PrePostContainer>
        <Style.LikeCommentContainer>
          <Style.LikeLogoContainer onClick={handleLikeCreateClick}>
            <Style.LikeLogo src={likeIcon} />
            <Style.ListCount darkMode={darkMode}>
              {postDetail?.likes.length}
            </Style.ListCount>
          </Style.LikeLogoContainer>
          <Style.CommentCountText darkMode={darkMode}>{' '}
            <Style.CommentCount>
              {postDetail?.comments.length}</Style.CommentCount>
            의 댓글이 있습니다.
          </Style.CommentCountText>
        </Style.LikeCommentContainer>
        <Style.PreCommentContainer>
          {postDetail?.comments.map(
            ({ comment, _id, author }, idx) =>
              titleAndCommentParsing(comment) && (
                <Style.PrePostComment
                  darkMode={darkMode}
                  key={idx}>
                  <Style.PrePostUserName
                    onClick={() => navigator(`/user/${author._id}`)}>
                    {`💬 ${titleAndCommentParsing(comment).title}: `}
                  </Style.PrePostUserName>
                  {titleAndCommentParsing(comment).comment}
                  <Style.CommentDeleteImg
                    src={deleteIcon}
                    data-id={_id}
                    onClick={handleDeleteCommentClick}
                  />
                </Style.PrePostComment>
              )
          )}
        </Style.PreCommentContainer>
      </Style.PrePostAndCommentContainer>
      <Toaster
        toastOptions={{
          style: toastStyle,
          duration: 1000
        }}
      />
    </>
  );
}
/** 받은 편지에 대한 댓글 작성 컴포넌트 */
function Comment({ darkMode, register, userName }: CommentProps) {
  return (
    <>
      <Style.CommentContainer darkMode={darkMode}>
        <Style.CommentTitleInput
          darkMode={darkMode}
          placeholder={userName ? '' : '작성자명을 입력해주세요'}
          value={userName ? userName : undefined}
          {...(userName === ''
            ? {
                ...register('commentTitle', {
                  required: '작성자명은 반드시 입력하셔야합니다.'
                })
              }
            : false)}
        />

        <Style.CommentTitleUnderLine />
        <Style.CommentContent
          darkMode={darkMode}
          placeholder="댓글을 입력하세요"
          {...register('commentContent', {
            required: '댓글 내용은 반드시 입력하셔야합니다.'
          })}
        />
      </Style.CommentContainer>
    </>
  );
}

이렇게 각 페이지에서 구현되었던 노가다 코드들을 공통 컴포넌트로 합성 컴포넌트 패턴을 적용하여 구현해보겠다!

우선 Textarea의 기본 뼈대가 될 코드부터 구현해보자.

React-hook-form, jotai 관련 코드는 집중해서 보지 않으셔도 됩니다.

import { ReactNode, createContext } from 'react';
import { FieldValues, Path, UseFormRegister } from 'react-hook-form';
import { useAtomValue } from 'jotai';
import { darkAtom } from '@/store/theme';
import TextareaContent from './TextareaContent';
import TextareaTitle from './TextareaTitle';
import TextareaUnderLine from './TextareaUnderLine';
import * as Style from './index.style';

export const TextareaContext = createContext({
  darkMode: false
});

interface TextareaContainerProps {
  children: ReactNode;
  width: string;
  height: string;
}

export interface TextareaProps<T extends FieldValues> {
  value?: string;
  register?: UseFormRegister<T>;
  placeholder: string;
  maxLength?: number;
  formKey: Path<T>;
  width: string;
  height: string;
}

function Textarea({ children, width, height }: TextareaContainerProps) {
  const darkMode = useAtomValue(darkAtom);

  return (
    <TextareaContext.Provider value={{ darkMode }}>
      <Style.TextareaContainer
        darkMode={darkMode}
        width={width}
        height={height}>
        {children}
      </Style.TextareaContainer>
    </TextareaContext.Provider>
  );
}

Textarea.TextareaTitle = TextareaTitle;
Textarea.TextareaContent = TextareaContent;
Textarea.TextareaUnderLine = TextareaUnderLine;

export default Textarea;

뼈대인 TextareaContainer 컴포넌트를 만들어주고, 제목, 내용, 밑줄 컴포넌트를 임포트해서 Textarea의 속성으로 구현해주었다. JS의 함수는 객체이기 때문에 객체처럼 사용할 수 있다.

제목, 내용, 밑줄 컴포넌트는 children에 들어갈 예정이다.

아래는 Textarea의 제목 부분에 해당하는 컴포넌트이다.

import { useContext } from 'react';
import { FieldValues } from 'react-hook-form';
import { TextareaContext, TextareaProps } from './index';
import * as Style from './index.style';

function TextareaTitle<T extends FieldValues>({
  value,
  register,
  placeholder,
  maxLength,
  formKey,
  width,
  height
}: TextareaProps<T>) {
  const { darkMode } = useContext(TextareaContext);
  return (
    <Style.TextareaTitle
      darkMode={darkMode}
      value={value}
      placeholder={placeholder}
      maxLength={maxLength}
      width={width}
      height={height}
      {...(register && {
        ...register(formKey, {
          required: '작성자명은 반드시 입력해야합니다.'
        })
      })}
    />
  );
}

export default TextareaTitle;

다음은 Textarea의 내용에 관한 컴포넌트이다.

import { useContext } from 'react';
import { FieldValues } from 'react-hook-form';
import { TextareaContext, TextareaProps } from './index';
import * as Style from './index.style';

function TextareaContent<T extends FieldValues>({
  register,
  placeholder,
  formKey,
  width,
  height
}: TextareaProps<T>) {
  const { darkMode } = useContext(TextareaContext);

  return (
    <Style.TextareaContent
      darkMode={darkMode}
      placeholder={placeholder}
      {...(register && {
        ...register(formKey, {
          required: '작성자명은 반드시 입력해야합니다.'
        })
      })}
      width={width}
      height={height}
    />
  );
}

export default TextareaContent;

추가로 밑줄 컴포넌트.

import * as Style from './index.style';

function TextareaUnderLine() {
  return <Style.TextareaUnderLine />;
}

export default TextareaUnderLine;

이제 구현한 TextareaTitle, TextareaContent, TextareaUnderLine을
Textarea 컴포넌트의 chidren 부분에 잘 합성해주면 끝!

아래는 합성 컴포넌트를 적용한 부분이다.

/** 편지지 컴포넌트 */
 <Textarea
          width={'100%'}
          height={'20.3125rem'}>
          <Textarea.TextareaTitle
            value={
              userName
                ? userName === '익명'
                  ? undefined
                  : userName
                : undefined
            }
            placeholder={
              userName
                ? userName === '익명'
                  ? '작성자명을 입력해주세요(최대 15자)'
                  : userName
                : '작성자명을 입력해주세요(최대 15자)'
            }
            maxLength={15}
            register={register}
            formKey={LETTER_TITLE}
            width={'95%'}
            height={'40px'}
          />
          <Textarea.TextareaUnderLine />
          <Textarea.TextareaContent
            placeholder={'내용을 입력하세요'}
            register={register}
            formKey={LETTER_CONTENT}
            width={'95%'}
            height={''}
          />
        </Textarea>

Textarea 의 자식 요소로(children) Textarea.TextareaTitle, Textarea.TextareaContent, Textarea.TextareaUnderLine이 들어가있다. 이 코드를 적용한 UI를 봐보자.

굉장히 잘 적용된 것을 볼 수 있다. 그렇다면 디자이너가 UnderLine이 마음에 안든다고 뺐으면 좋겠다고 요구했다고 쳤을때도 아주 쉽게 수정해볼 수 있다.

/** 편지지 컴포넌트 */
 <Textarea
          width={'100%'}
          height={'20.3125rem'}>
          <Textarea.TextareaTitle
            value={
              userName
                ? userName === '익명'
                  ? undefined
                  : userName
                : undefined
            }
            placeholder={
              userName
                ? userName === '익명'
                  ? '작성자명을 입력해주세요(최대 15자)'
                  : userName
                : '작성자명을 입력해주세요(최대 15자)'
            }
            maxLength={15}
            register={register}
            formKey={LETTER_TITLE}
            width={'95%'}
            height={'40px'}
          />
          // 이 부분을 제거만 해주면 됨 <Textarea.TextareaUnderLine />
          <Textarea.TextareaContent
            placeholder={'내용을 입력하세요'}
            register={register}
            formKey={LETTER_CONTENT}
            width={'95%'}
            height={''}
          />
        </Textarea>

밑줄을 삭제한 UI를 보면 아래와 같다.

현재 코드에서는 버튼 컴포넌트를 구현하여 적용하지는 않았지만 지금까지 구현해놓은 흐름과 비슷하게 작성하면 된다. 각각의 기능 또는 UI를 가진 컴포넌트를 구현하고 사용하는 입장에서 자식요소로 넣거나 넣지 않거나 해주면 되는 것이다.

합성 컴포넌트를 사용하면 유연하게 UI를 변경할 수 있으며 Props Drilling을 줄일 수 있다는 장점도 있어 합성 컴포넌트는 여러 측면에서 장점을 가지고 있는 것 같다.

profile
https://choi-ik.tistory.com/ 👈🏻 여기로 블로그 이전했습니다 ㅎ

2개의 댓글

comment-user-thumbnail
2024년 2월 19일

huns 특별 출연 감사합니다ㅎㅎ

1개의 답글