오래된 탭 하나가 새 일정을 지울 수 있었습니다: 전체 덮어쓰기 저장을 항목 단위 API와 409로 바꾼 기록
Plan Wall은 편집할 때마다 목록 전체를 PUT으로 올렸고, 서버는 업로드에 없는 행을 지웠습니다. updated_at 비교와 409 응답으로 저장 경로를 다시 짠 과정입니다.
제가 매일 쓰는 할 일 보드 Plan Wall은 지난주까지 항목 하나를 고쳐도 목록 전체를 서버에 올렸습니다. 서버는 그 목록을 정답으로 보고, 목록에 없는 행을 지웠습니다. 탭을 두 개 열어 두면 오래된 탭이 새 일정을 지울 수 있는 구조였습니다.
UX 개편 계획을 쓰려고 저장 경로를 다시 읽다가 이 사실을 확인했습니다. 실제로 일정이 사라진 장면을 본 것은 아닙니다.
다만 Plan Wall은 로그인만 하면 어느 기기에서든 여는 서비스라, 같은 보드가 두 곳에 열려 있는 상황은 드물지 않습니다. 지금까지는 운이 좋았을 뿐이라고 판단했습니다.
그래서 개편 계획서 첫 항목을 "DATA AND SYNC (do this first; everything else depends on it)"로 잡았습니다.
원래 저장 경로#
클라이언트는 상태가 바뀔 때마다 persist(next)를 불렀고, 이 함수는 저장 큐 뒤에 api.putTasks(next)를 붙였습니다. 요청 본문은 { tasks: [...] }, 즉 그 탭이 들고 있는 목록 전체였습니다.
서버 쪽 syncTasks는 트랜잭션 안에서 이렇게 동작했습니다.
const { rows: existing } = await client.query(
'SELECT id FROM tasks WHERE user_id = $1',
[userId],
);
const existingIds = new Set(existing.map((row) => row.id));
const incomingIds = new Set(tasks.map((task) => task.id));
for (const id of existingIds) {
if (!incomingIds.has(id)) {
await client.query('DELETE FROM tasks WHERE id = $1 AND user_id = $2', [id, userId]);
}
}트랜잭션이니 안전해 보였습니다. 그런데 트랜잭션은 요청 하나가 반만 반영되는 것을 막아 줄 뿐입니다. 요청에 담긴 목록이 낡았는지는 따지지 않습니다.
순서는 이렇습니다. 탭 A를 아침에 열어 둡니다. 점심에 탭 B에서 일정 하나를 추가합니다. 저녁에 탭 A로 돌아와 다른 항목의 체크박스를 누릅니다. 탭 A의 목록에는 점심에 추가한 일정이 없으므로, 서버는 그 행을 "업로드에 없는 행"으로 보고 지웁니다.
사용자는 체크박스 하나를 눌렀을 뿐입니다.
바꾼 구조: 항목 단위 요청과 updated_at 비교#
고친 방향은 두 가지입니다. 바뀐 항목만 보내고, 그 항목을 어느 버전 기준으로 고쳤는지 같이 보냅니다.
POST /api/tasks,PATCH /api/tasks/:id,DELETE /api/tasks/:idPOST /api/tasks/batch:{ ops: [...] }를 한 트랜잭션으로 적용하고, 하나라도 실패하면 전부 롤백합니다- 모든 쿼리에
WHERE user_id = $n을 유지합니다 PUT /api/tasks는 백업 복원 전용으로만 남겼습니다. 사용자가 직접 복원 버튼을 누를 때만 씁니다
수정과 삭제 요청에는 updated_at이 반드시 들어갑니다. 서버는 그 행을 SELECT ... FOR UPDATE로 잠근 뒤 값을 비교합니다.
/** Lock the caller's row; 404 if it isn't theirs, 409 if it moved past `baseUpdatedAt`. */
async function lockAtBase(db, userId, id, baseUpdatedAt) {
const current = await selectOwnedForUpdate(db, userId, id);
if (!current) throw new TaskOpError(404, 'not found');
if (current.updated_at !== Number(baseUpdatedAt)) throw new TaskOpError(409, 'stale updated_at', current);
return current;
}409 응답에는 서버에 있는 현재 행을 같이 담습니다. 클라이언트가 한 번 더 조회하지 않고 바로 최신 상태로 바꿀 수 있게 하려는 것입니다.
남의 행이면 409가 아니라 404를 줍니다. 다른 사용자의 id가 존재하는지조차 알려 주지 않기 위해서입니다.
새 updated_at은 Math.max(Date.now(), current.updated_at + 1)로 정합니다. 같은 밀리초 안에 두 번 고치면 Date.now()가 같은 값을 돌려줄 수 있고, 그러면 버전이 안 바뀐 것처럼 보이기 때문입니다. 시계가 뒤로 가도 값은 줄어들지 않습니다.
클라이언트: 화면을 먼저 바꾸고, 거절되면 되돌린다#
화면은 서버 응답을 기다리지 않고 먼저 바뀝니다. 대신 서버가 거절하면 상황에 따라 다르게 처리합니다.
409나 404가 오면 서버 행을 그대로 받아들이고 토스트를 띄웁니다. 문구는 "다른 곳에서 먼저 수정돼 최신 내용으로 바꿨어요"입니다.
배치 안의 나머지 요청은 서버에서 함께 롤백됐으므로 충돌난 항목만 빼고 다시 보냅니다. 이미 지워진 항목을 지우려다 404가 난 경우에는 토스트를 띄우지 않았습니다. 사용자가 원한 결과가 이미 서버에 있으니까요.
네트워크 오류나 서버 오류는 다르게 다룹니다. 해당 항목을 마지막으로 서버가 확인해 준 상태로 되돌리고, "저장하지 못했어요" 토스트에 "다시 시도" 버튼을 붙입니다. 실패한 편집 내용은 따로 보관해 두었다가 버튼을 누르면 다시 적용합니다.
요청 수도 신경 썼습니다. 글자를 입력할 때는 400ms를 기다렸다가 모아서 보내고(TEXT_DEBOUNCE_MS = 400), 이동이나 완료 표시처럼 구조가 바뀌는 편집은 바로 보냅니다.
한 항목에 예약된 전송이 여러 개면 가장 이른 시각 하나만 남깁니다. 타이핑 직후에 카드를 옮기면 기다리던 텍스트도 그 자리에서 같이 나갑니다. 탭이 가려질 때(visibilitychange)도 남은 요청을 바로 보냅니다.
항목 단위로 바꾸고 나니 되돌리기도 붙이기 쉬워졌습니다.
⌘Z와 ⇧⌘Z로 최대 50단계까지 되돌리고, 입력창에서는 브라우저 기본 텍스트 되돌리기가 이기도록 했습니다. 삭제 확인 대화상자는 없애고 "되돌리기" 버튼이 붙은 토스트로 바꿨습니다. 되돌릴 수 있으면 물어볼 이유가 없습니다.
테스트로 확인한 것#
서버 테스트에 "오래된 탭" 상황을 그대로 넣었습니다. v1에서 v2로 제목을 바꾼 뒤, v1의 updated_at을 들고 제목을 from old tab으로 바꾸는 요청을 보냅니다.
const stale = await a('PATCH', '/api/tasks/t1', { title: 'from old tab', updated_at: v1.updated_at });
assert.equal(stale.statusCode, 409);
assert.equal(stale.json().task.title, 'v2');같은 낡은 버전으로 보낸 DELETE도 409입니다. 사용자 A가 사용자 B의 항목을 PATCH하거나 DELETE하면 404, 배치 안에 잘못된 요청이 하나 섞이면 전체 롤백인지도 따로 확인합니다.
오늘 npm test를 다시 돌렸을 때 121개가 모두 통과했고 14초쯤 걸렸습니다.
이 변경은 커밋 하나(0af57ab)에 담았습니다. 서버 코드와 테스트만 7개 파일에 601줄이 추가됐고, 클라이언트 훅 useTasks.ts는 367줄이 바뀌었습니다. 개편을 여섯 단계로 나눴는데, 이게 첫 단계였습니다. 드래그 앤 드롭, 다중 선택, 기간 일정이 모두 이 저장 경로 위에서 돌아가기 때문입니다.
미결로 남긴 것#
충돌이 나면 서버가 이깁니다. 오래된 탭에서 고친 내용은 버려지고 토스트 한 줄만 남습니다.
필드 단위로 합치면 제목과 메모를 각각 다른 탭에서 고친 경우는 살릴 수 있겠지만, 한 사람이 쓰는 보드에서 그만큼 복잡도를 늘릴 이유는 아직 찾지 못했습니다.
오래된 탭은 편집을 시도해야 비로소 낡았다는 걸 압니다. 다른 탭의 변경을 미리 받아 오는 장치(BroadcastChannel이나 주기 조회)는 넣지 않았습니다. 데이터는 이제 안 사라지지만, 저녁에 돌아온 탭이 점심에 추가한 일정을 보여 주지 않는 문제는 그대로입니다.
새로고침이 필요합니다. 이건 미결입니다.