이미 정의된 dirty 플래그를 안 보던 자리 — 편집 중 draft가 사라지던 버그
Desktop에서 task 편집 중 첨부 같은 다른 곳을 누르면 작성하던 내용이 사라진다는 제보. 원인은 sync useEffect가 currentTask 변할 때마다 모든 local draft를 무조건 리셋하던 코드. 같은 파일 안에 이미 dirty 플래그가 정의되어 있었는데 그걸 안 보고 있었어요.
편집 중 draft가 사라지던 버그
사용자 제보로 시작.
“Desktop에서 편집하다가 다른 곳, 그러니까 첨부나 그런 곳을 누르면 편집하던 내용이 사라진다는 제보가 있어.”
재현은 단순했어요. Task 상세 화면에서 title 입력란에 문자를 치는 중에, 옆의 첨부 추가 버튼을 누르면 title 입력란이 서버의 옛 값으로 휙 돌아갔어요. 사용자 입장에서는 키보드로 친 글자가 흔적도 없이 날아간 셈.
원인 — sync useEffect가 너무 부지런했음
TaskRecordDetail.tsx와 TaskTemplateDetail.tsx에 같은 패턴의 useEffect가 있었어요.
useEffect(() => {
setTitle(currentTask.title ?? "");
setAddress(currentTask.address ?? "");
setDescriptionDraft(null);
setTargetDraft(null);
setExpectationDraft(null);
setObstacleDraft(null);
setRetrospectDraft(null);
// ... 토글들, 정리 작업 ...
}, [currentTask.id, task.revision]);
task.revision이 바뀔 때마다, 즉 task가 어디서든 갱신될 때마다 모든 local draft 상태를 server 값으로 무조건 reset. 의도는 “외부에서 task가 갱신되면 (다른 기기에서 수정 등) UI도 그걸 따라간다”였을 텐데, 결과는 사용자 본인이 만든 외부 갱신 도 동일하게 처리되는 것.
첨부 추가 버튼을 누르면:
- 사용자가 title 입력 중 (local state
title≠ server) - 첨부 업로드 API 호출
- 서버 응답 →
handleTaskUpdate(updated)→ atom 갱신 currentTask새 reference →task.revision변화- sync useEffect 발화 →
setTitle(currentTask.title ?? "")→ 사용자 draft가 옛 값으로 reset
같은 파일 안에 이미 답이 있었음
흥미로운 건, 이 useEffect 바로 위에 이미 dirty 플래그가 정의되어 있었어요.
// Dirty flags
const titleDirty = title !== (currentTask.title ?? "");
const descriptionDirty = descriptionDraft !== null && descriptionDraft !== (currentTask.description ?? "");
const retrospectDirty = retrospectDraft !== null && retrospectDraft !== (currentTask.retrospect ?? "");
const targetDirty = targetDraft !== null && targetDraft !== (currentTask.target ?? "");
// ... 등
이 플래그들의 원래 용도 는 InlineEditButtons(편집 중일 때만 화면에 뜨는 confirm/cancel 버튼)의 visibility 제어였어요. “사용자가 편집 중인지” 를 알려주는 플래그가 이미 있었던 거예요. 그런데 sync useEffect는 그 플래그를 전혀 안 봤어요.
Fix는 정직했어요. dirty인 필드만 reset 건너뛰면 됩니다.
useEffect(() => {
if (!titleDirty) setTitle(currentTask.title ?? "");
if (!addressDirty) setAddress(currentTask.address ?? "");
if (!descriptionDirty) {
setDescriptionDraft(null);
setDescriptionResetKey((k) => k + 1);
}
if (!retrospectDirty) {
setRetrospectDraft(null);
setRetrospectResetKey((k) => k + 1);
}
// ... 다른 draft들 동일
// 토글류는 항상 server 값으로:
setShowDetails(currentTask.showDetails ?? false);
setShowAttachments(currentTask.showAttachments ?? false);
// ...
}, [currentTask.id, task.revision]);
이제 외부 갱신이 들어와도 사용자가 편집 중인 필드는 보존, 건드리지 않은 필드만 server 값으로 정렬.
잠재 버그였던 게 왜 갑자기 보였나
이 버그는 오래전부터 있었을 가능성이 큼. 그런데 이번 주에 비로소 표면으로 떠올랐어요. 이유는 인접한 다른 작업에 있었어요.
직전에 template cache 작업을 진행하면서 모든 set* 호출 직후 taskTemplateMutations.upsertModel(response)를 명시적으로 박았어요. 그 전엔 cache 갱신이 SSE를 거쳐 수백 ms 뒤에 따라왔는데, 이제는 서버 응답이 오자마자 atom이 즉시 갱신. 결과적으로 currentTask의 갱신 빈도와 즉시성이 모두 올라갔고, useEffect도 그만큼 자주 발화하게 됐어요.
운으로 가려져 있던 자리가, 시스템이 빠르고 일관되게 동작하면서 드러났어요.
예전엔 사용자가 첨부를 누르고 SSE가 따라오는 사이 수백 ms 동안 자기가 친 글자를 다시 input에 채워 둘 수 있었거든요. 운 좋게 동작. 이제는 그 틈이 거의 없어요. 그래서 원래 깨져 있던 게 깨져 보이는 형태로 나타난 것.
회고
dirty 플래그가 이미 같은 파일에 정의되어 있었다는 게 이번 버그의 핵심이에요. 답이 같은 파일에 있는데 useEffect가 그걸 안 보고 있었던 것.
왜 안 봤을까. 두 가지 이유가 있어 보여요.
- 처음 useEffect를 작성할 때는 dirty 플래그가 아직 없었을 가능성. 나중에 InlineEditButtons를 도입하면서 dirty 플래그가 생겼는데, 그때 sync useEffect를 같이 보고 “여기도 dirty 체크해야지” 하는 단계를 안 거친 것.
- useEffect의 의도가 “외부 갱신을 따라간다” 였지, “사용자 입력을 보존한다” 가 아니었던 것. 후자는 자연스럽게 챙겨야 할 일이지만, 전자만 머릿속에 있는 동안엔 후자가 안 보여요.
머릿속에 “외부 갱신 sync”라는 한 가지 의도만 있던 useEffect가 사용자 입력 보존 이라는 또 다른 의무를 못 챙긴 자리. 하나의 useEffect가 한 가지 관심사만 가져야 한다는 원칙은 옳지만, 그 관심사가 충분히 넓게 정의되어 있어야 한다는 점도 같이 따라와요.
이 정도 fix가 정직해서 좋았어요. dirty 플래그를 새로 만들 필요도 없었고, 새 아키텍처를 깔 일도 없었어요. 그냥 이미 있던 답을 봐야 할 자리에서 본다 — 그게 다였어요.