저장은 됐는데 화면이 한 박자 늦는 이유 — 캐시가 옛 값을 먼저 준다

건설 작업일보 "설계량" 기능에서, 저장 후 다음 날로 넘기면 옛 값이 보였다. 도메인 로직(effective-dated)은 정확히 맞았고, 문제는 프론트의 캐시 ↔ 백엔드 데이터 동기화 한 겹 — stale-while-revalidate가 옛값을 먼저 주는 사이 "1회 오버레이" 게이트가 옛값으로 소진된 타이밍 레이스였다.

Seobway · · 13분

30초 요약


1. 증상부터 — 실제로 뭐가 깨졌나

말보다 영상이 빠르다. 아래는 실제 증상을 녹화한 화면이다.

증상 재현 — 설계량을 수정한 뒤 다음 날로 이동하면 옛 값이 그대로 보이고, 날짜를 몇 번 옮겨야 새 값이 반영된다.

정리하면:

설계량·잔량 같은 용어가 낯설면 아래 §3의 파란 카드를 먼저 열어봐도 된다. 여기선 "숫자를 고쳐 저장했는데, 다음 날 화면엔 옛 숫자가 남아 있다" 정도만 잡고 가면 충분하다.


2. 이 버그의 본질 — 도메인은 맞았고, 프론트 한 겹이 틀렸다

증상을 봤으니, 이 버그가 어디의 문제가 아닌지부터 못 박고 시작한다. 그래야 엉뚱한 곳을 파지 않는다.

상태
무엇을 보여줄지 (도메인: 어느 날짜에 어떤 설계량이 유효한가) 맞았다 — 백엔드 resolve_effective_design, 시나리오 테스트 S1~S8 통과
언제·어떻게 최신값으로 갱신할지 (프론트: 캐시·패치 타이밍·실시간 반영) 틀렸다 — 옛 캐시로 화면 잠금, 새 값 무시

그래서 이 글의 초점은 "도메인을 어떻게 모델링하나"가 아니라 "프론트 캐시와 백엔드 데이터를 어떻게 동기화해 항상 최신을 보장하나" 다. 왜 처음에 못 알아챘나는 §11, 프론트에서 AI를 어떻게 써야 하나는 §12, 이런 부류를 없애는 법은 §9에 있다.


3. 무엇을 요청받았나

두 단계로 왔다. (용어가 낯설면 아래 카드를 먼저 — 여기선 한 줄 요약만 곁들인다.)

  1. 기능 요구: 공정 "설계량"을 effective-dated로. — "오늘(D) 설계량을 5→10으로 바꾸면 과거 일보는 5 기준 그대로, D 이상 일보만 10 기준. 그 시점 이후로 계속 10." (는 카드 참고.)
  2. 버그 리포트(이 글의 주제):

    "설계량을 수정하고 바로 다음 날로 넘어갔는데 반영이 늦다. 날짜를 몇 번 옮겨야 그제서야 반영된다. 당일 수정한 날은 바로 반영된다. 프론트 문제 같은데 왜 그런지 파악해줘."


4. 어떻게 만들어져 있었나 (결과물)

문제의 코드(수정 전):

useEffect(() => {
  // ... 가드들 ...
  const token = `${project}:${date}`;
  if (designOverlaidRef.current === token) return; // ← (현장,날짜)당 딱 1번만 실행
  designOverlaidRef.current = token;
  setProcessRows((prev) => applyProcessDesignOverlay(prev, getEffectiveDesign));
}, [project, date, designMap /* ... */]);

5. 설계 ↔ 구현의 괴리 — 그리고 왜 이 순서대로 어긋나나

이번 기능은 AI 에이전트 파이프라인(설계 → 백엔드 → 프론트 → 리뷰)으로 만들었다. 설계 문서는 "날짜별 유효 설계량을 화면에 채운다"까지만 정의했고, "어떻게 1회만/여러 번 채울지"의 구체 전략은 프론트 구현 에이전트에게 맡겨졌다. 바로 그 빈틈에서 괴리가 생겼다.

항목 설계의 의도 실제 구현 괴리
오버레이 적용 시점 "그날 유효 설계량을 화면에 반영" (횟수는 미규정) (현장,날짜)당 1회 토큰으로 잠금 설계는 "반영"을, 구현은 "1회 반영"을 선택 — 데이터가 나중에 바뀔 수 있다는 전제를 놓침
편집 보호 "사용자 편집 행은 덮지 않는다" 토큰이 "1회 실행"으로 간접적으로 편집도 보호 편집 보호와 "1회 실행"이 한 장치에 뭉쳐짐 → 하나 풀면 하나 깨짐
데이터 최신성 "그날 유효값" (항상 최신) 첫 데이터로 잠금 → 갱신 무시 캐시가 stale→fresh로 갱신되는 특성과 충돌

AI가 왜 이렇게 했나(핵심): 프론트 에이전트는 같은 화면의 검증된 기존 패턴인 "금일 진도 오버레이"의 (현장,날짜) 토큰그대로 미러링했다. 금일 진도는 로컬 입력 위주라 "1회면 충분"했지만, 설계량은 저장 후 값이 바뀌어 다시 받아오는 데이터라 전제가 달랐다. → "검증된 패턴 재사용"이 오히려 전제 불일치를 가렸다.

아래 시퀀스가 그 어긋남을 시간순으로 보여준다.

%% desc: 저장 후 다음 날 이동 시, 캐시가 옛값(①)을 먼저 주고 새값(②)이 뒤늦게 도착하는 사이 "1회" 오버레이 토큰이 옛값으로 소진되는 순서
sequenceDiagram
    participant U as 사용자
    participant S as 화면(state)
    participant RQ as React Query 캐시
    participant API as 백엔드

    U->>S: D일에 설계량 5→10 편집 후 저장
    S->>API: 저장(버전 effective_from=D 생성)
    S->>RQ: 캐시 무효화(stale 표시)
    U->>S: D+1로 날짜 이동
    RQ-->>S: ① 옛 캐시(5) 즉시 반환
    S->>S: 오버레이가 5로 화면 채움 + 토큰 소진
    RQ->>API: ② 백그라운드 refetch
    API-->>RQ: 최신(10) 도착
    RQ-->>S: designMap 갱신(10)
    S--xS: 오버레이 재실행? → 토큰 이미 소진 → early return (10 미반영!)
    Note over S: → 날짜를 또 옮겨야 그제서야 반영

↑ 이 다이어그램을 한 줄씩 읽으면:

  1. 사용자가 D일에 설계량을 5→10으로 고치고 저장 → 백엔드에 "D부터 10" 버전이 생긴다.
  2. 저장 후 프론트는 캐시를 "낡음(stale)"으로 표시한다(= "다음에 쓸 때 새로 받아와").
  3. 사용자가 D+1로 날짜를 옮긴다.
  4. ① React Query가 캐시에 있던 옛 값(5)을 즉시 돌려준다 — 빠른 화면을 주려고. 아직 새 값을 안 받았다.
  5. 오버레이가 그 옛 값(5)으로 화면을 채우고, "이 날짜는 처리 끝" 토큰을 써버린다.
  6. ② React Query가 백그라운드에서 백엔드에 다시 물어본다.
  7. 최신 값(10)이 도착해 캐시(designMap)가 10으로 갱신된다.
  8. 데이터가 바뀌었으니 오버레이가 다시 돌려 하지만 — 토큰이 이미 소진돼 그냥 return. → 10이 화면에 안 들어간다.
  9. 그래서 날짜를 또 옮겨야(= 새 토큰) 그제서야 반영된다. 이게 "몇 번 옮겨야 반영"의 정체다.

즉 핵심은 4·5(옛 값으로 토큰 소진)와 7·8(새 값이 왔지만 토큰 때문에 무시) 의 어긋남이다.


6. 문제 정의 (Problem definition)

데이터 흐름을 3개 축으로 분리해 추적했다.

  1. 로컬 편집값: 당일 편집값은 화면 state에 그대로 → 당일 즉시 반영(정상).
  2. 서버 유효값 조회: 다른 날짜로 가면 useProcessDesign이 React Query로 그날 값 조회.
  3. 오버레이 적용: designMap이 로드되면 화면 표에 채움 — 단 (현장,날짜) 토큰으로 1회만.

후보 검증:


7. 근본 원인 (Root cause)

stale-while-revalidate 동작once-per-key 토큰의 충돌이다.

React Query는 stale 표시된 데이터를 다시 구독하면 ① 캐시의 옛 값을 즉시 주고 → ② 백그라운드로 새 값을 받아 갱신한다. 그래서:

시점 designMap 오버레이 동작
① 이동 직후 옛 값(캐시) 토큰 미소진 → 옛 값으로 채우고 토큰 소진
② refetch 완료 최신 값 designMap 변해 재실행 시도 → 토큰 이미 소진 → early return → 최신값 미반영

부수 위험: 토큰을 그냥 지우면 refetch마다 오버레이가 재실행되며 사용자가 방금 편집한 행까지 서버값으로 덮어쓸 수 있다. 즉 토큰은 "지연"의 원인이자 동시에 "편집 보호"도 겸했다 — 이 이중 역할을 분리해야 했다.


8. 해결과 예방

8.1 해결 — 토큰 제거 + 역할 분리

"1회 게이트"를 없애고, 데이터가 바뀔 때마다 재적용하되, 토큰이 겸하던 "편집 보호"는 명시적 가드로 분리했다.

  1. 토큰 제거designMap(서버 데이터)이 stale→fresh로 바뀌면 재실행되어 최신값을 반드시 반영.
  2. 편집 행 보존(isEdited) → refetch 재적용이 사용자가 방금 입력한 값을 덮지 않음.
  3. 무변경 시 원본 반환 → 불필요 렌더 방지.
const next = applyProcessDesignOverlay(prev, getEffectiveDesign, isEdited); // ← 편집 행 스킵
if (next === prev) return prev; // 변경 없으면 아무것도 안 함
// 값이 바뀐 "미편집" 행만 새 유효값으로 재-baseline

검증: tsc·eslint green, 관련 테스트 145개 통과(회귀 2건 추가 — "편집 행 보존", "무변경 시 원본 반환").

8.2 예방 대책 (재발 방지)


9. 이 부류(캐시 ↔ 백엔드 데이터 동기화)를 아예 없애려면 — 관점부터 바꾼다

이 버그의 진짜 교훈은 특정 토큰이 아니라 "프론트 캐시와 백엔드 데이터의 관계를 어떻게 볼 것인가" 다.

  1. 캐시는 "서버 진실의 사본(projection)" 이고, stale-while-revalidate에선 "결국 일치(eventually consistent)"할 뿐 순간엔 옛값일 수 있다. → 화면 파생 로직은 항상 이 전제로 짠다.
  2. 화면 갱신은 "데이터 주도(data-driven)"로. "몇 번 했나(키·횟수)"가 아니라 "지금 데이터가 뭐냐"에 반응하게. (새 데이터가 오면 자동으로 다시 반영 — 이번 해결이 바로 이것.)
  3. 서버 값은 화면의 단일 소스, 로컬 편집은 "덧칠(overlay)"로 명확히 분리. 둘을 섞지 말 것.
  4. 저장 직후 stale 창을 줄이려면, 무효화만 하지 말고 응답값으로 캐시를 직접 갱신(setQueryData) 하는 것도 방법. (편집 병합이 복잡하면 "무효화 + 멱등 재적용"이 더 안전.)
  5. "1회 게이트"가 꼭 필요하면 키에 데이터 버전·updatedAt을 포함해 새 데이터엔 새 키가 되게 한다.


10. 왜 굳이 effective-dated로 구현했나

가장 쉬운 구현은 "설계량 = 값 하나(현장 상수)"다. 하지만 그러면 오늘 5→10으로 바꾸는 순간 과거 모든 일보의 잔량이 10 기준으로 재계산돼 버린다. 작업일보는 정산·감사의 근거라 "그때 그 문서가 보여주던 숫자"가 보존돼야 한다(과거 왜곡 = 데이터 신뢰 붕괴).

그래서 설계량을 "값 하나"가 아니라 "일자별 버전(구간)"으로 저장한다.

%% desc: 일보를 열 때 서버가 '작업일자 이하 최신 설계량 버전'을 골라 유효값으로 잔량을 계산하는 흐름 (버전 없으면 템플릿 원값으로 폴백)
flowchart LR
    A["일보 열람(작업일자 W)"] --> B{"W 이하 설계량 버전 있나?"}
    B -- "있음" --> C["그 중 가장 최근 버전 사용"]
    B -- "없음" --> D["템플릿 원래 설계량(폴백)"]
    C --> E["잔량 = 유효설계량 − 누계"]
    D --> E

↑ 이 다이어그램이 말하는 것: 어떤 일보를 열 때(작업일자 W) 서버는 —

  1. 그 공정에 W 이하로 시작하는 설계량 버전이 있는지 본다.
  2. 있으면 그 중 가장 최근 버전을 그날 유효 설계량으로 쓴다(예: 3/5부터 10 버전이 있으면 3/7 일보는 10).
  3. 없으면 템플릿의 원래 설계량으로 폴백한다 — 아무도 안 바꾼 공정은 항상 원래 값. 그래서 버전이 하나도 없으면 예전과 100% 동일(데이터 무손상).
  4. 어느 쪽이든 그 유효 설계량으로 잔량 = 유효설계량 − 누계 를 계산한다.

핵심은 "각 일보가 자기 값을 저장"하는 게 아니라 "읽는 순간 자기 날짜에 맞는 버전을 고른다" 는 것 — 그래서 옛 날짜를 고치면 그 뒤의 이미 저장된 일보도 다시 열 때 새 값이 나온다.

이 "일자별 버전 + 조회 시 유효값 해석" 구조 덕분에 미래 일보도 저장을 다시 안 해도 읽는 순간 새 값이 반영된다 — 그래서 프론트가 그 값을 캐시로 받아오고, 바로 그 지점에서 이 글의 버그가 났다.


11. 왜 AI가 이 버그를 놓쳤나 — 회고와 리뷰 체크리스트

이 기능은 AI 에이전트로 만들었고, 이 버그는 2차 코드 리뷰(AI 리뷰어)에서도 안 걸렸다. 왜?

AI에게 요청·검토할 때 체크리스트

  1. 비동기 캐시 데이터에 "1회만" 로직이 보이면 의심하라. "이 게이트는 stale-while-revalidate(옛값 먼저→새값 나중)에서 새 값이 반드시 반영되는가?" 를 명시 질문으로.
  2. "기존 패턴 재사용" 지시엔 전제 차이를 물어라. "미러하려는 원본과 이 데이터의 갱신 특성(로컬 vs 서버-refetch)이 같은가?"
  3. 한 장치가 2개 역할이면 분리를 요구하라.
  4. 런타임 타이밍 시나리오를 테스트로 강제하라. ("저장→다른 날짜→옛 캐시 먼저→refetch 후 최신 반영")
  5. 사용자 관찰을 1급 단서로. "당일 즉시 / 다음날 지연 / 몇 번 옮겨야"가 이미 "캐시-타이밍"을 가리켰다.


12. 진짜 인사이트 — 프론트엔드에서 AI를 어떻게 써야 하나

12.1 한 문장 통찰

이번 버그가 정확히 그 경계선에서 났다 — 백엔드 도메인 로직(구조)은 완벽했고, 프론트 캐시 타이밍(시간)만 틀렸다. → 그래서 사람의 레버리지는 "코드 작성"이 아니라 "타임라인을 소유하는 것"으로 옮겨간다.

12.2 역할 분담 — 누가 뭘 잘하나

AI에게 맡겨라 (강점) 사람이 쥐어라 (AI의 사각지대)
데이터모델·스키마·순수함수·타입 전제 검증 ("이 미러링, 전제가 같나?")
보일러플레이트·패턴 적용·리팩터 런타임 시나리오 ("stale 캐시가 먼저 오면?")
로직 단위테스트 동시성·이벤트 순서·시간에 걸친 흐름
보안 체크리스트(자기결재·인젝션) 시스템 경계 (프론트 캐시 ↔ 백엔드 진실)
검증된 패턴 재사용 "요청 자체가 맞나" (도메인 타당성)

12.3 실전 인사이트 5개

  1. "패턴 미러해"라고 시키면 반드시 "전제도 같냐"를 함께 물어라. AI는 스스로 전제를 의심하지 않는다 — 그게 사람 몫. (금일 오버레이 = 로컬 입력 ≠ 설계량 = 서버-refetch)
  2. 정적 게이트(tsc·lint·유닛) 전부 green = "다 됐다"는 착각. 남은 리스크는 전부 런타임·타이밍·통합에 몰린다. 사람 주의를 거기에만 집중하라.
  3. 좋은 "관찰"이 좋은 "코드리뷰"보다 강하다. "당일 즉시 / 다음날 지연 / 몇 번 옮겨야" 같은 대조 관찰이 AI를 정확한 런타임 경로로 데려간다. 사람은 현실(관찰)을, AI는 구조·가설을 댄다.
  4. AI에게 "지루하고 단일목적으로" 짜라고 하라. 한 장치가 두 역할(지연방지 + 편집보호)을 겸하면 AI도 리뷰어도 못 잡는다 — 영리한 결합 코드가 사각지대다.
  5. 코딩 전에 AI가 "곧 조용히 내릴 가정"(예: 키당 1회 적용)을 말하게 하라. 가정을 명시화시키는 게 리뷰보다 앞선다.

12.4 프론트 특유의 한 줄 규율


부록: 타임라인 요약

단계 내용
요청 설계량 effective-dated + "다음날 지연" 버그 원인 규명
결과물 useProcessDesign(RQ 캐시) + 오버레이(토큰 1회)
에러 다음날 이동 시 옛 설계량, 날짜 몇 번 옮겨야 반영
문제 정의 오버레이가 최신값으로 재실행되지 않음
근본 원인 stale-while-revalidate + once-per-key 토큰 미스매치(옛값으로 소진)
해결 토큰 제거 + 데이터 변경마다 재적용 + 편집 보존(isEdited) + 무변경 스킵
예방 비동기 캐시엔 "1회 게이트" 금지, 역할 분리, 런타임 타이밍 테스트

참고

  1. [1] Caching Examples — TanStack Query
  2. [2] Important Defaults(staleTime·refetch) — TanStack Query

관련 글