[요고다 개발일지 #5] React Query 캐시 때문에 useState 초기값이 틀어지는 버그

지현·2026년 8월 27일

TIL

useState의 초기값은 컴포넌트가 처음 마운트되는 순간에 딱 한 번만 평가된다. 그런데 그 순간에 참조하는 값(예: React Query가 캐시에서 즉시 돌려준 데이터)이 이미 "최종 상태"와 같다면, "값이 바뀌면 리셋한다"는 로직 자체가 처음부터 무력화될 수 있다.

오늘 겪은 버그가 정확히 이 케이스였다.

상황

관리자 페이지에 AI 프롬프트를 수정하는 화면을 만들었다. 서버에서 현재 운영 중인 프롬프트를 불러와서 텍스트박스에 보여주고, 수정해서 새 버전으로 배포하는 화면이다.

그런데 이상한 버그가 있었다.

  • 새로고침(F5)해서 처음 들어가면 → 프롬프트 내용이 정상적으로 보인다
  • 다른 탭(대시보드 등) 갔다가 다시 돌아오면 → 텍스트박스가 텅 비어있다

같은 화면, 같은 API, 같은 컴포넌트인데 "어떻게 들어왔냐"에 따라 결과가 다르다는 게 단서였다.

문제의 코드

const { data: prompt } = useQuery({
  queryKey: ["admin", "prompts", "active"],
  queryFn: getActivePrompt,
});

const [content, setContent] = useState("");
const [syncedVersionId, setSyncedVersionId] = useState(prompt?.versionId);

// "버전이 바뀌면" 편집 중인 content를 서버 값으로 리셋
if (prompt && prompt.versionId !== syncedVersionId) {
  setSyncedVersionId(prompt.versionId);
  setContent(prompt.content);
}

content는 텍스트박스에 바인딩된 로컬 상태다. 사용자가 자유롭게 수정할 수 있어야 하니까, 서버에서 받아온 prompt.content를 매 렌더마다 그대로 밀어넣을 수는 없다. 그러면 타이핑하는 족족 서버 값으로 덮어써진다.

그래서 "버전(versionId)이 바뀌었을 때만 리셋한다"는 조건을 걸었다. React 공식 문서가 소개하는 패턴이기도 하다 — useEffect 없이 렌더링 도중에 상태를 조정하는 방식.

언뜻 문제없어 보인다. 근데 왜 탭을 갔다 오면 깨질까?

원인

핵심은 이 한 줄이다.

const [syncedVersionId, setSyncedVersionId] = useState(prompt?.versionId);

useState의 초기값 인자는 컴포넌트가 처음 마운트되는 순간에만 평가된다. 그 뒤로 몇 번을 리렌더링하든 무시된다.

문제는 "마운트되는 순간 prompt가 이미 값을 갖고 있느냐"가 케이스마다 다르다는 것이었다.

케이스 1 - 새로고침(첫 로드)

  1. 컴포넌트가 처음 마운트된다. 이 시점엔 API 응답이 아직 안 왔으니 prompt는 undefined
  2. syncedVersionId의 초기값도 undefined로 정해진다
  3. 잠시 후 API 응답이 오면 prompt가 채워지고 리렌더링된다
  4. prompt.versionId(실제 값)와 syncedVersionId(여전히 undefined)가 다르다 → 조건 참 → setContent(prompt.content) 실행 → 정상 동작

케이스 2 - 탭 갔다가 돌아옴

  1. /prompts를 떠나면 컴포넌트는 언마운트되지만, React Query 캐시엔 방금 받은 prompt 데이터가 그대로 남아있다 (기본 동작이고, 원래 이게 훨씬 빠른 UX를 위한 의도된 설계다)
  2. 다시 /prompts로 돌아오면 컴포넌트가 새로 마운트된다
  3. useQuery는 캐시에 데이터가 있으니 로딩 없이 첫 렌더부터 바로 prompt를 채워서 준다
  4. 그런데 syncedVersionId의 초기값도 바로 이 순간 정해지는데, prompt?.versionId를 참조하고 있어서 처음부터 prompt.versionId와 똑같은 값으로 시작해버린다
  5. prompt.versionId !== syncedVersionId → 처음부터 거짓 → setContent가 한 번도 실행되지 않음 → content는 최초 선언값인 빈 문자열("")로 방치

정리하면, "데이터가 나중에 도착하느냐 vs 이미 도착해 있느냐"라는 타이밍 차이가 useState 초기값의 정확성을 좌우했다. 새로고침 케이스에서는 우연히 "초기값 ≠ 실제값"이 성립해서 동작했을 뿐, 애초에 안정적인 로직이 아니었다.

해결

syncedVersionId의 초기값을 prompt에서 유도하지 않고, 무조건 확실히 다른 값(undefined)으로 고정한다.

const [syncedVersionId, setSyncedVersionId] = useState<string | undefined>(
  undefined,
);

이러면 마운트 시점에 prompt가 이미 채워져 있든 나중에 채워지든 상관없다. "방금 막 마운트된 시점"엔 항상 syncedVersionId(undefined) !== prompt.versionId(실제 값)가 성립하기 때문에, content를 채워주는 코드가 반드시 한 번은 실행된다.

정리

"prop이나 쿼리 결과가 바뀌면 로컬 상태를 리셋한다"는 패턴을 쓸 때, 비교 기준이 되는 초기값을 그 prop/쿼리 결과에서 유도하면 위험하다. 그 값이 이미 준비된 채로 컴포넌트가 마운트될 수 있는 경로(캐시, 재마운트, key 재사용 등)가 하나라도 있으면, "초기값 = 현재값"이 되어버려서 리셋 로직 자체가 무력화된다.

이 패턴을 쓸 땐 초기값을 "이 값과는 절대 같을 수 없는 것"(대표적으로 undefined, 또는 아직 존재하지 않는 sentinel 값)으로 잡아야, 첫 렌더 시점에 데이터가 이미 와 있든 나중에 오든 항상 동일하게 동작한다.

React Query처럼 캐시를 기본으로 깔고 가는 라이브러리를 쓸 땐 "이 컴포넌트가 마운트되는 순간엔 데이터가 비어있다"는 가정을 은연중에 깔고 코드를 짜기 쉬운데, 캐시 히트 상황에서는 그 가정이 깨진다는 걸 이번에 제대로 겪었다.

0개의 댓글