이 숫자, 초인가요 밀리초인가요? (코드는 안 물어보고 그냥 비교합니다)

지난번에는 폰이 걸음을 어떻게 세는지 썼는데, 오늘은 같은 라이브러리에서 걸음이 아니라 시간 때문에 생긴 이야기를 해보려고 합니다.
제 걸음 수 라이브러리 react-native-step-counter의 v0.3.0 CHANGELOG에는 "iOS timestamp unit safety"라는 한 줄이 있습니다.
ios/StepCounter.mm에 들어간 가드 두 개 이야기인데, 저 무미건조한 한 줄 뒤에 있는 게 오늘의 주제입니다.
증상부터 말하면 이렇습니다. 걸음 수 세션을 시작합니다. 에러가 안 납니다. 크래시도 안 납니다. 로그도 깨끗합니다. 그런데 걸음이 하나도 안 세집니다.
조용한 실패라는 장르
이런 버그에는 공통된 형태가 있습니다. 서로 다른 단위로 측정된 두 타임스탬프를 같은 단위인 척 비교하는 것입니다.
여기서 중요한 건, 이 비교식이 고장 나지 않는다는 점입니다. 파싱도 되고, 실행도 되고, 심지어 매번 결정론적인 답을 냅니다. 다만 두 피연산자가 애초에 비교 가능한 물건이 아니라서, 그 답이 아무 의미가 없을 뿐이죠.
컴파일러는 이걸 못 잡습니다.
double과 double을 비교하는데 뭐가 문제냐는 겁니다.
(응 네 값에 담긴 단위 우리는 모름~)
그래서 이 버그는 터지지 않고 조용히 틀린 답을 계속 내놓습니다.
왜 하필 1e12인가
제가 넣은 가드는 이겁니다: from > 1e12.
처음 보면 좀 임의로 고른 숫자 같습니다. "큰 값이면 밀리초겠지" 하는 대충 감으로 찍은 매직 넘버처럼 보이거든요. 그런데 이 숫자는 감이 아니라 달력에서 나온 값입니다.
유닉스 epoch를 초로 세면 지금 우리는 대략 10⁹ 자리에 있습니다.
2001년 9월에 1e9를 넘었고, 1e10을 넘으려면 서기 2286년쯤까지 기다려야 합니다.
즉 앞으로 260년쯤은 초 단위 타임스탬프가 1e10을 못 넘습니다.
같은 시각을 밀리초로 세면 자릿수가 통째로 세 칸 밀립니다.
1e12 밀리초가 2001-09-09 01:46:40 UTC거든요.
그러니까 밀리초 타임스탬프는 2001년 9월 이래로 계속 1e12 위에 있었고, 앞으로도 한참 그럴 겁니다.
두 사실을 겹쳐보면 결론이 나옵니다.
1e12보다 큰 값은, 현 시대에 그럴듯한 "초 단위 타임스탬프"일 수가 없다.
1e10(서기 2286년)보다도 100배 큰, 초로 읽으면 아득한 미래가 나오는 값이니까요.
그런 값이 들어왔다면 거의 확실히 JS에서 넘어온 밀리초입니다.
Date.now()는 밀리초를 주는데, 받는 쪽인 [NSDate dateWithTimeIntervalSince1970:]은 초를 기대하거든요.
경계를 건너면서 아무도 1000을 안 나눠준 겁니다.
그래서 1e12를 넘으면 1000으로 나눕니다.
그러면 오늘 기준 앞뒤로 수천 년 동안, 호출자가 의도한 값이 조용하고 정확하게 복구됩니다.
매직 넘버처럼 생겼지만 실은 두 단위의 값 범위가 겹치지 않는 구간을 고른 것이라, 꽤 넓은 안전 마진이 딸려 있는 셈입니다.
그런데 단위만 고쳐서는 부족했습니다
여기서 끝났으면 좋았을 텐데요.
이건 제가 지어낸 서사가 아니라 커밋 히스토리에 그대로 남아 있습니다. 같은 날 아침 5시 58분 커밋에 단위 감지 가드가 들어갔고, 미래 클램프는 2시간 13분 뒤 다른 커밋에 따로 붙었습니다. 그러니까 저는 단위를 고치고 나서 두 시간쯤 지나서야 "아 이걸로는 부족하구나"를 깨달은 거죠. (반년 뒤에 이 글을 쓰려고 히스토리를 다시 열어보고 알았습니다. 그때의 저는 저 두 시간 동안 뭘 봤을까요.)
단위 자동 감지는 실수로 밀리초를 넘긴 경우를 고칩니다.
하지만 실수로 너무 큰 초 값을 넘긴 경우는 못 고칩니다.
1e12를 안 넘는 값이니 가드에 안 걸리고 그냥 통과하거든요.
병리적이거나 버그 있는 호출자는 30분쯤 미래의 epoch-초 값을 넘길 수 있습니다. NTP가 어긋난 기기의 시계에서 왔을 수도 있고, "세션 시작 시각"을 계산하면서 순진하게 큰 오프셋을 더했을 수도 있고요.
그리고 여기가 진짜 함정입니다. CMPedometer는 미래 날짜를 no-op으로 취급합니다. 아직 일어나지 않은 일을 보고할 수는 없으니 당연한 동작이죠. 아직 걷지도 않은 걸음을 내놓으라고 하면 애플도 곤란할 겁니다.
그러니까 미래에서 시작한 세션은 실패하지도 않고, 경고도 안 하고, 그냥 아무 데이터도 안 만듭니다. 맨 위에서 말한 그 증상, "에러도 없는데 걸음이 0"이 여기서 나옵니다.
그래서 60초짜리 미래 클램프를 하나 더 붙였습니다.
from이 now + 60보다 작거나 같으면 받아들이고, 크면 그냥 now로 눌러버립니다.
NTP 동기가 살짝 어긋난 기기의 정당한 시계 오차 정도는 봐주되, 임의로 먼 미래에서 세션이 시작되는 건 거부하는 겁니다.
두 가드를 합치면 이렇게 생겼습니다.
RCT_EXPORT_METHOD(startStepCounterUpdate:(double)from) {
// Stop any in-progress pedometer session before starting a new one.
[self.pedometer stopPedometerUpdates];
// --- Timestamp guard (ms vs seconds) ---
// Valid Unix timestamp in seconds is ~1.7–2.0e9 (2024–2033),
// while milliseconds is ~1.7–2.0e12.
double fromSeconds = (from > 1e12) ? (from / 1000.0) : from;
// Extra sanity: if caller accidentally passes ms-but-small (unlikely) or future timestamps,
// clamp to "now" to avoid "future date" sessions.
NSDate *now = [NSDate date];
NSDate *requested = (fromSeconds > 0) ? [NSDate dateWithTimeIntervalSince1970:fromSeconds] : now;
if ([requested timeIntervalSinceDate:now] > 60.0) { // > 60s into the future
requested = now;
}
// ... 이후 세션 상태 초기화
주석에 제가 적어둔 범위가 앞에서 길게 설명한 그 이야기입니다.
초로 읽으면 1.7–2.0e9(2024~2033년), 밀리초로 읽으면 1.7–2.0e12.
두 범위가 안 겹치니까 1e12 하나로 가를 수 있는 겁니다.
fromSeconds > 0 삼항 연산자도 하나 더 있는데, 0이나 음수가 들어오면 아예 now로 대체합니다.
가드가 결국 세 개인 셈이죠. (하나 붙일 때마다 "이건 좀 과한가" 싶었는데, 지나고 보니 셋 다 서로 다른 사고를 막고 있습니다.)
두 가드가 굳이 한 함수에 같이 들어간 이유가 있습니다. 과거에서 시작한 세션은 의미 있는 걸음 수를 보고하지만, 미래에서 시작한 세션은 조용히 아무것도 안 만들기 때문입니다. 단위를 고쳐도 방향이 틀리면 결과는 똑같이 0이거든요.
더 고약한 사촌: 밀리초 대 마이크로초
제 라이브러리 얘기는 여기까지고, 이제 남의 코드를 뜯어보다 만난 사례를 하나 소개하겠습니다. XMPP 기반의 어떤 오픈소스 채팅 컴포넌트인데, 그쪽 버그 수정 원장에 아주 교과서적인 케이스가 남아 있습니다.
안 읽은 메시지를 세는 roomsSlice.countNewerMessages에 버그가 두 개 겹쳐 있었다고 합니다.
하나는 방향 반전입니다.
"이 시각보다 나중에 온 메시지"를 세야 하니 >가 필요한데 <를 쓰고 있었습니다.
다른 하나가 오늘의 주제, 단위 불일치입니다.
Number(message.id)를 밀리초 타임스탬프인 것처럼 썼는데, XMPP message.id에는 마이크로초 프리픽스가 붙어 있었거든요.
1차 수정은 결국 이 한 줄이었습니다.
// 밀리초 대 밀리초, 그리고 방향도 맞다
new Date(msg.date).getTime() > timestamp;
다만 이게 최종본은 아닙니다. 이후 릴리스에서 비교 소스가 한 번 더 바뀌는데, 그 이야기는 조금 뒤에 나옵니다.
왜 아무도 못 알아챘을까
이 케이스가 흥미로운 건 수정 내용이 아니라, 왜 이게 그렇게 오래 조용했는가입니다. 이유가 세 개 겹쳐 있습니다.
첫째, 잘못된 비교식이 결정론적이었습니다. 카운트가 들쭉날쭉했다면 누구든 금방 의심했을 텐데, 아주 안정적이었습니다. 안정적으로 틀렸을 뿐이죠.
둘째, 마이크로초 값이 밀리초 값을 항상 압도적으로 초과했습니다. 자릿수가 세 칸 차이 나니까 모든 비교가 예외 없이 같은 답을 반환했습니다. 그 결과 증상이 "안 읽음 카운터가 잘못 계산된다"가 아니라 **"안 읽음 카운터가 절대 리셋되지 않는다"**처럼 보였습니다. 후자는 단위 버그가 아니라 상태 관리 버그처럼 들리잖아요. 수사망이 처음부터 엉뚱한 동네로 갔던 겁니다.
셋째, 하필 같은 시기에 리듀서/미들웨어 분리와 관련된 다른 버그가 동시에 활성이었습니다. 그 불일치가 만드는 잡음이 단위 문제를 가려주고 있었고요. (버그가 둘일 때 하나가 다른 하나의 알리바이가 되는 상황, 겪어보신 분들은 아실 겁니다.)
Date.now() 폴백이라는 함정
같은 라이브러리에서 하나 더 배울 게 있습니다.
아까 "최종본이 아니다"라고 한 게 이 대목입니다.
new Date(msg.date).getTime()으로 고친 뒤, 이후 릴리스에서 비교 소스를 한 번 더 옮깁니다.
사실 msg.id에 박힌 서버 권위 시간 성분을 의도적으로 정렬 키로 쓰는 건 정당한 기법입니다.
서버가 매긴 시각이라 클라이언트 시계 드리프트에 안 흔들리니까요.
다만 조건이 하나 붙습니다 — 양쪽이 반드시 같은 추출기를 통과해야 한다는 것.
msgSortableMs(msg) 같은 이름 붙은 헬퍼 하나로 모아두면, 단위가 뭐든 최소한 양쪽이 같은 물건을 봅니다.
그런데 그쪽 코드에는 createMessageFromXml 안에 msg.date용 Date.now() 폴백이 숨어 있었습니다.
그래서 어떤 코드 경로에서는 안 읽음 리듀서와 안 읽음 미들웨어가 같은 메시지를 두고 서로 다른 타임스탬프를 비교하고 있었습니다.
수정은 결국 양쪽 모두 하나의 결정론적 추출기를 보게 만드는 것이었고요.
여기서 얻어갈 교훈은 이겁니다.
모델 생성자 안에 숨은 Date.now() 폴백을 조심하세요.
그건 편의 기능처럼 생겼지만, 실제로는 서버 시계로부터의 드리프트를 내가 한정할 수 없는 병렬 시간 소스를 조용히 들여오는 짓입니다.
그래서, 어떻게 막나
가장 확실한 건 코드에게 단위를 알려주는 겁니다. 타입 시스템이 받쳐주는 언어라면 브랜디드 타입으로 단위를 박아버리면 됩니다.
type Ms = number & { __unit: "ms" };
type Us = number & { __unit: "us" };
이러면 잘못된 비교가 런타임까지 못 가고 타입 체크에서 죽습니다.
리뷰에서 잡는 방법도 있습니다.
Number(messageIdField)나 parseFloat(idField) 같은 표현식을 단위 세탁(unit laundering) 냄새로 취급하세요.
id 문자열을 숫자로 바꾸는 순간, 그 안에 어떤 단위가 들어 있었는지에 대한 정보는 증발합니다.
그 id가 무슨 단위를 담고 있는지 확인한 뒤에만 통과시키면 됩니다.
그리고 임계값 자동 감지 — 그러니까 제가 위에서 쓴 1e12 트릭 — 에 대해서는 솔직히 짚고 넘어가야 할 게 있습니다.
저건 만능이 아닙니다.
임계값이 작동하는 조건은 자릿수 차이가 아니라, 여러분 도메인에서 실제로 들어올 수 있는 두 단위의 값 범위가 서로 겹치지 않는 것입니다.
이게 왜 까다롭냐면, 자릿수만 놓고 보면 초 대 밀리초(10⁹ 대 10¹²)나 밀리초 대 마이크로초(10¹² 대 10¹⁵)나 똑같이 세 칸 차이거든요. 차이를 만드는 건 입력이 어디까지 넓어질 수 있는가입니다.
구체적으로 보죠.
1700000000000이라는 값은 밀리초로 읽으면 2023년 11월 14일이지만, 마이크로초로 읽으면 1970년 1월 20일입니다.
즉 "요즘 날짜의 밀리초"와 "epoch 초기 날짜의 마이크로초"가 숫자로 완전히 같은 자리에 앉습니다.
입력이 현재 시각 근처로만 들어온다는 보장이 있으면 임계값이 둘을 갈라주지만, 오래된 날짜까지 섞여 들어오는 순간 그 보장이 깨집니다.
제 1e12 가드가 성립하는 것도 이 라이브러리의 입력이 "방금 시작한 세션의 시작 시각" 이라는 아주 좁은 도메인이기 때문입니다.
그러니 이 트릭을 보고 "아 단위 문제는 임계값으로 때우면 되는구나" 하고 가져가시면 안 됩니다.
먼저 물어보셔야 할 건 자릿수가 아니라 "내 입력의 범위가 어디까지 넓어질 수 있나" 입니다.
더 튼튼한 장기 자세는 경계에 타입 계약을 두는 겁니다.
TurboModule 스펙에서 인자를 seconds: Double 대 milliseconds: Double처럼 이름과 타입으로 선언해두면, codegen이 잘못 부른 호출자를 컴파일 타임에 실패시킵니다.
런타임에 숫자를 노려보는 것보다 이쪽이 훨씬 마음 편하죠.
마무리 — 자기 이름을 안 달고 다니는 숫자들
정리하면, JS ↔ 네이티브 경계에서 epoch 값을 주고받는 모든 지점이 이 버그의 후보입니다. 백그라운드 페치 스케줄링, 알람 타이밍, 세션 시작/종료 보고, 과거 데이터 조회. 두 런타임이 서로 다른 단위로 시간 값을 넘긴다면 선택지는 둘 중 하나입니다. 양쪽에 타입 단위 계약을 강제하거나, 아니면 잘 고른 숫자 임계값으로 자동 감지하거나.
이 버그 계열 자체는 모든 시간 단위 조합에 대칭적으로 존재합니다 — ms 대 s, ms 대 µs, ms 대 ns까지.
그런데 실무에서 압도적으로 자주 만나는 건 ms 대 µs입니다.
마이크로초 타임스탬프는 XMPP id 필드, RTP/SIP 트레이스, 일부 DB의 clock_timestamp() 출력처럼 여기저기서 튀어나오는데, 그 주변을 감싼 JS나 React Native 코드는 기본이 밀리초거든요.
서로 다른 단위를 쓰는 두 세계가 맞닿는 자리에 버그가 앉는 겁니다.
돌아보면 재밌는 건, 이 모든 게 숫자가 자기 단위를 안 달고 다니기 때문이라는 점입니다.
장 볼 때는 "500g"이라고 붙어 있으니까 아무도 헷갈리지 않는데, 코드 안에서는 그냥 1700000000000이 굴러다닙니다.
저게 초인지 밀리초인지는 오직 이 값을 만든 사람 머릿속에만 있었고, 그 사람은 6개월 뒤의 자기 자신도 아니고요.
그래서 브랜디드 타입이든 함수 이름이든, 단위를 값에 붙여두는 모든 시도는 결국 미래의 나에게 라벨을 붙여주는 일입니다.
(저는 이걸 가드를 두 개나 붙이고 나서야 문장으로 정리할 수 있었습니다. 꺄하하.)
혹시 지금 여러분 코드에서 두 시각을 비교하는 줄이 있다면, 한 번만 물어봐 주세요. "이 둘, 정말 같은 단위 맞아?" 십중팔구는 맞을 겁니다. 그리고 아닌 그 한 번이, 아마 지금 여러분을 제일 오래 괴롭히고 있는 그 버그일 거예요.