AdminJS의 커스텀 리스트는 조회·필터·정렬·페이지네이션·엑셀·요약 로직을 페이지마다 복사해 다시 짰습니다. 이 뼈대를 설정 객체(ListPageConfig)로 선언하면 표준 리스트 페이지가 구성되도록 공통화하고 상태 관리와 표현 계층을 분리했습니다. 페이지마다 다른 API 응답 구조와 정렬 파라미터는 빌더 외부 설정으로 흡수하고 비표준 요구사항을 위한 확장 포인트(슬롯·훅 단독 사용)도 뒀습니다. 현재 10개 이상의 관리자 페이지가 이 빌더를 쓰고 있습니다.
AdminJS는 단순 CRUD 리스트를 기본 제공합니다. 하지만 도메인별 필터·요약·엑셀이 붙는 운영용 리스트는 커스텀 페이지로 직접 만들어야 했고 그 페이지들이 같은 뼈대를 반복하고 있었습니다.
useState로 새로 선언ApiClient.resourceAction 호출, 빈 값 필터링, 응답 매핑, 에러 알림을 페이지마다 다시 작성원칙은 표준화와 확장성의 균형이었습니다. 표준 페이지는 설정만으로 구성되고 비표준 요구사항도 빌더를 버리지 않고 수용할 수 있어야 했습니다.
useListPage 훅으로 공통화했습니다.ListPageBuilder는 훅이 돌려준 상태를 받아 렌더러를 조립하는 표현 계층만 담당합니다.useListPage 훅만 가져다 써도 상태 관리가 재사용됩니다.쿠폰 관리 페이지는 필터·뱃지/링크 컬럼·정렬·페이지네이션을 갖춘 리스트입니다. 빌더 도입 후 페이지 코드는 대부분 선언형 config로 정리됐습니다.
// 페이지마다 반복되던 뼈대 (요약) 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 // + 정렬 헤더 + 페이지네이션 // + 엑셀 + 빈 상태 + 로딩… // → 페이지마다 수백 줄 중복
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에 들어가는 셀 렌더러도 함수로 떼어내 재사용합니다. 표시 로직(숫자 포맷·날짜 변환·뱃지·링크)을 한 줄로 선언합니다.
// 숫자 천단위 + 접미사 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' } }) }
공통화의 가장 큰 위험은 예외가 생기면 통째로 갈아엎어야 하는 경직성입니다. 비표준 요구사항을 빌더 안에서 흡수하도록 단계적 확장 포인트를 뒀습니다.
가장 신경 쓴 설계 지점입니다. 리스트 페이지를 공통화하려 해도 API 응답 구조와 정렬 파라미터명이 페이지마다 달랐습니다. 이를 빌더 코드에 분기로 넣지 않고 설정으로 외부화해, 서버 계약 차이가 빌더 내부로 새어 들어오지 않게 했습니다.
responseMapping으로 목록/총건수가 담긴 키(data.items, data.list 등)를 페이지별로 지정sortField/sortOrder vs orderBy/direction)를 sortParamMapping으로 흡수buildParams 콜백에 위임// 응답 { 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: [ /* ... */ ], };
beforeFilters · afterFilters · beforeTable · afterTable에 임의의 노드를 끼워 탭바·배너·일괄작업 버튼 같은 비표준 영역을 삽입useListPage만 가져다 상태를 위임하고 UI는 직접 작성immediate 플래그와 applyFilter로 처리refreshKey를 바꾸면 필터·정렬·페이지 상태는 유지한 채 목록만 다시 조회<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} ... />;
position:fixed + z-index level 0이라 셀렉트 메뉴와 sticky 헤더가 dim에 가려지는 문제. 메뉴를 menuPortal로 body에 띄우고 컨테이너 z-index를 0으로 묶어 해결하고 원인을 주석으로 명시blob: Content-Disposition 파일명 파싱·디코딩 포함)와 대용량 비동기 요청(async: 접수 알림)을 같은 설정으로 분기optionsKey↔dynamicOptions로 연결resetValue를 별도로 둠badgeCell · numberCell · dateCell · linkCell · adminLinkCell · mapCell로 표시 로직까지 재사용(중첩 키 params.name 접근 지원)// 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>
useListPage를 훅으로 떼어낸 덕분에 표준 레이아웃이 안 맞는 화면도 상태 관리는 재사용할 수 있었습니다.