10개 어드민을 만들면서 정리한 React Admin 패턴

10개 어드민을 만들면서 정리한 React Admin 패턴

어드민 페이지. 모든 서비스에 필요하지만, 매번 처음부터 만들기 귀찮은 그것.

2019년부터 2021년까지, 회사에서 10개가 넘는 어드민을 만들었다. medilance_admin, ppl_admin, oldHero_admin, 그리고 수많은 프로젝트들. 처음엔 매번 새로 만들었지만, 어느 순간부터 패턴이 보이기 시작했다.

모든 어드민은 비슷하다

어드민의 90%는 CRUD다.

  • 목록 조회 (List)
  • 상세 조회 (Detail)
  • 생성 (Create)
  • 수정 (Update)
  • 삭제 (Delete)

회원 관리, 게시판 관리, 주문 관리… 도메인만 다르지 패턴은 같다.

/user/list     → 회원 목록
/user/detail/1 → 회원 상세
/user/edit/1   → 회원 수정

/board/list    → 게시판 목록
/board/detail/1 → 게시판 상세
/board/edit/1  → 게시판 수정

이걸 깨닫고 나서, 패턴화를 시작했다.


폴더 구조

src/
├── api/              # API 호출
│   ├── user.js
│   ├── board.js
│   └── index.js
├── components/       # 공통 컴포넌트
│   ├── Table/
│   ├── Form/
│   ├── Modal/
│   └── Layout/
├── pages/            # 페이지
│   ├── User/
│   │   ├── UserList.jsx
│   │   ├── UserDetail.jsx
│   │   └── UserEdit.jsx
│   └── Board/
├── hooks/            # 커스텀 훅
│   ├── useList.js
│   ├── useDetail.js
│   └── useForm.js
├── utils/            # 유틸리티
└── App.jsx

핵심은 도메인별 분리공통 로직 추출이다.


목록 페이지 패턴

모든 목록 페이지는 같은 구조다:

  1. 검색 필터
  2. 테이블
  3. 페이지네이션
// hooks/useList.js
export const useList = (apiFunc, initialParams = {}) => {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);
  const [params, setParams] = useState({
    page: 1,
    limit: 20,
    ...initialParams,
  });
  const [meta, setMeta] = useState({ total: 0 });

  const fetch = useCallback(async () => {
    setLoading(true);
    try {
      const res = await apiFunc(params);
      setData(res.data.data);
      setMeta(res.data.meta);
    } catch (e) {
      console.error(e);
    } finally {
      setLoading(false);
    }
  }, [apiFunc, params]);

  useEffect(() => {
    fetch();
  }, [fetch]);

  const handleSearch = (searchParams) => {
    setParams({ ...params, ...searchParams, page: 1 });
  };

  const handlePageChange = (page) => {
    setParams({ ...params, page });
  };

  return {
    data,
    loading,
    meta,
    params,
    handleSearch,
    handlePageChange,
    refetch: fetch,
  };
};

사용하는 쪽:

// pages/User/UserList.jsx
const UserList = () => {
  const {
    data,
    loading,
    meta,
    handleSearch,
    handlePageChange,
  } = useList(api.user.list);

  const columns = [
    { key: 'id', title: 'ID' },
    { key: 'name', title: '이름' },
    { key: 'email', title: '이메일' },
    { key: 'createdAt', title: '가입일', render: formatDate },
  ];

  return (
    <PageLayout title="회원 관리">
      <SearchFilter onSearch={handleSearch} />
      <Table
        columns={columns}
        data={data}
        loading={loading}
      />
      <Pagination
        total={meta.total}
        onChange={handlePageChange}
      />
    </PageLayout>
  );
};

useList 훅 하나로 모든 목록 페이지를 만들 수 있다.


상세/수정 페이지 패턴

상세 조회와 수정도 패턴화했다:

// hooks/useDetail.js
export const useDetail = (apiFunc, id) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (!id) return;
    
    const fetch = async () => {
      try {
        const res = await apiFunc(id);
        setData(res.data.data);
      } catch (e) {
        console.error(e);
      } finally {
        setLoading(false);
      }
    };
    
    fetch();
  }, [apiFunc, id]);

  return { data, loading };
};
// hooks/useForm.js
export const useForm = (initialData, submitFunc) => {
  const [form, setForm] = useState(initialData);
  const [loading, setLoading] = useState(false);
  const [errors, setErrors] = useState({});

  useEffect(() => {
    if (initialData) {
      setForm(initialData);
    }
  }, [initialData]);

  const handleChange = (key, value) => {
    setForm({ ...form, [key]: value });
    setErrors({ ...errors, [key]: null });
  };

  const handleSubmit = async () => {
    setLoading(true);
    try {
      await submitFunc(form);
      return true;
    } catch (e) {
      if (e.response?.data?.errors) {
        setErrors(e.response.data.errors);
      }
      return false;
    } finally {
      setLoading(false);
    }
  };

  return { form, loading, errors, handleChange, handleSubmit };
};

공통 컴포넌트

1. 테이블

// components/Table/index.jsx
const Table = ({ columns, data, loading, onRowClick }) => {
  if (loading) return <Skeleton rows={10} />;
  
  return (
    <table className="admin-table">
      <thead>
        <tr>
          {columns.map(col => (
            <th key={col.key}>{col.title}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map(row => (
          <tr key={row.id} onClick={() => onRowClick?.(row)}>
            {columns.map(col => (
              <td key={col.key}>
                {col.render ? col.render(row[col.key], row) : row[col.key]}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
};

2. 검색 필터

// components/SearchFilter/index.jsx
const SearchFilter = ({ filters, onSearch }) => {
  const [values, setValues] = useState({});

  const handleSubmit = (e) => {
    e.preventDefault();
    onSearch(values);
  };

  return (
    <form onSubmit={handleSubmit} className="search-filter">
      {filters.map(filter => (
        <FilterInput
          key={filter.key}
          type={filter.type}
          label={filter.label}
          value={values[filter.key]}
          onChange={(v) => setValues({ ...values, [filter.key]: v })}
        />
      ))}
      <Button type="submit">검색</Button>
    </form>
  );
};

3. 페이지 레이아웃

// components/Layout/PageLayout.jsx
const PageLayout = ({ title, children, actions }) => {
  return (
    <div className="page-layout">
      <div className="page-header">
        <h1>{title}</h1>
        {actions && <div className="page-actions">{actions}</div>}
      </div>
      <div className="page-content">
        {children}
      </div>
    </div>
  );
};

API 레이어

모든 API 호출을 한 곳에서 관리:

// api/user.js
import axios from './axios';

export const user = {
  list: (params) => axios.get('/admin/user/list', { params }),
  detail: (id) => axios.get(`/admin/user/detail/${id}`),
  create: (data) => axios.post('/admin/user/register', data),
  update: (id, data) => axios.put(`/admin/user/update/${id}`, data),
  delete: (id) => axios.delete(`/admin/user/delete/${id}`),
};
// api/index.js
import { user } from './user';
import { board } from './board';
import { order } from './order';

export const api = {
  user,
  board,
  order,
};

새 도메인 추가할 때 복붙해서 수정하면 끝.


실제 적용 사례

medilance_admin

의료 서비스 어드민. 회원, 예약, 결제 관리.

// 예약 목록 - 5분만에 완성
const ReservationList = () => {
  const { data, loading, meta, handleSearch, handlePageChange } = useList(
    api.reservation.list
  );

  const columns = [
    { key: 'id', title: '예약번호' },
    { key: 'userName', title: '환자명' },
    { key: 'doctorName', title: '의사명' },
    { key: 'status', title: '상태', render: StatusBadge },
    { key: 'reservedAt', title: '예약일시', render: formatDateTime },
  ];

  return (
    <PageLayout title="예약 관리">
      <SearchFilter
        filters={[
          { key: 'status', type: 'select', label: '상태', options: STATUS_OPTIONS },
          { key: 'date', type: 'dateRange', label: '예약일' },
        ]}
        onSearch={handleSearch}
      />
      <Table columns={columns} data={data} loading={loading} />
      <Pagination total={meta.total} onChange={handlePageChange} />
    </PageLayout>
  );
};

ppl_admin

PPL 매칭 서비스. 인플루언서, 브랜드, 캠페인 관리.

oldHero_admin

시니어 돌봄 서비스. 요양보호사, 이용자, 매칭 관리.

모두 같은 패턴. 도메인만 다를 뿐.


이 패턴의 장점

  1. 개발 속도: 새 어드민 만드는 데 하루면 충분
  2. 일관성: 어떤 프로젝트든 같은 구조
  3. 유지보수: 한 곳 고치면 모든 곳에 적용
  4. 온보딩: 신규 개발자도 빠르게 파악

단점

  1. 유연성 부족: 특이한 요구사항에 대응 어려움
  2. 과한 추상화: 간단한 건 오히려 복잡해질 수 있음
  3. 학습 비용: 패턴을 먼저 이해해야 함

마무리

어드민은 사용자가 사내 직원이다. 화려할 필요 없다. 빠르고 정확하면 된다.

패턴화의 핵심은 반복을 줄이는 것이다. 10개 프로젝트에서 같은 코드를 쓰지 말고, 한 번 만들어서 재사용하자.

이 패턴들은 나중에 cucu-generator로 발전했다. CLI로 보일러플레이트를 생성하는 도구다.

다음엔 백엔드도 비슷하게 패턴화하고 싶다. NestJS가 답일 것 같다.


다음 글: 왜 NestJS로 갈아탔나

관련 포스트가 없어요.

profile
손상혁.
Currently Managed
Currently not managed