OCR 서비스의 백엔드를 며칠간 갈아엎고 나서, 문득 프론트엔드를 쳐다봤다. 파이프라인 정확도는 95%를 넘겼는데, 정작 그 결과를 검수하는 UI는 2주 전에 "일단 돌아가게" 만든 그 상태 그대로였다. 승인 버튼을 누르면 0.5초 동안 아무 반응이 없고, 파일은 하나씩만 올릴 수 있고, API가 실패하면 사용자는 그 사실조차 모른다.

검수 담당자가 하루에 수백 개의 필드를 처리한다는 걸 생각하면, 0.5초 x 300 = 2분 30초. 매일 2분 30초를 허공에 버리고 있었다.

"조용한 실패"라는 시한폭탄

가장 먼저 손댄 건 에러 처리였다. Review 페이지의 핵심 핸들러 4개 — 승인, 거부, 수정, 일괄승인 — 가 전부 await client.patch(...) 한 줄이었다. try/catch가 없다. API가 500을 던지면? 사용자는 버튼을 눌렀는데 아무 일도 안 일어났다고 생각한다. 더 나쁜 건, 프론트 상태는 이미 바뀌어 있어서 "승인했다"고 믿고 넘어간다는 것이다. 서버에는 안 바뀌어 있는데.

// Before: 기도 메타
await client.patch(`/documents/${id}/fields/${fieldId}`, { status: "approved" });
setFields(prev => prev.map(f => f.id === fieldId ? { ...f, status: "approved" } : f));

// After: 낙관적 업데이트 + 롤백
setFields(fs => fs.map(f => f.id === fieldId ? { ...f, status: "approved" } : f));
try {
  await client.patch(`/documents/${id}/fields/${fieldId}`, { status: "approved" });
} catch {
  setFields(fs => fs.map(f => f.id === fieldId ? { ...f, status: prev } : f));
  toast.error("승인 처리에 실패했습니다");
}

순서가 바뀐 걸 눈치챘는가? setFieldsawait 위로 올라갔다. 이게 Optimistic Update다. UI를 먼저 바꾸고, API를 기다리고, 실패하면 되돌린다. 사용자 입장에서 0.5초가 0ms가 된다.

일괄승인의 함정: Promise.all vs Promise.allSettled

고신뢰 필드 30개를 한 번에 승인하는 "일괄승인" 버튼이 있다. 처음엔 Promise.all로 30개 API를 동시에 쏘고, 하나라도 실패하면 전체를 롤백했다. 27개는 성공하고 3개만 실패해도 30개를 되돌리는 것이다.

Promise.allSettled로 바꿨다. 각 요청의 성공/실패를 개별 추적하고, 실패한 것만 롤백한다. 서버에 이미 저장된 27개를 "pending"으로 되돌리는 위험도 사라졌다.

const results = await Promise.allSettled(fieldIds.map(fid =>
  client.patch(`/documents/${id}/fields/${fid}`, { status: "approved" })
));
const failedIds = new Set(
  results.map((r, i) => r.status === "rejected" ? fieldIds[i] : null).filter(Boolean)
);
// 실패한 것만 롤백
setFields(fs => fs.map(f => failedIds.has(f.id) ? { ...f, status: snapshot.get(f.id)!.status } : f));

Toast: 가장 간단한 피드백

승인하면 카드 색이 바뀌긴 한다. 하지만 스크롤 밖에 있으면? 30개를 일괄승인하면 "뭔가 바뀐 것 같은데 정확히 뭐지?" 싶다.

Zustand로 3줄짜리 toast store를 만들었다. toast.success("30개 필드 일괄승인 완료"). 3초 후 자동 소멸. CSS 애니메이션 한 줄. 외부 라이브러리 0개.

이 toast가 에러 핸들링과 결합되면서 모든 액션에 피드백이 생겼다. "업로드 실패", "재처리 요청 실패", "문서 삭제 완료" — 이제 사용자가 결과를 추측하지 않아도 된다.

검수 워크플로우: 끊어진 고리 잇기

검수자의 하루를 상상해 보자. 문서 목록에서 하나를 골라 검수 화면에 들어간다. 모든 필드를 처리한다. 그리고... 뒤로 가기를 눌러 목록으로 돌아가고, 다음 문서를 찾아 클릭한다. 100개 문서를 처리하면 200번의 불필요한 클릭이 발생한다.

모든 필드가 완료되면 "다음 문서" 버튼이 나타나도록 했다. review 상태인 다음 문서를 자동으로 찾아서 바로 이동한다. 더 이상 검수할 문서가 없으면 "목록으로" 버튼이 나온다. 검수자의 플로우가 끊기지 않는다.

TYPE_LABELS, 세 곳에서 각자 다른 진실을 말하다

Home.tsx에 6개 유형, Dashboard.tsx에 9개, FieldList.tsx에 9개의 TYPE_LABELS가 각각 정의되어 있었다. medical_receipt를 Home에서 추가하는 걸 잊으면? 문서 목록에선 "medical_receipt"라는 영문 코드가 그대로 노출된다.

constants.ts 하나로 통합했다. 9개 유형, 단일 진실 소스. 새 문서 유형이 추가되면 한 곳만 고치면 된다.

대시보드는 보는 곳이 아니라 출발하는 곳이어야 한다

"검수 대기 12건" — 보기만 하는 숫자였다. 지금은 클릭하면 /?status=review로 이동한다. "보험공제 유형의 신뢰도가 72.3%로 가장 낮습니다" — 클릭하면 /?type=insurance_deduction으로 이동한다. 인사이트에서 행동까지 한 번의 클릭.

이 과정에서 URL 파라미터 동기화 문제를 만났다. statusFilteruseState로 관리하면, 대시보드에서 /?status=review로 이동해도 이미 마운트된 Home 컴포넌트는 이전 상태를 유지한다. searchParams에서 직접 파생하는 것으로 바꿨다.

Ctrl+휠, 그리고 Passive Event Listener의 배신

문서 뷰어에 마우스 휠 줌을 추가했다. React의 onWheele.preventDefault()를 넣었는데, 브라우저가 씹는다. React 17 이후로 wheel 이벤트가 passive로 등록되기 때문이다. passive 리스너에서는 preventDefault()가 무시된다.

해결: useEffect로 네이티브 이벤트 리스너를 { passive: false }로 직접 등록했다. 작은 디테일이지만, 이걸 놓치면 Ctrl+스크롤이 브라우저 자체 줌과 문서 줌을 동시에 트리거하는 괴현상이 벌어진다.

줌인 상태에서 이미지를 드래그로 이동할 수 있게 한 것도 같은 맥락이다. zoom > 1일 때만 활성화되는 마우스 드래그 핸들러. 커서가 grab/grabbing으로 바뀌는 것만으로도 "이걸 잡고 움직일 수 있구나"라는 어포던스가 생긴다.

스켈레톤은 거짓말이지만, 좋은 거짓말이다

문서 목록이 로딩 중일 때 "불러오는 중..."이라는 텍스트 한 줄이 덩그러니 나왔다. 로딩이 끝나면 갑자기 리스트가 쏟아져 나온다. 이 레이아웃 시프트가 사용자를 불안하게 만든다.

5개의 애니메이션 스켈레톤 행으로 교체했다. 실제 DocRow와 같은 구조 — 원형 dot, 텍스트 바, 날짜 영역, 상태 배지 — 를 회색 animate-pulse 블록으로 보여준다. 데이터가 도착하면 자연스럽게 치환된다.

반응형: 모바일에서 55% 분할은 농담이다

Review 페이지가 w-[55%] + flex-1로 고정 분할되어 있었다. 375px 아이폰에서 열면? 문서 이미지 206px, 필드 목록 169px. 둘 다 사용 불가.

lg 브레이크포인트 기준으로 탭 전환 UI를 추가했다. 모바일에서는 "문서" / "필드" 탭이 나타나고, 한 번에 하나만 보여준다. 필드 탭에는 미완료 건수가 표시되어 전환 동기를 준다. 데스크톱에서는 기존 분할 레이아웃 그대로.

세 번의 코드 리뷰가 잡아낸 것들

이 모든 작업에 코드 리뷰를 세 번 돌렸다. 매번 예상치 못한 걸 잡아냈다:

  1. Passive wheel listener — React의 onWheel에서 preventDefault()가 안 먹히는 문제
  2. URL 파라미터 동기화useState 대신 searchParams에서 직접 파생해야 SPA 내비게이션이 정확히 동작
  3. Promise.all 부분 실패 — 27개 성공 + 3개 실패인데 30개를 롤백하는 문제
  4. Non-null assertionfields.find(f => f.id === fieldId)!가 재처리 중에 크래시하는 문제
  5. pageFields 미메모이제이션filter()가 매 렌더마다 새 배열 참조를 만들어 useCallback 무효화

자동화된 리뷰가 잡는 건 "문법"이 아니라 "의미"다. "이 setFields 호출 후 advanceToNextPending이 stale closure를 읽습니다" 같은 피드백은 린터로는 불가능하다.

마무리: 0.5초의 가치

총 20건의 개선, 새 파일 3개, 수정 7개. 가장 임팩트 있는 건 결국 Optimistic Update와 검수 완료 후 다음 문서 이동이었다. 하나는 매 클릭마다 0.5초를 돌려주고, 다른 하나는 매 문서마다 2번의 클릭을 없앤다.

300필드 x 0.5초 = 150초. 100문서 x 2클릭 x 1초 = 200초. 하루에 약 6분. 일주일에 30분. 한 달이면 2시간.

"그냥 버튼 하나 바꾼 거잖아"라고 할 수 있다. 맞다. 버튼의 응답 순서를 바꾼 것이다. API 호출 전에 UI를 먼저 바꾸는 것. 이 단순한 발상의 전환이 사용자의 시간을 돌려준다.

결국 좋은 UI란 사용자가 "기다리고 있다"는 사실을 인식하지 못하게 만드는 것이다. 0.5초를 없앤 게 아니라, 0.5초를 사용자의 시야 밖으로 옮긴 것이다.