Skip to content
작성일2026년 02월 13일
수정일2026년 05월 31일
분량7,471
읽는 시간약 7분
26.02.13 작성· 26.05.31 수정· 약 7분

AG Grid Drawer + URL 상태 동기화

useNavigate · URLSearchParams · getRowId · ensureNodeVisible 한 번에 정리

목록에서 상세보기를 누르면 Drawer가 열리고, URL에 상태가 저장되어 새로고침해도 동일한 상태가 복원되는 패턴입니다.


목차

  1. 개요
  2. ProductsApp — URL ↔ 상태 동기화
  3. ProductList — Row 자동 선택 + 스크롤
  4. ProductDetailDrawer — Drawer 구성
  5. 핵심 포인트 4가지

1. 개요

1. 목록에서 "상세보기" 클릭
2. Drawer 오픈 + URL에 ?id=3&page=1&detail=true 저장
3. 새로고침 → URL 파싱 → 데이터 로드 완료 후 Drawer 재오픈
4. 해당 Row 자동 선택 + 화면 중앙으로 스크롤

컴포넌트 구조

ProductsApp              ← URL 읽기·쓰기, 상태 관리
├── ProductList          ← AG Grid, Row 선택·스크롤
└── ProductDetailDrawer  ← Ant Design Drawer

URL 파라미터

파라미터예시역할
id3선택한 상품 ID
page2현재 페이지 번호
detailtrueDrawer 오픈 여부

2. ProductsApp

URL을 읽어 Drawer 상태를 복원하고, Drawer를 열고 닫을 때 URL을 갱신합니다.

jsx
import { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Card } from 'antd';
import ProductList from './ProductList';
import ProductDetailDrawer from './ProductDetailDrawer';

function ProductsApp() {
  const location = useLocation();
  const navigate = useNavigate();

  const [drawerVisible, setDrawerVisible] = useState(false);
  const [drawerProduct, setDrawerProduct] = useState(null);
  const [currentPage, setCurrentPage] = useState(1);
  const [allProducts, setAllProducts] = useState([]);

  // URL → 상태 복원
  useEffect(() => {
    const params = new URLSearchParams(location.search);
    const productId = parseInt(params.get('id'));
    const page = parseInt(params.get('page')) || 1;
    const detail = params.get('detail') === 'true';

    setCurrentPage(page);

    // allProducts가 채워진 뒤에만 find() 실행
    if (detail && productId && allProducts.length > 0) {
      const product = allProducts.find((p) => p.id === productId);
      if (product) {
        setDrawerProduct(product);
        setDrawerVisible(true);
      }
    }
  }, [location.search, allProducts]);

  // Drawer 열기 → URL 갱신
  const handleOpenDrawer = (product, page = currentPage) => {
    setDrawerProduct(product);
    setDrawerVisible(true);
    navigate(`${location.pathname}?${new URLSearchParams({ id: product.id, page, detail: 'true' })}`);
  };

  // Drawer 닫기 → URL 초기화
  const handleCloseDrawer = () => {
    setDrawerVisible(false);
    setDrawerProduct(null);
    navigate(location.pathname);
  };

  return (
    <>
      <Card title="상품 목록">
        <ProductList
          onOpenDrawer={handleOpenDrawer}
          onProductsLoaded={setAllProducts}
          currentPage={currentPage}
          selectedProductId={drawerProduct?.id ?? null}
        />
      </Card>
      <ProductDetailDrawer
        product={drawerProduct}
        open={drawerVisible}
        onClose={handleCloseDrawer}
      />
    </>
  );
}

export default ProductsApp;

3. ProductList

getRowId로 Row를 식별하고, selectedProductId가 바뀔 때 해당 Row를 선택·스크롤합니다.

jsx
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Button } from 'antd';
import { AgGridReact } from 'ag-grid-react';
import { fetchProducts } from '@/api/productApi';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-quartz.css';

function ProductList({ onOpenDrawer, onProductsLoaded, currentPage = 1, selectedProductId = null }) {
  const [products, setProducts] = useState([]);
  const [gridApi, setGridApi] = useState(null);

  useEffect(() => {
    fetchProducts().then((data) => {
      setProducts(data);
      onProductsLoaded?.(data);
    });
  }, [onProductsLoaded]);

  // selectedProductId 변경 시 Row 선택 + 스크롤
  useEffect(() => {
    if (!gridApi || !selectedProductId) return;
    const rowNode = gridApi.getRowNode(String(selectedProductId));
    if (rowNode) {
      rowNode.setSelected(true);
      gridApi.ensureNodeVisible(rowNode, 'middle');
    }
  }, [gridApi, selectedProductId]);

  const handleOpenDrawer = useCallback(
    (product) => onOpenDrawer?.(product, currentPage),
    [onOpenDrawer, currentPage]
  );

  const columnDefs = useMemo(
    () => [
      {
        headerName: '상세',
        width: 80,
        cellRenderer: (params) => (
          <Button
            type="link"
            size="small"
            onClick={(e) => {
              e.stopPropagation(); // Row click 버블링 방지
              handleOpenDrawer(params.data);
            }}
          >
            상세보기
          </Button>
        ),
      },
      { field: 'name', headerName: '상품명', width: 150 },
      { field: 'description', headerName: '설명', width: 200 },
      {
        field: 'price',
        headerName: '가격',
        width: 120,
        valueFormatter: (p) => `₩${p.value.toLocaleString()}`,
      },
      {
        field: 'stock',
        headerName: '재고',
        width: 100,
        cellStyle: (p) => ({ color: p.value < 20 ? '#ff4d4f' : '#52c41a' }),
      },
      { field: 'category', headerName: '카테고리', width: 120 },
    ],
    [handleOpenDrawer]
  );

  return (
    <div className="ag-theme-quartz" style={{ height: 500 }}>
      <AgGridReact
        rowData={products}
        columnDefs={columnDefs}
        rowSelection={{ mode: 'singleRow' }}
        pagination
        paginationPageSize={5}
        onGridReady={(params) => setGridApi(params.api)}
        getRowId={(p) => String(p.data.id)}
      />
    </div>
  );
}

export default ProductList;

4. ProductDetailDrawer

jsx
import { Drawer, Descriptions, Tag } from 'antd';

function ProductDetailDrawer({ product, open, onClose }) {
  return (
    <Drawer title={product?.name ?? '상품 상세'} open={open} onClose={onClose} width={480}>
      {product && (
        <Descriptions column={1} bordered size="small">
          <Descriptions.Item label="상품명">{product.name}</Descriptions.Item>
          <Descriptions.Item label="카테고리">
            <Tag color="blue">{product.category}</Tag>
          </Descriptions.Item>
          <Descriptions.Item label="가격">₩{product.price.toLocaleString()}</Descriptions.Item>
          <Descriptions.Item label="재고">
            <span style={{ color: product.stock < 20 ? '#ff4d4f' : '#52c41a' }}>
              {product.stock}개
            </span>
          </Descriptions.Item>
          <Descriptions.Item label="설명">{product.description}</Descriptions.Item>
        </Descriptions>
      )}
    </Drawer>
  );
}

export default ProductDetailDrawer;

참고: Ant Design v5부터 visible 대신 open prop을 사용합니다.


5. 핵심 포인트 4가지

1. getRowId 필수

jsx
getRowId={(p) => String(p.data.id)}

이게 없으면 gridApi.getRowNode()로 특정 Row를 찾을 수 없습니다.
반환값은 반드시 문자열이어야 합니다.

2. ensureNodeVisible로 스크롤 이동

jsx
gridApi.ensureNodeVisible(rowNode, 'middle');

Row를 선택(setSelected)만 해도 화면 밖에 있으면 보이지 않습니다.
반드시 함께 호출해 화면 중앙으로 스크롤하세요.

3. e.stopPropagation()으로 버블링 차단

jsx
onClick={(e) => {
  e.stopPropagation();
  handleOpenDrawer(params.data);
}}

버튼 클릭이 Row click 이벤트로 번지면 selection이 꼬입니다.

4. 데이터 로드 타이밍 처리

jsx
useEffect(() => {
  if (detail && productId && allProducts.length > 0) { // 데이터 있을 때만
    // ...
  }
}, [location.search, allProducts]); // allProducts도 의존성에 포함

allProducts를 의존성에 추가하면 데이터 로드 후 useEffect가 재실행되어 Drawer가 정상 복원됩니다.


핵심 요약

URL에 ?id=...&detail=true를 저장 → useEffect로 복원 → getRowId + ensureNodeVisible로 Row 동기화.

MIT 라이선스에 따라 릴리즈되었습니다.