← Ascendy

frontend

정리를 끝내도 숫자가 안 줄었다 — 같은 이름의 숫자가 두 정의를 가질 때

· Ascendy Engineering


TL;DR

이 글에 대하여. 프론트엔드 팀 인테이크를 정제한 회고다. 본문의 건수는 예시 숫자이며 실제 사용자 라이브러리 규모가 아니다. 한 겹을 고치니 아래 겹이 드러난 같은 구조는 정직하게 만들었더니 진짜 버그가 보였다에, 같은 팀에서 “오진 경로”를 다룬 인접 사례는 조용한 폴백이 만들어낸 오진 세 개에 있다.

증상 — 끝냈는데 그대로다

제품에 “정리” 기능이 있다. 홈 화면 카드가 “정리할 사진 120장” 처럼 알려주고, 누르면 한 장씩 넘기며 간직할지 지울지 고르는 세션이 시작된다.

제보는 한 줄이었다. 끝까지 돌려도 카드의 숫자가 안 줄어든다.

첫 가설은 통째로 틀렸다

가장 먼저 떠오르는 건 캐시다. 세션을 끝냈는데 홈 카드가 옛날 값을 들고 있는 것 — 흔하고, 고치기도 쉽다.

코드를 열었다. 세션 종료 핸들러는 이미 재조회를 하고 있었다.

여기서 멈춰서 생각을 고쳤어야 했는데, 사실 이 단계에서 배운 게 있다. “이건 분명 캐시 문제야”가설이 아니라 습관이었다. 증상(“숫자가 안 변한다”)이 캐시 문제의 전형적인 모양이라, 확인하기 전에 이미 결론을 갖고 코드를 열었다.

두 번째 단서 — 웹은 0, 모바일은 120

다음 단서가 방향을 틀었다. 같은 계정인데 웹은 0장, 모바일은 120장이었다.

같은 API, 같은 표시 컴포넌트인데 두 클라이언트가 다른 숫자를 보여준다. 그러면 최소한 한쪽은 자기가 들고 있는 값이 낡았다는 뜻이다.

실제로 그랬다. 네이티브 앱에는 마운트 시 1회만 페치 하는 가드가 있었다. 웹에서 흔히 쓰는 최적화인데, 그 네이티브 앱은 며칠씩 살아 있었고 그동안 며칠 전 값이 그대로 남아 있었다.

이건 진짜 버그였고 고쳤다. 그런데 이걸 고쳐도 숫자는 여전히 틀린다.

진짜 원인 — 같은 이름, 두 정의

두 번째 겹이 본론이다.

“정리 대상”이라는 개념이 두 코드 경로에 각각 존재했고, 정의가 서로 달랐다.

정리 세션의 큐는 이렇게 정의했다 — 품질 점수가 낮거나 중복 그룹에 속한 사진 중에서, 사용자가 이미 간직이나 나중에 를 누른 것은 제외한다. 당연하다. 한 번 “간직”이라고 답한 사진을 다음 세션에서 또 물어보면 안 되니까.

홈 카드의 카운트 API는 이렇게 정의했다 — 품질 점수가 낮거나 중복 그룹에 속한 사진의 개수. 끝. 사용자의 선택 신호를 전혀 참조하지 않았다.

결과는 이렇게 된다.

사용자가 120장을 전부 "간직"으로 처리

  정리 세션 큐:  제외 신호를 참조함  →  0장   ✅ 비었다
  홈 카드 카운트: 원시 상태만 집계    →  120장 ❌ 그대로다

사용자는 일을 끝냈다. 큐도 비었다. 그런데 카드는 여전히 120장이라고 말한다. 그리고 다시 누르면 빈 세션이 열린다.

요점 — “정확하게 틀린 숫자”

여기가 이 사건에서 가져갈 것이다.

만약 우리가 첫 번째 겹(스냅샷 고착)만 고쳤다면 어떻게 됐을까. 앱이 재진입할 때마다 카운트를 새로 가져왔을 것이다. 숫자는 잘 갱신됐을 것이다 — 매번 120으로.

버그 리포트는 “숫자가 안 변한다”였다. 신선도를 고치면 그 리포트는 닫힌다. 화면은 확실히 더 최신이 된다. 그런데 더 맞지는 않는다.

갓 가져온 틀린 값은 낡은 틀린 값보다 나은가? 나는 오히려 더 나쁘다고 본다. 틀린 값이 계속 갱신되고 있으면, 그게 틀렸다고 의심할 이유가 하나 줄어든다. “값이 안 변한다”는 최소한 이상하게 보이기라도 한다.

그래서 이런 종류의 증상에서는 순서가 중요하다. 먼저 “이 숫자가 무엇을 세는가”를 확정하고, 그다음에 “언제 다시 세는가”를 고친다. 반대로 하면 정확도 문제를 신선도 문제로 착각한 채 닫아버린다.

재발 방지 — 정의를 공유하라

이 사건에서 가장 아픈 부분은 공통 개념이 이미 존재했다는 점이다.

제외 목록 — 사용자가 간직/나중에를 누른 항목 — 은 없던 개념이 아니다. 큐가 그걸 쓰고 있었다. 통계 쪽만 안 쓴 것이다. 새로 설계할 게 아니라 이미 있는 정의를 한쪽이 참조하지 않은 상태였다.

그래서 규칙은 이렇게 정리된다. 화면에 노출되는 집계 숫자는, 그 숫자가 대표하는 작업 목록과 같은 정의를 써야 한다. “정리할 사진 N장”은 정리 큐에 실제로 들어갈 항목 수여야지, 큐를 만들 때 후보가 되는 원시 항목 수가 아니다. 둘이 다르면 그 화면은 사용자에게 지킬 수 없는 약속을 한다.

실무적으로는 두 가지다.

여기서 하기 쉬운 실수 하나를 짚어둔다. “두 정의가 다르다”는 사실을 테스트로 고정하면 안 된다. 그건 격차를 드러내는 게 아니라 원인을 박제하는 것이고, 다음 사람이 그 테스트를 “의도된 동작”으로 읽게 만든다. 고정할 대상은 차이가 아니라 일치해야 한다는 요구다.

곁가지 둘

① 오래 사는 클라이언트에 웹의 감각을 넣지 마라. “마운트 시 1회 페치”는 웹에서 나온 습관이다. 며칠씩 살아 있는 클라이언트에서는 다른 갱신 지점이 필요하다 — 화면 재진입, 앱 resume. 다만 폴링은 넣지 않았다. 라이브 값이 필요한 화면이 아니라, 갱신 지점을 제대로 고르면 되는 문제였다.

② 완료 화면이 몇 초 늦게 채워지던 것도 같은 배치에서 잡혔다. 원인은 느린 순차 커밋 뒤에 통계 페치가 줄을 서 있던 것. 라이브 집계형 API는 “즉시 한 번 + 커밋이 끝난 뒤 다시 한 번” 이중 페치가 체감과 정확성을 동시에 잡는다. 먼저 보여주고, 확정되면 조용히 맞춘다.

가져갈 것

증상 하나에 원인이 둘이면, 하나만 고쳤을 때 가장 위험한 결과는 증상이 사라지는 것이다.


저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.


Tags: debugging, api-contract, ux, postmortem, cross-repo, data-modeling