Lab / rest-domain-state-manager

rest-domain-state-manager

REST API 응답 데이터를 Proxy로 감싸 변경을 자동 추적하고, 저장 시점에 최적의 HTTP 메서드(POST / PUT / PATCH)로 자동 분기하는 순수 ES Module 기반 도메인 상태 관리 라이브러리.

번들러 없음  ·  npm 없음  ·  외부 의존성 없음

빠른 길잡이

목적에 해당하는 항목을 선택해 해당 섹션으로 이동한다.

⚡ Part 1 — Getting Started

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 플러그인을 등록하면 DomainStaterenderTo() 메서드가 추가된다.

// 앱 초기화 시 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',
});
🔬 Part 2 — Deep Dive

2-1. ApiHandler 전체 옵션

ApiHandler 생성자는 UrlConfig 객체를 받는다.

옵션타입설명
hoststring프로토콜을 제외한 호스트 (예: api.example.com)
basePathstring공통 경로 접두사 (예: /app/api)
baseURLstringhost + basePath 통합 문자열
protocolstring'HTTP' | 'HTTPS' | 'FILE' | 'SSH'
envstring'development' | 'production'
debugbooleantrue이면 개발 환경으로 간주
⚠️
hostbaseURL은 택일이다. 동시에 입력하면 자동 해소를 시도하고, 해소 불가능한 경우 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)defaultfromVO() 호출 시 초기 객체 생성
유효성 검사(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 플래그

팩토리isNewsave() 결과
api.get() / fromJSON()false변경 있으면 PATCH, 없으면 PUT
fromVO()truePOST
fromForm()truePOST

POST 성공 후 isNew는 자동으로 false로 전환된다.

changeLog 직접 접근

console.log(user._getChangeLog());
// [ { op, path, value, oldValue }, ... ]

2-4. 팩토리 메서드 상세

fromJSON(jsonText, handler, options?)

옵션타입설명
urlConfigUrlConfigURL 설정 오버라이드
debugboolean디버그 모드
labelstring디버그 팝업 표시 이름
voDomainVO스키마 검증 + validators/transformers 주입
formstring | HTMLFormElement응답 데이터를 지정한 form에 자동으로 채움
// GET 응답을 VO로 검증하면서 동시에 form에 값을 채움
const user = DomainState.fromJSON(jsonText, api, {
  vo:   new UserVO(),
  form: 'user-form',
});

fromForm(form, handler, options?)

요소 타입추적 이벤트이유
input[type=text], textareablur타이핑 중 불필요한 set 트랩 방지
select, radio, checkboxchange선택 즉시 값이 확정

fromVO(vo, handler, options?)

💡
label을 생략하면 클래스 이름(UserVO)이 자동으로 사용된다. 디버그 팝업에서 각 인스턴스를 구분하는 데 유용하다.

2-5. save() 분기 전략

save(path?)isNewchangeLog 길이를 보고 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첫 실패에서 즉시 rejectreject
// 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)

옵션타입설명
typestring'select' | 'radio' | 'checkbox' | 'button'
valueFieldstring각 항목의 value 출처 필드명
labelFieldstring각 항목의 표시 텍스트 출처 필드명
classstring요소의 className
cssobject요소의 inline style (camelCase 키)
eventsobject{ 이벤트명: 핸들러 }

select 추가 옵션

옵션타입설명
placeholderstring첫 번째 비활성 옵션 텍스트
multipleboolean다중 선택 여부

radio / checkbox 추가 옵션

옵션타입설명
namestringinput[name] 속성. 미입력 시 valueField 자동 사용
containerClassstring각 항목 wrapper div의 className
containerCssobject각 항목 wrapper div의 inline style
labelClassstringlabel 요소의 className
labelCssobjectlabel 요소의 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();

팝업에서 확인할 수 있는 정보

⚠️
브라우저 팝업 차단을 허용해야 한다. fromForm()으로 생성한 DomainState는 form 이벤트 발생 시 팝업이 자동으로 갱신된다.

통신 방식

BroadcastChannel — 브라우저 내장 Web API, 외부 의존성 없음. BroadcastChannel은 자기 자신이 보낸 메시지를 수신하지 않으므로, TAB_PING을 팝업이 직접 broadcast하고 각 탭이 응답하는 구조로 동작한다.

🏗 Architecture

전체 레이어 구조

라이브러리는 세 개의 수직 레이어로 구성된다. 외부 개발자는 진입점 파일 하나만 알면 된다.

┌──────────────────────────────────────────────────────────────────────┐
│                         외부 개발자 진입점                               │
│                  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 결과를 그대로 반환한다.

데이터 흐름

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
📋 API Reference

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 타입

필드타입필수설명
hoststringhost 또는 baseURL 중 하나프로토콜 제외 호스트
basePathstring공통 경로 접두사
baseURLstringhost 또는 baseURL 중 하나host + basePath 통합
protocolstring'HTTP'|'HTTPS'|'FILE'|'SSH'
envstring'development'|'production'
debugbooleantrue → 개발 환경 간주

에러 구조

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 (체이닝)

인스턴스 프로퍼티 / 메서드

항목타입설명
.dataProxy (getter)변경 추적 Proxy 객체. 유일한 외부 진입점.
.save(path?)Promise<void>POST / PATCH / PUT 자동 분기
.remove(path?)Promise<void>DELETE
.log()voidchangeLog 콘솔 출력 (debug: true 시만)
.openDebugger()void디버그 팝업 열기 (debug: true 시만)
._getChangeLog()array내부용. 변경 이력 얕은 복사본.
._getTarget()object내부용. 원본 객체.

options 공통 필드

필드타입설명
urlConfigUrlConfigURL 설정 오버라이드
debugboolean디버그 채널 활성화
labelstring디버그 팝업 표시 이름

DomainVO

도메인 구조 선언 베이스 클래스. DomainState.fromVO()의 인자로 사용한다.

static 선언

선언타입설명
static baseURLstringsave() 경로 폴백. 미선언 시 null.
static fieldsobject필드 스키마 선언

fields 스키마 구조

타입설명
defaultany기본값. Object/Array는 deep copy 적용.
validate(v) => booleansave() 전 검증 함수
transform(v) => v직렬화 직전 타입 변환 함수

인스턴스 메서드

메서드반환설명
toSkeleton()objectfields.default로 초기 객체 생성
getValidators()object{ 필드: fn } 맵 반환
getTransformers()object{ 필드: fn } 맵 반환
getBaseURL()string|nullstatic 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)
인자타입설명
selectorstring | HTMLElement컨테이너 CSS 셀렉터 또는 DOM 요소
configobject렌더링 설정 (위 2-7 섹션 참고)
⚠️
DomainState가 배열 데이터를 보유하고 있을 때만 정상 동작한다. (api.get('/api/roles') 등 목록 API 응답)
💡 Core Concepts

라이브러리 정체성

이 라이브러리는 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
// 예측 가능한 한 가지 인터페이스, 세 가지 동작
📝 Design Decisions

각 결정은 대안을 검토하고 trade-off를 평가한 뒤 내려졌다. "왜 이렇게 설계됐는가"에 대한 기록이다.

DD-1. Proxy 적용 범위

검토한 대안

대안장점단점
A. 서비스/도메인 계층에서만 React 등 프레임워크의 불변성 규칙과 충돌 없음. 디버깅 범위 명확. 뷰 계층에서 "속성 대입 = 동기화" 경험 불가.
B. 뷰/상태 계층까지 전달 model.name = '...'으로 즉시 동기화 가능. 코드 단순. React 얕은 비교 충돌. Array.isArray 오작동. 디버깅 난도 상승.
채택: A — 1차 구현에서는 서비스/도메인 계층에서만 Proxy를 사용한다. React 연동은 이후 별도 실험 브랜치에서 평가한다.

DD-2. save()의 HTTP 메서드 전략

검토한 대안

대안장점단점
A. PUT만 사용 구현 단순. 변경 추적 불필요. 경로 단위 변경 추적 목표 미달. 대형 객체 낭비.
B. PATCH만 사용 변경된 필드만 전송. 변경 없을 때 빈 배열 전송. POST 분기 미결.
C. POST / PATCH / PUT 혼합 각 시나리오에 의미론적으로 정확. PUT의 멱등성 보장. isNew + changeLog 두 가지 기준 관리 필요.
채택: C (A+C 혼합) — isNew 플래그와 changeLog 길이의 조합으로 세 메서드를 자동 분기한다.

DD-3. deep proxy 적용 범위

검토한 대안

대안장점단점
A. 순수 Object만 Proxy 로직 단순. 배열 케이스 이후로 미룰 수 있음. 실무에서 리스트/컬렉션이 빠질 수 없어 범위가 좁음.
B. Object + Array 초반부터 JSON Patch의 /items/0/name 패턴 지원. push/splice 래핑 필요 → 1차 구현 복잡도 상승.
채택: Object + 1차원 Array 전제, 단계적 구현 — 설계는 Array를 포함하되 구현 순서는 Object → Array 인덱스 접근 → 중첩 구조 순으로 간다. Array 변형 메서드(push, splice) 추적 정밀도 개선은 추후 과제.

DD-4. 변경 기록 포맷

검토한 대안

대안장점단점
A. 내부 전용 포맷 + 변환 레이어 oldValue, diff 등 추가 정보 자유롭게 활용 가능. 프로토콜 변경에 유연. 변환 레이어(api-mapper.js) 추가 필요.
B. 처음부터 RFC 6902 고정 서버 구현과 바로 연결. oldValue 등 추가 정보 필요 시 유연성 부족.
채택: A — 내부는 { op, path, value, oldValue }를 유지하고, api-mapper.toPatch()에서 RFC 6902로 변환한다.

DD-5. 폼 바인딩 방식

검토한 대안

대안장점단점
A. 직접 DOM 조회 친숙하고 명시적. 이 라이브러리의 존재 이유 자체가 이 방식을 제거하는 것. 채택 불가.
B. 경로 기반 바인딩 Proxy set 트랩 하나로 모든 바인딩 처리. fromForm() 하나로 완결. input[name]에 경로 표기법 규칙 필요.
채택: Binput[name]의 점(.) 표기법으로 중첩 경로를 표현한다. fromForm() 하나로 초기화 + 바인딩 + 추적이 완결된다.

DD-6. DomainPipeline strict 기본값

검토한 대안

대안장점단점
strict: true 기본 첫 실패에서 즉시 인지. 독립적인 리소스 실패가 전체 파이프라인을 중단. 과잉 반응.
strict: false 기본 부분 실패 허용. _errors로 실패 지점 추적. 실패를 놓칠 수 있어 명시적 에러 확인 코드 필요.
채택: strict: false — HTTP Request/Response는 이미 완료된 비용이다. 후처리 핸들러 실패는 독립적인 관심사이므로 계속 진행하며 _errors로 추적한다.

DD-7. 디버그 팝업 통신 방식

검토한 대안

대안장점단점
window.opener 팝업에서 부모 탭 객체 직접 접근. Same Origin 전용. 탭 종료 감지 어려움. 다중 탭 지원 비자연스러움.
BroadcastChannel 브라우저 내장 API. 탭 종료 감지(beforeunload). 다중 탭 자연스럽게 지원. 자기 자신이 보낸 메시지 수신 불가 → TAB_PING 구조 설계 필요.
채택: BroadcastChannel — TAB_PING을 팝업이 직접 broadcast하고 각 탭이 TAB_REGISTER로 응답하는 구조로 자기 수신 불가 제약을 우회한다. 팝업 HTML도 _buildPopupHTML()로 문자열 생성하여 별도 파일 없이 단일 모듈로 완결한다.

DD-8. radio / checkbox name 속성 기본값

검토한 대안

대안장점단점
별도 name 옵션 필수화 명시적. 대부분의 경우 valueField와 동일한 값을 반복 입력하는 중복 발생.
valueField를 name 기본값으로 MyBatis form submit 시 필드명 자동 일치. 선언 중복 제거. 암묵적 동작 — 다른 name이 필요한 경우 오버라이드를 알아야 함.
채택: valueField를 name 기본값으로 — MyBatis ResultMap 필드명과의 자동 일치를 의도한 설계. SI 프로젝트 JSP 폼 submit 패턴에서 별도 설정 없이 동작하도록 한다. 다른 name이 필요한 경우에만 name 옵션으로 명시 오버라이드.