1. 개요
프론트엔드 버그의 절반은 “이 값이 있는가 / 바뀌었는가 / 비었는가” 판정이 틀려서 난다. 세 사이트를 운영하며 같은 부류의 버그를 세 번 다른 모양으로 밟았다. 각각은 한 줄 수정인데, 판정 기준을 잘못 잡으면 저장소 전체에서 반복된다. 셋을 모아 둔다.
2. 핵심 내용
2-1. initialData 에 빈 배열을 넘기면 재조회가 막힌다
서버 컴포넌트가 목록을 SSR 로 받아 클라이언트 useQuery 의 initialData 로 넘기는 패턴이다. 첫 화면이 빈 로딩 없이 뜬다. 좋다.
// 서버 컴포넌트
const branches = await fetchBranches().catch(() => []) // 실패하면 []
<BranchList initialBranches={branches} />
// 클라이언트
useQuery({ queryKey: ["branches"], queryFn, initialData: initialBranches })SSR fetch 가 실패해 [] 가 넘어가면, React Query 는 [] 를 유효한 최신 데이터 로 본다. staleTime 이 5분이면 5분 동안 클라이언트 재조회를 하지 않는다. 백엔드는 1초 뒤 살아났는데 사용자는 5분 동안 빈 목록을 본다. 새로고침하면 된다. 그러니 신고도 안 들어온다.
initialData: initialBranches.length ? initialBranches : undefined실패(또는 빈 결과)는 undefined 를 넘긴다. 그러면 React Query 가 즉시 queryFn 을 돌린다. 진짜 빈 목록이면 한 번 더 조회하는 비용이 들지만, 실패를 5분 캐시하는 것보다 낫다. 진짜 빈 목록과 실패를 구분하려면 서버 컴포넌트가 [] 대신 null 을 넘기는 편이 더 정직하다.
같은 뿌리의 규칙: 조회 상태는 isLoading / isError / data.length === 0 세 불리언을 조합하지 말고 loading | load-error | empty | ready 네 상태 하나 로 정한다. 그리고 isError 가 아니라 isLoadingError 를 본다. 이미 데이터가 있는데 백그라운드 재조회가 실패한 것은 에러 화면이 아니다. 로드 실패와 빈 데이터를 같은 화면으로 그리면 사용자는 “데이터가 없다” 고 믿는다. 이 혼동은 UI 감사에서 가장 자주 나온 결함이었다.
2-2. JSON.stringify 로 dirty 를 재면 스프레드가 오탐을 만든다
편집 시트에 “저장 안 한 변경이 있으면 닫기 전에 확인” 가드를 붙였다. 변경 여부는 열 때의 스냅샷과 지금 값을 JSON.stringify 로 비교. 붙이자마자 아무것도 안 바꿨는데 확인창이 떴다.
// 에디터 내부
setContent((prev) => ({ ...prev, heading: value }))스프레드는 새 객체를 만든다. 키 순서가 원본과 다를 수 있다. {a, b} 와 {b, a} 는 같은 객체지만 JSON.stringify 결과는 다르다. React 상태를 스프레드로 갱신하는 코드에서 직렬화 비교는 거의 확정적으로 오탐한다.
const stableSnapshot = (v: unknown) =>
JSON.stringify(v, (_, val) =>
val && typeof val === "object" && !Array.isArray(val)
? Object.fromEntries(Object.entries(val).sort(([a], [b]) => a.localeCompare(b)))
: val
)키를 정렬해 직렬화한다. 이 함수를 훅 하나에 두고 모든 dirty 판정이 그걸 쓴다. 한 곳에서만 일반 JSON.stringify 를 쓰는 화면이 남아 있으면 그 화면만 오탐한다. grep 으로 JSON.stringify( 를 훑어 가드 목적의 비교가 전부 stableSnapshot 인지 본다.
여기서는 키 순서가 문제였지만, JSON.stringify 로 값을 비교할 때 조심할 게 하나 더 있다. 클래스 인스턴스를 직렬화하면 프로토타입에 있는 메서드는 아예 결과에 안 나온다. “직렬화 문자열로 값이 같은지 본다” 는 건 보이는 값이 아니라 직렬화 규칙이 훑는 범위 안의 값만 비교한다는 뜻이다. 이 성질은 자바스크립트 기초 4 - 프로토타입 체인과 class에서 다뤘다.
2-3. 리치 에디터의 빈 문서는 truthy 다
다국어 콘텐츠를 content.ko || content.en 처럼 폴백한다. 한국어가 없으면 영어. 관리자가 한국어를 쳤다가 지웠다. 화면이 빈다. 폴백이 안 된다.
BlockNote 같은 블록 에디터는 텅 빈 문서를 [{type: "paragraph", content: []}] 로 저장한다. 빈 단락 하나. 배열이니 truthy. || 는 여기서 멈춘다.
const hasVisibleBody = (doc: Block[] | undefined) =>
!!doc && doc.some((b) => b.content?.length || b.children?.length || b.type === "image")
const body = hasVisibleBody(content.ko) ? content.ko : content.en“값이 있는가” 가 아니라 “그려지는가” 를 판정한다. 빈 단락만 있으면 그려지는 게 없으니 폴백. 이미지 블록은 content 가 비어도 그려지니 예외. 에디터마다 빈 문서의 모양이 다르므로 이 함수는 에디터 어댑터 옆에 산다.
2-4. 공통 뿌리
셋 다 “비어 있음” 의 정의를 잘못 잡았다.
| 함정 | 잘못된 판정 | 맞는 판정 |
|---|---|---|
| initialData | [] 는 데이터다 | 실패·빈 결과는 undefined |
| dirty | 직렬화 문자열이 다르면 바뀜 | 키 정렬 후 비교 |
| 에디터 폴백 | truthy 면 있음 | 그려지는 내용이 있으면 있음 |
그리고 셋 다 한 곳의 헬퍼로 모아야 저장소 전체에서 같은 판정이 된다. 화면마다 인라인으로 짜면 화면마다 다른 버그가 난다.
2-5. 덤: 확인 다이얼로그와 beforeunload
dirty 가드를 붙일 때 beforeunload 만 쓰면 App Router 의 클라이언트 내비게이션(사이드바 링크 클릭)에는 발화하지 않는다. 탭을 닫을 때만 뜬다. 캡처 단계 document 클릭 리스너로 같은 origin 링크를 가로채고, 통과 조건(새 탭, 수정키)은 next/link 의 isModifiedEvent 를 그대로 베낀다. 직접 짜면 Cmd+클릭을 막게 된다.
3. 마무리
요약
initialData에[]는 5분 캐시된 실패다. 실패는undefined. 조회 상태는 네 상태 하나로.- 스프레드 갱신 +
JSON.stringify비교 = 오탐. 키 정렬 직렬화를 한 곳에.- 빈 에디터 문서는 truthy 다. “값이 있는가” 가 아니라 “그려지는가”.
- 판정 헬퍼는 한 곳에. 인라인은 화면마다 다른 버그를 만든다.