frontend
정리를 끝내도 숫자가 안 줄었다 — 같은 이름의 숫자가 두 정의를 가질 때
· Ascendy Engineering
TL;DR
- 홈 카드에 “정리할 사진 120장”이라고 떴다. 정리 세션을 끝까지 돌려도 숫자가 꿈쩍하지 않았다.
- 첫 의심은 캐시 신선도였다 — “완료하고 나서 다시 안 가져오나 보다.” 코드를 열어보니 세션 종료 핸들러가 이미 재조회를 하고 있었다. 가설이 통째로 틀렸다.
- 진짜 원인은 두 겹이었다. (a) “정리 대상”이라는 같은 개념이 두 코드 경로에서 다르게 정의돼 있었고, (b) 오래 살아 있는 네이티브 앱에 스냅샷이 고착돼 있었다.
- 이 글의 요점은 여기다. 신선도(b)만 고쳤다면 숫자는 잘 갱신됐을 것이다 — 틀린 값으로. 갓 가져온 틀린 숫자가 이전보다 나은지는 전혀 분명하지 않다.
- 재발 방지는 정의를 공유하는 것이다. 제외 목록이라는 공통 개념이 이미 있었는데, 통계를 내는 쪽만 그걸 참조하지 않았다.
이 글에 대하여. 프론트엔드 팀 인테이크를 정제한 회고다. 본문의 건수는 예시 숫자이며 실제 사용자 라이브러리 규모가 아니다. 한 겹을 고치니 아래 겹이 드러난 같은 구조는 정직하게 만들었더니 진짜 버그가 보였다에, 같은 팀에서 “오진 경로”를 다룬 인접 사례는 조용한 폴백이 만들어낸 오진 세 개에 있다.
증상 — 끝냈는데 그대로다
제품에 “정리” 기능이 있다. 홈 화면 카드가 “정리할 사진 120장” 처럼 알려주고, 누르면 한 장씩 넘기며 간직할지 지울지 고르는 세션이 시작된다.
제보는 한 줄이었다. 끝까지 돌려도 카드의 숫자가 안 줄어든다.
첫 가설은 통째로 틀렸다
가장 먼저 떠오르는 건 캐시다. 세션을 끝냈는데 홈 카드가 옛날 값을 들고 있는 것 — 흔하고, 고치기도 쉽다.
코드를 열었다. 세션 종료 핸들러는 이미 재조회를 하고 있었다.
여기서 멈춰서 생각을 고쳤어야 했는데, 사실 이 단계에서 배운 게 있다. “이건 분명 캐시 문제야” 는 가설이 아니라 습관이었다. 증상(“숫자가 안 변한다”)이 캐시 문제의 전형적인 모양이라, 확인하기 전에 이미 결론을 갖고 코드를 열었다.
두 번째 단서 — 웹은 0, 모바일은 120
다음 단서가 방향을 틀었다. 같은 계정인데 웹은 0장, 모바일은 120장이었다.
같은 API, 같은 표시 컴포넌트인데 두 클라이언트가 다른 숫자를 보여준다. 그러면 최소한 한쪽은 자기가 들고 있는 값이 낡았다는 뜻이다.
실제로 그랬다. 네이티브 앱에는 마운트 시 1회만 페치 하는 가드가 있었다. 웹에서 흔히 쓰는 최적화인데, 그 네이티브 앱은 며칠씩 살아 있었고 그동안 며칠 전 값이 그대로 남아 있었다.
이건 진짜 버그였고 고쳤다. 그런데 이걸 고쳐도 숫자는 여전히 틀린다.
진짜 원인 — 같은 이름, 두 정의
두 번째 겹이 본론이다.
“정리 대상”이라는 개념이 두 코드 경로에 각각 존재했고, 정의가 서로 달랐다.
정리 세션의 큐는 이렇게 정의했다 — 품질 점수가 낮거나 중복 그룹에 속한 사진 중에서, 사용자가 이미 간직이나 나중에 를 누른 것은 제외한다. 당연하다. 한 번 “간직”이라고 답한 사진을 다음 세션에서 또 물어보면 안 되니까.
홈 카드의 카운트 API는 이렇게 정의했다 — 품질 점수가 낮거나 중복 그룹에 속한 사진의 개수. 끝. 사용자의 선택 신호를 전혀 참조하지 않았다.
결과는 이렇게 된다.
사용자가 120장을 전부 "간직"으로 처리
정리 세션 큐: 제외 신호를 참조함 → 0장 ✅ 비었다
홈 카드 카운트: 원시 상태만 집계 → 120장 ❌ 그대로다
사용자는 일을 끝냈다. 큐도 비었다. 그런데 카드는 여전히 120장이라고 말한다. 그리고 다시 누르면 빈 세션이 열린다.
요점 — “정확하게 틀린 숫자”
여기가 이 사건에서 가져갈 것이다.
만약 우리가 첫 번째 겹(스냅샷 고착)만 고쳤다면 어떻게 됐을까. 앱이 재진입할 때마다 카운트를 새로 가져왔을 것이다. 숫자는 잘 갱신됐을 것이다 — 매번 120으로.
버그 리포트는 “숫자가 안 변한다”였다. 신선도를 고치면 그 리포트는 닫힌다. 화면은 확실히 더 최신이 된다. 그런데 더 맞지는 않는다.
갓 가져온 틀린 값은 낡은 틀린 값보다 나은가? 나는 오히려 더 나쁘다고 본다. 틀린 값이 계속 갱신되고 있으면, 그게 틀렸다고 의심할 이유가 하나 줄어든다. “값이 안 변한다”는 최소한 이상하게 보이기라도 한다.
그래서 이런 종류의 증상에서는 순서가 중요하다. 먼저 “이 숫자가 무엇을 세는가”를 확정하고, 그다음에 “언제 다시 세는가”를 고친다. 반대로 하면 정확도 문제를 신선도 문제로 착각한 채 닫아버린다.
재발 방지 — 정의를 공유하라
이 사건에서 가장 아픈 부분은 공통 개념이 이미 존재했다는 점이다.
제외 목록 — 사용자가 간직/나중에를 누른 항목 — 은 없던 개념이 아니다. 큐가 그걸 쓰고 있었다. 통계 쪽만 안 쓴 것이다. 새로 설계할 게 아니라 이미 있는 정의를 한쪽이 참조하지 않은 상태였다.
그래서 규칙은 이렇게 정리된다. 화면에 노출되는 집계 숫자는, 그 숫자가 대표하는 작업 목록과 같은 정의를 써야 한다. “정리할 사진 N장”은 정리 큐에 실제로 들어갈 항목 수여야지, 큐를 만들 때 후보가 되는 원시 항목 수가 아니다. 둘이 다르면 그 화면은 사용자에게 지킬 수 없는 약속을 한다.
실무적으로는 두 가지다.
- 집계 쿼리와 목록 쿼리가 같은 술어를 공유하게 한다(같은 필터 함수, 같은 뷰, 같은 스펙).
- 공유가 당장 어렵다면, 두 값이 같아야 한다는 사실을 테스트로 고정한다 — 전부 간직 / 전부 나중에 / 섞임 / 아무 선택 없음, 네 경우에서 카운트가 큐 길이와 일치하는지를 본다. 이 테스트는 고쳐지기 전까지 빨간불로 남아 격차를 드러낸다.
여기서 하기 쉬운 실수 하나를 짚어둔다. “두 정의가 다르다”는 사실을 테스트로 고정하면 안 된다. 그건 격차를 드러내는 게 아니라 원인을 박제하는 것이고, 다음 사람이 그 테스트를 “의도된 동작”으로 읽게 만든다. 고정할 대상은 차이가 아니라 일치해야 한다는 요구다.
곁가지 둘
① 오래 사는 클라이언트에 웹의 감각을 넣지 마라. “마운트 시 1회 페치”는 웹에서 나온 습관이다. 며칠씩 살아 있는 클라이언트에서는 다른 갱신 지점이 필요하다 — 화면 재진입, 앱 resume. 다만 폴링은 넣지 않았다. 라이브 값이 필요한 화면이 아니라, 갱신 지점을 제대로 고르면 되는 문제였다.
② 완료 화면이 몇 초 늦게 채워지던 것도 같은 배치에서 잡혔다. 원인은 느린 순차 커밋 뒤에 통계 페치가 줄을 서 있던 것. 라이브 집계형 API는 “즉시 한 번 + 커밋이 끝난 뒤 다시 한 번” 이중 페치가 체감과 정확성을 동시에 잡는다. 먼저 보여주고, 확정되면 조용히 맞춘다.
가져갈 것
- 신선도를 고치기 전에 정의를 확정하라. 순서가 반대면 갓 가져온 틀린 값을 만들고, 그건 낡은 틀린 값보다 의심받기 어려워서 더 나쁠 수 있다.
- 같은 이름의 숫자가 두 코드 경로에 있으면 정의도 두 개다. “정리 대상”이 큐와 통계에서 다른 것을 뜻하고 있었다.
- 집계 숫자는 그것이 대표하는 목록과 같은 술어를 써야 한다. 안 그러면 화면이 지킬 수 없는 약속을 한다.
- 공통 개념이 이미 있는데 한쪽이 참조를 안 하는 경우가 많다. 새 설계보다 먼저 이미 있는 정의를 다 쓰고 있는가를 본다.
- 오래 사는 네이티브 앱에 “마운트 1회 페치”를 넣지 마라. 재진입·resume 에지에서 갱신하되 폴링은 성급하다.
- “이건 캐시 문제야”는 가설이 아니라 습관일 수 있다. 증상의 모양이 익숙하다고 원인이 익숙한 건 아니다.
증상 하나에 원인이 둘이면, 하나만 고쳤을 때 가장 위험한 결과는 증상이 사라지는 것이다.
저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.
Tags: debugging, api-contract, ux, postmortem, cross-repo, data-modeling