frontend
'동기화 완료'라는데 0장 올라갔다 — 조용한 성공이 가장 위험하다
· Ascendy Engineering
TL;DR
- 운영자 제보: “특정 날짜 이후 찍은 사진이 동기화가 안 된다.” 그런데 앱은 “동기화 완료” 토스트를 띄웠다 — 0장 업로드된 채로. 조용한 성공(silent success) 이 가장 위험한 실패 모드다.
- 프론트는 백엔드 prod 로그를 못 보는 상태에서 9개 가설을 ranking해 독립 진단했고, 그 1순위가 백엔드가 나중에 들고 온 prod 로그에서 그대로 관측됐다.
- 그런데 그건 secondary였다. 진짜 원인은 client scan cap — 클라이언트가 고정 크기 윈도우(상위 일부)만 스캔하는데, 라이브러리가 그 cap을 넘으면 윈도우 밖에 놓인 사진은 스캔에 아예 안 보여서 서버에 도달조차 못 했다.
- root cause를 닫는 청크 동기화 fix를 짰는데, 독립 LLM 리뷰어가 그 fix 안의 데이터 분포 버그를 잡았다 — lint도, 기존 테스트도 못 보는, “데이터를 상상해야 보이는” 결함.
소스 노트. frontend 팀 인테이크 2건(독립 진단·수렴 / root cause fix·리뷰어 캐치)을 합쳐 정제한 글이다. 내부 식별자·절대 수치·API 경로·DB명은 일반화했다(제품이 사진 라이브러리를 동기화한다는 사실만 공개 도메인). 같은 조용한 실패 결의 INFO 로그가 한 줄도 안 찍히던 Celery 이야기와 이어진다.
증상 — “완료”인데 0장
운영자가 제보했다. “특정 날짜 이후 찍은 사진이 자동·수동 동기화 모두 안 올라온다.” 그런데 화면에는 “동기화 완료” 토스트가 떴다. 에러도, 실패 표시도 없이. 실제로는 한 장도 올라가지 않은 채로.
이게 디버깅에서 가장 까다로운 종류다. 시끄러운 실패는 스택트레이스라도 준다. 조용한 성공은 “다 됐다”고 말하면서 아무것도 안 한다 — 어디서부터 봐야 할지조차 안 알려준다. 이 회귀는 6월 들어 세 번째 라운드였고, 매번 다른 원인 후보로 넘어왔다.
1부 — prod 로그 없이, 코드만 보고 9개 가설
프론트는 결정을 하나 내렸다. 백엔드 prod 로그를 기다리지 말고, 지금 가진 것(코드)으로 진단을 시작한다. 운영자의 adb logcat 캡처를 기다렸으면 차단이 며칠 늦었을 것이다.
그래서 코드만 보고 약 9개 가설을 의심 정도 순으로 ranking했다. 1순위는 둘이었다:
- 병렬 동기화 race — 자동 동기화와 수동 버튼이 동시에 동기화를 시작하면, 서버가 첫 세션을 interrupt하고 모든 대기 항목을 새 세션으로 rebind → 첫 세션의 완료 처리가 조용한 no-op이 된다.
- 보이지 않는 시작 실패 — 동기화 시작이 실패해도 콘솔에만 찍히고 조용히 반환. 사용자도 로그도 모른다.
9개 후보가 전부 프론트 단독으로 고칠 수 있는 결함 카테고리에 있었다. 그래서 그 중 셋을 한 PR로 한꺼번에 닫았다 — 병렬을 막는 single-entry 락, 취소 플래그가 모든 백그라운드 리스너를 영구히 침묵시키던 버그 제거, 그리고 마지막 동기화 에러를 UI에 노출.
두 진단이 같은 prod 로그에서 만났다
며칠 뒤, 백엔드가 prod 구조화 로그와 DB 집계를 들고 왔다. 세 가지가 한 번에 결판났다.
- 새 사진이 서버에 도달조차 안 했다. 클라이언트가 보낸 목록에 새 항목이 하나도 없었다. 즉 백엔드는 깨끗했고, 문제는 전적으로 클라이언트가 새 사진을 보내지 않는다는 데 있었다.
- 라이브러리가 client scan cap을 넘었다. 클라이언트는 성능을 위해 고정 크기 윈도우(상위 일부)만 스캔한다. 라이브러리가 그 cap을 넘어가면, 그 윈도우가 라이브러리 전체를 더는 덮지 못한다. 운영자가 찾던 — 안 올라오던 — 사진이 그 윈도우 밖에 있으면, 클라이언트 스캔에 아예 안 보여서 서버에 보낼 목록에 들어가지도 못한다. prod 증거(라이브러리가 cap을 넘었고, 새 항목이 서버에 하나도 도달 안 함)가 정확히 그 상황을 가리켰다.
- 세션 interrupt가 prod에서 관측됐다. 프론트 단독 ranking의 1순위(병렬 race)가 그대로 확인됐다.
프론트가 코드만 보고 세운 가설과 백엔드가 prod 데이터로 확정한 결론이, 같은 두 갈래로 정확히 만났다.
진짜 원인은 race가 아니었다
여기가 이 사건의 핵심이다. 병렬 race는 진짜 버그였지만 사용자의 버그는 아니었다.
- race는 secondary — 운영자가 체감하던 불안정(가끔 멈춤·중복 세션)을 만들었고, 프론트 fix로 닫혔다.
- scan cap coverage gap이 root — “완료인데 0장”의 진짜 원인. race를 닫아도 새 사진은 여전히 안 올라온다.
운영자에게 race fix를 배포한 뒤 보고가 그대로였다: “여전히 새 사진은 안 올라온다.” 예상한 결과였다. 진짜 버그 하나를 닫는 것과, 사용자가 신고한 그 버그를 닫는 것은 다른 일이다. 둘 다 필요했다.
2부 — root cause fix, 그리고 리뷰어가 잡은 데이터 분포 버그
root cause를 닫는 방법은 명확했다. “최신 N장”이라는 고정 윈도우 대신, cursor로 라이브러리 전체를 청크 단위로 walk하면 된다. 한 번에 한 세션만 열고, 그 세션을 완주한 뒤 다음 청크로. (백엔드는 이미 hash를 idempotent하게 처리하니 백엔드 변경은 0.)
v1을 짜서 리뷰에 올렸다. 그리고 여기서 오늘 가장 의미 있는 일이 일어났다 — 독립 LLM 리뷰어가 결함 하나(D1)를 잡았다: 이 cursor는 tie-safe가 아니다.
cursor의 키를 타임스탬프 하나로만 쓰면, 같은 시각에 청크 크기보다 많은 사진이 몰릴 때 문제가 생긴다. 외부 메신저 앱에서 사진을 일괄로 import하거나, 클라우드에서 한꺼번에 복원하면 — 수백·수천 장이 같은 타임스탬프를 갖는다. 이 “plateau”가 청크보다 크면:
- 첫 청크가 그 타임스탬프의 일부만 가져온다(어떤 일부인지는 native 구현에 달렸다).
- 다음 호출이 또 어떤 일부를 가져온다. 운 나쁘면 같은 청크가 반복된다.
- 그러면 “새 건 0장, 받아온 건 가득” 분기에 걸려 cursor를 그 타임스탬프 아래로 strict-advance 한다.
- plateau 안쪽의 나머지 사진은 영원히 못 보게 된다.
이건 lint도, typecheck도, 기존 단위테스트도 못 잡은 결함이다 — 그 데이터 분포를 모델링한 테스트 없이는 안 보인다. 코드 본문만 봐서는 안 보인다 — “타임스탬프가 unique하다”는 적히지 않은 가정에 의존했고, 그 가정이 깨지는 시나리오를 데이터의 분포를 상상해야 비로소 보인다.
v2 — plateau 가정 자체를 없앤다
리뷰어 지적을 받아 v2로 갔다:
- cursor를 compound로 — 타임스탬프 하나가 아니라 (타임스탬프, collection 내에서 단조 증가하는 id).
- native 쿼리에 2차 정렬을 추가해 같은 시각 안에서도 결정적 순서를 만든다.
- strict-less selection으로, 다음 청크가 직전 청크의 마지막 (시각, id) 바로 아래부터 시작하게 한다.
- 이미지와 동영상은 id 공간이 달라 cursor를 합칠 수 없으므로, 두 단계로 나눠 walk한다.
이 fix의 좋은 점은 plateau 크기와 무관하게 forward progress를 보장한다는 것이다. plateau가 청크보다 아무리 커도, 각 청크가 strict-less로 다음 영역으로 정확히 한 칸 밀려나기 때문에 누락이 없다. plateau 크기 가정 자체가 사라진다. (그리고 plateau 시나리오를 명시적으로 거는 단위테스트를 함께 박았다 — 바로 그 데이터 분포를 모델링한 테스트다.)
가져갈 것
- 조용한 성공이 가장 위험하다. “완료” 토스트 + 0장 업로드. 에러가 안 보이면 1차 의심은 입력이 조용히 잘렸는가 — scan cap 같은 윈도우가 데이터를 가렸는가 — 여야 한다.
- 진짜 버그를 닫는 것 ≠ 사용자의 버그를 닫는 것. secondary(체감되는 불안정)와 root(데이터로만 보이는 누락)를 분리하라. 둘 다 닫아야 신고가 사라진다.
- prod 데이터를 기다리지 말고 코드로 가설을 ranking하라 — 단, 도착할 데이터가 그 가설을 retroactively 입증할 확률이 높을 때. 우리 1순위 가설은 나중에 prod 로그에서 그대로 보였다.
- LLM 리뷰어의 진짜 값은 적히지 않은 가정을 의심하는 데 있다. lint는 타입을, typecheck는 시그니처를, 테스트는 적힌 가정을 본다. “타임스탬프가 unique하다” 같은 암묵적 가정이 깨지는 데이터 분포는, 그걸 상상하는 리뷰어만 잡는다. v1을 그냥 머지했으면 운영자 빌드 → 재발견 → 다음 라운드의 비용을 그대로 치렀을 것이다.
저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.
Tags: debugging, silent-failure, code-review, frontend, data-distribution