기술 기록 · 2026.10.20
참여를 마쳤는데 화면은 그대로였다 — TanStack Query에서 나눈 세 가지 판단
참여 뒤에도 낡은 화면이 남는 문제를 여러 제품에서 겪었습니다. 같은 데이터의 기준, 다시 읽을 시점, 실패 뒤 안전하게 반복할 일을 TanStack Query의 기능과 실제 수정 사례로 설명합니다.
사용자가 광고나 콘텐츠 참여를 마치고 화면에 돌아왔는데 참여 상태는 이전 그대로였습니다. 처음에는 재조회를 더 자주 하면 될 것 같았습니다. 하지만 한 화면에서는 조회와 캐시 갱신이 서로 다른 데이터를 가리켰고, 다른 화면에서는 돌아온 뒤 무엇을 다시 읽을지 정하지 않았습니다. 완료 요청의 결과가 불명확할 때는 같은 요청을 다시 보내도 되는지도 판단해야 했습니다.
저는 참여 상태의 캐시 키를 맞추고 콘텐츠 복귀 시 재조회를 연결했으며, 완료 결과가 불명확할 때 상태를 확인하는 경로를 구현했습니다. 조회 범위를 더 세밀하게 나눈 작업과 초기 Quest 데이터 레이어에는 동료들의 기여도 있습니다. 아래의 사례는 하나의 장애가 아니라, 참여 전후에 서버 상태를 화면에 맞추며 세 번 답해야 했던 질문입니다.
| 먼저 정할 질문 | TanStack Query에서 구별할 기능 | 잘못 정하면 |
|---|---|---|
| 같은 데이터는 무엇인가? | queryKey, setQueryData | 참여 뒤 다른 캐시를 고쳐 화면이 그대로 남음 |
| 언제 다시 읽어야 하는가? | enabled, 폴링·신선도·재조회 | 첫 목록을 못 읽거나 복귀 뒤 낡은 상태를 봄 |
| 실패 뒤 무엇을 반복해도 되는가? | retry, useMutation, 직접 요청 | 이미 처리됐을 수 있는 참여를 다시 보냄 |
여기서 ‘참여 문맥’은 현재 사용자·앱·참여 회차처럼 요청 결과를 바꾸는 입력입니다. 공통 흐름은 참여 대상을 고르고 → 콘텐츠나 광고를 열고 → 돌아와 상태와 보상을 확인하는 것입니다. 인앱·외부 진입 차이는 인앱 콘텐츠 참여 기록에서 더 다뤘습니다.
queryKey: 조회와 갱신이 같은 데이터를 말하는가
TanStack Query는 queryKey로 결과를 구분합니다. Query Keys 문서가 설명하듯 요청 결과를 바꾸는 값은 키에도 있어야 합니다. 키가 비슷해 보여도 배열의 한 값이 다르면 다른 캐시입니다.
참여형 콘텐츠에서는 조회 키에 사용자 식별자가 있었지만, 참여 뒤 캐시를 고치는 호출에는 그 값이 빠진 사례가 있었습니다. 서버에서 새 결과가 와도 화면이 보고 있는 항목과 수정한 항목이 달랐습니다.
// 당시 불일치를 보여 주는 축약 예시
useQuery({
queryKey: ["status", deviceId, appId, viewerId],
queryFn: () => fetchStatus({ deviceId, appId, viewerId }),
});
queryClient.setQueryData(
["status", deviceId, appId, undefined],
updateAfterParticipation,
);
setQueryData는 비슷한 키를 찾아 수정하지 않습니다. 전달한 키의 항목을 수정하거나 새 항목을 만들 수 있습니다. 재조회 주기를 줄여도 서로 다른 항목을 가리키는 문제는 해결되지 않습니다. 저는 조회·읽기·수정·무효화가 같은 키 생성 함수를 쓰도록 맞췄습니다. 이후 팀에서 인앱 여부도 요청과 키에 반영했습니다.
const statusKey = (context: {
deviceId: string;
viewerId: string;
appId: string;
surface: "in-app" | "external";
}) => ["participation-status", context] as const;
useQuery({ queryKey: statusKey(context), queryFn: () => fetchStatus(context) });
queryClient.setQueryData(statusKey(context), updateAfterParticipation);
queryClient.invalidateQueries({ queryKey: statusKey(context), exact: true });
invalidateQueries는 일치하는 캐시를 낡은 것으로 표시하고 활성 조회의 재확인으로 이어질 수 있습니다. 예시의 exact: true는 한 키만 대상으로 삼으려는 선택입니다. 키는 데이터의 정체성이고, 캐시에 손대는 호출은 같은 정체성을 말해야 합니다.
enabled와 refetchInterval: 처음 읽을 수 있는가, 계속 읽을 필요가 있는가
enabled는 자동 첫 조회를 시작할 조건이고, refetchInterval은 관찰 중인 데이터를 다시 가져올 간격입니다. 폴링 문서에 따르면 폴링은 fresh 여부와도 별도로 동작합니다. 두 설정을 같은 잠금 스위치로 쓰면 필요한 첫 데이터까지 사라질 수 있습니다.
미션 화면에서 잠김 상태를 처음에는 조회 자체를 막는 조건으로 사용했습니다. 하지만 사용자는 잠겨 있어도 현재 미션 목록을 봐야 했습니다. 첫 조회에는 사용자·앱 문맥이 필요하고, 잠금은 그 뒤에도 주기적으로 확인할지를 결정했습니다.
| 화면 상태 | enabled | 반복 조회 | 사용자가 보는 결과 |
|---|---|---|---|
| 참여 문맥이 없음 | false | 없음 | 준비되지 않은 요청을 보내지 않음 |
| 문맥은 있고 미션은 잠김 | true | 중지 | 현재 목록은 볼 수 있음 |
| 문맥이 있고 참여 가능 | true | 실행 | 목록을 읽고 정한 간격으로 다시 확인 |
다른 제휴 앱의 콘텐츠 진입에서도 enabled를 사용했지만 이유는 달랐습니다. 그 화면은 기기 문맥과 세션이 모이기 전에는 광고 목록을 요청할 수 없었습니다. 첫 조회를 기다리는 경우와 이미 읽은 목록의 폴링을 멈추는 경우를 구별한 것입니다.
신선도와 재조회: 화면에 돌아온 순간 무엇을 확인할까
캐시를 오래 둔다는 말에는 신선도, 보관 기간, 다시 요청할 시점이 섞입니다. 기본값 문서를 읽을 때도 이 셋을 나눠야 합니다.
| 설정·동작 | 답하는 질문 |
|---|---|
staleTime | 캐시를 언제까지 fresh로 취급할까? |
gcTime | 아무 화면도 관찰하지 않는 캐시를 얼마나 보관할까? |
refetchOnMount | 조회를 쓰는 화면이 다시 마운트될 때 요청할까? |
| 명시적 재조회 | 화면이 그대로인 채 외부에서 복귀했을 때 지금 다시 읽을까? |
fresh는 라이브러리가 캐시를 신선하게 취급한다는 뜻이고, 최신은 서버의 현재 상태와 실제로 같다는 뜻입니다. 한 콘텐츠 목록에는 긴 staleTime을 두면서 포커스 변화로 값이 흔들리지 않게 했습니다. 재진입으로 컴포넌트가 다시 마운트될 때는 refetchOnMount: "always"로 확인했습니다. 반면 화면이 마운트된 채 외부 콘텐츠에서 돌아오는 경우는 별도 복귀 처리에서 재조회했습니다. gcTime을 짧게 한다고 현재 화면을 더 자주 조회하는 것은 아닙니다.
복귀 처리에서는 실제 이탈과 경과 시간을 확인한 뒤 활성 콘텐츠 Query를 다시 읽었습니다. 처음에는 관련 콘텐츠 전체를 접두 키로 재조회했지만, 섹션이 늘어나자 현재 돌아온 섹션과 다른 섹션을 구별해야 했습니다. 동료와 함께 필요한 화면에는 완전한 키와 exact: true를 전달하도록 조정했습니다.
await queryClient.refetchQueries(
{ queryKey: currentSectionKey, exact: true, type: "active" },
{ throwOnError: true },
);
여기서 type: "active"는 관찰 중인 항목을 대상으로 삼고, throwOnError: true는 실패를 복귀 처리에서 받을 수 있게 합니다. 비슷한 세 호출도 서로 다른 증거를 전제합니다.
| 호출 | 이 화면에서 알 수 있던 것 |
|---|---|
setQueryData | 새 값이 이미 확인됐으므로 캐시에 반영함. 서버 요청은 하지 않음 |
invalidateQueries | 값은 모르지만 캐시가 낡았음을 알림. 활성·사용 가능한 조회는 기본적으로 재확인될 수 있음 |
refetchQueries | 새 값이 필요하므로 지정한 조회를 지금 실행함 |
두 재조회 함수 모두 Promise를 기다릴 수 있습니다. 차이는 기다림의 유무가 아니라 언제, 어느 범위를 다시 읽을지에 대한 결정입니다. 키 필터는 기본적으로 부분 일치하므로 한 섹션만 대상이면 exact: true가 필요합니다. enabled: false인 Query는 두 클라이언트 호출만으로 자동 재요청되지 않습니다. 자세한 조건은 QueryClient와 비활성화 문서에서 확인할 수 있습니다.
변경 요청과 재시도: 실패 뒤 무엇을 다시 해도 안전한가
참여 완료 POST가 시간초과되면 화면은 결과를 모르지만 서버는 이미 처리했을 수 있습니다. 같은 POST를 곧바로 다시 보내는 것과 상태 GET으로 현재 결과를 확인하는 것은 다릅니다. 광고 뒤 보상 단계로 가는 Quest 미션에서는 완료 요청의 결과가 불명확하면 POST를 자동 반복하지 않고 상태를 다시 조회하는 경로를 넣었습니다.
완료 POST의 결과를 알 수 없음
├─ 같은 POST를 자동 반복하지 않음
└─ 상태 GET으로 다시 확인
├─ 처리된 상태 → 최신 상태에 맞게 다음 화면 결정
├─ 이전 상태 → 반영 지연일 수도 있어 여전히 미확정
└─ 확인 실패 → 완료로 단정하지 않음
한 번의 상태 GET에서 이전 값이 보인다고 POST가 미처리됐다고 확정할 수도 없습니다. 서버의 상태 전파와 중복 처리 계약이 필요합니다. 제가 상태 GET에 둔 retry는 그 GET 자체가 시간초과됐을 때 한 번 더 조회하는 규칙입니다. 완료 POST 뒤 상태를 새로 확인하려고 refetch를 호출하는 결정과 다릅니다. 시간초과 판정은 당시 HTTP 클라이언트 ky의 TimeoutError를 사용했고, 취소된 요청은 실패와 구분했습니다.
useMutation의 onSuccess도 제품의 완료와 같지 않습니다. mutation 함수의 Promise가 정상 반환했다는 뜻이므로, 서버 충돌을 { ok: false }로 반환하는 계약이라면 호출될 수 있습니다. 충돌 뒤 캐시를 무효화해 서버 상태를 다시 확인하는 것과 사용자에게 완료를 보여주는 판단은 분리해야 합니다. 정상 반환, 참여 인정, 실제 지급은 서로 다른 결과입니다. 상태 조회만으로 최종 정산까지 증명하지는 않습니다.
같은 안전성 질문은 요청을 공유 캐시에 맡길지를 정할 때도 필요했습니다.
세 번째 판단의 예외: 공유 캐시보다 한 번의 시도가 중요했던 요청
보너스 광고 준비에서는 이번 시도의 최신 할당 응답과 이전 시도의 취소 경계가 중요했습니다. 자동 실행을 끈 Query의 수동 refetch로 시작했지만, A의 광고 요청 뒤 참여 문맥이 B로 바뀌면 A의 응답이 늦게 올 수 있었습니다. 그 결과를 B의 버튼에 적용하면 화면과 준비된 광고가 어긋납니다.
이 경로는 직접 요청으로 바꿨습니다. 문맥이 바뀌면 이전 요청을 취소하고, 시작 당시의 시도 번호와 여전히 일치할 때만 결과를 적용했습니다. staleTime: 0이나 gcTime: 0은 어느 시도의 결과인가를 정하지 않습니다. TanStack Query로 명령형 요청을 처리할 수 없다는 뜻은 아닙니다. 이 화면에서는 공유 캐시보다 한 번의 시도에 속한 요청·취소·결과를 한곳에서 관리하는 편이 명확했습니다.
참여 뒤 화면을 맞추기 위해 남긴 기준
| 먼저 결정한 것 | 제품 코드에서 바꾼 경계 |
|---|---|
| 어느 결과가 같은 데이터인가? | 조회와 캐시 갱신이 같은 참여 문맥의 키를 사용 |
| 언제 다시 읽어야 하는가? | 준비 전 첫 조회와 복귀한 활성 섹션의 재조회를 구분 |
| 실패 뒤 무엇을 반복해도 되는가? | 불명확한 완료 POST는 상태 GET으로 확인하고, 보너스 준비는 현재 시도의 요청만 적용 |
이 변경들이 화면의 모든 참여 오류나 최종 지급 결과를 증명하지는 않습니다. 제가 고친 캐시 키·복귀 조회·불명확한 완료 결과 확인은 무엇을 같은 상태로 볼지, 어느 순간 다시 읽을지, 무엇을 반복해도 안전한지를 코드에 나누어 적은 사례입니다. 비슷한 이름의 기능을 외우기보다 먼저 제품이 답할 질문을 정해야 했습니다.