← 메인 포트폴리오
Backend Portfolio · Admin Tooling · DX

ListBuilder — AdminJS 리스트 페이지 빌더

AdminJS의 커스텀 리스트는 조회·필터·정렬·페이지네이션·엑셀·요약 로직을 페이지마다 복사해 다시 짰습니다. 이 뼈대를 설정 객체(ListPageConfig)로 선언하면 표준 리스트 페이지가 구성되도록 공통화하고 상태 관리와 표현 계층을 분리했습니다. 페이지마다 다른 API 응답 구조와 정렬 파라미터는 빌더 외부 설정으로 흡수하고 비표준 요구사항을 위한 확장 포인트(슬롯·훅 단독 사용)도 뒀습니다. 현재 10개 이상의 관리자 페이지가 이 빌더를 쓰고 있습니다.

역할 공통 컴포넌트 설계·구현 스택 React · TypeScript · AdminJS · @adminjs/design-system 채택 주문 · 쿠폰 · 멤버십 · POD · 라이선스 정산 등 10+ 페이지

① 문제 — 리스트 페이지마다 같은 코드를 다시 짠다

AdminJS는 단순 CRUD 리스트를 기본 제공합니다. 하지만 도메인별 필터·요약·엑셀이 붙는 운영용 리스트는 커스텀 페이지로 직접 만들어야 했고 그 페이지들이 같은 뼈대를 반복하고 있었습니다.

Problem
한 페이지를 만들 때마다 반복되던 것들
UX 개선이나 버그 수정이 한 곳에 모이지 못하고 페이지 수만큼 흩어졌습니다. "필터 Enter 키 검색" "정렬 토글" "모달 안에서 셀렉트 드롭다운이 가려지는 z-index 문제" 같은 디테일이 페이지마다 제각각이거나 누락됐습니다.

② 설계 — 선언형 config + 상태/표현 분리 + 확장 포인트

원칙은 표준화와 확장성의 균형이었습니다. 표준 페이지는 설정만으로 구성되고 비표준 요구사항도 빌더를 버리지 않고 수용할 수 있어야 했습니다.

ListPageConfig
선언형 설정 객체
cellRenderers
badge·number·date·link…
props로 주입
ListPageBuilder
레이아웃 조립 + 슬롯
상태 위임
useListPage 훅
상태·조회·정렬·엑셀
API
AdminJS
ApiClient
FilterRenderer · TableRenderer · PaginationBar · SummaryBox 가 ListPageBuilder 아래에서 표현을 담당한다. 상태 관리와 조회 로직은 useListPage 훅으로 공통화했다.
Decision · 분리
상태 관리는 훅으로, 표현은 렌더러로
▸ 설계
  • 리스트 페이지에서 반복되던 상태 관리와 조회 로직(필터·정렬·페이지네이션·엑셀)을 useListPage 훅으로 공통화했습니다.
  • ListPageBuilder는 훅이 돌려준 상태를 받아 렌더러를 조립하는 표현 계층만 담당합니다.
  • UI를 새로 그려야 하는 화면은 useListPage 훅만 가져다 써도 상태 관리가 재사용됩니다.
상태 관리·표현 계층 분리관심사 분리(SoC)재사용성

③ Before / After

쿠폰 관리 페이지는 필터·뱃지/링크 컬럼·정렬·페이지네이션을 갖춘 리스트입니다. 빌더 도입 후 페이지 코드는 대부분 선언형 config로 정리됐습니다.

● Before — 페이지마다 직접
// 페이지마다 반복되던 뼈대 (요약)
const [filters, setFilters] = useState({...});
const [items, setItems]     = useState([]);
const [total, setTotal]     = useState(0);
const [page, setPage]       = useState(1);
const [sort, setSort]       = useState(...);
const [loading, setLoading] = useState(false);

useEffect(() => {
  setLoading(true);
  api.resourceAction({ actionName, params })
     .then(...)            // 응답 매핑
     .catch(...)           // 에러 알림
     .finally(...);        // 로딩 해제
}, [filters, page, sort]);

// + 필터 JSX + 테이블 JSX
// + 정렬 헤더 + 페이지네이션
// + 엑셀 + 빈 상태 + 로딩…
// → 페이지마다 수백 줄 중복
● After — 선언형 config
const config: ListPageConfig = {
  actionName: 'getCoupons',
  filterRows: [
    [{ key: 'id',   type: 'text',   label: 'ID' },
     { key: 'name', type: 'text',   label: 'Name' }],
    [{ key: 'type', type: 'select', label: 'Type',
       options: TYPE_OPTIONS, isClearable: true }],
  ],
  columns: [
    { key: 'id',        header: 'ID',
      render: adminLinkCell('CouponEntity') },
    { key: 'discount',  header: '할인', align: 'right',
      render: numberCell('원') },
    { key: 'createdAt', header: '생성일',
      render: dateCell() },
  ],
  pagination: { defaultPerPage: 20 },
};

return <ListPageBuilder config={config} props={props} />;

컬럼의 render에 들어가는 셀 렌더러도 함수로 떼어내 재사용합니다. 표시 로직(숫자 포맷·날짜 변환·뱃지·링크)을 한 줄로 선언합니다.

● cellRenderers — 표시 로직도 재사용
// 숫자 천단위 + 접미사
export const numberCell = (suffix?: string): CellRenderer =>
  (value) => value == null
    ? '-'
    : `${Number(value).toLocaleString('ko-KR')}${suffix ?? ''}`;

// 코드 → 뱃지 (variant 매핑)
export const badgeCell = (map: Record<string, {label; variant}>): CellRenderer =>
  (value) => {
    const c = map[String(value)];
    return c ? <Badge variant={c.variant}>{c.label}</Badge> : <>-</>;
  };

// 사용처 — config.columns 안에서
{ key: 'discount', header: '할인', render: numberCell('원') }
{ key: 'status',   header: '상태',
  render: badgeCell({ active: { label: '활성', variant: 'success' } }) }

④ 확장 포인트 — 비표준 요구사항을 흡수하는 구조

공통화의 가장 큰 위험은 예외가 생기면 통째로 갈아엎어야 하는 경직성입니다. 비표준 요구사항을 빌더 안에서 흡수하도록 단계적 확장 포인트를 뒀습니다.

Contract
페이지마다 다른 API 계약을 빌더 외부에서 흡수

가장 신경 쓴 설계 지점입니다. 리스트 페이지를 공통화하려 해도 API 응답 구조와 정렬 파라미터명이 페이지마다 달랐습니다. 이를 빌더 코드에 분기로 넣지 않고 설정으로 외부화해, 서버 계약 차이가 빌더 내부로 새어 들어오지 않게 했습니다.

● 같은 빌더, 다른 서버 계약
// 응답 { data: { list: [...], count: 120 } }, 정렬 키 orderBy/direction
const config: ListPageConfig = {
  actionName: 'getPods',
  responseMapping: { items: 'list', total: 'count' },
  sortParamMapping: { field: 'orderBy', order: 'direction' },
  buildParams: (v) => ({ ...v, publisherId: v.publisher?.id }),
  columns: [ /* ... */ ],
};
API 계약설정 외부화표준화
Extension Points
비표준 요구사항을 흡수하는 방법
슬롯 패턴점진적 복잡도 수용확장성
● 슬롯 — 표준 레이아웃은 두고 비표준 영역만 삽입
<ListPageBuilder
  config={config}
  props={props}
  // 필터 위에 탭바, 테이블 위에 일괄작업 버튼
  beforeFilters={<StatusTabs onChange={onTab} />}
  beforeTable={<BulkActionBar ids={selected} />}
/>;
● 훅 단독 — 레이아웃은 직접 그리고 상태만 위임
// 빌더를 안 쓰는 화면도 상태 로직은 재사용
const {
  items, total, isLoading,
  formValues, setFormValue, handleSearch,
  sortField, handleSort,
  page, handlePageChange,
} = useListPage(config, props);

return <MyCustomLayout items={items} ... />;
Details
공통 레이어에서 한 번만 해결한 디테일
● 모달 안 드롭다운 z-index — 원인을 주석으로 남긴 한 번의 해결
// AdminJS Modal의 Wrapper는 position:fixed + z-index:auto(level 0)라
// dim/카드가 그 안에 갇힌다. 필터가 양수 z-index로 body 루트에서
// 탈출하면 dim을 뚫고 위로 올라오므로 0으로 둔다.
<Box style={{ position: 'relative', zIndex: 0 }}>
  <Select
    menuPortalTarget={document.body}   // 메뉴를 body로 포탈
    menuPosition="fixed"
    styles={{ menuPortal: (b) => ({ ...b, zIndex: 9999 }) }}
  />
</Box>

⑤ 효과

10+
이 빌더를 쓰는 관리자 페이지
(주문·쿠폰·멤버십·POD·정산…)
8종
필터 타입
(text·select·multiSelect·date·month·dateRange·radio·custom)
6종
재사용 셀 렌더러
(badge·number·date·link·adminLink·map)

⑥ 회고