Skip to content

IMPL-002 — Optimistic Update 롤백 메커니즘

항목내용
날짜2026-03-20
브랜치feature/optimistic-rollback
상위 결정ARD-0001
상태완료

1. 문제 정의

1.1 HTTP 실패 시 상태 불일치

기존 구현에서 save() 가 HTTP 오류로 실패하면 클라이언트 상태가 이미 변경된 채로 서버와 불일치 상태에 놓였다.

text
user.data.name = 'Davi'  ← Proxy가 target에 변경 반영
await user.save('/api/users/1')
  └─ handler._fetch('PATCH', ...)
      └─ throw HttpError { status: 409 }

           save()가 중단됨.
           target: 이미 'Davi'로 변경된 상태.
           changeLog: 변경 이력 그대로.
           _isNew: POST 시도였다면 상태 오염 가능.

이 상태에서 재시도를 하면 동일한 PATCH 페이로드가 생성되어 올바른 것처럼 보이지만, 개발자가 직접 user.data.name = 'originalValue' 로 되돌리지 않으면 클라이언트와 서버의 실제 상태가 어긋나는 버그가 잠재된다.

1.2 롤백 대상 상태 목록

복원해야 할 상태와 복원이 불필요한 상태를 명확히 분리했다.

상태위치롤백 필요 이유
domainObject 데이터createProxy() 클로저target이 이미 변경되어 있음
changeLog[]createProxy() 클로저재시도 시 올바른 PATCH 페이로드 재생성에 필요
dirtyFields SetcreateProxy() 클로저재시도 시 올바른 PUT/PATCH 분기 판단에 필요
_isNewDomainState 인스턴스POST 실패 시 false 전환 전 복원 필요
_errorsDomainState 인스턴스HTTP 상태와 무관한 인스턴스 레벨 검증 에러. 롤백 불필요

2. 설계 결정

2.1 스냅샷 전략 — structuredClone() 채택

save() 진입 직전 현재 상태의 깊은 복사 스냅샷을 생성한다.

복제 방법깊은 복사제약
JSON.parse(JSON.stringify())YESundefined, Date, Map, Set 소실
structuredClone()YSE함수, DOM 노드, Symbol 불가
재귀 수동 복사YES코드 복잡도 증가

REST API JSON 응답 데이터는 순수 JSON-compatible 값(문자열, 숫자, 배열, 플레인 객체)으로만 구성된다. structuredClone() 이 지원하지 않는 타입이 DTO에 포함되는 경우는 라이브러리의 사용 전제 조건에 어긋나므로 이를 JSDoc에 명시하고 structuredClone() 을 채택한다.

javascript
// save() 진입 직전 스냅샷
const snapshot = {
    data:        structuredClone(this._getTarget()),  // 깊은 복사
    changeLog:   this._getChangeLog(),                // [...changeLog] 얕은 복사본
    dirtyFields: this._getDirtyFields(),              // new Set(dirtyFields) 복사본
    isNew:       this._isNew,                         // 원시값 복사
}

2.2 restoreTarget() — Proxy 우회 직접 복원

domainObjectcreateProxy() 클로저 안에 격리되어 있다. 외부에서 참조 자체를 교체하는 것은 클로저 원리상 불가능하므로, 기존 참조가 가리키는 객체의 내부 프로퍼티를 직접 교체해야 한다.

javascript
// createProxy() 클로저 내부 — Proxy를 거치지 않고 원본에 직접 접근
restoreTarget: (data) => {
    if (Array.isArray(domainObject)) {
        domainObject.splice(0)         // 배열 루트 객체 처리
        domainObject.push(...data)
    } else {
        for (const key of Object.keys(domainObject)) {
            Reflect.deleteProperty(domainObject, key)
        }
        Object.assign(domainObject, data)
    }
}

이 복원 작업은 Proxy 트랩을 우회하기 때문에 changeLog 에 기록되지 않는다. 롤백이 새로운 변경 이력을 만들면 안 된다.

proxyCache WeakMap의 정합성: restoreTarget() 실행 후 WeakMap에는 이전 중첩 객체를 가리키는 오래된 캐시 항목이 남는다. 그러나 structuredClone() 으로 만든 스냅샷은 완전히 새로운 객체 참조를 가지므로, 롤백 후 proxy.address.city 에 처음 접근하면 캐시 미스가 발생하여 새 Proxy가 생성된다. 기존 항목은 키가 되는 원본 객체 참조가 사라지면 WeakMap 특성상 자동 GC 대상이 된다.

2.3 _isNew 플래그 롤백 포함 이유

POST 실패 시 _fetch() 가 throw되면 this._isNew = false 라인은 실행되지 않으므로 _isNewtrue 를 유지한다. 롤백 시 _isNew 를 복원할 필요가 없어 보이지만, 스냅샷에 포함하여 항상 일관되게 복원한다. 향후 코드가 변경되어 _fetch() 이전에 _isNew 를 조작하는 로직이 추가되더라도 이 보장이 깨지지 않는다.

2.4 네트워크 타임아웃 처리

_fetch()HttpError 뿐 아니라 일반 Error(네트워크 단절, 타임아웃)도 throw할 수 있다. catch (err) 블록은 모든 에러를 동일하게 잡아 롤백을 실행한다. 어떤 이유로 서버 동기화가 실패했든 클라이언트 상태는 pre-save 시점으로 복원되어야 한다.

3. 변경 파일 및 커밋 시퀀스

파일변경 종류주요 내용
src/core/api-proxy.js수정ProxyWrapperrestoreTarget, restoreChangeLog, restoreDirtyFields 추가
src/constants/error.messages.js수정ERR.SAVE_ROLLBACK 메시지 추가
src/domain/DomainState.js수정_rollback() 내부 메서드 추가, save() try/catch 재작성
text
feat(core): add restore methods to ProxyWrapper for optimistic update rollback
feat(constants): add SAVE_ROLLBACK error message for rollback event logging
feat(domain): implement optimistic update rollback in save()

4. 결과 및 검증

성공 경로: 스냅샷이 생성되고 HTTP 요청이 성공하면 changeLog, dirtyFields 가 초기화된다. 스냅샷은 try 블록 종료 후 GC 대상이 된다.

실패 경로: HTTP 오류 발생 시 _rollback(snapshot) 이 4개 상태를 복원하고 에러를 re-throw한다. 호출자는 catch 블록에서 동일한 save() 를 다시 호출할 수 있으며, 이때 changeLogdirtyFields 가 복원되어 있으므로 동일한 HTTP 메서드와 페이로드로 올바르게 재시도된다.

Vitest 테스트 케이스 TC-DS-006~009 통과 확인.

Released under the ISC License.