reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Start
Playground
Core Concepts
Core Concepts
DataGrid
DataGrid 개요
Editable Data Entry
Operations Monitoring
Large Data
Remote Protocol
Migration Playbook
App Composition Guide
Column Playbook
Build & Operate
Skills
Release Notes
Reference
Hook Reference
Type Reference
Oopt-datagrid
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

DataGrid
  1. Docs
  2. /
  3. DataGrid
  4. /
  5. Remote Protocol

DataGrid - 원격 연동 계약

백엔드를 DataGrid에 맞춰 설계할 때 필요한 view session, window fetch, batch save, invalidation 계약을 한 페이지에 정리했습니다.

reopt design · Updated Jun 26, 2026

시작하기핵심 개념개요편집형 데이터 입력운영 모니터링대규모 데이터원격 연동 계약마이그레이션 플레이북앱 조합 가이드컬럼 설계 플레이북Skills릴리즈 노트Hook 레퍼런스타입 레퍼런스

1. 설계 원칙

- 리스트 API가 아니라 view session 기반 계약으로 설계합니다.

- scroll은 전체 rows sync가 아니라 window fetch로 처리합니다.

- save는 cell 단건이 아니라 batch patch로 묶습니다.

- playground는 Next Route Handler mock으로 open/window/edits/close만 먼저 구현합니다.

- save 응답은 canonical rows, rejectedEdits, invalidateRanges를 함께 돌려주는 shape를 유지합니다.

- push invalidation은 실제 백엔드 단계에서 SSE/WebSocket으로 추가합니다.

2. 요청 흐름

playground는 아래 순서로 mock session을 열고 닫습니다. 실제 화면에서도 이 흐름을 그대로 쓰고, 저장소와 invalidation만 실제 구현으로 바꾸면 됩니다.

text
1. Playground client calls POST /api/playground/datagrid/views/open.
2. Route Handler creates a mock view session and returns viewId, totalRowCount, snapshotVersion.
3. Client calls GET /api/playground/datagrid/views/:viewId/window for the visible viewport.
4. Route Handler returns only the requested rows and visibleColumnIds projection.
5. Client batches edits to POST /api/playground/datagrid/views/:viewId/edits.
6. Route Handler returns canonical rows, rejectedEdits, totalRowCount, snapshotVersion.
7. Client closes the session on reset/unmount or lets the mock TTL clean it up.

3. Playground Mock 범위

실제 동작 예제는 datagrid playground에 들어 있습니다. mock backend는 /api/playground/datagrid/views 아래에 `open`, `window`, `edits`, `close` Route Handler만 두고, SSE push route는 intentionally 생략했습니다.

4. openView / loadRows 계약

view session은 정렬/필터가 반영된 stable row order를 서버가 유지하게 해줍니다. `visibleColumnIds`를 같이 받으면 wide table에서도 필요한 projection만 읽을 수 있습니다.

ts
type GridViewId = string;
type GridSnapshotVersion = string | number;

interface OpenGridViewRequest {
  sort: readonly { columnId: string; direction: "asc" | "desc" }[];
  filters: readonly { columnId: string; operator: string; value: unknown }[];
  queryKey: readonly unknown[];
}

interface OpenGridViewResponse {
  viewId: GridViewId;
  totalRowCount: number;
  snapshotVersion: GridSnapshotVersion;
}

interface LoadGridWindowRequest {
  viewId: GridViewId;
  start: number;
  end: number;
  visibleColumnIds: readonly string[] | null;
  snapshotVersion: GridSnapshotVersion | null;
}

interface LoadGridWindowResponse<Row> {
  rows: readonly Row[];
  totalRowCount: number;
  snapshotVersion: GridSnapshotVersion;
}

5. Next Route Handler: open / window

open route는 세션 생성만 담당하고, window route는 search params를 canonical request shape로 읽어 shared mock service에 넘깁니다. Next.js App Router의 Route Handler 형태만 먼저 맞춰두면 playground와 실제 서버 코드의 경계가 분리됩니다.

ts
import { NextResponse } from "next/server";
import { openDatagridPlaygroundRemoteView } from "@/lib/datagrid-playground/mock-remote-service";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

export async function POST(request: Request) {
  const payload = await request.json();
  const response = await openDatagridPlaygroundRemoteView(payload, request.signal);
  return NextResponse.json(response);
}
ts
import { NextResponse, type NextRequest } from "next/server";
import { loadDatagridPlaygroundRemoteWindow } from "@/lib/datagrid-playground/mock-remote-service";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

export async function GET(
  request: NextRequest,
  context: { params: Promise<{ viewId: string }> },
) {
  const { viewId } = await context.params;
  const searchParams = request.nextUrl.searchParams;

  const response = await loadDatagridPlaygroundRemoteWindow(
    viewId,
    {
      start: Number(searchParams.get("start") ?? "0"),
      end: Number(searchParams.get("end") ?? "0"),
      snapshotVersion: searchParams.get("snapshotVersion") ?? undefined,
      visibleColumnIds:
        searchParams
          .get("visibleColumnIds")
          ?.split(",")
          .filter((columnId) => columnId.length > 0) ?? null,
      reason:
        (searchParams.get("reason") as
          | "visible-region"
          | "retry"
          | "refresh"
          | "revalidate"
          | "push"
          | null) ?? undefined,
    },
    request.signal,
  );

  return NextResponse.json(response);
}

6. saveEdits / rejection 계약

저장 응답은 성공 여부 하나로 끝내지 말고, canonical rows와 partial rejection을 같이 내려야 합니다. 프론트는 accepted edit만 확정하고, rejected edit는 rollback 또는 serverValue로 치환합니다.

ts
interface SaveGridEditsRequest<Row> {
  viewId: GridViewId;
  snapshotVersion: GridSnapshotVersion | null;
  edits: readonly {
    rowId: string;
    rowIndex: number;
    columnId: string;
    nextValue: unknown;
  }[];
}

interface SaveGridEditsResponse<Row> {
  rows?: readonly {
    rowId?: string;
    rowIndex?: number;
    row: Row;
  }[];
  totalRowCount?: number;
  snapshotVersion?: GridSnapshotVersion;
  movedRowIds?: readonly string[];
  invalidateRanges?: readonly { start: number; end: number }[];
  rejectedEdits?: readonly {
    rowId?: string;
    rowIndex?: number;
    columnId: string;
    message?: string;
    serverValue?: unknown;
    row?: Row;
  }[];
}
ts
import { NextResponse } from "next/server";
import { saveDatagridPlaygroundRemoteEdits } from "@/lib/datagrid-playground/mock-remote-service";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

export async function POST(
  request: Request,
  context: { params: Promise<{ viewId: string }> },
) {
  const { viewId } = await context.params;
  const payload = await request.json();
  const response = await saveDatagridPlaygroundRemoteEdits(
    viewId,
    payload,
    request.signal,
  );

  return NextResponse.json(response);
}

7. Playground 클라이언트 연결

playground는 `useDataGridRemoteProtocol()`로 canonical fetch helper를 만들고, 그 결과를 `useDataGridRemoteDataSource()`에 연결합니다. mock 동작을 제어하는 latency/failure/seed 값은 `queryKey`로 같이 넘깁니다.

tsx
import {
  DataGrid,
  useDataGridRemoteDataSource,
  useDataGridRemoteProtocol,
} from "@reopt-ai/opt-datagrid";

function PlaygroundRemoteGrid() {
  const remoteProtocol = useDataGridRemoteProtocol<Row>({
    basePath: "/api/playground/datagrid/views",
    getRowId: (row) => String(row.id),
    includeLoadReason: true,
  });

  const remote = useDataGridRemoteDataSource<Row>({
    rowCount: asyncConfig.rowCount,
    pageSize: asyncConfig.pageSize,
    preloadPages: asyncConfig.preloadPages,
    queryKey: [
      "playground-remote",
      {
        rowCount: asyncConfig.rowCount,
        latencyMs: asyncConfig.latencyMs,
        loadFailureRate: asyncConfig.loadFailureRate,
        saveLatencyMs: asyncConfig.saveLatencyMs,
        saveFailureRate: asyncConfig.saveFailureRate,
        seed: asyncConfig.seed,
        blockedRatio: generatorBlockedRatio,
      },
    ],
    getRowId: (row) => String(row.id),
    getVisibleColumnIds: (region) =>
      columns
        .slice(region.startCol, region.endCol + 1)
        .map((column) => column.id),
    openView: remoteProtocol.openView,
    closeView: remoteProtocol.closeView,
    loadRows: remoteProtocol.loadRows,
    saveEdits: remoteProtocol.saveEdits,
    makePlaceholderRow: (rowIndex) => ({
      id: rowIndex + 1,
      service: `loading-${String(rowIndex + 1).padStart(6, "0")}`,
    }),
    revalidateAfterSave: "affected-pages",
  });

  return (
    <DataGrid
      rows={remote.rows}
      columns={columns}
      getRowId={(row) => String(row.id)}
      onVisibleRegionChanged={remote.onVisibleRegionChanged}
      onCellsEdited={remote.onCellsEdited}
    />
  );
}

8. push invalidation은 다음 단계

현재 playground mock은 single-user 개발용이라 push route를 넣지 않았습니다. 실제 백엔드로 넘어가면 save 응답과 같은 payload shape를 유지한 채 invalidate event를 추가하면 됩니다.

ts
interface GridInvalidationEvent<Row> {
  rows?: readonly {
    rowId?: string;
    rowIndex?: number;
    row: Row;
  }[];
  totalRowCount?: number;
  snapshotVersion?: GridSnapshotVersion;
  movedRowIds?: readonly string[];
  invalidateRanges?: readonly { start: number; end: number }[];
}

// SSE example
event: invalidate
data: {"snapshotVersion":42,"movedRowIds":["srv-18"],"invalidateRanges":[{"start":0,"end":200}]}
text
1. Client calls openView with sort/filter/query inputs.
2. Server returns viewId, totalRowCount, snapshotVersion.
3. Client calls loadRows for visible pages with visibleColumnIds.
4. Client batches edits and sends saveEdits with snapshotVersion.
5. Server responds with canonical rows, rejectedEdits, movedRowIds, invalidateRanges.
6. Client reapplies optimistic queue and revalidates affected visible pages.
7. Server pushes invalidate events for multi-user changes on the same viewId.

9. 백엔드 구현 체크리스트

- openView에서 sort/filter/queryKey 조합별 viewId와 snapshotVersion을 발급합니다.

- loadRows는 start/end와 visibleColumnIds를 받아 projection fetch를 수행합니다.

- saveEdits는 rowId 중심 batch mutation을 트랜잭션으로 처리합니다.

- 정렬/필터 key 편집으로 위치가 바뀌면 movedRowIds 또는 invalidateRanges를 채웁니다.

- 부분 실패가 가능하면 rejectedEdits에 message와 serverValue 또는 canonical row를 넣습니다.

- playground mock은 persistent storage 대신 메모리 세션과 TTL 정리만 둡니다.

- 실서비스로 확장할 때 같은 view에 대한 외부 변경은 invalidate event로 push합니다.

PreviousLarge Data가상화, 캐시, 렌더링 제한을 중심으로 한 대량 데이터 전략DataGrid
Go to Large Data
NextMigration Playbook기존 grid 라이브러리에서 opt-datagrid로 옮길 때 필요한 모델 재정의와 단계별 이행 전략DataGrid