📚 React 프론트엔드 삽질기 #2

React Admin DataProvider 커스터마이징 — useList가 안 되면 useCustom, total이 0이면 경로 확인

Refine 기반 React 어드민에서 DataProvider 커스터마이징 시 흔히 겪는 두 가지 함정을 정리합니다. BE가 객체를 반환하는데 useList를 쓴 경우, 그리고 total 파싱 경로가 어긋난 경우의 증상·원인·해결법입니다.

React Admin DataProvider, 왜 데이터가 안 나오지?

Refine으로 어드민 패널을 만들다 보면 DataProvider와 싸우는 시간이 생각보다 길다.

API는 200을 반환하고, Network 탭을 보면 데이터도 잘 온다. 그런데 화면엔 아무것도 안 뜬다. 혹은 목록은 나오는데 “총 0건”이라고 뜬다.

두 경우 모두 DataProvider가 BE 응답 구조를 잘못 해석하고 있었다. 하나는 배열 vs 객체 불일치, 다른 하나는 total 파싱 경로 문제. 각각 30분, 1시간을 날렸다.


🔍 증상 1: 데이터가 분명히 오는데 화면에 안 뜬다

디버깅 시작

어드민 대시보드에 시스템 상태 모니터링 패널을 만들고 있었다. API 서버와 DB 연결 상태를 보여주는 간단한 위젯이다.

API를 호출하면 이런 응답이 온다.

{
  "data": {
    "api": "healthy",
    "database": "connected"
  }
}

깔끔한 객체 응답이다. 그런데 화면에는 항상 **“연결 끊김”**이 표시됐다.

Network 탭을 열었다. 200 OK. 응답 데이터도 멀쩡하다. 그런데 컴포넌트에서 찍어보면 undefined다.

// 상태 조회 코드
const { data: statusData } = useList({
  resource: "system/status",
});

const status = statusData?.data?.[0];
console.log(status);  // undefined 😱

statusData?.data까지는 값이 있는데, [0]을 붙이는 순간 undefined가 된다.

왜?

useList는 이름 그대로 **목록(배열)**을 기대하는 hook이다. 내부적으로 DataProvider의 getList를 호출하고, 응답을 { data: Array, total: number } 형태로 파싱한다.

그런데 이 API는 배열이 아니라 단일 객체를 반환한다.

useList가 기대하는 것:  { "data": [{ ... }, { ... }] }
실제 BE 응답:          { "data": { "api": "healthy", "database": "connected" } }

DataProvider의 getList가 응답을 배열로 감싸려고 시도하면서 구조가 꼬인다. data[0]은 당연히 undefined다. 배열이 아니니까.


🛠️ 해결 1: useList → useCustom

해결 완료

단일 객체를 반환하는 API에는 useCustom을 써야 한다. useCustom은 DataProvider의 custom 메서드를 호출하며, 응답 형식에 대한 가정이 없다. 받은 그대로 돌려준다.

❌ Before — useList (배열 기대)

import { useList } from "@refinedev/core";

const { data: statusData } = useList({
  resource: "system/status",
});

// data가 배열이라고 가정 → 객체이므로 undefined
const status = statusData?.data?.[0];

✅ After — useCustom (형식 무관)

import { useCustom } from "@refinedev/core";

const { data: statusData } = useCustom({
  url: "/system/status",
  method: "get",
});

// 객체 직접 접근
const status = statusData?.data?.data;

useCustom으로 바꾸니까 바로 됐다. 30분 삽질의 원인은 hook 선택 실수였다.

📐 판단 기준

BE 응답 형태적절한 hook이유
배열 [{...}, {...}]useList목록 데이터, 페이지네이션 지원
단일 객체 {...}useCustomDataProvider 파싱 로직 우회
단일 레코드 (id 기반)useOne리소스의 개별 조회

팁: API 응답이 { data: [...] } 형태인지 { data: {...} } 형태인지 먼저 확인하자. 이 한 가지만 확인해도 hook 선택 실수를 방지할 수 있다.


🔍 증상 2: 목록은 나오는데 “총 0건”

또 다른 버그 발견

첫 번째 문제를 고치고 나서 자신감이 붙었다. 그런데 다른 페이지에서 또 이상한 현상이 나타났다.

데이터 목록 페이지에서 카드가 잘 나온다. 419개 항목이 쭉 렌더링된다. 그런데 상단의 총 건수가 0으로 찍힌다.

데이터 목록 (총 0건)   ← ???
────────────────────
📄 항목 1
📄 항목 2
📄 항목 3
...
(419개가 실제로 렌더링됨)

데이터는 다 보이는데 total만 0이다. 페이지네이션도 안 된다. “1페이지 / 0페이지”라고 나온다.

BE 응답 구조 확인

Network 탭에서 응답을 봤다.

{
  "success": true,
  "data": {
    "data": [
      { "id": 1, "title": "..." },
      { "id": 2, "title": "..." }
    ],
    "total": 419
  }
}

totaldata.total에 있다. 그런데 DataProvider 코드를 열어보니…

// DataProvider getList 구현
getList: async ({ resource, pagination, filters, sorters }) => {
  const response = await httpClient(url);
  const json = response.data;
  
  const dataArray = Array.isArray(json.data) 
    ? json.data 
    : json.data?.data || [];

  return {
    data: dataArray,
    total: json.meta?.total || dataArray.length,  // 👈 여기!
  };
},

json.meta?.total에서 total을 찾고 있었다. 그런데 BE 응답에는 meta가 없다. totaljson.data.total에 있다.

fallback으로 dataArray.length를 쓰게 되어 있었는데, 페이지네이션이 적용된 상태라 현재 페이지의 항목 수(20개 등)만 반환된다. 전체 419개가 아니라 현재 페이지의 20개만 세는 것이다.

아, 그런데 왜 “총 0건”인가? dataArray가 비어있진 않은데?

다시 보니 DataProvider의 getList 코드가 두 곳에서 호출되고 있었다. 카드 렌더링은 별도 쿼리로 데이터를 가져오고, 상단의 total 표시는 useList의 반환값을 쓰고 있었다. 타이밍 이슈로 dataArray.length가 0인 시점이 있었다.


🛠️ 해결 2: total 파싱 경로 수정

문제 해결

BE 응답 구조에 맞게 total 파싱 우선순위를 수정했다.

❌ Before — meta에서만 조회

return {
  data: dataArray,
  total: json.meta?.total || dataArray.length,
};

json.meta가 없으면 undefined. fallback으로 현재 페이지 길이. 전체 건수를 모른다.

✅ After — data.total 우선 조회

const total = 
  (json.data as Record<string, unknown>)?.total ??  // 1순위: data.total
  json.meta?.total ??                                // 2순위: meta.total
  dataArray.length;                                  // 3순위: 현재 배열 길이

return {
  data: dataArray,
  total: total as number,
};

BE 응답 구조에 따라 data.total을 먼저 확인하도록 변경했다. 우선순위가 명확해지니 어떤 API 형식이든 대응할 수 있다.

주의: || 대신 ?? (nullish coalescing)를 쓴 이유가 있다. total0인 경우 ||는 falsy로 판단해서 fallback으로 넘어간다. ??nullundefined만 건너뛰므로 total: 0을 정상 처리한다.


🛡️ 예방: DataProvider 커스터마이징 체크리스트

예방이 최선

DataProvider를 커스터마이징할 때마다 이 체크리스트를 확인한다.

📋 API 연동 전 확인사항

  • BE 응답이 배열인가 객체인가? → hook 선택에 직결
  • total이 어디에 있는가? (data.total / meta.total / 헤더)
  • useCustomurl이 baseUrl과 중복되지 않는가?
  • HTTP 메서드가 대문자인가? (이전 글 Vite 프록시 CORS 해결법 참고)

📋 DataProvider 파싱 디버깅 순서

  1. Network 탭 — 실제 응답 JSON 구조 확인 (200인지, 데이터가 있는지)
  2. console.log — DataProvider 내부에서 json 객체 전체를 찍어보기
  3. 타입 확인Array.isArray(json.data) 결과 확인
  4. total 경로json.data.total, json.meta.total 순서로 탐색

📌 Refine hook 선택 가이드

목록 조회 (배열)         → useList
단건 조회 (id 기반)      → useOne  
커스텀 엔드포인트 (객체)  → useCustom
생성/수정/삭제           → useCreate / useUpdate / useDelete

단순해 보이지만, 이 판단을 BE 응답 형태 확인 없이 감으로 하면 오늘 겪은 삽질을 반복하게 된다.


📌 정리

오늘의 교훈

증상원인해결
데이터가 오는데 화면에 안 뜸useList에 객체 응답을 넘김useCustom으로 변경
목록은 나오는데 total이 0meta.total 경로 불일치data.total 우선 파싱

두 문제 모두 BE 응답 구조를 정확히 확인하지 않은 채 코드를 작성해서 생긴 거다.

DataProvider는 BE와 FE 사이의 번역기다. 번역기가 원문 구조를 모르면 오역이 나온다. 새 API를 연동할 때마다 Network 탭부터 여는 습관을 들이자. 응답 JSON을 한 번 확인하는 데 10초, 안 확인해서 디버깅하는 데 1시간이다 ✨

📚 React 프론트엔드 삽질기 시리즈 (2편)

  1. 1. Vite 6.x 프록시에서 PATCH만 CORS 에러? 소문자 메서드 함정과 해결법
  2. 2. React Admin DataProvider 커스터마이징 — useList가 안 되면 useCustom, total이 0이면 경로 확인