HTTP 자동 라우팅
이 문서는 save() 가 HTTP 메서드를 자동으로 결정하는 알고리즘의 설계 배경, 구현 원리, 그리고 엣지 케이스 처리 방식을 기술합니다.
설계 배경
REST API에서 HTTP 메서드는 단순한 액션 식별자가 아닙니다. 각 메서드는 서버-클라이언트 간 약속된 의미론(Semantics)과 멱등성(Idempotency) 규약을 내포합니다.
- POST (비멱등) — 리소스를 새로 생성합니다. 동일한 요청을 두 번 보내면 두 개의 리소스가 생성됩니다.
- PUT (멱등) — 타겟 리소스를 완전히 교체합니다. 동일한 요청을 N번 보내도 결과는 항상 동일합니다. 요청 본문에 리소스의 모든 필드가 포함되어야 합니다.
- PATCH (부분/비멱등) — 리소스의 일부만 변경합니다. RFC 6902 JSON Patch 포맷을 사용합니다.
이 의미론을 준수하지 않으면 데이터 무결성 오류가 발생합니다. 예를 들어 변경된 필드만 있다는 이유로 항상 PATCH를 보내다가 요청 실패 시 서버 상태와 불일치가 생깁니다.
Dirty Checking — 설계 원리
Java Hibernate 등의 ORM에서 오래전부터 사용해온 Dirty Checking 메커니즘을 JavaScript Proxy 기반으로 구현합니다.
핵심 아이디어는 단순합니다: 어떤 최상위 필드가 변경되었는가를 Set으로 추적하여, 변경된 필드의 비율에 따라 전체 교체(PUT) 또는 부분 교체(PATCH)를 결정합니다.
changeLog vs dirtyFields — 역할 분리
두 자료구조는 서로 다른 목적을 가집니다.
| 구분 | changeLog | dirtyFields |
|---|---|---|
| 자료구조 | ChangeLogEntry[] | Set<string> |
| 저장 단위 | RFC 6902 op 단위 (ADD/REPLACE/REMOVE) | 변경된 최상위 키 |
| 목적 | PATCH 페이로드 직렬화 | PUT/PATCH 분기 비율 계산 |
| 중복 처리 | 동일 경로 변경 시 항목이 쌓임 | Set이므로 동일 키 중복 없음 |
| 질문 | "어떻게 바뀌었나 (감사 이력)" | "어느 키가 바뀌었나 (존재 여부)" |
최상위 키 추출 원리
모든 변경 경로는 JSON Pointer(RFC 6901) 스타일로 / 로 시작합니다. dirtyFields 는 경로의 두 번째 세그먼트(최상위 키)만 추출합니다.
'/name' → split('/')[1] → 'name'
'/address/city' → split('/')[1] → 'address' (중첩 변경은 최상위만)
'/items/0/price' → split('/')[1] → 'items'
'/items' → split('/')[1] → 'items' (배열 전체 REPLACE도 동일)이 설계는 totalFields = Object.keys(target).length 와 수학적으로 정합합니다. address.city 를 변경한 것은 루트 객체 기준으로 address 하나를 변경한 것이며, PUT/PATCH 비율 계산은 루트 필드 수를 기준으로 합니다.
분기 알고리즘
save() 진입
│
├─ _isNew === true
│ └─ POST
│ payload: JSON.stringify(getTarget())
│ 성공 시: _isNew = false
│
└─ _isNew === false
│
dirtyRatio = dirtyFields.size / Object.keys(getTarget()).length
(totalFields === 0인 경우 dirtyRatio = 0으로 처리 — ZeroDivisionError 방어)
│
├─ dirtyFields.size === 0
│ └─ PUT
│ payload: JSON.stringify(getTarget())
│ 의미: 변경 없는 의도적 재저장 — 멱등성 보장
│
├─ dirtyRatio >= DIRTY_THRESHOLD (0.7)
│ └─ PUT
│ payload: JSON.stringify(getTarget())
│ 의미: 70% 이상 변경 — 전체 교체가 PATCH 오버헤드보다 효율적
│
└─ dirtyRatio < DIRTY_THRESHOLD
└─ PATCH
payload: toPatch(changeLog) → RFC 6902 배열DIRTY_THRESHOLD = 0.7 의 선택 근거
PATCH 방식은 변경된 필드만 전송하여 페이로드 크기와 서버 처리 비용을 줄입니다. 그러나 필드 수가 많을수록 JSON Patch 배열 자체의 직렬화 오버헤드가 증가합니다. 전체 필드의 70% 이상이 변경된 경우, PUT으로 전체 객체를 한 번에 전송하는 것이 구조적으로 더 단순하고 효율적이라는 판단입니다. 이 임계값은 src/constants/dirty.const.js 에 상수로 선언되어 있으며 필요 시 조정 가능합니다.
Optimistic Update 롤백
save() 는 요청을 보내기 전에 현재 상태의 스냅샷을 생성합니다. 스냅샷은 structuredClone() 을 사용한 깊은 복사이므로, 이후 domainObject 가 변경되어도 스냅샷은 영향받지 않습니다.
save() 진입
│
snapshot = {
data: structuredClone(getTarget()), ← 깊은 복사
changeLog: getChangeLog(), ← 얕은 복사본 (이미 [...changeLog])
dirtyFields: getDirtyFields(), ← new Set 복사본
isNew: _isNew, ← 원시값
}
│
try:
_fetch() 호출
성공 → clearChangeLog(), clearDirtyFields(), broadcast
│
catch:
_rollback(snapshot)
├─ restoreTarget(snapshot.data) ← Proxy 우회 직접 복원
├─ restoreChangeLog(snapshot.changeLog)
├─ restoreDirtyFields(snapshot.dirtyFields)
├─ _isNew = snapshot.isNew
└─ if debug: broadcast()
throw err ← 반드시 re-throwrestoreTarget — Proxy 우회 복원
롤백 시 domainObject 의 프로퍼티를 복원하는 작업은 Proxy 트랩을 우회하여 원본 객체에 직접 접근합니다. 이렇게 하지 않으면 복원 작업 자체가 changeLog 에 기록되어 이력이 오염됩니다.
// createProxy() 클로저 내부
restoreTarget: (data) => {
if (Array.isArray(domainObject)) {
domainObject.splice(0) // 배열 루트인 경우 splice로 처리
domainObject.push(...data) // (length 복원 보장)
} else {
for (const key of Object.keys(domainObject)) {
Reflect.deleteProperty(domainObject, key) // 기존 키 제거
}
Object.assign(domainObject, data) // 스냅샷으로 채우기
}
}이 작업은 Proxy 트랩이 아닌 클로저 내부의 domainObject 참조에 직접 접근하므로, 트랩이 개입하지 않습니다.
엣지 케이스 처리
빈 객체 (
totalFields === 0)
dirtyRatio = 0 → dirtyFields.size === 0 조건 우선 → PUT배열 루트 객체에서의 save()
배열 루트 객체는 Object.keys() 가 인덱스 문자열을 반환합니다. totalFields 계산 자체는 동일하게 동작하지만, 배열 데이터를 가진 인스턴스에서 save() 를 호출하는 경우는 DomainRenderer 의 렌더링 소스로만 사용하는 것이 일반적입니다.
POST 실패 후 isNew 복원
_fetch() 가 throw하면 this._isNew = false 줄이 실행되지 않습니다. 그러나 스냅샷에 isNew: true 가 포함되어 있으므로 _rollback() 이 항상 일관되게 복원합니다.