rest-domain-state-manager
REST API 응답 데이터를 Proxy로 감싸 변경을 자동 추적하고,
저장 시점에 최적의 HTTP 메서드(POST / PUT / PATCH)로 자동 분기하는
순수 ES Module 기반 도메인 상태 관리 라이브러리.
번들러 없음 · npm 없음 · 외부 의존성 없음
빠른 길잡이
목적에 해당하는 항목을 선택해 해당 섹션으로 이동한다.
1-1. 설치
번들러, npm, CDN 불필요. 폴더째 복사 후 import만 하면 된다.
your-project/
├── rest-domain-state-manager/ ← 복사한 라이브러리 폴더
└── index.html
<!-- index.html -->
<script type="module">
import { ApiHandler, DomainState }
from './rest-domain-state-manager/rest-domain-state-manager.js';
</script>
1-2. ApiHandler 생성
ApiHandler는 HTTP 전송을 담당하는 클래스다.
인스턴스 생성은 소비자가 직접 한다. 서버 주소를 여기서 결정한다.
const api = new ApiHandler({
host: 'localhost:8080', // 서버 주소
debug: true, // true → HTTP 자동 선택, 콘솔 로그 활성화
});
DomainState의 모든 요청 대상이 된다.const userApi = new ApiHandler({ host: 'user-service.com' });
const orderApi = new ApiHandler({ host: 'order-service.com' });
// 각자 맞는 api를 팩토리에 넘김
const user = await userApi.get('/api/users/1');
const order = await orderApi.get('/api/orders/999');
// save()도 생성 시 주입된 api 그대로 사용
await user.save('/api/users/1'); // → user-service.com
await order.save('/api/orders/999'); // → order-service.com
1-3. GET → 변경 → 저장
api.get()은 서버에서 데이터를 가져와 DomainState 인스턴스를 반환한다.
이후 .data를 통해 값을 읽고 쓴다.
// 1. GET 요청 → DomainState 생성
const user = await api.get('/api/users/user_001');
// 2. 데이터 읽기
console.log(user.data.name);
// 3. 데이터 변경 (Proxy가 자동으로 변경을 추적)
user.data.name = 'Davi';
user.data.address.city = 'Seoul'; // 중첩 객체도 추적
// 4. 저장
// 변경이 있으면 → PATCH (변경된 필드만 전송)
// 변경이 없으면 → PUT (전체 객체 전송)
await user.save('/api/users/user_001');
// 5. 삭제
await user.remove('/api/users/user_001');
1-4. 신규 생성 → POST
신규 데이터 생성에는 fromVO() 또는 fromForm() 팩토리를 쓴다. 두 방법 모두 save() 호출 시 POST로 전송한다.
fromVO() — DomainVO 기반 생성
// 1. DomainVO 선언
class UserVO extends DomainVO {
static baseURL = 'localhost:8080/api/users'; // save() 경로 자동 사용
static fields = {
user_id: { default: '' },
name: { default: '' },
role: { default: 'USER' },
address: { default: { city: '', zip: '' } },
};
}
// 2. DomainState 생성 (isNew: true)
const newUser = DomainState.fromVO(new UserVO(), api);
// 3. 값 설정
newUser.data.user_id = 'user_' + Date.now();
newUser.data.name = 'Davi';
// 4. POST 전송 (static baseURL 자동 사용)
await newUser.save();
fromForm() — HTML Form 기반 생성
<form id="user-form">
<input name="name" />
<input name="address.city" /> <!-- 점(.) 표기로 중첩 구조 표현 -->
<select name="role">...</select>
</form>
// form 초기화 후 blur / change 이벤트를 자동으로 추적
const formState = DomainState.fromForm('user-form', api);
// 사용자가 입력을 마치면 변경이 자동으로 기록되어 있음
await formState.save('/api/users');
1-5. 여러 API 한 번에 (DomainPipeline)
DomainState.all()은 여러 API를 병렬로 호출하고,
완료 후 후처리를 순서대로 실행한다.
const result = await DomainState.all({
roles: api.get('/api/roles'),
user: api.get('/api/users/user_001'),
})
.after('roles', async (roles) => {
// roles → DomainState 인스턴스
console.log(roles.data);
})
.after('user', async (user) => {
console.log(user.data);
})
.run();
// result → { roles: DomainState, user: DomainState }
if (result._errors?.length) {
console.warn('일부 실패:', result._errors);
}
1-6. 목록을 UI 요소로 렌더링 (DomainRenderer)
DomainRenderer 플러그인을 등록하면 DomainState에
renderTo() 메서드가 추가된다.
// 앱 초기화 시 1회 등록
DomainState.use(DomainRenderer);
const roles = await api.get('/api/roles');
// select
roles.renderTo('#role-select', {
type: 'select',
valueField: 'roleId',
labelField: 'roleName',
class: 'form-select',
placeholder: '역할 선택',
events: {
change: (e) => console.log('선택:', e.target.value),
},
});
// radio
roles.renderTo('#role-radio', {
type: 'radio',
valueField: 'roleId',
labelField: 'roleName',
containerClass: 'form-check',
class: 'form-check-input',
labelClass: 'form-check-label',
});
2-1. ApiHandler 전체 옵션
ApiHandler 생성자는 UrlConfig 객체를 받는다.
| 옵션 | 타입 | 설명 |
|---|---|---|
host | string | 프로토콜을 제외한 호스트 (예: api.example.com) |
basePath | string | 공통 경로 접두사 (예: /app/api) |
baseURL | string | host + basePath 통합 문자열 |
protocol | string | 'HTTP' | 'HTTPS' | 'FILE' | 'SSH' |
env | string | 'development' | 'production' |
debug | boolean | true이면 개발 환경으로 간주 |
host와 baseURL은 택일이다. 동시에 입력하면 자동 해소를 시도하고, 해소 불가능한 경우 Error를 throw한다.// 방식 1 — 구조 분해형
const api = new ApiHandler({
host: 'api.example.com',
basePath: '/app/api',
env: 'production', // HTTPS 자동 선택
});
// 방식 2 — 통합 문자열형
const api = new ApiHandler({
baseURL: 'localhost:8080/app/api',
debug: true, // HTTP 자동 선택
});
프로토콜 결정 우선순위
1. 명시적 protocol 옵션
2. env 값 → development: HTTP, production: HTTPS
3. env 없음 + debug: true → HTTP
4. env 없음 + debug: false → HTTPS
요청별 URL 오버라이드
const user = await api.get('/api/users/1', {
urlConfig: { host: 'other-server.com' },
});
2-2. DomainVO 필드 선언
DomainVO는 도메인 구조를 선언하는 베이스 클래스다.
DomainState.fromVO()의 인자로 사용하며 세 가지를 제공한다.
| 제공 항목 | 선언 방법 | 사용 시점 |
|---|---|---|
| 기본값 골격(Skeleton) | default 키 | fromVO() 호출 시 초기 객체 생성 |
| 유효성 검사(Validator) | validate 함수 | save() 전 각 필드 검사 |
| 타입 변환(Transformer) | transform 함수 | save() 직전 직렬화 시 변환 |
class ProductVO extends DomainVO {
static baseURL = 'localhost:8080/api/products';
static fields = {
productId: {
default: '',
},
name: {
default: '',
validate: (v) => v.trim().length > 0, // 빈 문자열 거부
},
price: {
default: 0,
validate: (v) => v >= 0, // 음수 거부
transform: Number, // 문자열 입력 → 숫자 변환
},
tags: {
default: [], // 배열도 선언 가능
},
meta: {
default: { createdAt: '', updatedAt: '' }, // 중첩 객체
},
};
}
static baseURL 폴백
const product = DomainState.fromVO(new ProductVO(), api);
await product.save(); // static baseURL로 POST
await product.save('/api/products/override'); // 명시하면 오버라이드
checkSchema(data)
fromJSON()에 vo 옵션을 함께 넘기면 서버 응답이 VO 스키마와 일치하는지 검증한다.
const product = DomainState.fromJSON(jsonText, api, { vo: new ProductVO() });
// 스키마 불일치 시 콘솔에 경고 출력 (실행은 계속)
2-3. DomainState 내부 구조
Proxy와 data
.data는 원본 객체를 Proxy로 감싼 것이다.
Proxy는 set / deleteProperty 트랩을 통해 모든 변경을 changeLog에 기록한다.
const user = await api.get('/api/users/1');
user.data.name = 'Davi';
// changeLog = [{ op: 'replace', path: '/name', value: 'Davi', oldValue: '기존값' }]
user.data.address.city = 'Seoul'; // 중첩도 자동 추적
// changeLog = [..., { op: 'replace', path: '/address/city', value: 'Seoul' }]
delete user.data.phone;
// changeLog = [..., { op: 'remove', path: '/phone', oldValue: '010-...' }]
isNew 플래그
| 팩토리 | isNew | save() 결과 |
|---|---|---|
api.get() / fromJSON() | false | 변경 있으면 PATCH, 없으면 PUT |
fromVO() | true | POST |
fromForm() | true | POST |
POST 성공 후 isNew는 자동으로 false로 전환된다.
changeLog 직접 접근
console.log(user._getChangeLog());
// [ { op, path, value, oldValue }, ... ]
2-4. 팩토리 메서드 상세
fromJSON(jsonText, handler, options?)
| 옵션 | 타입 | 설명 |
|---|---|---|
urlConfig | UrlConfig | URL 설정 오버라이드 |
debug | boolean | 디버그 모드 |
label | string | 디버그 팝업 표시 이름 |
vo | DomainVO | 스키마 검증 + validators/transformers 주입 |
form | string | HTMLFormElement | 응답 데이터를 지정한 form에 자동으로 채움 |
// GET 응답을 VO로 검증하면서 동시에 form에 값을 채움
const user = DomainState.fromJSON(jsonText, api, {
vo: new UserVO(),
form: 'user-form',
});
fromForm(form, handler, options?)
| 요소 타입 | 추적 이벤트 | 이유 |
|---|---|---|
input[type=text], textarea | blur | 타이핑 중 불필요한 set 트랩 방지 |
select, radio, checkbox | change | 선택 즉시 값이 확정 |
fromVO(vo, handler, options?)
label을 생략하면 클래스 이름(UserVO)이 자동으로 사용된다. 디버그 팝업에서 각 인스턴스를 구분하는 데 유용하다.2-5. save() 분기 전략
save(path?)는 isNew와 changeLog 길이를 보고 HTTP 메서드를 결정한다.
isNew === true
→ POST (전체 객체 직렬화)
isNew === false
changeLog.length > 0 → PATCH (RFC 6902 JSON Patch 배열)
changeLog.length === 0 → PUT (전체 객체 직렬화)
await user.save('/api/users/user_001'); // 경로 명시
await user.save(); // urlConfig 또는 static baseURL 사용
| 전송 결과 | 후처리 |
|---|---|
| PATCH / PUT 성공 | changeLog 자동 초기화 |
| POST 성공 | isNew → false 전환 + changeLog 초기화 |
2-6. DomainPipeline strict 모드 / _errors 처리
| strict | 동작 | 반환 |
|---|---|---|
false (기본값) | 실패 시 _errors에 기록 후 계속 진행 | resolve + _errors 포함 |
true | 첫 실패에서 즉시 reject | reject |
// strict: false — 부분 실패 허용 (기본)
const result = await DomainState.all({
roles: api.get('/api/roles'),
user: api.get('/api/users/INVALID'), // 실패
}, { strict: false })
.after('roles', async (roles) => { /* ... */ })
.run();
result._errors?.forEach(({ key, error }) => console.warn(key, error));
// strict: true — 첫 실패에서 중단
try {
await DomainState.all({ ... }, { strict: true })
.after('roles', async (roles) => { /* ... */ })
.run();
} catch (err) {
console.error('Pipeline 중단:', err);
}
after()의 실행 순서는 등록 순서를 따른다. fetch 완료 순서와는 무관하다.2-7. renderTo() 전체 config
공통 옵션 (모든 type)
| 옵션 | 타입 | 설명 |
|---|---|---|
type | string | 'select' | 'radio' | 'checkbox' | 'button' |
valueField | string | 각 항목의 value 출처 필드명 |
labelField | string | 각 항목의 표시 텍스트 출처 필드명 |
class | string | 요소의 className |
css | object | 요소의 inline style (camelCase 키) |
events | object | { 이벤트명: 핸들러 } |
select 추가 옵션
| 옵션 | 타입 | 설명 |
|---|---|---|
placeholder | string | 첫 번째 비활성 옵션 텍스트 |
multiple | boolean | 다중 선택 여부 |
radio / checkbox 추가 옵션
| 옵션 | 타입 | 설명 |
|---|---|---|
name | string | input[name] 속성. 미입력 시 valueField 자동 사용 |
containerClass | string | 각 항목 wrapper div의 className |
containerCss | object | 각 항목 wrapper div의 inline style |
labelClass | string | label 요소의 className |
labelCss | object | label 요소의 inline style |
name 기본값이 valueField인 이유: MyBatis form submit 시 필드명이 자동으로 일치한다.button
valueField 값이 button[data-value] 속성으로 주입된다. 클릭 핸들러에서 e.target.dataset.value로 읽는다.
roles.renderTo('#role-buttons', {
type: 'button',
valueField: 'roleId',
labelField: 'roleName',
class: 'btn btn-sm btn-outline-primary',
events: {
click: (e) => console.log(e.target.dataset.value),
},
});
2-8. 플러그인 시스템 (use())
DomainState.use(plugin)은 { install(DomainState): void }
계약을 가진 플러그인을 등록한다.
const MyPlugin = {
install(DomainState) {
// prototype에 메서드 주입
DomainState.prototype.toCSV = function () {
const target = this._getTarget();
return Object.values(target).join(',');
};
},
};
DomainState.use(MyPlugin);
const user = await api.get('/api/users/1');
console.log(user.toCSV());
| 특성 | 동작 |
|---|---|
| 중복 등록 | 동일 플러그인 객체를 여러 번 use()해도 install()은 1회만 실행 |
| 체이닝 | DomainState.use(A).use(B).use(C) |
| 계약 미준수 | install 없으면 TypeError |
2-9. Debug Popup
debug: true로 생성하면 디버그 채널이 활성화된다.
openDebugger()로 팝업을 열면 같은 출처(Origin)의 모든 탭의 상태를 실시간으로 확인할 수 있다.
const user = DomainState.fromVO(new UserVO(), api, {
debug: true,
label: 'UserVO', // 팝업에 표시될 이름
});
user.openDebugger();
팝업에서 확인할 수 있는 정보
- 탭 목록 (URL 기준)
- 각 탭의 DomainState 레이블
- 현재
data(실시간 갱신) changeLog목록_errors배열
fromForm()으로 생성한 DomainState는 form 이벤트 발생 시 팝업이 자동으로 갱신된다.통신 방식
BroadcastChannel — 브라우저 내장 Web API, 외부 의존성 없음.
BroadcastChannel은 자기 자신이 보낸 메시지를 수신하지 않으므로,
TAB_PING을 팝업이 직접 broadcast하고 각 탭이 응답하는 구조로 동작한다.
전체 레이어 구조
라이브러리는 세 개의 수직 레이어로 구성된다. 외부 개발자는 진입점 파일 하나만 알면 된다.
┌──────────────────────────────────────────────────────────────────────┐
│ 외부 개발자 진입점 │
│ rest-domain-state-manager.js │
│ { ApiHandler, DomainState, DomainVO, │
│ DomainPipeline, DomainRenderer } │
└───────────────┬──────────────────────────────┬───────────────────────┘
│ │
┌───────────────▼──────────────┐ ┌────────────▼─────────────────────┐
│ model/ │ │ plugin/ │
│ │ │ │
│ DomainState │ │ domain-renderer/ │
│ ├─ fromJSON() │ │ ├─ DomainRenderer.js │
│ ├─ fromForm() │ │ │ (prototype 주입) │
│ ├─ fromVO() │ │ └─ renderers/ │
│ ├─ save() / remove() │ │ ├─ select.renderer.js │
│ └─ all() ────────────────────────────▶ ├─ radio-checkbox.js │
│ │ │ └─ button.renderer.js │
│ DomainVO │ └──────────────────────────────────┘
│ ├─ toSkeleton() │
│ ├─ getValidators() │
│ ├─ getTransformers() │
│ └─ checkSchema() │
│ │
│ DomainPipeline │
│ ├─ after() │
│ └─ run() │
└───────────────┬──────────────┘
│ 내부 호출
┌───────────────▼──────────────────────────────────────────────────────┐
│ src/ (내부 레이어) │
│ │
│ handler/ core/ constants/ debug/ │
│ api-handler.js api-proxy.js error.messages debug- │
│ (fetch 래퍼, api-mapper.js log.messages channel.js │
│ URL 조합) url-resolver.js op.const (BroadcastChannel│
│ protocol.const + 팝업 HTML) │
│ common/ channel.const │
│ js-object-util.js │
└──────────────────────────────────────────────────────────────────────┘
도개교(Drawbridge) 패턴
createProxy()가 반환하는 네 가지 클로저(Closure) 세트를 DomainState가 보관한다.
이를 도개교 패턴이라 부른다 — 클로저 세계로의 출입문을 외부에서 제어하는 구조다.
// createProxy()의 반환값
{
proxy: object, // 변경 추적이 활성화된 Proxy 객체
// 외부 개발자가 접근하는 유일한 진입점
getChangeLog: () => [], // 현재까지의 변경 이력 얕은 복사본 반환
getTarget: () => {}, // 변경이 반영된 원본 객체 반환
clearChangeLog: () => void, // 동기화 성공 후 이력 초기화
}
domainState.data(= proxy)만 사용한다.
나머지 세 함수는 DomainState 내부에서만 호출된다.
이 분리가 변경 추적 무결성을 보장한다.
Proxy 트랩 설계
경로 누적 방식
루트 Proxy 생성 시 basePath = ''에서 시작하며, 중첩 객체 접근마다 makeHandler(basePath + '/' + prop)을 재귀 호출한다.
proxy.address.city = 'Seoul'
// get 트랩: prop='address', basePath=''
// → path='/address' 로 새 Proxy 반환
// set 트랩: prop='city', basePath='/address'
// → path='/address/city' 로 changeLog에 기록
트랩별 동작표
| 트랩 | 발생 시점 | changeLog op | 비고 |
|---|---|---|---|
get |
프로퍼티 읽기 | 기록 없음 | Object/Array이면 deep proxy 반환 |
set (신규) |
없던 키에 값 대입 | add |
target에도 실제 반영 |
set (교체) |
기존 키 값 변경 | replace |
oldValue도 기록 |
deleteProperty |
delete proxy.field |
remove |
oldValue도 기록 |
bypass 대상
다음 프로퍼티는 deep proxy를 씌우지 않고 Reflect.get 결과를 그대로 반환한다.
- Symbol 프로퍼티 —
Symbol.toPrimitive,Symbol.iterator등 toJSON—JSON.stringify호환성 보존then— Promise 체인 호환성 보존 (Proxy를 thenable로 오인 방지)valueOf— 원시값 변환 호환성 보존
데이터 흐름
GET → 화면 표시
서버 (JSON 응답)
↓ fetch()
api-handler._fetch() — 공통 헤더, response.ok 검사
↓ text (JSON 문자열)
api-mapper.toDomain() — JSON.parse → 도메인 객체
↓ plain object
api-proxy.createProxy() — Proxy 래핑, 도개교 세트 생성
↓ { proxy, getChangeLog, getTarget, clearChangeLog }
DomainState 생성 — 메타데이터 보관, debug broadcast
↓ domainState
외부 개발자 코드 — domainState.data.xxx 읽기/쓰기
저장 (save)
domainState.save(path)
↓
isNew 확인
├─ true → toPayload() → POST
└─ false → changeLog 확인
├─ length > 0 → toPatch() → PATCH (RFC 6902)
└─ length = 0 → toPayload() → PUT
↓
api-handler._fetch() — 전송
↓ 성공 시
clearChangeLog() — 이력 초기화
isNew = false (POST일 때만)
파일 구조
rest-domain-state-manager/
│
├── rest-domain-state-manager.js 단일 진입점 — 외부 개발자는 이것만 import
├── proxy.test.html 통합 테스트 (7개 케이스)
│
├── model/
│ ├── DomainState.js 핵심 외부 인터페이스
│ ├── DomainVO.js 도메인 구조 선언 베이스 클래스
│ └── DomainPipeline.js 병렬 fetch + 순차 after() 체이닝
│
├── src/
│ ├── constants/
│ │ ├── protocol.const.js PROTOCOL / ENV / DEFAULT_PROTOCOL
│ │ ├── op.const.js RFC 6902 add / replace / remove
│ │ ├── channel.const.js DEBUG_CHANNEL_NAME / MSG_TYPE
│ │ ├── error.messages.js ERR / WARN 메시지 함수 상수
│ │ └── log.messages.js LOG 템플릿 + formatMessage()
│ ├── common/
│ │ └── js-object-util.js 타입 판별 유틸
│ ├── core/
│ │ ├── api-proxy.js Proxy 변경 추적 엔진 (createProxy)
│ │ ├── url-resolver.js URL 조합 + 충돌 해소
│ │ └── api-mapper.js toDomain / toPayload / toPatch
│ ├── handler/
│ │ └── api-handler.js fetch 래퍼 클래스 (ApiHandler)
│ └── debug/
│ └── debug-channel.js BroadcastChannel + 팝업 HTML 생성
│
└── plugin/
└── domain-renderer/
├── DomainRenderer.js renderTo() 주입 플러그인 본체
├── renderer.const.js RENDERER_TYPE / TRACK_EVENT
└── renderers/
├── select.renderer.js
├── radio-checkbox.renderer.js
└── button.renderer.js
ApiHandler
HTTP 전송 레이어 클래스. URL 설정을 보관하고 fetch()를 래핑한다.
new ApiHandler(urlConfig: UrlConfig)
| 메서드 | 시그니처 | 반환 | 설명 |
|---|---|---|---|
get() |
get(path, options?) |
Promise<DomainState> |
GET 요청 → DomainState 반환 (isNew: false) |
_fetch() |
_fetch(url, options?) |
Promise<string|null> |
내부 전용. DomainState.save/remove에서 호출 |
getUrlConfig() |
getUrlConfig() |
object |
정규화된 URL 설정 반환 |
isDebug() |
isDebug() |
boolean |
debug 플래그 반환 |
UrlConfig 타입
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
host | string | host 또는 baseURL 중 하나 | 프로토콜 제외 호스트 |
basePath | string | — | 공통 경로 접두사 |
baseURL | string | host 또는 baseURL 중 하나 | host + basePath 통합 |
protocol | string | — | 'HTTP'|'HTTPS'|'FILE'|'SSH' |
env | string | — | 'development'|'production' |
debug | boolean | — | true → 개발 환경 간주 |
에러 구조
response.ok가 false이면 아래 구조의 객체를 throw한다. catch(err)에서 err.status로 분기할 수 있다.
{ status: number, statusText: string, body: string }
DomainState
REST 리소스 단위의 상태 관리자. 직접 new하지 않고 팩토리 메서드로 생성한다.
정적 메서드
| 메서드 | 시그니처 | 반환 |
|---|---|---|
fromJSON() |
fromJSON(jsonText, handler, opts?) |
DomainState (isNew: false) |
fromForm() |
fromForm(form, handler, opts?) |
DomainState (isNew: true) |
fromVO() |
fromVO(vo, handler, opts?) |
DomainState (isNew: true) |
all() |
all(resourceMap, opts?) |
DomainPipeline |
use() |
use(plugin) |
typeof DomainState (체이닝) |
인스턴스 프로퍼티 / 메서드
| 항목 | 타입 | 설명 |
|---|---|---|
.data | Proxy (getter) | 변경 추적 Proxy 객체. 유일한 외부 진입점. |
.save(path?) | Promise<void> | POST / PATCH / PUT 자동 분기 |
.remove(path?) | Promise<void> | DELETE |
.log() | void | changeLog 콘솔 출력 (debug: true 시만) |
.openDebugger() | void | 디버그 팝업 열기 (debug: true 시만) |
._getChangeLog() | array | 내부용. 변경 이력 얕은 복사본. |
._getTarget() | object | 내부용. 원본 객체. |
options 공통 필드
| 필드 | 타입 | 설명 |
|---|---|---|
urlConfig | UrlConfig | URL 설정 오버라이드 |
debug | boolean | 디버그 채널 활성화 |
label | string | 디버그 팝업 표시 이름 |
DomainVO
도메인 구조 선언 베이스 클래스. DomainState.fromVO()의 인자로 사용한다.
static 선언
| 선언 | 타입 | 설명 |
|---|---|---|
static baseURL | string | save() 경로 폴백. 미선언 시 null. |
static fields | object | 필드 스키마 선언 |
fields 스키마 구조
| 키 | 타입 | 설명 |
|---|---|---|
default | any | 기본값. Object/Array는 deep copy 적용. |
validate | (v) => boolean | save() 전 검증 함수 |
transform | (v) => v | 직렬화 직전 타입 변환 함수 |
인스턴스 메서드
| 메서드 | 반환 | 설명 |
|---|---|---|
toSkeleton() | object | fields.default로 초기 객체 생성 |
getValidators() | object | { 필드: fn } 맵 반환 |
getTransformers() | object | { 필드: fn } 맵 반환 |
getBaseURL() | string|null | static baseURL 반환 |
checkSchema(data) | { valid, missingKeys, extraKeys } | 응답 데이터와 스키마 비교 |
DomainPipeline
DomainState.all()이 반환하는 파이프라인 객체. 직접 생성하지 않는다.
| 메서드 | 시그니처 | 반환 | 설명 |
|---|---|---|---|
after() |
after(key, handler) |
DomainPipeline (체이닝) |
key에 해당하는 DomainState를 인자로 받는 후처리 핸들러 등록 |
run() |
run() |
Promise<result> |
병렬 fetch → 순차 after() 실행 → 결과 반환 |
run() 반환값
{
[key: string]: DomainState, // resourceMap의 각 키
_errors?: Array<{ key: string, error: any }>
// strict: false 시, 실패한 항목
}
DomainRenderer
DomainState.use(DomainRenderer) 등록 후 DomainState.prototype.renderTo()가 추가된다.
domainState.renderTo(selector, config)
| 인자 | 타입 | 설명 |
|---|---|---|
selector | string | HTMLElement | 컨테이너 CSS 셀렉터 또는 DOM 요소 |
config | object | 렌더링 설정 (위 2-7 섹션 참고) |
DomainState가 배열 데이터를 보유하고 있을 때만 정상 동작한다. (api.get('/api/roles') 등 목록 API 응답)라이브러리 정체성
이 라이브러리는 HTTP 클라이언트가 아니다.
fetch()를 내부적으로 사용하지만, 핵심 가치는 "어떻게 보내는가"가 아니라
"무엇이 바뀌었는가를 추적하고, 저장 시점에 올바른 방식으로 동기화하는가"에 있다.
| 구분 | HTTP 클라이언트 | rest-domain-state-manager |
|---|---|---|
| 관심사 | 요청/응답 전송 | 도메인 상태 변경 추적 + 동기화 |
| 사용 방식 | fetch('/api', { method, body }) | data.name = '...' → 자동 추적 |
| HTTP 메서드 | 개발자가 결정 | 라이브러리가 자동 결정 |
| 변경 범위 | 전체 페이로드 | 변경된 경로만 (RFC 6902 Patch) |
단일 상태 소스 (Single Source of Truth)
화면이 떠 있는 동안 하나의 DomainState 인스턴스가
해당 리소스의 유일한 진실을 담는다.
DOM 요소, form 입력, 코드 내 직접 대입 — 모든 변경은
domainState.data(Proxy)를 통해서만 이루어진다.
// ❌ 안티패턴 — DOM에서 직접 읽어 별도 객체 구성
const payload = {
name: document.getElementById('name').value,
city: document.getElementById('city').value,
};
// ✅ 올바른 패턴 — Proxy 하나가 모든 변경을 수집
const user = DomainState.fromForm('userForm', api);
// form 이벤트 → user.data.name, user.data.address.city 자동 반영
await user.save('/api/users/1');
changeLog 구조
changeLog는 마지막 동기화 이후 발생한 모든 변경의 순서 있는 기록이다.
각 항목은 RFC 6902 JSON Patch 연산과 호환되는 내부 포맷을 따른다.
// 내부 changeLog 항목 구조
{
op: 'add' | 'replace' | 'remove',
path: '/address/city', // JSON Pointer 스타일 경로
value: 'Seoul', // 새 값 (remove 시 없음)
oldValue: 'Busan', // 이전 값
}
save()가 PATCH를 결정하면 api-mapper.toPatch()가
이 내부 포맷을 RFC 6902 표준 배열로 변환하여 서버에 전송한다.
// 서버로 전송되는 RFC 6902 JSON Patch 배열
[
{ "op": "replace", "path": "/address/city", "value": "Seoul" },
{ "op": "add", "path": "/phone", "value": "010-0000-0000" }
]
isNew 플래그
isNew는 이 인스턴스가 서버에 아직 존재하지 않는 신규 리소스인지를 나타낸다.
팩토리 메서드 호출 시점에 결정되며, POST 성공 후 자동으로 false로 전환된다.
// isNew 전환 흐름
const newUser = DomainState.fromVO(new UserVO(), api);
// newUser._isNew === true
await newUser.save(); // → POST 전송
// 성공 후: newUser._isNew === false
newUser.data.name = 'Davi';
await newUser.save(); // → PATCH 전송 (이제 기존 리소스)
Locality of Behavior
이 라이브러리는 Locality of Behavior를 중요한 설계 원칙으로 삼는다. 관련 동작이 한 곳에 모여 있어야 읽으면 이해되는 코드가 된다.
Clean Code의 "함수는 한 가지 일만"보다, 잘 읽히는 한 곳에서 흐름 전체를 파악할 수 있는 구조를 우선한다. 5줄짜리 함수 10개보다 주석이 달린 50줄짜리 함수 1개를 선호한다.
// 이 라이브러리의 설계 의도:
// "save() 한 줄 뒤에서 무슨 일이 일어나는지
// 코드를 보지 않아도 예측할 수 있어야 한다"
await user.save('/api/users/1');
// isNew가 false이고 changeLog가 있으면 → PATCH
// isNew가 false이고 changeLog가 없으면 → PUT
// isNew가 true이면 → POST
// 예측 가능한 한 가지 인터페이스, 세 가지 동작
각 결정은 대안을 검토하고 trade-off를 평가한 뒤 내려졌다. "왜 이렇게 설계됐는가"에 대한 기록이다.
DD-1. Proxy 적용 범위
검토한 대안
| 대안 | 장점 | 단점 |
|---|---|---|
| A. 서비스/도메인 계층에서만 | React 등 프레임워크의 불변성 규칙과 충돌 없음. 디버깅 범위 명확. | 뷰 계층에서 "속성 대입 = 동기화" 경험 불가. |
| B. 뷰/상태 계층까지 전달 | model.name = '...'으로 즉시 동기화 가능. 코드 단순. |
React 얕은 비교 충돌. Array.isArray 오작동. 디버깅 난도 상승. |
DD-2. save()의 HTTP 메서드 전략
검토한 대안
| 대안 | 장점 | 단점 |
|---|---|---|
| A. PUT만 사용 | 구현 단순. 변경 추적 불필요. | 경로 단위 변경 추적 목표 미달. 대형 객체 낭비. |
| B. PATCH만 사용 | 변경된 필드만 전송. | 변경 없을 때 빈 배열 전송. POST 분기 미결. |
| C. POST / PATCH / PUT 혼합 | 각 시나리오에 의미론적으로 정확. PUT의 멱등성 보장. | isNew + changeLog 두 가지 기준 관리 필요. |
DD-3. deep proxy 적용 범위
검토한 대안
| 대안 | 장점 | 단점 |
|---|---|---|
| A. 순수 Object만 | Proxy 로직 단순. 배열 케이스 이후로 미룰 수 있음. | 실무에서 리스트/컬렉션이 빠질 수 없어 범위가 좁음. |
| B. Object + Array 초반부터 | JSON Patch의 /items/0/name 패턴 지원. |
push/splice 래핑 필요 → 1차 구현 복잡도 상승. |
push, splice) 추적 정밀도 개선은 추후 과제.DD-4. 변경 기록 포맷
검토한 대안
| 대안 | 장점 | 단점 |
|---|---|---|
| A. 내부 전용 포맷 + 변환 레이어 | oldValue, diff 등 추가 정보 자유롭게 활용 가능. 프로토콜 변경에 유연. | 변환 레이어(api-mapper.js) 추가 필요. |
| B. 처음부터 RFC 6902 고정 | 서버 구현과 바로 연결. | oldValue 등 추가 정보 필요 시 유연성 부족. |
{ op, path, value, oldValue }를 유지하고, api-mapper.toPatch()에서 RFC 6902로 변환한다.DD-5. 폼 바인딩 방식
검토한 대안
| 대안 | 장점 | 단점 |
|---|---|---|
| A. 직접 DOM 조회 | 친숙하고 명시적. | 이 라이브러리의 존재 이유 자체가 이 방식을 제거하는 것. 채택 불가. |
| B. 경로 기반 바인딩 | Proxy set 트랩 하나로 모든 바인딩 처리. fromForm() 하나로 완결. | input[name]에 경로 표기법 규칙 필요. |
input[name]의 점(.) 표기법으로 중첩 경로를 표현한다. fromForm() 하나로 초기화 + 바인딩 + 추적이 완결된다.DD-6. DomainPipeline strict 기본값
검토한 대안
| 대안 | 장점 | 단점 |
|---|---|---|
| strict: true 기본 | 첫 실패에서 즉시 인지. | 독립적인 리소스 실패가 전체 파이프라인을 중단. 과잉 반응. |
| strict: false 기본 | 부분 실패 허용. _errors로 실패 지점 추적. | 실패를 놓칠 수 있어 명시적 에러 확인 코드 필요. |
_errors로 추적한다.DD-7. 디버그 팝업 통신 방식
검토한 대안
| 대안 | 장점 | 단점 |
|---|---|---|
| window.opener | 팝업에서 부모 탭 객체 직접 접근. | Same Origin 전용. 탭 종료 감지 어려움. 다중 탭 지원 비자연스러움. |
| BroadcastChannel | 브라우저 내장 API. 탭 종료 감지(beforeunload). 다중 탭 자연스럽게 지원. | 자기 자신이 보낸 메시지 수신 불가 → TAB_PING 구조 설계 필요. |
_buildPopupHTML()로 문자열 생성하여 별도 파일 없이 단일 모듈로 완결한다.DD-8. radio / checkbox name 속성 기본값
검토한 대안
| 대안 | 장점 | 단점 |
|---|---|---|
| 별도 name 옵션 필수화 | 명시적. | 대부분의 경우 valueField와 동일한 값을 반복 입력하는 중복 발생. |
| valueField를 name 기본값으로 | MyBatis form submit 시 필드명 자동 일치. 선언 중복 제거. | 암묵적 동작 — 다른 name이 필요한 경우 오버라이드를 알아야 함. |
name 옵션으로 명시 오버라이드.