역할에 맞는 테이블

HyunHo Lee·2024년 5월 19일
4

프론트

목록 보기
56/56
post-thumbnail

서론

대규모 서비스를 운영 및 개발하는 회사에서는, 같은 프론트엔드 직무라도 목적에 맞게 팀이 나누어지는 경우가 있다. 그 중 하나가 프로젝트 내에서 사용할 공통 컴포넌트를 설계하는 팀과 서비스 내에서 유저에게 제공할 기능을 구현하는 팀이 따로 있는 것이다. A팀은 앞으로 프로젝트에서 사용할 Input, Select, Radio, Modal, Toast와 같은 컴포넌트를 개발하고, B팀은 이 컴포넌트들을 이용하여 회원가입 페이지를 구성하는 형태이다.

컴포넌트 개발에 시간을 쏟을 수 없는 상황이거나, 백오피스의 경우에는 Bootstrap, Mui, Antd와 같은 서드파티(3rd party) 라이브러리를 사용하기도 한다.

필자도 웹 프론트엔드 개발자가 6명이라는 적지 않은 규모의 팀에 속해 있다. 그러다 보니 우리 팀은 새로운 요구사항이 들어오면, 공통 컴포넌트를 담당하는 개발자를 정한다. 프론트엔드 팀 자체가 나누어져 있는 기업에서는 어떤 프로세스를 가지고 있는지 모르겠지만, 우리는 공통 컴포넌트 개발 및 스토리북 작성 후에 문서화를 진행하여 팀내에 공유한다. 그 후에는 불편한 사항이나 궁금한 내부 로직에 대해 토론하는 형태이다. 그렇다면, 여기서 말하는 불편한 사항은 무엇을 의미할까?

대부분의 프론트엔드 개발자는 컴포넌트를 잘 설계하고 싶을 것이다. 단일 책임 원칙을 지킨다거나 재사용성과 확장성을 고려하고, 테스트 코드로 인한 검증을 하며, 성능까지 챙기는 이상적인 형태로 말이다. 이와 더불어 공통 컴포넌트를 설계할 때에는 한 가지 고민을 더 해야한다. "어떻게 하면 내가 만든 컴포넌트를 팀원들이 쉽게 사용할 수 있을까?" 이다. 공통 컴포넌트 관련 팀이 따로 있거나, 협업하는 동료 개발자가 많은 경우에는 중요한 요구사항이다. 만약 스토리북과 컴포넌트 관련 문서로 동료 개발자의 공감을 얻지 못했다면, 해당 컴포넌트를 더 개선해야 한다.

오늘은 이와 같은 고민들을 하며, 테이블 관련 컴포넌트들을 다룬 경험을 소개하겠다.

가끔 컴포넌트 내의 코드가 복잡해지면, 컴포넌트를 사용하는 입장은 편해지는 경우가 있다. 이런 경우에는 컴포넌트 내의 코드도 단순하고, 사용하는 측면도 쉬워지는 좋은 구조가 있음에도 발견하지 못했을 가능성이 크기 때문에, 많은 고민이 필요하다. 컴포넌트를 목적에 맞게 설계하는데 집중하고, 관심사 분리를 하다보면 원하는 형태로 나올 것이다.


테이블

프론트엔드 개발자라면, 테이블(Table)을 사용해본 경험이 있을 것이다. 더 나아가 높은 확률로 페이지네이션(pagination)까지 접목시켜본 경험이 있을 것이다. 간단한 형태의 게시판 프로젝트를 설계하더라도, 게시글 목록 페이지에서는 보통 테이블과 페이지네이션을 이용하기 때문이다. 우리의 서비스에서는 이 두 개의 컴포넌트를 함께 사용하는 경우가 대부분이었다. 그래서 테이블 컴포넌트와 페이지네이션 컴포넌트를 이용하여, 새로운 컴포넌트를 설계하게 되었다.

글의 주제는 테이블 컴포넌트와 페이지네이션 컴포넌트를 잘 설계하는 방법이 아니다. 이것부터 다루게 되면, 아마 밤을 세워야 할 것이다. 우리 팀에서 사용하는 테이블은 어떤 형태로 사용하는지 알아보고, 이미 테이블과 페이지네이션 컴포넌트가 잘 설계되어 있다고 가정하며 새로운 컴포넌트를 설계할 것이다.

MUI TableAntd Table는 테이블을 렌더링한다는 목적은 같지만, 사용하는 방법은 다르다. 이처럼 개발자가 추구하는 방향에 따라 테이블 컴포넌트의 사용 방법은 달라질 수 있다.

가장 기본적인 형태의 테이블이다. row와 column으로 구성되어 있다.

<Table :data="tableList">
  <TableColumn prop="userName">
    <template #label>
      <span>name</span>
    </template>
  </TableColumn>
  <TableColumn prop="userAge" label="age" />
  <TableColumn prop="userAddress" label="address" />
  // ...
</Table>

const tableList: TableList = [
  {
  	userName: "A",
    userAge: 20,
    userAddress: "한국",
  },
  // ...
];

우리 팀에서 사용하는 테이블 컴포넌트는 array 타입의 데이터를 받고, TableColumn 컴포넌트로 column을 구성하는 구조이다. table header 혹은 table row에 대해 커스텀도 가능하다.

vue3의 :data="tableList"는 react의 data={tableList} 와 유사하다.

<Pagination :page="page" :total="total" :pageSize="pageSize" ... />

페이지네이션 컴포넌트를 사용하는 방식도 간단하다. 현재의 페이지를 나타내는 리엑티브한 변수 page를 받고, 유저가 다른 페이지를 클릭하는 경우 page가 변경된다. pageSize는 한 페이지에 몇 개의 목록을 보여줄 것인지에 대한 크기이고, total은 총 목록의 갯수를 나타낸다.


역할 생각하기

팀의 서비스에서는 테이블이 있는 곳에 페이지네이션이 함께 존재할 확률이 95% 정도 된다. 테이블과 페이지네이션 컴포넌트를 포함하고 있는 랩핑 컴포넌트를 설계하는 것이 효율적인 것이다. 이 컴포넌트는 어떤 역할을 하는지에 따라 클라이언트 테이블과 서버 테이블이 된다. 여기서 말하는 역할은 무엇일까?

첫 번째는 클라이언트 측 페이지네이션(Client Side Pagination)이다. 백엔드로부터 받아온 데이터를 프론트에서 직접 잘라서 페이지네이션을 해주는 형태이다. 예를 들면, Api의 response에서 모든 데이터를 반환받고, 10개씩 잘라서 보여주는 것이다. 한 번에 데이터를 받아오기 때문에, fetch시 로딩이 길어지고 비용적인 측면에서도 불리하다는 단점이 있다. 하지만 그 이후에는 유저가 페이지네이션을 통해 다른 페이지로 이동하는 경우 로딩없이 빠르게 다음 목록을 불러올 수 있다는 장점(?)이 있기도 하다.

두 번째 방법은 서버 측 페이지네이션(Server Side Pagination)이다. 딱 10개에 해당하는 데이터만 받아와 테이블을 구성하는 방법이다. 전체 데이터가 많아지더라도 유저는 현재 페이지에 대한 정보만 가져오기 때문에, 긴 로딩을 보지 않아도 되며 비용적인 측면에서도 큰 장점이 있다. 하지만 클라이언트 측 페이지네이션과 달리 프론트에서 전체 데이터를 가지고 있는 것이 아니기 때문에, 유저는 페이지 이동시 로딩을 경험할 것이다.

테이블과 페이지네이션이 결합된 이 컴포넌트를 각각 클라이언트 테이블(Client Table)서버 테이블(Server Table)이라고 부르기로 결정했다. 이 두개의 테이블은 서로의 장단점이 정반대다. 그럼에도 불구하고, 왠만하면 서버 테이블을 사용하는 것이 현명해보인다. 경제적이고 효율적이기 때문이다.

어쩔 수 없이 클라이언트 테이블을 사용해야 하는 경우들이 있었다.
직접 경험해본 케이스는 두 가지였다.

  • 상황
    • 백엔드 이슈로 페이지네이션이 불가능한 데이터인 경우
    • 자사 백엔드팀에서 제공하는 api가 아닌 다른 서비스에서 제공중인 api를 사용해야 하는 상황에서, 해당 api에 페이지네이션 기능이 없는 경우

클라이언트 테이블 컨셉

// 클라이언트 테이블 내부 템플릿 코드
<section>
  <Table
    :data="pageDataList"
    ... >
    // ...
  </Table>
  
  <Pagination
    v-if="showPagination"
    v-model:current-page="currentPage"
    :current-page="currentPage"
    ... />
</section>
// 클라이언트 테이블 내부 로직
  const currentPage = ref<number>(1);
  const currentPageSize = ref<number>(10);

  const pageDataList = computed(() =>
    props.data.slice(
          currentPageSize.value * (currentPage.value - 1),
          currentPageSize.value * currentPage.value,
        ),
  );

클라이언트 테이블은 테이블에 바인딩 할 데이터 리스트인 dataprops로 받고 있다. currentPage는 페이지네이션의 현재 페이지이고, currentPageSize 는 테이블을 몇개씩 잘라서 보여줄지에 대한 값이다. 이 3개의 변수를 이용하여 pageDataList를 구성하면, 페이지네이션 값에 맞게 동적으로 테이블 목록을 출력할 수 있게 된다.

// 클라이언트 테이블 내부 로직
  const showPagination = computed<boolean>(() => props.usePagination);

  const pageDataList = computed(() =>
    showPagination.value
      ? sortedDataList.value.slice(
          currentPageSize.value * (currentPage.value - 1),
          currentPageSize.value * currentPage.value,
        )
      : sortedDataList.value,
  );

목적에 맞게 간단한 구조부터 만들어 놓고, 추후에 기능을 추가하는게 좋다. 한 번에 데이터를 가져와서 넘겨주면, 현재 페이지에 맞게 목록을 잘라서 보여주는 클라언트 테이블을 설계한 후에 정렬을 구현한다거나 페이지네이션을 사용하지 않을 경우를 대비하여 propsusePagination 옵션을 추가하는 등 말이다.

// 클라이언트 테이블 사용 예시
<ClientTable :data="exampleList">
  <TableColumn prop="userName" label="name" />
  <TableColumn prop="userAge" label="age" />
</ClientTable>

페이지네이션이 포함된 클라이언트 테이블을 쉽게 사용할 수 있게 되었다.


서버 테이블 컨셉

// 서버 테이블 내부 템플릿 코드
<section>
  <Table
    :data="tableDataList"
    ... >
    // ...
  </Table>
  
  <Pagination
    v-if="showPagination"
    v-model:current-page="currentPage"
    :current-page="currentPage"
    ... />
</section>
// 서버 테이블 내부 로직
const tableDataList = ref([]);

async function fetchCurrentPage(): Promise<void> {
  const res = await props.fetchCurrentPage({
    page: currentPage.value,
    pageSize: currentPageSize.value,
    sort: transformedSortList.value,
  	...
  });

  tableDataList.value = res.tableData;
  ...
}

서버 테이블은 테이블 목록을 props로 받는 것이 아니라, 리스트를 반환하는 fetch하는 함수 자체를 props로 받는다. 그리고 서버 테이블 자체에서 테이블에 대한 리스트를 tableDataList 변수로 관리한다.

api endpoint에서는 목록을 몇 개씩 보여줄지, 현재 몇 페이지의 데이터를 요청할지, 테이블의 어떤 column을 정렬할지, 그 외의 탐색이 필요한지(검색) 등 정보가 필요하다. 클라이언트 테이블에서는 페이지네이션의 값이 비지니스 로직과 연관 없었지만, 서버 테이블의 경우에는 페이지네이션에 대한 정보를 api에 넘겨주어야 하는 것이다. 서버 테이블 컴포넌트 내에서 다루고 있는 페이지네이션 정보(page, pageSize, sort 등)를 props로 받은 fetch 함수의 argument 객체로 넘겨주는 것이다.

// 서버 테이블 사용 예시
<ServerTable
    :fetch-current-page="fetchTableData"
	...>
  ...
</ServerTable>

// ...

async function fetchTableData({
    page,
    pageSize,
    sort,
    ...,
  }: ServerTableFetchCurrentPage): Promise<FetchModel> {
    const requestModel: RequestModel = {
      page,
      pageSize,
      sort,
      ...,
    };

    return await fetchUserList({
      requestModel,
      ...,
    });
  }

이제 간단하게 서버 테이블을 사용할 수 있다. 서버 테이블의 fetchCurrentPage 함수를 통해 받은 parameter에는 page, pageSize, sort 등의 정보가 완성되어 있다. 테이블 목록을 가져오는 fetch 함수에서 이 정보를 이용하여 requestModel 을 구성하면 된다.

테이블 컬럼의 정렬

개발자의 입장에서 클라이언트 테이블과 서버 테이블은 아예 다른 컴포넌트다. 하지만 기획자나 디자이너가 보기에는 똑같은 테이블로 보일 뿐이다. 그래서 테이블과 페이지네이션을 이용하여 만든 이 컴포넌트에 하나의 요구사항이 있더라도, 개발자는 이것이 정말 하나인지 파악해볼 필요가 있다. 그 중 하나가 정렬이다.

클라이언트 테이블과 서버 테이블이 정렬하는 방식 자체는 다르다. 하지만 컴포넌트, 컴포저블, 헬퍼 함수 등을 이용하여 정렬에 대한 정보를 핸들링하는 구조를 잘 설계한다면, 이 다른 성격을 지닌 두 컴포넌트에서도 충분히 효율적으로 사용할 수 있다.

<ClientTable :data="exampleList">
  <TableColumn prop="userName" label="name" sortable />
  <TableColumn prop="userAge" label="age" sortable />
</ClientTable>

이제부터 클라이언트 테이블과 서버 테이블을 통틀어서 랩핑 테이블이라고 부르겠다. 랩핑 테이블에서는 column의 정보를 표시하기 위해 TableColumn 컴포넌트를 사용하고 있다. 그리고 TableColumn 컴포넌트에 prop 정보와 sotrtable 옵션을 주면, 정렬 기능을 사용할 수 있다. 이와같은 간단한 구조로 정렬 기능을 사용하기 위해, 내부에서 어떤식으로 설계되었는지 알아보자.

테이블 컬럼은 자신의 정렬 상태를 기억해야한다. 날짜를 최신순으로 정렬하기 위해, Date 테이블 컬럼 컴포넌트의 정렬 버튼을 클릭했다. 내림차순을 나타내는 아래 화살표는 active 상태가 되어야 한다. 또한, 테이블 컬럼은 랩핑 테이블로 정렬 정보를 보내야 한다. 랩핑 테이블은 넘겨받은 정보를 이용하여, 데이터를 정제하면 된다. 즉, 테이블 컬럼은 정렬 없음, 내림차순, 오름차순과 같은 정렬 상태를 관리하고, 랩핑 컴포넌트로 정렬 상태를 전달하는 구조가 있어야 한다.

// 랩핑 테이블 내부 로직
const { 
lastUpdatedTableColumn,
  sortedTableColumnList,
  setCurrentTableColumn
} = useTableColumn(props.sortedList);

  provide<TableProvideModel>(TABLE_PROVIDE_KEY, {
    setSortList,
    sortedTableColumnList,
  });

이렇게 랩핑 테이블과 TableColumn은 연관 관계가 깊다. 또한, 랩핑 테이블은 무조건 TableColumndefatul slot으로 받을 것이고, TableColumn 컴포넌트도 랩핑 테이블 안에서만 사용한다. 이와 같은 이유로 provide/inject 구조를 사용했다.

sortedTableColumnListarray 타입으로, 테이블 컬럼들의 정렬 정보를 담고 있는 리엑티브한 변수이다. 이 값은 setSortList함수를 통해 핸들링된다. 유저가 마지막으로 수행한 정렬이 무엇인지는 lastUpdatedTableColumn 를 업데이트 하면서 추적하는데, 이것이 필요한 이유는 아래에서 설명하겠다.

랩핑 컴포넌트 종류에 따라 정렬 기능에 대한 내부 로직이 다르다. 클라이언트 테이블은 전체 데이터를 정제해야 한다. 최신순으로 정렬하는 경우, 전체 데이터에서 컬럼의 이름이 Date인 것을 찾아 내림차순으로 정렬 해야하는 것이다. 이에 반해 서버 테이블은 Date에 대한 정렬 정보를 받아, API에 요청하기 위한 requestModel을 만들어 fetch를 수행한다. 그 후 전달받은 response를 그대로 테이블에 출력하게 된다.

// 테이블 컬럼 컴포넌트 내부 템플릿 코드
<template>
	    //...
        <div>
          <span>{{ label }}</span>
          <span v-if="showSortNumber">
            {{ sortNumber }}
          </span>
        </div>
        <IconButton
          v-if="isSortable"
          :icon="sortIconName"
          @click="handleSortButton" />
</template>

테이블 컬럼 컴포넌트에는 labelIconButton이 있다. 정렬 상태에 따라 알맞는 아이콘을 보여주기 위해 sortIconName를 바인딩 시켜주고 있다. 그리고 다중 정렬의 경우에는 유저에게 sortNumber 정보까지 보여준다. 정렬이 어떤 순서를 가지고 있는지 알려주기 위함이다.

// 테이블 컬럼 컴포넌트 내부 로직

const injected = inject<TableProvideModel>(TABLE_PROVIDE_KEY);

// ...

function changeSortType(): void {
  if (sortType.value === "default") {
    sortType.value = "desc";
  } else if (sortType.value === "desc") {
    sortType.value = "asc";
  } else {
    sortType.value = "default";
  }
}

function handleSortButton(e: MouseEvent): void {
  changeSortType();

  if (벨리데이션 체크) {
    const isMultiple = e.ctrlKey;
    injected.setSortList({
      column: props.prop,
      order: sortType.value,
      isMultiple,
    });
}

테이블 컬럼의 정렬 버튼을 클릭하면, 테이블 컬럼의 정렬 상태를 changeSortType으로 저장하고, 랩핑 컴포넌트의 정렬 정보를 setSortList으로 업데이트한다. 해당 컬럼에 대한 정렬 정보가 이미 있는 경우에 대한 처리는 setSortList 함수에서 내부적으로 존재하고 있다. 또한, Ctrl + 마우스 클릭의 경우에는 다중 정렬로 판단하여 isMultipletrue로 넘긴다.

랩핑 컴포넌트에서는 단일인지 다중인지에 따라 정렬 정보에 대한 초기화 룰이 달라질 것이다. 다중 정렬과 달리 단일 정렬의 경우 sortedTableColumnList 는 항상 한 개의 정렬 정보만 있어야 하기 때문이다.

  watch(
    () => props.lastUpdatedTableColumn.value,
    () => {
      if (lastUpdatedTableColumn가 자기 자신이 아닌경우 && 그 외 벨리데이션) {
        setSortType();
      }
    },
  );

이제 자기 자신이 아닌 다른 테이블 컬럼의 정렬 버튼이 클릭되었을 경우를 고려해야한다. 이름과 나이로 구성된 테이블이 있다고 가정해보자. 이름 테이블 컬럼에 있는 정렬 버튼을 클릭하면, handleSortButton으로 인해 정렬 기능이 정상적으로 동작한다. 이 상태에서 나이 테이블 컬럼의 정렬 버튼을 클릭하면 어떻게 될까? 나이 테이블 컬럼에 있는 handleSortButton 함수가 동작하여 정상적으로 정렬된 데이터를 보여주겠지만, 이름 테이블 컬럼의 아래 화살표는 여전히 active 상태일 것이다. 나이 테이블 컬럼의 정렬 상태 정보가 업데이트 되었을 때, 이름 테이블 컬럼은 그 상황을 모르기 때문이다.

이 상황을 위해 마지막으로 업데이트 된 컬럼의 정렬 정보를 lastUpdatedTableColumn 으로 관리하는 것이다. 마지막 컬럼 정보가 업데이트 되고, 이 값이 자신의 컬럼이 아닌 것이 판명되면 setSortType함수를 실행한다. 이 함수는 단일 정렬의 경우, 정렬 타입을 default로 초기화한다. 다른 컬럼을 단일 정렬했다는 것은 이전 컬럼의 정렬을 해제한다는 의미이기 때문이다. 다중 정렬방금 클릭된 정렬이 다중인지 단일인지에 따라 현재 숫자 + 1 혹은 0 으로 수정한다.

이렇게 단일 정렬과 다중 정렬의 쉽고 간단하면서도 기본적인 형태를 만들어보았다. 이제 추가적인 기능은 서비스에서 추구하는 가치관에 맞게 붙여나가면 된다. 예를 들어, 유저가 직접 정렬 버튼을 이용하여 기능을 사용하는 것이 아니라, 서비스에서 랩핑 테이블을 렌더링하면서 바로 특정 상태로 정렬된 데이터를 보여주고 싶은 경우에는 onMounted 라이플 사이클을 적절히 이용하여 설계해주면 될 것이다.


마무리

오늘은 역할에 따라 클라이언트 테이블과 서버 테이블을 나누고, 컨셉을 소개해보았다. 다음에 기회가 되면, 컬럼을 설정에 대해서도 소개해보겠다. 테이블에 10개의 컬럼이 존재한다고 했을 때, 유저가 보고 싶어하는 컬럼을 저장하고, 다음에도 해당 컬럼만 보여주는 기능이다.

잊지 말아야할 점!

  • 컴포넌트의 역할을 항상 생각해보자.
  • 유지보수 하기 좋은 코드를 작성하자.
  • 팀원들이 사용하기 좋은 컴포넌트를 설계하자.
  • 스토리북과 문서를 잘 작성하자.
profile
함께 일하고 싶은 개발자가 되기 위해 달려나가고 있습니다.

2개의 댓글

comment-user-thumbnail
2024년 5월 20일

스토리북까지 이어지는 업무 프로세스에대해 조금 더 상세하게 알려주실 수 있을까요?

1개의 답글