invalidateQueries를 불렀는데 요청이 안 나갔다

경험을 토대로 AI를 활용하여 작성한 글 입니다.

CI가 빨간불이 됐다. 실패한 건 템플릿 시딩 e2e 하나였다.

1 failed
  [chromium] › e2e/template.spec.ts:21:7 › 템플릿을 추가하면 오늘 일간 목록에
  시딩되고, 삭제까지 정리된다
47 passed

이 테스트는 전에도 flaky했던 이력이 있어서 또인가 싶었는데, 로그를 열어보니 성격이 달랐다. 재시도 2회까지 포함해 3번 모두 실패했다. 매번 다른 타임스탬프로 새 템플릿을 만들고, 매번 같은 지점에서 죽었다. 가끔 깨지는 게 아니라 일관되게 깨지고 있었다.

먼저 범인부터 좁혔다

바로 직전에 올린 PR이 의심스러웠다. 그런데 그 PR은 측정 스크립트와 문서만 건드렸고 앱 코드는 한 줄도 안 바꿨다. 그래도 확인은 해야 해서, 같은 데이터 상태에서 커밋을 세 개 골라 돌려봤다.

커밋결과
내 PR 브랜치
main
그 이전 머지 커밋

셋 다 실패했다. 코드가 원인이 아니다. 그러면 남는 건 데이터뿐이다.

방아쇠는 지워지지 않고 남은 템플릿이었다

테스트 계정을 들여다보니 이런 게 쌓여 있었다.

활성 템플릿 63개 (그중 "E2E 템플릿 ..." 33개)

이 테스트는 템플릿을 만들고, 시딩된 태스크를 확인하고, 둘 다 지우고 끝난다. 그런데 중간에 실패하면 정리 단계에 도달하지 못한다. 남은 템플릿은 활성 상태라 매일 시딩되고, 그만큼 일간 목록이 커진다.

일간 조회 지연을 재보니 이랬다.

새 템플릿 추가 직후 일간 GET (시딩 포함)
  1회: 3613ms
  2회: 1373ms
  3회:  620ms

템플릿이 몇 개였을 땐 수백 ms였던 요청이 최대 3.6초까지 늘어나 있었다. 그리고 이건 자기증폭이다 — 실패하면 템플릿이 쌓이고, 쌓이면 느려지고, 느려지면 더 실패한다. 그날 하루에만 활성 템플릿이 54 → 57 → 60 → 63으로 올라갔다.

여기까지는 "테스트 위생 문제"로 보였다. 남은 것들을 지우면 되는 문제. 그런데 왜 느려지면 실패하는지는 아직 설명되지 않았다.

서버는 멀쩡했다

먼저 서버를 의심했다. 템플릿을 만들고 곧바로 일간 API를 직접 호출해봤다.

1) 템플릿 생성: E2E 진단 1785236547191  → 201
2) 일간 GET (시딩 트리거)                → 200, 66건
   새 템플릿 태스크 포함? ✅ 있음

문제없다. 지연도 620ms~3.6초로 테스트의 10초 예산 안이다. 서버는 시딩을 하고 있고 결과도 정확히 돌려준다. 그러면 브라우저 쪽이다.

타임라인 재구성

브라우저에서 네트워크를 찍으면서 테스트와 같은 순서로 조작해봤다. 실패가 재현된 케이스의 로그다.

  +768ms   → GET  /api/tasks?scope=daily
  +888ms   템플릿 추가 클릭
  +929ms   → POST /api/task-templates
 +1184ms   ← POST 201
 +1525ms   모달에 표시됨
 +1514ms   ← GET 응답 도착
 +1589ms   ❌ 일간 목록에 새 태스크 없음

→ GET /api/tasks한 번뿐이다.

이게 이상했다. 템플릿 추가 성공 시 invalidateQueries로 일간 목록을 무효화하도록 되어 있는데, 그렇다면 두 번째 요청이 나가야 한다. 나가지 않았다.

성공하는 케이스와 나란히 놓으니 차이가 선명해졌다.

  → GET  tasks
  ← 200 69건            ← 무효화 전에 이미 완료됨
  → POST task-templates
  → GET  tasks          ← 진짜 새 요청
  ← 200 70건            ← 새 태스크 포함 ✅

성공할 땐 GET tasks두 번 나간다. 차이는 하나다. 첫 요청이 이미 끝났는가.

원인: 무효화가 진행 중인 요청에 흡수된다

TanStack Query는 같은 쿼리 키에 대한 요청을 dedupe한다. 이미 날아가는 중인 요청이 있으면 새로 보내지 않고 그 요청의 결과를 기다린다. 보통은 좋은 최적화다. 같은 데이터를 두 번 받아올 이유가 없으니까.

그런데 이 경우엔 첫 요청과 두 번째 요청이 같은 데이터를 뜻하지 않는다.

시간순으로 다시 보면 이렇다.

 t=768ms   일간 GET 발사. 쿼리 상태: fetching
 t≈800ms   서버가 이 요청을 처리 시작
           시딩: 지금 시점의 활성 템플릿을 조회 → 새 템플릿은 아직 없음
           → 새로 만들 게 없다고 판단, 목록을 계산해 응답 준비
 t=929ms   POST /api/task-templates 발사
 t≈1100ms  서버: 템플릿 INSERT 커밋
 t=1184ms  POST 201 도착. onSuccess 실행
             ① setQueryData(templates) → 모달 목록 즉시 갱신 ✅
             ② invalidateQueries(daily)
                · 쿼리에 isInvalidated = true 표시
                · 재조회 시도
                · ❗ 같은 키의 요청이 이미 진행 중 → 새로 안 보내고 편승
 t=1514ms  t=768ms에 출발한 응답 도착 (t≈800ms에 계산된 내용)
           TanStack: "재조회를 걸었고, 응답을 받았다" → 성공으로 처리
             · data = 이 응답 (새 태스크 없음)
             · isInvalidated = false   ← 무효화 표시가 지워진다
             · staleTime(5분) 동안 fresh 상태

핵심은 마지막 줄이다. 뒤늦게 도착한 응답이 무효화를 "처리 완료"로 만든다. 그 응답이 언제 계산된 것인지는 TanStack이 알 방법이 없다. 요청을 보냈고 응답을 받았으니 임무를 마쳤다고 볼 뿐이다.

결과적으로 캐시에는 "방금 추가한 템플릿의 태스크만 빠진 목록"이 신선한 데이터로 앉는다. 그리고 5분간 다시 조회하지 않는다.

실패 조건은 두 개가 겹쳐야 한다

정리하면 실패는 아래 둘이 동시에 성립할 때만 일어난다.

A. 서버가 시딩을 템플릿 커밋 전에 처리했다 서버가 GET을 늦게 집어서 커밋 후에 시딩했다면, 응답에 새 태스크가 들어 있다. 통과한다.

B. GET 응답이 invalidateQueries 후에 도착했다 응답이 먼저 와 있었다면 무효화 시점에 진행 중인 요청이 없으므로 진짜 새 요청이 나간다. 통과한다.

응답이 무효화 전 도착응답이 무효화 후 도착
서버가 커밋 시딩✅ (운 좋게 포함됨)
서버가 커밋 시딩✅ (새 요청이 나감)

넷 중 하나에서만 깨진다. 그래서 오랫동안 간헐적이었다. 그리고 지워지지 않은 템플릿이 쌓여 일간 GET이 3.6초까지 늘어나자, "요청이 진행 중"인 구간이 수백 ms에서 수 초로 넓어졌다. 테스트는 페이지 진입 후 100여 ms 만에 템플릿을 추가하니 거의 항상 그 창 안에 들어갔다.

버그는 처음부터 있었다. 데이터가 창을 벌려서 드러났을 뿐이다.

증명: 타이밍을 조작해 결과를 뒤집기

가설이 맞다면 타이밍을 바꿔서 결과를 뒤집을 수 있어야 한다. 첫 일간 요청만 인위적으로 지연시켜봤다.

let first = true
await page.route('**/api/tasks?scope=daily**', async route => {
  if (first && delay) { first = false; await new Promise(r => setTimeout(r, delay)) }
  await route.continue()
})
초기 GET 지연    0ms → ❌ 태스크 안 보임 (재현)
초기 GET 지연 2500ms → ✅ 태스크 보임

직관과 반대로 지연을 주면 통과한다. 요청이 늦게 서버에 도달해 템플릿 커밋 후에 처리되기 때문이다(위 표의 A 조건). 이 뒤집힘이 가설의 가장 강한 증거였다.

수정: 편승할 요청을 없앤다

원인이 "진행 중인 요청에 편승한다"이니, 편승할 대상을 먼저 치우면 된다.

const invalidateToday = useCallback(async () => {
  await queryClient.cancelQueries({ queryKey: taskKeys.scope('daily') })
  await queryClient.invalidateQueries({ queryKey: taskKeys.scope('daily') })
}, [queryClient])

취소된 요청은 나중에 도착해도 캐시에 반영되지 않고, "재조회 성공"으로도 간주되지 않아 무효화 표시를 지우지 못한다. 그래서 뒤이은 invalidateQueries반드시 새 요청을 만든다. 위 표의 오른쪽 아래 칸 자체가 사라진다.

onSuccessasync로 바꿔 await하도록 했다. 세 뮤테이션(추가·수정·삭제)이 모두 같은 경로를 쓴다.

검증

경합은 타이밍 의존이라 한 번 통과는 통과가 아니다. 같은 흐름을 반복하는 하네스를 만들어 돌렸다.

1/6 ✅  2/6 ✅  3/6 ✅  4/6 ✅  5/6 ✅  6/6 ✅
결과: 6 통과 / 0 실패

수정 전 같은 하네스에서 재현됐던 조건이다. 이어서 e2e 전체 48/48 통과, CI도 통과했다. CI에서 3번 연속 실패하던 테스트다.

증폭 고리도 끊었다

원인을 고쳤어도 "실패하면 템플릿이 남는다"는 구조는 그대로다. 다음에 다른 이유로 실패하면 같은 눈덩이가 다시 굴러간다. 그래서 성공·실패와 무관하게 정리하도록 바꿨다.

test.afterEach(async ({ request }) => {
  const templates = await (await request.get('/api/task-templates')).json()
  for (const t of templates) {
    if (t?.title?.startsWith('E2E 템플릿')) {
      await request.delete(`/api/task-templates/${t.id}`)
    }
  }
})

이미 시딩된 태스크는 여기서 지우지 않는다. 실패당 하루 1건이라 누적되지 않고, 증폭의 원인은 매일 다시 시딩하는 활성 템플릿 쪽이기 때문이다. 그리고 이미 쌓여 있던 33개와 잔여 태스크는 따로 정리했다(활성 템플릿 63 → 30, 오늘 일간 목록 65 → 32건).

뿌리는 따로 있다

이 경합이 성립한 근본 이유는 일간 목록 GET이 서버에서 템플릿 시딩(쓰기)을 하고 있어서다. 크론이 없어서, 그날 처음 일간 화면을 열 때 조회 경로가 lazy하게 시딩하는 구조다.

그래서 이 GET은 응답 내용이 요청 파라미터가 아니라 "서버가 그 요청을 처리한 시각" 에 좌우된다. 순수한 조회였다면 낡은 응답이 도착해도 그저 조금 오래된 목록일 뿐이었을 텐데, 쓰기가 얹혀 있으니 "그 요청 자체가 만들어냈어야 할 데이터가 없는" 상태가 된다.

다음 작업으로 이렇게 바꾸기로 했다.

POST /api/task-templates
  → 템플릿 생성
  → (하루 시작 시각 게이트 통과 시) 오늘 태스크 시딩
  → 응답: { template, seededTask }

클라이언트: setQueryData(daily, prev => [...prev, seededTask])

재조회가 없으니 경합이 성립할 수 없다. 창을 닫는 게 아니라 창이 사라진다. 왕복도 늘지 않는다 — 오히려 재조회 한 번이 없어진다.

재밌는 건, 이게 지난번 낙관적 업데이트 경합 글에서 "invalidateQueries 대신 setQueryData로 최적화하는 순간 그 책임도 함께 가져온다"고 경계했던 바로 그 패턴이라는 점이다. 이번엔 반대 방향에서 같은 결론에 도착했다. 두 방식 모두 공짜가 아니고, 어느 쪽을 택하든 "이 데이터가 아직 최신인가"를 누군가는 물어야 한다.

교훈

  • invalidateQueries는 "새 요청이 나간다"를 보장하지 않는다. 같은 키의 요청이 진행 중이면 편승하고, 그 응답이 무효화를 처리 완료로 만든다. 뮤테이션 직후처럼 "이전 요청의 결과로는 안 되는" 상황에서는 cancelQueries를 먼저 불러야 한다.
  • 조회에 쓰기를 얹으면 응답의 의미가 시각에 의존한다. 같은 URL·같은 파라미터로도 서버가 언제 처리했느냐에 따라 다른 결과가 나오고, 클라이언트는 그걸 구분할 수 없다. GET을 safe하게 유지하라는 규약은 취향 문제가 아니었다.
  • 일관되게 깨지는 테스트를 flaky로 넘기지 말 것. 재시도 3회가 모두 같은 지점에서 죽으면 그건 타이밍 운이 아니라 조건이 갖춰진 것이다. 그리고 코드를 안 건드렸는데 깨지기 시작했다면, 남은 변수는 데이터다.