REST API 리소스를 Proxy로 감싸, 필드 변경을 자동으로 추적하고, POST / PATCH / PUT을 스마트하게 분기하는 zero-dependency 상태 관리 라이브러리.
저장 실패 시 클라이언트 상태를 자동 복원하는 보상 트랜잭션까지 내장.
JSP / 레거시 환경 → SI 빠른 시작
Spring Boot + JSP + jQuery 환경에서 1:N 폼 그리드를 10줄로 만드세요.
fnAddRow(), fnRemoveRow(), fnReindexRows() — 전부 사라집니다.
React / Vue → 프레임워크 연동 빠른 시작
useDomainState() 한 줄로 GET → 수정 → PATCH 사이클을 자동화하세요.
fetch, useState, useEffect, 롤백 로직 — 전부 사라집니다.
REST API 프론트엔드 개발에서 반복되는 세 가지 문제를 해결합니다.
// ❌ Before — 모든 필드를 수동으로 모아야 한다
const payload = {
name: document.getElementById('name').value,
email: document.getElementById('email').value,
phone: document.getElementById('phone').value,
// ...필드가 30개면 30줄
};
await fetch('/api/users/1', { method: 'PUT', body: JSON.stringify(payload) });// ✅ After — 변경한 필드만 자동으로 추적되고, 적절한 HTTP 메서드가 선택된다
const user = await api.get('/api/users/1');
user.data.name = 'Davi'; // ← 이 변경이 자동으로 기록된다
await user.save('/api/users/1'); // → PATCH [{ "op": "replace", "path": "/name", "value": "Davi" }]// ❌ Before — 실패 시 수동 복원 코드를 매번 작성
try {
await fetch('/api/users/1', { method: 'PATCH', body: ... });
} catch {
// 이전 상태로 어떻게 되돌리지? UI에 반영된 값은?
}// ✅ After — 실패 시 save() 이전 상태로 자동 복원
try {
await user.save('/api/users/1');
} catch (err) {
// user.data는 이미 save() 호출 이전 상태로 되돌아가 있다.
console.log(user.data.name); // → 변경 전 값
}// ❌ Before — 화면마다 복사되는 보일러플레이트
function fnAddRow() { /* N0 줄 */ }
function fnRemoveRow() { /* 인덱스 밀림 버그 */ }
function fnReindexRows() { /* N0 줄 */ }
function fnSelectAll() { /* N0 줄 */ }// ✅ After — HTML template 선언 + 컨트롤 함수 한 줄
const { addEmpty, removeChecked, validate } =
certs.bind('#certGrid', { layout: CertLayout });
// addEmpty() — 빈 행 추가
// removeChecked() — 체크된 행 역순 제거 (인덱스 밀림 자동 방지)
// validate() — required 필드 검증npm install @2davi/rest-domain-state-managerNode.js ≥ 20. 브라우저: Chrome 94+, Firefox 93+, Safari 15.4+.
import { ApiHandler } from '@2davi/rest-domain-state-manager';
const api = new ApiHandler({ host: 'localhost:8080', debug: true });import { DomainState, UIComposer, UILayout } from '@2davi/rest-domain-state-manager';
DomainState.use(UIComposer); // 플러그인 설치 (앱 진입점에서 1회)
// ── UI 계약 선언: 어떤 필드가 어떤 DOM 요소에 연결되는지 ──
class UserFormLayout extends UILayout {
static templateSelector = '#userFormTemplate';
static columns = {
name: { selector: '[data-field="name"]', required: true },
email: { selector: '[data-field="email"]' },
city: { selector: '[data-field="city"]' },
};
}// GET 응답이 자동으로 DomainState로 변환된다
const user = await api.get('/api/users/1');
// 폼에 바인딩하면, 사용자가 입력하는 동안 Proxy를 통해 상태가 자동으로 변경된다.
// 개발자가 user.data.name = '...' 같은 코드를 직접 작성할 필요가 없다.
const { unbind } = user.bindSingle('#userForm', { layout: UserFormLayout });
// 사용자가 name 필드에 'Davi'를 입력하고, city 필드에 'Seoul'을 입력한 뒤 저장 버튼을 클릭하면:
await user.save('/api/users/1');
// → PATCH [{ "op": "replace", "path": "/name", "value": "Davi" },
// { "op": "replace", "path": "/city", "value": "Seoul" }]
// 사용자가 건드리지 않은 필드는 페이로드에 포함되지 않는다.스크립트에서 직접 값을 넣는 것도 동일하게 동작합니다:
user.data.name = 'Davi'; // → changeLog에 replace 기록
user.data.address.city = 'Seoul'; // → 중첩 경로도 자동 추적폼 바인딩이든 스크립트 대입이든, Proxy의 set 트랩을 통과하는 모든 변경이 자동 기록됩니다.
import { DomainState, DomainVO } from '@2davi/rest-domain-state-manager';
class UserVO extends DomainVO {
static fields = {
name: { default: '', validate: v => v.trim().length > 0 },
email: { default: '' },
age: { default: 0, transform: Number },
};
}
const newUser = DomainState.fromVO(new UserVO(), api);
newUser.data.name = 'Davi';
newUser.data.email = 'davi@example.com';
await newUser.save('/api/users'); // → POST (isNew === true)DomainVO는 선택적 레이어입니다. DomainState.fromJSON()은 VO 없이도 완전히 동작합니다.
save()는 두 가지 내부 상태를 분석하여 HTTP 메서드를 자동 결정합니다.
| 조건 | 메서드 | 근거 |
|---|---|---|
isNew === true |
POST | 서버에 아직 존재하지 않는 신규 리소스 |
변경 없음 (dirtyFields.size === 0) |
no-op | save() 조기 종료 |
| 변경 비율 ≥ 70% | PUT | 전체 교체가 JSON Patch 배열보다 효율적 |
| 변경 비율 < 70% | PATCH | RFC 6902 JSON Patch — 변경 부분만 전송 |
POST 성공 후 isNew가 false로 전환되어, 이후 저장은 PATCH 또는 PUT으로 분기합니다.
save() 진입 시 structuredClone()으로 현재 상태 4종(데이터, 변경이력, dirty 필드, isNew 플래그)을
깊은 복사합니다. HTTP 요청이 실패하면 4종을 모두 save() 이전 시점으로 원자적 복원합니다.
user.data.name = 'Davi'; // 변경 기록됨
await user.save('/api/users/1'); // 서버 500 에러 발생!
// → user.data.name은 자동으로 이전 값으로 복원됨
// → changeLog, dirtyFields도 save() 진입 이전 상태로 복원됨
// → 즉시 재시도 가능DomainPipeline의 보상 트랜잭션과도 연계됩니다.
strict: false 모드에서 후속 save() 실패를 감지한 뒤,
이미 성공한 인스턴스에 restore()를 호출하여 전체 파이프라인의 일관성을 복구할 수 있습니다.
import { DomainCollection } from '@2davi/rest-domain-state-manager';
// GET 응답 배열 → DomainCollection 변환
const certs = DomainCollection.fromJSONArray(
await fetch('/api/certificates').then(r => r.text()),
api
);
certs.add({ certName: '정보처리기사', certType: 'IT' }); // 항목 추가
certs.remove(0); // 인덱스로 제거
await certs.saveAll({
strategy: 'batch', // 배열 전체를 단일 HTTP 요청으로 전송
path: '/api/certificates',
});
// → PUT (기존 배열 전체 교체) 또는 POST (신규 생성)HTML <template> 기반으로 DOM 구조를 선언하고, 라이브러리가 데이터를 채웁니다.
JS에서 DOM 구조를 생성하지 않으므로, CSS 프레임워크(Bootstrap, Tailwind)와 충돌 없이 사용할 수 있습니다.
<template id="certRowTemplate">
<tr>
<td><input type="checkbox" class="dsm-checkbox"></td>
<td><span class="dsm-row-number"></span></td>
<td><input type="text" data-field="certName" placeholder="자격증명"></td>
<td>
<select data-field="certType">
<option value="IT">IT</option>
<option value="LANG">어학</option>
</select>
</td>
</tr>
</template>
<table>
<tbody id="certGrid"></tbody>
</table>
<button id="btnAdd">행 추가</button>
<button id="btnRemove">선택 삭제</button>
<button id="btnSave">저장</button>import {
ApiHandler, DomainState, DomainCollection,
UIComposer, UILayout
} from '@2davi/rest-domain-state-manager';
// 플러그인 설치 (앱 진입점에서 1회)
DomainState.use(UIComposer);
// 화면별 UI 계약 선언
class CertLayout extends UILayout {
static templateSelector = '#certRowTemplate';
static itemKey = 'certId';
static columns = {
certName: { selector: '[data-field="certName"]', required: true },
certType: { selector: '[data-field="certType"]' },
};
}
const api = new ApiHandler({ host: 'localhost:8080' });
const certs = DomainCollection.fromJSONArray(
// NOTE: 현재 ApiHandler.get() 메서드는 단일 객체 응답 중심으로 설계되어 있습니다 ^0^
// TODO: 빠른 업데이트를 통해 fetch 병행 없이 불러오도록 개선하겠습니다 ^~^;
await fetch('/api/certificates').then(r => r.text()),
api
);
// 바인딩 → 컨트롤 함수 반환
const { addEmpty, removeChecked, validate } =
certs.bind('#certGrid', { layout: CertLayout });
document.getElementById('btnAdd').onclick = addEmpty;
document.getElementById('btnRemove').onclick = removeChecked;
document.getElementById('btnSave').onclick = async () => {
if (!validate()) return;
await certs.saveAll({ strategy: 'batch', path: '/api/certificates' });
};| 함수 | 역할 |
|---|---|
addEmpty() |
빈 행 추가 (template 복제 + input 리스너 자동 등록) |
removeChecked() |
체크된 행 역순(LIFO) 제거 — 인덱스 밀림 자동 방지 |
removeAll() |
전체 행 제거 |
selectAll(checked) |
전체 체크박스 일괄 설정 |
invertSelection() |
체크 상태 반전 |
validate() |
required: true 필드 검증 + is-invalid CSS 클래스 토글 |
getCheckedItems() |
체크된 DomainState 배열 반환 |
getItems() |
전체 DomainState 배열 반환 |
getCount() |
총 행 수 반환 |
destroy() |
이벤트 리스너 정리 |
서브패스 import로 React 어댑터를 사용합니다. React 18+ useSyncExternalStore 기반입니다.
React가 peerDependencies(optional)로 선언되어 있어, React 없이 설치해도 경고가 뜨지 않습니다.
import { useDomainState } from '@2davi/rest-domain-state-manager/adapters/react';
function UserProfile({ userState }) {
const data = useDomainState(userState);
return (
<div>
<input
value={data.name}
onChange={e => { userState.data.name = e.target.value; }}
/>
<button onClick={() => userState.save('/api/users/1')}>
저장
</button>
</div>
);
}혹은,
import { ApiHandler, DomainState } from '@2davi/rest-domain-state-manager';
import { useDomainState } from '@2davi/rest-domain-state-manager/adapters/react';
const api = new ApiHandler({ host: 'localhost:8080' });
function UserProfile({ userId }) {
const [state, setUserState] = useState(null);
useEffect(() => {
api.get(`/api/users/${userId}`).then(setUserState);
}, [userId]);
const data = useDomainState(state); // Shadow State — 변경 시 자동 리렌더링
if (!data) return <div>로딩 중...</div>;
return (
<form>
<input
value={data.name}
onChange={e => { state.data.name = e.target.value; }}
/>
<button onClick={() => state.save(`/api/users/${userId}`)}>
저장 (PATCH 자동 분기)
</button>
</form>
);
}userState.data.name = '...'로 Proxy를 변이하면:
- Microtask 배칭 완료 → Structural Sharing 기반 불변 스냅샷 재빌드
- 변경된 키만 새 참조, 나머지 키는 이전 참조 재사용
- React가
getSnapshot()재호출 →Object.is()비교 → 리렌더링
변경이 없으면 동일 참조를 반환하여 무한 리렌더링 루프가 발생하지 않습니다.
저장 실패 시 모든 상태가 save() 이전으로 자동 복원됩니다. useState로 에러 상태를 따로 관리할 필요가 없습니다.
여러 API를 병렬로 요청하고, 응답 순서와 무관하게 후처리를 체이닝합니다.
const result = await DomainState.all({
roles: api.get('/api/roles'),
user: api.get('/api/users/1'),
}, { strict: false })
.after('roles', async roles => {
// roles 응답으로 셀렉트박스 옵션 채우기
})
.after('user', async user => {
// user 응답으로 폼 데이터 채우기
})
.run();
// 개별 실패는 result._errors에 기록 (strict: false)
if (result._errors?.length) console.warn(result._errors);IETF Idempotency-Key 표준 초안에 기반합니다.
const api = new ApiHandler({ host: 'api.example.com', idempotent: true });
try {
await user.save('/api/users/1');
} catch {
// 네트워크 타임아웃 후 재시도 — 동일 UUID가 자동 재사용됨
await user.save('/api/users/1');
// 서버는 동일 Idempotency-Key를 감지하여 중복 처리 방지
}save() 성공 시 UUID 즉시 초기화. 실패 시 유지되어 재시도 안전.
OWASP CSRF Prevention Cheat Sheet 준수.
POST, PUT, PATCH, DELETE 요청에만 X-CSRF-Token 헤더를 삽입합니다.
const api = new ApiHandler({ host: 'localhost:8080' });
// DOM이 준비된 시점에 1회 호출
api.init();
// → <meta name="csrf-token" content="..."> 파싱
// → 이후 모든 뮤테이션 요청에 X-CSRF-Token 헤더 자동 주입
// 또는 쿠키에서 파싱
api.init({ csrfCookieName: 'XSRF-TOKEN' });init() 미호출 시 CSRF 기능은 비활성 상태이며, 뮤테이션 요청에 토큰이 삽입되지 않습니다.
const user = await api.get('/api/users/1', { trackingMode: 'lazy' });
user.data.name = 'A';
user.data.name = 'B';
user.data.name = 'C'; // 같은 필드를 3번 변경
await user.save('/api/users/1');
// realtime 모드: PATCH에 3개 항목 (A, B, C 각각 기록)
// lazy 모드: PATCH에 1개 항목 (최종 결과 C만 전송)lazy 모드에서는 Proxy set 트랩이 changeLog 기록을 건너뛰고,
save() 호출 시 초기 스냅샷과 현재 상태를 LCS 알고리즘으로 deep diff하여
최종 변경 결과만 PATCH 페이로드에 포함합니다.
diff 연산은 브라우저 환경에서 Web Worker로 오프로딩되어 메인 스레드를 차단하지 않습니다.
const api = new ApiHandler({ host: 'localhost:8080', debug: true });
const user = await api.get('/api/users/1');
user.openDebugger(); // 디버그 팝업 열기BroadcastChannel 기반으로 동일 출처의 모든 탭에서 DomainState의 상태를 실시간으로 확인할 수 있습니다.
탭이 닫히거나 응답이 없으면 Heartbeat GC가 자동으로 정리합니다.
| 기능 | 설명 |
|---|---|
| Proxy 자동 추적 | set, delete, 배열 변이(push, splice, sort 등) 전체 인터셉트 |
| RFC 6902 JSON Patch | changeLog를 표준 JSON Patch 배열로 직렬화 |
| HTTP 메서드 분기 | isNew + dirtyRatio 기반 POST / PATCH / PUT 자동 결정 |
| 보상 트랜잭션 | structuredClone 기반 4종 상태 원자적 롤백 |
| DomainCollection | 1:N 배열 상태 + saveAll({ strategy: 'batch' }) |
| UIComposer | HTML <template> 기반 그리드/폼 바인딩 + 컨트롤 함수 반환 |
| React 어댑터 | useSyncExternalStore 기반 useDomainState() 훅 |
| Idempotency-Key | IETF Draft 기반 UUID 자동 발급/재사용 |
| CSRF 인터셉터 | 3-상태 설계. <meta> + 쿠키 파싱 |
| Lazy Tracking | LCS deep diff + Worker 오프로딩. 최종 변경만 전송 |
| Microtask 배칭 | queueMicrotask 스케줄러. 동기 블록 내 다중 변경 → 단일 flush |
| V8 최적화 | WeakMap Lazy Proxying + Reflect API + DomainVO Shape 고정 |
| 플러그인 시스템 | DomainState.use(plugin) — 선택적 DOM 의존 기능 분리 |
| 멀티탭 디버거 | BroadcastChannel + Heartbeat GC + Worker 직렬화 |
| DomainPipeline | 병렬 fetch + 순차 after() 체이닝 + 보상 트랜잭션 연계 |
| Zero Dependency | 런타임 의존성 0. sideEffects: false Tree-shaking 허용 |
import {
ApiHandler, // HTTP 전송 레이어 (인스턴스 생성은 소비자가 담당)
DomainState, // 팩토리 3종 + save/remove + Shadow State + 플러그인
DomainVO, // 선택적 — 신규 INSERT 스키마 선언 시
DomainCollection, // 1:N 배열 상태 컨테이너 + saveAll
DomainPipeline, // 병렬 fetch + 순차 after() 체이닝
UIComposer, // HTML <template> 기반 그리드/폼 바인딩 플러그인
UILayout, // 화면별 UI 계약 선언 베이스 클래스
closeDebugChannel, // 디버그 채널 명시적 종료 (SPA 전환 시)
} from '@2davi/rest-domain-state-manager';
// React 어댑터 (별도 서브패스)
import { useDomainState } from '@2davi/rest-domain-state-manager/adapters/react';전체 가이드, 아키텍처 심층 분석, 인터랙티브 플레이그라운드: lab.the2davi.dev/rest-domain-state-manager
| 카테고리 | 페이지 |
|---|---|
| Quick Start | SI 빠른 시작 · 모던 빠른 시작 |
| Guide | DomainCollection · UIComposer & UILayout · Tracking Modes · Idempotency · save() 분기 전략 |
| Architecture | Proxy 엔진 · HTTP 라우팅 · V8 최적화 |
| Playground | 인터랙티브 데모 11종 |
| API Reference | TypeDoc 자동 생성 |
| Decision Log | ARD 4편 + IMPL 5편 |
ISC © 2026 2davi