클린 아키텍쳐 이슈 트래커 (4) 공용 컴포넌트

2024. 3. 19. 21:49카테고리 없음

나는 그동안 재사용 가능한 공용 컴포넌트를 만들었나?

코드스쿼드에서 했던 프로젝트를 곰곰히 생각해보면 내가 어떤 컴포넌트를 과연 재사용했는가?에 대한 대답은 NO 였다. 물론 내 기준에 적절한 크기로 컴포넌트를 분리는 했지만 과연 공용 컴포넌트 폴더에 넣어둔 공용 컴포넌트가 정말 공용이었을까? 

 

어떻게 하면 컴포넌트를 재사용 가능하게 사용할 수 있을까? 이 글이 정말 큰 도움을 줬다. 코드스쿼드를 다닐 때도 읽었던 글인데, 그 당시엔 전혀 공감되지 않았다. 코드스쿼드 수료 후에 리액트를 다시 공부하고 읽어보니 이제는 조금 이해했다. 아직 그 행간을 모두 이해하지는 못했지만 컴포넌트를 나누는 나만의 기준을 세우는데 가장 큰 도움을 준 글이다.

 

도메인과 분리된 공용 컴포넌트

 

1) 이슈 목록을 표시하기 위한 테이블

2) 라벨 목록을 표시하기 위한 테이블

 

둘의 도메인은 각각 이슈와 라벨로 다르다. 하지만 테이블의 모양, 배경 색 등의 UI는 동일하다. 만약 이런 UI를 표시하기 위한 테이블 컴포넌트가 이슈 또는 라벨과 같은 도메인에 강하게 엮여있다면 재사용하기 어렵다.

 

Table 컴포넌트는 어떤 데이터가 들어오는지에 대한 관심사가 없다. UI에만 관심이 있다. 따라서 이런 도메인에 결합이 된게 아닌 테두리가 둥글고, 테이블 Header는 회색, 테이블 Body는 약간 덜 진한 회색 등의 스타일에만 관심이 있다. 공용 컴포넌트로 사용되는 Table 컴포넌트는 어떤 데이터가 들어오는 지 몰라야 한다.

 

만약 Table 컴포넌트의 인터페이스의 특정 도메인 맥락(이슈, 라벨)이 있다면 재사용하기 어려워지기 때문에 공용컴포넌트에는 특정 도메인과 결합돼있으면 안된다는 것이 공용 컴포넌트를 설계하는 제 1원칙이었다.

 

Compound component 패턴을 이용한 변화에 대응하기

 

이는 또다른 화면에서 사용된 테이블이다. 하지만 위의 테이블과 다른 부분이 있는데 바로 테이블의 Header라고 할 수 있는 부분이 없다. 하지만 Body 부분은 위에서 사용한 테이블과 완벽히 동일한 UI를 가진다. 

 

이렇게 비슷하지만 조금씩 다른 부분이 있는 컴포넌트는 어떻게 대응해야 할까? 바로 Compound component 패턴이다.

interface TableProps {
  columns: string;
  children: ReactNode;
  size: keyof typeof sizes;
}

interface HeaderProps {
  children: ReactNode;
}

interface BodyProps<T> {
  data: T[] | undefined;
  render: (item: T) => ReactNode;
}

interface RowProps {
  children: ReactNode;
}

const sizes = {
  S: '..',
  L: '..',
};

const TableContext = createContext<TableContextType>(null!);

function Table({ columns, children, size }: TableProps) {
  return (
    <TableContext.Provider value={{ columns, size }}>
      <div className="...">
        {children}
      </div>
    </TableContext.Provider>
  );
}

function Header({ children }: HeaderProps) {
  const { columns, size } = useContext(TableContext);

  return (
    <div className="...">
      {children}
    </div>
  );
}

function Body<T>({ data, render }: BodyProps<T>) {
  if (!data || data.length === 0)
    return <p className="...">표시할 항목이 없습니다</p>;

  return <div className="...">{data.map(render)}</div>;
}

function Row({ children, ...rest }: RowProps) {
  const { columns, size } = useContext(TableContext);

  return (
    <div className="...">
      {children}
    </div>
  );
}

Table.Header = Header;
Table.Body = Body;
Table.Row = Row;

export default Table;

 

이렇게 선언을 하면 다음과 같이 사용하는 쪽에서 Header를 사용하는 경우와 사용하지 않는 경우를 결정할 수 있다.

// 1) Header를 사용하는 경우.
// 2) 참고로 IssueRow는 Table.Row를 래핑한 컴포넌트다.

<Table columns="1rem 1fr auto" size="L">
  <Table.Header>
    <IssueHeader />
  </Table.Header>

  <Table.Body<IssuesResponse['data'][number]>
    data={issues}
    render={(issue) => <IssueRow key={issue.id} issue={issue} />}
  />
</Table>
// 1) Header를 사용하지 않는 경우.

<Table columns="1fr" size="S">
   <Table.Body<LabelsResponse['data'][number]>
    data={labels}
    render={(label) => (
      <LabelRow label={label} key={label.id} />
    )}
/>

 

 

물론 테이블 Header와 테이블 Body를 각각 다른 컴포넌트로 선언하여 사용하는 곳에서 Header는 사용하지 않고 Body만 사용해도 된다. 그럼에도 불구하고 Compound component 기법을 사용한 이유는 테이블에서 일반적으로 Header와 Body의 columns를 동일하게 사용하기 때문이다. 딱히 상태로 관리하고 있진 않지만 이를 Context를 통해 내려주면 Header와 Body의 열에 대한 스타일을 그대로 유지할 수 있다. 

 

만약 Header와 Body를 다른 컴포넌트에서 사용하는 경우에, 동일한 스타일 (나같은 경우 grid를 사용했다)을 제공해주어야 한다면 코드의 중복이 일어난다. 이는 하나의 수정사항에 대해 두 곳의 수정을 요하기 때문에 Header와 Body를 별개의 컴포넌트로 관리하지 않았다. 

 

 

Render Props 패턴

추가적으로 Table.Body에서 render props 패턴도 사용했는데, 이유는 다음과 같다.

 

1) 렌더링할 아이템이 없으면, 즉 Table.Body에 전달되는 데이터가 빈 배열이거나 할 경우, 특정 메세지를 보여주고 싶다.

2) Table.Body를 사용하는 측에서 데이터의 존재 유무를 판별해야 할까?라고 한다면, 데이터를 특정한 UI로 보여주는 책임은 Table.Body에 있다. 사용하는 곳에서는 data를 특정 UI로 렌더링 하는 책임을 Table.Body에게 위임할 뿐이다.

3) 그렇다면 Table.Body 에서는 데이터에 대한 정보가 있어야 한다. 하지만 아까도 말했듯 공용 컴포넌트는 특정 도메인에 연관되면 안된다.

4) render prop을 사용하게 되면 특정 도메인에 얽매이지 않고 데이터가 빈 배열인지 아닌지 판단할 수 있는 근거가 된다. 

 

리액트 공식문서에서는 render prop을 그렇게 권장하진 않지만, 여전히 많은 라이브러리에서 사용하기도 하는 패턴이고 내 문제를 해결하기에 적합한 패턴이라고 판단했다.

 

테이블 안의 구체적인 모습은 조금씩 다른데?

이전에는 1번과 2번 테이블 안의 UI가 다르다는 이유로 둘을 서로 다른 컴포넌트로 따로 만들거나 prop에 온갖 옵션을 주어서 다양한 변화에 대응하려고 했다. 하지만 이런 식의 접근은 다음과 같은 단점이 있다.

 

1) 따로 만들게 됐을때 만약 테이블의 배경색을 모두 빨간색으로 바꿔야 하는 상황이라고 생각해보면, 두 컴포넌트를 모두 열어 배경색을 바꿔줘야 한다. 하지만 만약 비슷하지만 다른듯한 테이블이 수십 수백개라면? 모든 파일을 모두 수정해주어야 디자인 변화에 대응할 수 있게 된다.

2) prop에 의존하게 되면, 내부 스타일을 위한 로직이 너무 복잡해지게 된다. 또 prop의 개수가 많아질수록 사용하는 측에서 이게 어떤 prop인지 알기 점점 어려워진다. 또한 prop끼리 서로 로직이 얽히게 되는 문제가 있었다. 결국 공용 컴포넌트를 사용하기 위해 공용 컴포넌트의 내부 로직을 이해해야 한다. 

 

그럼 어떤 방법으로 해결하는게 좋을까? 바로 합성(Composition)이다. Table의 Header를 예로 들어보자.

 

function Header({ children }: HeaderProps) {
  const { columns, size } = useContext(TableContext);

  return (
    <div className="...">
      {children}
    </div>
  );
}

 

 

보다 싶이 children prop을 받고 있다. 사용하는 쪽에서 합성을 통해 자신이 원하는 헤더를 주입해주면 된다. 

<Table.Header>
  <IssueHeader
    issues={issues}
    openIssueCount={openIssueCount}
    closeIssueCount={closeIssueCount}
  />
</Table.Header>


// 참고로 IssueHeader는 다음과 같다.
function IssueHeader({
  issues,
  openIssueCount,
  closeIssueCount,
}: IssueHeaderProps) {

  return (
    <>
      <Checkbox ... />

      <StatusFilterButtons ... />

      <div>
        <AssigneeFilterMenu />
        <LabelFilterMenu />
        <MilestoneFilterMenu />
        <AuthorFilterMenu />
      </div>
    </>
  );
}

 

<Table.Header>
  <span className="...">
    {labels.length}개의 레이블
  </span>
</Table.Header>

 

이런 식으로 사용하는 측에서 children을 통해 합성을 해준다면 테이블 내부의 UI가 서로 다른 부분에 유연하게 대처할 수 있다.

 

 

공용 컴포넌트의 상태 역시 내부로 옮기자

 

모달의 예를 들어보자. 과거에는 모달이 떠있는 상태와 모달이 숨겨져있는 상태를 모달을 사용하는 쪽에서 관리하도록 코드를 짰다.

const Component = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);
  
  return (
    <button onClick = () => setIsModalOpen(true)> 모달 열기 </button>
    {isModalOpen && <Modal />}
  )
}



하지만 Modal이 열려있는 상태를 굳이 사용하는 컴포넌트에서 알 필요가 있을까? Modal이 떠 있어야 하는 상태에 대한 책임은 Modal에 있지 않을까? 또 만약 여러 Modal을 관리해야 하는 상태라면? useState를 Modal 갯수 만큼 생성해 일일히 어떤 모달이 열려있는지 관리하거나 useReducer를 활용한 복잡한 상태관리가 필요할 것이다. 하지만 Modal을 사용하는 컴포넌트 쪽에서 이러한 상태에 책임이 없다.

 

그럼 어떻게 관리할 수 있을까?

const ModalContext = createContext();

const Modal = ({ children }) => {
  const [openName, setOpenName] = useState("");

  const close = () => setOpenName("");
  const open = setOpenName;

  return (
    <ModalContext.Provider value={{ openName, close, open }}>
      {children}
    </ModalContext.Provider>
  );
}

const Open = ({ children, opensWindowName }) => {
  const { open } = useContext(ModalContext);

  return cloneElement(children, { onClick: () => open(opensWindowName) });
}

const Window = ({ children, name }) => {
  const { openName, close } = useContext(ModalContext);
  const ref = useOutsideClick(close);

  if (name !== openName) return null;

  return createPortal(
    <div className="...">  {/* 모달 dim */}
      <div ref={ref}> {/* 모달*/}
        <button onClick={close}>
          닫기
        </button>
        <div>{children}</div>
      </div>
    </div>,
    document.body
  );
}

Modal.Open = Open;
Modal.Window = Window;

export default Modal;

 

위 예제에서 볼 수 있듯이, Compound Component 패턴과 cloneElement API를 활용하면 Modal 내부로 모달 관련 상태를 모두 옮길 수 있다.

 

const Component = () => {
  // ....

  return (
    <Modal>
      <Modal.Open opensWindowName="모달1">
        <button>모달 1 열기</button>
      </Modal.Open>
      <Modal.Open opensWindowName="모달2">
        <button>모달 2 열기</button>
      </Modal.Open>
      
      <Modal.Window name="모달1">
        <div>모달 1</div>
      <Modal.Window>
      <Modal.Window name="모달2">
        <div>모달 2</div>
      <Modal.Window>
    </Modal>
  )
}

 

사용하는 측에서는 더 이상 Modal 상태에 대한 책임을 가지지 않는다. 여러 개의 모달을 관리해야 하는 상황에서도 Modal의 상태는 Modal 내부에서 관리되고, 외부에서는 이에 대한 책임을 가지지 않는다.