도입
어느 날 Crashlytics를 열었더니 캘린더 화면 한 곳에서만 4주간 98건이 쌓여 있었다. 신호는 EXC_BREAKPOINT. 영향 사용자는 15명. 같은 사용자가 평균 9~10번씩 죽는 SIGNAL_REPETITIVE 패턴.
스택은 단순했다.
at closure #1 in CalendarViewModel.filter(date:)
at CalendarViewModel.filter(date:) (CalendarViewModel.swift:177)
문제의 한 줄.
| |
ClosedRange의 precondition 강제
Swift 표준 라이브러리의 ClosedRange.init(uncheckedBounds:)가 아니라 lower...upper 슈가를 쓰면, 컴파일러는 Range._precondition(lower <= upper)를 함께 호출한다. lower > upper면 즉시 trap. iOS 런타임에선 EXC_BREAKPOINT로 죽는다.
이게 평소엔 안 보인다. startOfDay() / endOfDay()처럼 보장된 함수로 만들면 안전하다. 문제는 사용자/서버 데이터가 들어오는 순간이다.
| |
한참 더 깊었던 Root cause
크래시 지점은 표면이었다. 진짜 원인은 데이터 매핑 단계에 있었다.
| |
함수 진입 첫 줄에서 dtEnd.isValid.not인 경우를 dtStart로 폴백하는 로컬 변수를 만들어 둔다. 그런데 그 변수는 endMinute 계산할 때만 쓰이고, 정작 snippet.end에는 raw event.dtEnd가 들어간다.
서버가 어느 순간부터 일부 일정에 대해 dtEnd를 누락해서 내려보내기 시작했다. event.dtEnd는 .invalidDate (1970-01-01)이 됐다. snippet.start = 2026-05-25, snippet.end = 1970-01-01. 그리고 trap.
두 겹 가드
데이터 레이어에서 한 번, 표현 레이어에서 한 번.
레이어 1 — root cause fix
| |
max(event.dtStart, resolvedDtEnd)로 폴백하면 invalid도 막고, end < start인 비정상 데이터도 end == start로 정규화돼서 빠진다.
레이어 2 — 표현 레이어 가드
Date extension에 하나 추가했다.
| |
필터 closure 진입부에서 한 번 더 막는다.
| |
데이터 레이어가 뚫려도 표현 레이어에서 안 죽는다.
교훈
a...b 슈가는 입력이 신뢰될 때만 안전하다. 사용자/서버 데이터가 들어오면 무조건 guard a <= b 또는 옵셔널 반환 헬퍼를 거쳐야 한다.
invalid sentinel 가드는 한 곳만 막으면 의미 없다. 함수 상단에서 정성스럽게 isValid ? dtEnd : dtStart 해놓고 정작 snippet.end에는 raw 값을 넣으면, 그 가드는 코드 리뷰 통과용 장식이 된다. 가드를 만들었으면 그 함수 안 모든 사용처에서 가드된 변수를 써야 한다.
Crashlytics의 SIGNAL_REPETITIVE + 적은 영향 사용자 = 특정 데이터 트리거. 같은 사용자가 9~10번씩 죽는 패턴은 보통 “특정 깨진 row가 사용자 DB에 누적된” 신호다. 코드 변경이 없는데 갑자기 크래시가 늘었다면 데이터 매핑 단계부터 의심해야 한다.
서버 탓하기 전에… 클라 매핑 한 번 더 보자. “갑자기 서버가 dtEnd 안 내려준다"가 첫 가설이었고, 이슈 트래커까지 뒤졌다. 결국 같은 코드가 운영에 있던 거였고, 서버 변화는 그냥 트리거였다. 우리 매핑이 그 트리거를 못 받아낸 게 진짜 버그였다.