[React] 합성 컴포넌트로 테이블 구현하기

문정민·2024년 9월 17일

React

목록 보기
11/11
post-thumbnail

테이블 컴포넌트의 필요성

지난 한 주는 새로운 기획에 넘어온 것이 없었기에 리팩토링에 집중했다. 그중에서도 테이블을 재사용 가능한 컴포넌트로 만들고 싶었다. 회사에서 운영중인 여러 서비스에서 테이블을 자주 사용하고 있는데, 이미 만들어진 테이블 코드를 그대로 복붙해서 쓰다보니 코드의 중복이 많아져서 항상 신경이 쓰이던 참이었다. 마침 시간이 널널하니 이때 재사용 가능한 테이블 컴포넌트를 만들기로 했다.

구현 과정

사내 어드민에서 사용하는 테이블의 요구사항이 가장 단순해서, 먼저 이 부분부터 테이블 컴포넌트를 구현해보았다. 기본적인 구조를 잡아두면 다른 서비스에서도 각 서비스의 필요에 맞게 변형하여 사용할 수 있을 것이라는 판단이었다.

요구사항은 다음과 같다.
1. 테이블에 텍스트 이외의 요소들이 들어갈 수 있어야한다. (체크박스 등)
2. 각 컬럼의 사이즈를 지정할 수 있어야한다.
3. 로우를 클릭했을 때 모달이 열려야한다.
4. 셀을 클릭했을 때 텍스트가 input으로 변하면서 수정이 가능해야한다.
5. 필요에 따라 외부에서 스타일을 줄 수 있어야한다.

위의 요구사항을 충족하기 위해 Compound Component Pattern을 사용하기로 했다.

Compound Component Pattern (합성 컴포넌트 패턴)

합성 컴포넌트는 여러 개의 작은 컴포넌트를 결합하여 하나의 큰 컴포넌트를 구성하는 방식이다. 각 컴포넌트들은 각자의 기능을 수행하기 때문에 관심사를 분리할 수 있으며, 독립접으로 컴포넌트를 관리할 수 있다. 또한, 필요에 따라 외부에서 스타일이나 기능을 주입할 수 있어 요구사항에 맞춰 유연하게 대응할 수 있다.

합성 컴포넌트를 사용한 이유

테이블의 각 구성 요소들이 각각의 역할을 수행해야 하는 부분들이 있었다. 예를 들어 로우를 클릭했을 때 모달을 띄우거나, 셀을 클릭했을 때 텍스트가 input으로 변환되는 등의 기능이 필요했다. 이러한 요구사항을 잘 구현하기 위해서는 각 요소가 자신의 역할을 명확하게 분담하고, 이를 하나의 큰 컴포넌트로 합치는 것이 적절하다고 판단했다. 또한, 합성 컴포넌트 패턴을 사용하면 테이블의 계층적인 구조를 한눈에 파악할 수 있어 다른 사람이 보더라도 이해하기가 쉬울 것이라 생각했다.

interface IPropsTHead extends React.HTMLAttributes<HTMLTableSectionElement> {
  children: React.ReactNode;
}

function THead({ children, ...props }: IPropsTHead) {
  return <thead {...props}>{children}</thead>;
}

export default THead;

위와 같은 형태로 THead, TBody, TRow, TColumn, TCell을 각각 컴포넌트로 만들었다.

interface IPropsTable extends React.HTMLAttributes<HTMLTableElement> {
  children: React.ReactNode;
}

export default function Table({ children, ...props }: IPropsTable) {
  return <StyleTable {...props}>{children}</StyleTable>;
}

Table.THead = THead;
Table.TBody = TBody;
Table.TRow = TRow;
Table.TColumn = TColumn;
Table.TCell = TCell;

그리고 하나의 큰 테이블 컴포넌트에서 이들을 합쳤다. Table을 부모 컴포넌트로 사용하면서 관련된 하위 요소들을 그룹화했다. 사용하는 곳에서는 다음과 같이 사용하면 된다.

<Table>
	<Table.THead>
    	<Table.TRow>
        	<Table.TColumn>제목</Table.TColumn>
			<Table.TColumn>날짜</Table.TColumn>
        </Table.TRow>
    </Table.THead>
    <Table.TBody>
    	<Table.TRow>
        	<Table.TCell>오늘의 일기</Table.TCell>
			<Table.TCell>2024-09-17</Table.TCell>
        </Table.TRow>
    </Table.TBody>
</Table>

컬럼 사이즈 조정 및 외부 스타일 적용

각 컬럼의 사이즈는 sm, md, lg로 나뉘는데, 테이블을 사용하는 곳에서 props로 전달할 수 있도록 했다. Styled Component를 활용해서 props로 전달받은 사이즈 정보에 따라 각 컬럼의 크기를 지정할 수 있도록 구현했다. 사용하는 곳에서 필요에 따라 스타일을 적용해야하는 경우도 있었는데, style을 props로 전달하여 추가적인 스타일을 덧붙일 수 있도록 구성했다.

다양한 요소의 삽입과 재사용성

테이블에 체크박스는 매우 자주 사용되는 요소이기 때문에 아예 컴포넌트를 만들어서 재사용할 수 있도록 했다. TCell의 하위 요소로 DefaultCell과 CheckboxCell을 나누어 DefaultCell에서는 기본 텍스트와 아이콘 등을 넣어서 사용하고, CheckboxCell은 onChange 콜백함수나 isChecked와 같은 상태를 외부에서 주입할 수 있도록 했다.


interface IPropsTCell extends React.HTMLAttributes<HTMLTableCellElement> {
  children?: React.ReactNode;
  onClick?: () => void;
  icon?: React.ReactNode;
  pointer?: boolean;
  isChecked?: boolean;
  handleCheckBox?: (a: any) => void;
  inputId?: number | string;
  highlight?: boolean;
}

const DefaultCell = ({
  children,
  icon,
  onClick,
  pointer = false,
  highlight = false,
  ...props
}: IPropsTCell) => {
  return (
    <Td
      onClick={onClick ? onClick : () => {}}
      pointer={pointer}
      highlight={highlight}
      {...props}
    >
      {icon && icon}
      {children}
    </Td>
  );
};

const CheckboxCell = ({
  children,
  isChecked,
  handleCheckBox,
  inputId,
  ...props
}: IPropsTCell) => {
  return (
    <Td pointer={false} {...props}>
      <input
        type="checkbox"
        checked={isChecked}
        onChange={handleCheckBox}
        id={String(inputId)}
      />
      {children}
    </Td>
  );
};

const TCell = {
  DefaultCell,
  CheckboxCell,
};

export default TCell;

요구사항 중 테이블 셀을 클릭했을 때 수정 기능을 위한 input 요소로 변경되는 부분은 여러 곳에서 사용하는 것이 아니라 특정 테이블에서만 필요한 기능이었다. 그래서 따로 컴포넌트로 분리하지 않고 Children 속성을 통해 외부에서 해당 요소를 삽입하도록 유연하게 구현하였다.

 <Table.TCell.DefaultCell onClick={onSetEdit(v.id)} pointer>
	{editId === v.id ? (
         <input
           type="text"
           defaultValue={v.serialNumber}
           ref={serialNumberRef}
         />
	) : (v.serialNumber)}
</Table.TCell.DefaultCell>

느낀 점

합성 컴포넌트를 사용함으로써 코드 중복을 크게 줄일 수 있었다. 각 페이지에서 테이블을 사용할 때마다 동일한 스타일과 코드를 반복적으로 사용하곤 했는데, 합성 컴포넌트로 전환함으로써 테이블의 일부가 변경될 때마다 모든 페이지를 수정해야 하는 번거로움을 크게 줄일 수 있었다. 이는 개발 효율성을 높이는 데 가장 큰 성과였다고 생각한다.

이에 더하여, 요구사항에 맞게 테이블을 확장하거나 변경하기가 쉬워졌다. 명확한 컴포넌트 구조 덕분에 일관된 테이블 디자인을 유지하면서도 필요에 따라 간단하게 props로 전달하여 스타일을 처리할 수 있다. 특히, 클릭 이벤트도 외부에서 주입할 수 있어 기존 컴포넌트를 수정하지 않고도 다양한 기능을 구현할 수 있게 되었다.

현재 구현한 테이블은 기본 형태이지만, 앞으로는 정렬이나 필터 기능을 추가해나갈 예정이다.

이번 작업을 통해 디자인 패턴의 중요성을 깨달았다. 사실 디자인 패턴에 대해서는 잘 알지 못했다. 정처기 공부하면서 배우는 gof 패턴 정도, 그마저도 이름만 들어본 정도로 무지에 가까운 수준이었다. 그런데 우연히 오픈소스를 보다가 이 패턴을 발견했고 이를 적용해본 것이었는데, 아는 만큼 보인다는 말이 실감되었다. 이번 경험을 계기로 React 디자인 패턴에 더 관심이 생겼고, 앞으로도 더 깊이 공부해보고 싶어졌다.

0개의 댓글