폼에 서버 데이터를 그대로 물려 쓰면 왜 위험한가?

결론부터 말하면, 서버에서 받아온 값을 useEffect로 폼 state에 계속 동기화하는 패턴이 입력값 유실의 직접 원인이었다. 서버 상태와 편집 중인 상태를 같은 그릇에 담으면, 백그라운드 리페치 한 번에 사용자가 방금 입력한 값이 통째로 날아갈 수 있다. 이건 라이브러리 버그가 아니라 설계 문제였다.

  • 폼의 "서버에서 온 초기값"과 "사용자가 편집 중인 값"은 반드시 분리해야 한다
  • useEffect([data], () => setForm(data)) 형태의 동기화 코드는 리페치마다 폼을 리셋시키는 함정이 된다
  • TanStack Query는 서버 상태 동기화 도구이지 로컬 편집 상태를 대체하는 도구가 아니다 — 공식 문서도 이 점을 명시한다
  • 재발 방지는 라이브러리 교체가 아니라 "언제 동기화할지"를 명시적으로 통제하는 것으로 끝났다

사내 관리자 콘솔에서 있었던 일이다. 사용자 프로필 편집 폼은 TanStack Query v5로 사용자 정보를 GET해온 뒤, 그 data를 useState 초기값으로 넣고 useEffect로 계속 동기화하는 구조였다. 배포 후 2주쯤 지나 CS 채널에 "자기소개를 쓰다가 갑자기 빈칸이 됐다"는 문의가 일주일 새 7건 들어왔다. 공통점은 폼을 열어둔 채 다른 탭으로 갔다가 돌아온 사용자들이었다.

브라우저 자동완성이나 새로고침 때문 아니었나?

가설은 기각됐다. 처음엔 단순 새로고침이나 자동완성 확장 프로그램 문제로 의심했지만, 세션 리플레이 로그를 보니 페이지 이동이나 새로고침 이벤트가 전혀 없었다. 사용자는 그냥 다른 탭에서 메일을 확인하고 돌아왔을 뿐이었다.

브라우저 네트워크 탭을 다시 열어 재현해보니, 탭이 다시 포커스를 받는 순간 동일한 쿼리 키로 GET 요청이 한 번 더 나가는 게 보였다. TanStack Query의 기본 옵션에서 refetchOnWindowFocus가 켜져 있으면, 윈도우가 다시 포커스될 때마다 쿼리가 재요청된다는 건 TanStack Query 공식 개요 문서에도 나와 있는 동작이다. 문제는 이 리페치 자체가 아니라, 리페치로 새로 들어온 data가 폼 state를 다시 덮어쓴다는 점이었다.

그럼 리페치 이후 어디서 폼 값을 덮어썼나?

병목은 컴포넌트 최상단의 동기화 useEffect였다. 코드는 대략 이런 모양이었다.

const { data } = useQuery({ queryKey: ['user', id], queryFn: fetchUser })
const [form, setForm] = useState(data)

useEffect(() => {
  setForm(data) // data 참조가 바뀔 때마다 실행
}, [data])

쿼리가 리페치되면 서버 응답이 이전과 완전히 동일한 값이어도 React Query는 새 객체 참조를 만들어 data를 갱신한다. 이 참조 변경이 useEffect의 의존성 배열을 트리거했고, 사용자가 폼에 입력해둔 값을 서버에서 방금 받아온(그리고 사용자 입장에서는 "옛날" 값인) 데이터로 그대로 덮어썼다. 즉 사용자 입력은 화면에는 있었지만 상태 트리에서는 이미 사라진 상태였다.

재현 절차로 원인을 어떻게 확정했나?

재현은 세 단계로 고정했다. 첫째, 폼을 열고 아무 필드나 수정한다. 둘째, 개발자 도구 콘솔에서 window.dispatchEvent(new Event('blur'))와 focus 이벤트를 순서대로 강제 발생시킨다. 셋째, 네트워크 탭에서 동일 쿼리 키 요청이 다시 나가는 걸 확인하고, React DevTools Profiler로 폼 컴포넌트가 리렌더되는 시점을 캡처한다.

이 절차로 매번 100% 같은 결과가 나왔다 — 리페치 응답이 도착하는 순간 폼 필드 값이 서버 원본으로 되돌아갔다. staleTime을 기본값 0으로 두고 있었기 때문에 리페치 자체는 거의 항상 발생했고, useEffect 동기화 코드가 있는 한 이 유실은 조건이 아니라 사실상 확정된 결과였다. 장애 재현에 별도 부하나 특수 환경이 필요 없었다는 점이 오히려 이 문제를 더 심각하게 만들었다.

무엇을 바꿨고 어떤 지표로 확인했나?

바꾼 건 크게 세 가지다. 첫째, 폼 초기값은 쿼리 데이터가 처음 도착한 시점에 한 번만 세팅하도록 마운트 시 1회 실행으로 제한했다. 둘째, 서버 데이터 리페치와 폼 편집 상태를 완전히 분리해서, 리페치 결과는 "저장 안 된 변경사항이 있습니다" 배너를 띄우는 비교 기준으로만 쓰고 폼 값 자체는 건드리지 않도록 했다. 셋째, 제출은 별도 mutation으로 처리하고 성공 시에만 쿼리를 invalidate했다. 이는 서버 원본은 초기값/비교 기준, 폼 상태는 편집분, 제출은 mutation으로 나누는 구조로, React 공식 상태 공유 가이드가 안내하는 "상태를 어디에 둘지" 원칙과도 맞아떨어졌다.

배포 후 2주간 관찰한 결과, 동일 유형의 CS 문의는 0건이었다. 물론 이건 사내 관측치일 뿐 어떤 조건에서도 재발이 없다고 보장할 수는 없다. 다만 재현 절차를 동일하게 반복해도 더 이상 값이 사라지지 않는 것은 확인했다.

같은 함정을 피하려면 무엇을 기본값으로 삼나?

폼 데이터는 기본적으로 서버 상태가 아니라 클라이언트 편집 상태로 취급하는 걸 기본값으로 삼았다. 서버에서 온 데이터는 캐시에 두고 비교·초기화 용도로만 참조하고, 사용자가 건드린 값은 별도 로컬 state(또는 폼 라이브러리 상태)로 관리하며, 두 상태 사이의 동기화는 "언제" 일어날지를 명시적으로 정한다. 여러 입력 컴포넌트가 같은 편집 상태를 공유해야 할 때는 로컬 state 대신 폼 전용 컨텍스트나 상위 컴포넌트로 끌어올리는 편이 낫다는 것도 이번 회고에서 다시 확인했다. 2026년 기준으로도 이 원칙은 크게 달라지지 않았고, useActionState처럼 제출 상태를 별도로 다루는 React의 최신 폼 API도 결국 같은 방향 — 입력 중 상태와 결과 상태를 나누라는 것이었다.

핵심 정리

  • 서버 데이터를 useEffect로 폼 state에 계속 동기화하면, 백그라운드 리페치가 사용자 입력을 덮어쓸 수 있다
  • 원인은 라이브러리 버그가 아니라 서버 상태와 편집 상태를 하나의 state로 합친 설계였다
  • 재현은 포커스 이벤트 강제 발생과 네트워크 탭 확인만으로 100% 재현 가능했다
  • 해결은 초기값 1회 세팅 + 편집 상태 분리 + 제출은 별도 mutation, 이 세 가지 구조 변경이었다
  • 폼은 기본적으로 클라이언트 편집 상태로 보고, 서버 원본은 비교·초기화 기준으로만 쓰는 걸 기본값으로 삼는다