저장한 설계량이 다음 날짜 화면에 늦게 반영된 원인 분석

작업일보에서 설계량을 고쳐 저장한 뒤 다음 날짜로 옮기면 옛 값이 보였다. 원인은 TanStack Query 가 캐시의 옛 값을 먼저 주고 새 값을 나중에 주는 동안, 화면을 채우는 오버레이가 (현장, 날짜) 키당 한 번만 실행되어 옛 값으로 실행 기회를 써 버린 것이었다. 키 게이트를 없애고 데이터가 바뀔 때마다 다시 적용하도록 고쳤다.

Seobway · · 12분

1. 배경

1.1 대상 기능

는 건설 현장의 하루 작업 기록이다. 화면의 공정 표는 공정마다 (계획된 전체 물량), 누계, 잔량을 보여 주고, 잔량은 설계량 − 누계 로 계산한다.

설계량은 현장 여건에 따라 바뀐다. 작업일보는 정산과 감사의 근거라서, 오늘 설계량을 바꿔도 과거 날짜의 일보는 예전 설계량으로 계산되어야 한다(). 그래서 설계량을 날짜별 버전으로 저장하는 적용일 기준 버전 방식으로 구현했다.

%% desc: 일보를 열 때 서버가 작업일자 이하에서 가장 최근 설계량 버전을 골라 잔량을 계산한다. 버전이 없으면 템플릿 원래 값을 쓴다
flowchart LR
    A["일보 열람(작업일자 W)"] --> B{"W 이하 버전이 있나"}
    B -- "있음" --> C["가장 최근 버전"]
    B -- "없음" --> D["템플릿 원래 값"]
    C --> E["잔량 = 설계량 − 누계"]
    D --> E

1.2 문제 상황

사용자 보고는 다음과 같았다.

설계량을 수정하고 바로 다음 날로 넘어갔는데 반영이 늦다. 날짜를 몇 번 옮겨야 그제서야 반영된다. 당일 수정한 날은 바로 반영된다.

증상 재현. 설계량을 수정해 저장한 뒤 다음 날짜로 이동하면 옛 값이 보이고, 날짜를 몇 번 옮긴 뒤에 새 값이 나온다.
조건 결과
설계량을 고친 당일 즉시 반영
저장 후 처음 여는 날짜 즉시 반영
저장 후 이전에 방문한 적 있는 날짜 옛 값, 날짜를 몇 번 옮긴 뒤 반영

당일이 정상이라는 점은 편집한 값을 담는 화면 상태에는 문제가 없다는 뜻이다. 이전에 방문한 날짜에서만 생긴다는 점은 캐시에 데이터가 남아 있는 경우와 관련이 있다는 뜻이다.

1.3 목표와 제약


2. 방법

2.1 데이터 경로 분리

설계량이 화면에 들어오는 경로를 세 개로 나눠 하나씩 확인했다.

경로 내용 당일 다른 날짜
로컬 편집 값 편집한 값이 화면 상태에 그대로 남는다 사용 사용 안 함
서버 유효 값 조회 useProcessDesign 이 그날 값을 가져온다 사용 사용
오버레이 적용 받은 값으로 표의 설계량 열을 채운다 사용 사용

1.2절의 대조 결과로 첫 번째 경로는 제외했다. 남은 두 경로를 가설로 세웠다.

2.2 가설과 확인 방법

가설 확인 방법 결과
(a) 저장 후 무효화하는 쿼리 키가 틀려서 다른 날짜 캐시가 stale 이 되지 않는다 무효화 호출의 키와 쿼리 키 비교 기각
(b) 새 값은 도착하지만 오버레이가 다시 실행되지 않는다 오버레이 useEffect 의 실행 조건 확인 채택

(a) 는 기각했다. 무효화는 쿼리 키의 앞부분으로 일치하는 쿼리를 모두 찾는다[3]. 저장 후 호출은 날짜를 뺀 앞부분 키를 넘기므로 그 현장의 모든 날짜 쿼리가 stale 이 된다.

2.3 오버레이 구현 (수정 전)

useEffect(() => {
  // ... 데이터 로딩, 저장된 일보 여부 같은 가드 ...
  const token = `${project}:${date}`;
  if (designOverlaidRef.current === token) return; // (현장, 날짜)당 한 번만 실행
  designOverlaidRef.current = token;
  setProcessRows((prev) => applyProcessDesignOverlay(prev, getEffectiveDesign));
}, [project, date, designMap /* ... */]);

의존성 배열에 designMap 이 있어서 데이터가 바뀌면 이 effect 는 다시 실행된다. 하지만 같은 (현장, 날짜) 에서 두 번째 실행은 token 비교에서 바로 끝난다.


3. 결과

3.1 원인: 옛 값이 먼저 오고 게이트가 닫힌다

TanStack Query 는 캐시에 데이터가 있는 쿼리를 다시 구독하면 그 데이터를 바로 돌려주고, 쿼리 함수는 백그라운드에서 실행한다. 요청이 끝나면 캐시를 새 데이터로 바꾼다[1].
stale 상태의 쿼리는 새 인스턴스가 마운트될 때 자동으로 다시 요청된다[2]. 이것이 stale-while-revalidate 동작이다.

D일에 설계량을 5에서 10으로 저장하고, 전에 방문한 D+1 로 옮기면 다음 순서로 실행된다.

인터랙티브 다이어그램: D+1 로 이동한 뒤 오버레이가 받는 값 (단계별로 재생할 수 있다)

  1. D일에 5 → 10 으로 저장한다. 서버에 effective_from=D 버전이 생기고, 설계량 쿼리가 모두 stale 이 된다.
  2. D+1 로 이동한다. 캐시에 남은 5를 바로 돌려주고 백그라운드 요청을 시작한다.
  3. 오버레이가 5로 설계량 열을 채우고 키 site:D+1 을 기록한다.
  4. 요청이 끝나 캐시가 10으로 바뀐다. designMap 이 바뀌어 effect 가 다시 실행된다.
  5. 키 site:D+1 이 이미 기록되어 있어 effect 가 바로 끝난다. 화면은 5로 남는다.
  6. 다른 날짜로 갔다 돌아오면 키가 바뀌어 다시 실행된다. 이때 캐시에 10이 있어 그제야 반영된다.
시점 designMap 오버레이
이동 직후 캐시의 옛 값 5 키가 기록되어 있지 않아 실행, 화면 5, 키 기록
요청 완료 새 값 10 effect 재실행, 키가 이미 있어 종료, 화면 5 유지

「날짜를 몇 번 옮겨야 반영된다」는 증상은 이 순서로 설명된다. 다른 날짜로 갔다 돌아오면 designOverlaidRef 에 다른 키가 기록되어 있으므로 게이트가 열리고, 이때 캐시에는 이미 10이 있다.

3.2 재현 시뮬레이션

아래 코드는 캐시가 옛 값 5를 바로 주고 120 ms 뒤 새 값 10을 주는 상황을 만들고, 수정 전 게이트와 수정 후 방식을 차례로 실행한다. 실행하면 콘솔에 각 시점의 화면 값이 찍힌다.

// 캐시는 옛 값(5)을 바로 주고, 120ms 뒤 백그라운드 refetch 가 새 값(10)을 준다.
function subscribeDesign(onData) {
  onData({ design: 5, from: "캐시(stale)" });
  setTimeout(() => onData({ design: 10, from: "refetch" }), 120);
}

const KEY = "site-1:2026-07-16";

// 수정 전: (현장, 날짜) 키당 한 번만 적용
function overlayOncePerKey() {
  let rows = [{ name: "강관말뚝", design: null }];
  let appliedKey = null;
  subscribeDesign(({ design, from }) => {
    if (appliedKey === KEY) {
      console.log("[수정 전]", from, design, "도착, 이미 적용한 키라 건너뜀");
      return;
    }
    appliedKey = KEY;
    rows = rows.map((r) => ({ ...r, design }));
    console.log("[수정 전]", from, design, "도착, 화면", rows[0].design);
  });
  setTimeout(() => console.log("[수정 전] 최종 화면:", rows[0].design), 200);
}

// 수정 후: 데이터가 바뀔 때마다 적용, 사용자가 고친 행은 건너뜀
function applyOverlay(prev, design) {
  let changed = false;
  const next = prev.map((r) => {
    if (r.edited || r.design === design) return r;
    changed = true;
    return { ...r, design };
  });
  return changed ? next : prev; // 바뀐 행이 없으면 원본을 그대로 돌려준다
}

function overlayDataDriven() {
  let rows = [
    { name: "강관말뚝", design: null, edited: false },
    { name: "두부정리", design: 7, edited: true },
  ];
  subscribeDesign(({ design, from }) => {
    const next = applyOverlay(rows, design);
    console.log("[수정 후]", from, design, "도착,", next === rows ? "변경 없음" : "화면 " + next.map((r) => r.design).join(", "));
    rows = next;
  });
  setTimeout(() => console.log("[수정 후] 최종 화면:", rows.map((r) => r.name + " " + r.design).join(", ")), 200);
}

overlayOncePerKey();
setTimeout(overlayDataDriven, 300);

Node.js 로 실행한 출력은 다음과 같다. 두부정리 행은 사용자가 7로 고친 행이라 두 번 모두 그대로다.

[수정 전] 캐시(stale) 5 도착, 화면 5
[수정 전] refetch 10 도착, 이미 적용한 키라 건너뜀
[수정 전] 최종 화면: 5
[수정 후] 캐시(stale) 5 도착, 화면 5, 7
[수정 후] refetch 10 도착, 화면 10, 7
[수정 후] 최종 화면: 강관말뚝 10, 두부정리 7

3.3 수정

수정 전후 오버레이 동작
그림 1. 수정 전에는 옛 값 5로 키를 기록한 뒤 새 값 10을 건너뛰었다. 수정 후에는 값이 바뀔 때마다 다시 적용해 최종 화면이 10이 된다.

키 게이트를 지우기만 하면 다른 문제가 생긴다. 게이트는 같은 키에서 오버레이를 한 번만 실행하게 막으면서, 결과적으로 사용자가 편집한 행을 다시 덮지 않게 하는 역할도 하고 있었다. 게이트를 지우면 refetch 가 끝날 때마다 사용자가 방금 입력한 값이 서버 값으로 바뀐다. 그래서 두 역할을 나눴다.

  1. 키 게이트 제거: designMap 이 바뀔 때마다 오버레이를 다시 실행한다.
  2. 편집 행 가드: isEdited 로 사용자가 고친 행은 건너뛴다.
  3. 변경 없음 처리: 바뀐 행이 없으면 이전 배열을 그대로 돌려줘 React 가 다시 렌더하지 않게 한다. 같은 데이터로 여러 번 실행해도 결과가 같은 멱등 함수가 된다.
setProcessRows((prev) => {
  const next = applyProcessDesignOverlay(prev, getEffectiveDesign, isEdited); // 편집 행은 건너뜀
  if (next === prev) return prev; // 바뀐 행이 없으면 상태를 바꾸지 않는다
  return next; // 값이 바뀐 미편집 행만 새 유효 값으로 바뀐다
});

3.4 검증

항목 결과
tsc 타입 검사 통과
eslint 통과
관련 테스트 145개 통과
추가한 회귀 테스트 「편집 행 보존」, 「변경 없을 때 원본 반환」 2개

4. 논의

4.1 검토한 대안

대안 판단
저장 응답으로 setQueryData 를 호출해 캐시를 바로 갱신[4] 응답으로 바로 바꿀 수 있는 것은 저장한 날짜의 캐시다. 이후 날짜의 유효 값은 서버가 버전을 골라 계산하고, 편집 중인 행과 합치는 처리도 따로 필요하다. 무효화 후 멱등 재적용이 더 단순해 채택하지 않았다.
게이트 키에 데이터 버전(updatedAt 등)을 넣기 새 데이터가 오면 키가 바뀌어 게이트가 열린다. 게이트를 유지해야 하는 이유가 없어 단순한 쪽을 택했다.
staleTime 을 0으로 낮추기 이 문제와 관계없다. 무효화한 쿼리는 staleTime 설정과 상관없이 stale 이 된다[3].
게이트 제거 + 편집 행 가드 + 변경 없음 처리 채택. 서버 값을 기준으로 두고 사용자 편집만 따로 보존한다.

4.2 원인이 구현과 리뷰에서 걸리지 않은 이유

이 기능은 AI 에이전트 파이프라인(설계 → 백엔드 → 프론트엔드 → 리뷰)으로 만들었다. 설계 문서는 「그날 유효한 설계량을 화면에 채운다」까지만 정했고, 몇 번 채울지는 프론트엔드 구현 에이전트가 정했다.

항목 설계 의도 구현 차이
적용 시점 그날 유효 값을 반영 (횟수 미정) (현장, 날짜)당 1회 같은 키 안에서 값이 바뀔 수 있다는 점을 반영하지 않음
편집 보호 사용자 편집 행은 덮지 않음 1회 실행이 간접적으로 보호 두 역할이 한 장치에 들어감
최신성 항상 그날 유효 값 첫 데이터로 고정 stale 에서 fresh 로 바뀌는 캐시 동작과 충돌

이번 작업에서 구조로 검증할 수 있는 부분(데이터 모델, 버전 선택 로직, 타입)은 AI 에이전트가 정확히 만들었다. 시간 순서에 따라 결과가 달라지는 부분(캐시 갱신 순서, effect 재실행 조건)에서 문제가 났다. 리뷰할 때 확인할 항목은 다음과 같다.

4.3 한계


5. 결론


부록 A. 진행 기록

단계 내용
요청 설계량을 적용일 기준 버전으로 저장, 이후 「다음 날짜 반영 지연」 원인 규명
구현 상태 useProcessDesign (TanStack Query, staleTime 30초) + (현장, 날짜)당 1회 오버레이
증상 저장 후 이전에 방문한 날짜로 이동하면 옛 설계량, 날짜를 몇 번 옮긴 뒤 반영
기각한 가설 무효화 쿼리 키 오류 (앞부분 일치로 모든 날짜가 stale 처리됨)
근본 원인 stale-while-revalidate 와 키당 1회 게이트의 충돌
수정 게이트 제거, isEdited 편집 행 가드, 변경 없을 때 원본 반환
검증 tsc, eslint 통과, 테스트 145개 통과 (회귀 2개 추가)

참고

  1. [1] Caching Examples — TanStack Query
  2. [2] Important Defaults — TanStack Query
  3. [3] Query Invalidation — TanStack Query
  4. [4] Updates from Mutation Responses — TanStack Query

관련 글