Firebase 상태 페이지가 초록인 동안 iOS 크래시의 범위는 GitHub 댓글에서 나왔다
iOS 앱이 한꺼번에 죽고 있을 때, 안드로이드는 괜찮은지를 누가 답해 줄 수 있을까요. 오늘(2026년 9월 29일) 아침 Firebase Analytics 를 넣은 iOS 앱들이 앱 업데이트도 없이 동시에 크래시를 내기 시작했고, 저는 출근길에 두 채팅방에서 거의 동시에 그 소식을 받았습니다.
하나는 iOS 와 안드로이드를 같이 하는 옆 팀 쪽이었습니다. 옆 팀이 실장에게 먼저 보고를 올렸습니다. 같은 무렵 슬랙에서는 iOS 팀이 크래시를 보고하고 있었습니다. 첫 크래시가 한국 시각으로 오전 9시 41분이었으니 딱 출근하는 시간대에 터진 셈입니다.
실장은 곧바로 각 팀에 상황을 전파했습니다. 크래시가 난 iOS 팀에는 현재 상황을 보고해 달라고 했고, 안드로이드 팀에는 이슈가 없는지 확인해 달라고 했습니다. 옆 팀에도 확인을 요청했습니다.
이 글은 그 두 번째 요청에서 출발합니다. 안드로이드 팀에 간 질문은 「무엇이 원인이냐」가 아니라 「너희도 그러냐」였습니다. 그 질문에 답해야 할 자리가 어디였는지를 공개된 기록을 따라가며 보려고 합니다.
실장은 iOS 팀에 현황을, 안드로이드 팀에 이상 유무를 물었습니다
두 요청을 나란히 놓으면 모양이 다릅니다. 크래시가 난 팀에는 현황을 묻고, 조용한 팀에는 괜찮은지를 묻습니다. 어느 쪽에도 원인을 묻는 말은 없습니다.
그런데 두 질문은 같은 것을 향합니다. 이 장애가 어디까지냐입니다. iOS 팀의 현황 보고는 「우리 쪽에서 얼마나 크게 터졌나」를 채우는 답이고, 안드로이드 팀의 확인은 「다른 플랫폼으로 번졌나」를 채우는 답입니다. 옆 팀에 간 확인 요청도 같은 질문을 다른 서비스 쪽에서 채우려는 것입니다. 원인은 그다음입니다.
장애가 나고 첫 한 시간 동안 조직이 하는 일은 대부분 범위를 확정하는 일이라고 저는 보고 있습니다. 범위가 정해져야 그다음 결정이 서기 때문입니다. 누가 움직여야 하는지, 사용자에게 공지를 해야 하는지, 되돌릴 배포가 있는지, 다른 팀을 깨워야 하는지가 전부 범위에 걸려 있습니다. 원인은 고치는 사람에게 필요한 정보이고, 범위는 결정하는 사람에게 필요한 정보입니다. 실장의 자리에서 먼저 필요한 것은 뒤쪽입니다.
그리고 범위라는 말 안에는 질문이 여러 개 들어 있습니다. 우리 앱만인가 다른 앱도인가. iOS 만인가 안드로이드도인가. 특정 버전만인가 전부인가. 사용자가 앱을 아예 못 쓰는가 한 번 튕기고 마는가. 언제까지 계속되는가. 오늘 아침 실장이 보낸 요청들은 그중 앞쪽 몇 개를 팀 단위로 나눠서 물은 것이었습니다.
문제는 이 질문들 대부분이 우리 쪽에서 답할 수 없는 종류였다는 점입니다.
Firebase 상태 페이지는 롤아웃이 끝난 뒤에도 「No incidents」였습니다
서드파티 SDK 에서 장애가 나면 가장 먼저 떠올리는 곳은 벤더의 상태 페이지입니다. 벤더가 자기 서비스의 상태를 직접 공지하는 자리이고, 「너희도?」에 대한 공식 답이 있다면 거기 있어야 합니다.
Firebase 상태 페이지는 이 글을 쓰는 시점까지 「No incidents」입니다. 구글이 롤아웃을 마쳤다고 밝힌 뒤에도 마찬가지입니다. 상태 페이지가 읽어 가는 장애 기록 JSON을 열어 보면 오늘 날짜 항목이 아예 없습니다. 가장 최근 항목은 9월 22일 Test Lab 건입니다.
그 목록에서 눈에 걸린 항목이 하나 있습니다. 9월 16일에 올라간 「Crashlytics and Performance Monitoring consoles experiencing low availability」입니다. Crashlytics 와 Performance Monitoring 콘솔이 잘 안 열렸다는 기록입니다. 개발자가 보는 대시보드가 느려진 일은 장애로 올라가 있습니다.
콘솔이 느린 일은 벤더의 장애이고, 고객 앱이 죽은 일은 벤더의 장애 목록에 없습니다.
왜 그렇게 갈리는지는 상태 페이지가 무엇을 재는 물건인지를 생각하면 어느 정도 짐작이 됩니다. 상태 페이지는 벤더가 운영하는 서비스가 제대로 돌고 있는지를 알리는 자리입니다. 콘솔은 구글이 운영하는 화면이니 그게 안 열리면 구글의 서비스가 멈춘 것입니다. 오늘 크래시를 일으킨 서버는 멈추지 않았습니다. 요청을 받았고 응답을 돌려줬습니다. 돌려준 응답 안에 이름이 빠진 항목이 하나 들어 있었을 뿐입니다. 서버 입장에서 보면 정상 응답이고, 죽은 것은 그 응답을 받아 든 고객의 앱입니다. 이건 제가 상태 페이지의 기준을 바깥에서 추측한 것이고, 구글이 이렇게 설명한 적은 없습니다. 오늘 장애에 대한 공식 RCA 도 아직 나오지 않았습니다.
상태 페이지를 사람이 직접 열어 보는 경우만 있는 것도 아닙니다. 벤더의 장애 피드를 슬랙 채널에 붙여 두고 새 항목이 올라오면 알림을 받는 팀이 많은데, 그 피드가 읽는 것이 바로 위의 장애 기록입니다. 기록에 항목이 없으니 그런 알림도 오늘은 한 번도 울리지 않았을 것입니다. 사람이 보든 봇이 보든 같은 초록을 봤다는 뜻입니다.
어쨌든 결과는 같습니다. 오늘 아침 「안드로이드도 괜찮냐」에 답하려고 벤더의 공식 채널을 본 사람은 아무것도 얻지 못했습니다. 얻지 못한 것보다 더 곤란한 것은 초록 불이 켜져 있었다는 점입니다. 비어 있으면 다른 데를 찾아보는데, 초록이면 「벤더 쪽은 멀쩡하다」로 읽힙니다. 그러면 원인을 우리 쪽에서 찾기 시작합니다.
이슈에 달린 댓글 중에 바로 그 이야기가 있었습니다. 상태 페이지가 초록이어서 한참을 자기 코드에서 디버깅하다가, 그제서야 묻혀 있던 이슈를 찾았다는 댓글입니다.
GitHub 이슈 #16728 에서 범위가 먼저 나왔습니다
그 묻혀 있던 이슈가 firebase/firebase-ios-sdk#16728입니다. 제목은 크래시 메시지 그대로입니다.
[Analytics] Crash after sdk-exp response: -[__NSDictionaryM setObject:forKeyedSubscript:]: key cannot be nil
이슈는 첫 크래시 18분 뒤인 오전 9시 59분에 올라왔습니다. 라벨은 api: analytics 와 priority: p0 입니다. 이슈 트래커에서는 최우선 순위가 붙은 일이 같은 회사의 상태 페이지에서는 장애로 잡히지 않았다는 뜻입니다. 저는 이 두 기록의 간격이 오늘 장애의 모양을 가장 잘 보여 준다고 봅니다. 그 직후부터 「우리도」 댓글이 쏟아졌고, 댓글은 400개를 넘었습니다. 이름을 밝힌 대형 서비스도 여럿 보였고 한국 개발자 댓글도 여럿 있었습니다. 영향받은 앱이 수백 개 단위였다는 데는 의심의 여지가 없어 보입니다.
이 이슈를 시간 순서로 따라가면 그 「우리도」 사이에 성격이 다른 댓글 몇 개가 끼어 있습니다.
오전 10시 37분에 한 외부 개발자가 빈 앱으로 크래시를 재현했습니다. 그리고 앱이 받은 서버 응답을 직접 디코딩했습니다. 응답은 https://app-analytics-services.com/sdk-exp 에 대한 225바이트짜리 protobuf 였고, 그 안에 name 필드가 없는 플래그 항목이 하나 들어 있었습니다. SDK 는 플래그 이름을 딕셔너리 키로 쓰니까, 이름이 없으면 nil 을 키로 넣으려다 NSInvalidArgumentException 으로 죽습니다. 이슈 제목의 에러 메시지가 정확히 그 자리입니다.
2분 뒤인 10시 39분에는 다른 개발자가 합성한 protobuf 로 오프라인 재현을 올렸습니다. 이름이 빠지거나 UTF-8 이 깨진 항목을 넣으면 죽는다는 것을 네트워크 없이 보였습니다.
이 재현이 중요했던 이유는 문제를 데이터 하나로 좁혔기 때문입니다. 네트워크 타이밍도 기기 상태도 앱 설정도 상관없고, 그 응답 바이트만 있으면 죽습니다. 그러면 남는 질문은 「그 바이트를 누가 받았나」 하나입니다. 범위를 묻는 질문이 원인 분석 한 줄로 바뀌는 지점이 여기였습니다.
범위 질문의 답은 이 두 댓글과 그 뒤를 이은 분석에서 나왔습니다.
빈 앱으로 재현됐고, 앱이 보낸 요청에 앱별로 구분되는 정보가 없었습니다. 그렇다면 같은 시각에 이 엔드포인트를 부른 iOS 앱은 전부 같은 응답을 받았다는 뜻입니다. A/B Testing 이나 Remote Config 를 쓰지 않아도, Analytics 만 넣었으면 해당합니다. 「우리 앱만인가」의 답이 여기서 나왔습니다. 아닙니다. Analytics 를 쓰는 모든 iOS 앱입니다.
영향 버전도 댓글에서 좁혀졌습니다. SDK 10.22 부터 12.19.2 까지 전 버전이 같은 경로를 탑니다. 최신으로 올린다고 피할 수 있는 문제가 아니었습니다. 「특정 버전만인가」의 답도 나왔습니다.
사용자에게 어떻게 보이는지도 댓글에서 나왔습니다. SDK 가 fetch 성공 시각을 응답을 파싱하기 전에 저장하기 때문에, 한 번 죽고 나면 다음 실행에서는 fetch 를 건너뜁니다. 그래서 크래시 루프가 아닙니다. 새로 설치한 앱은 실행하고 10초에서 30초쯤 지나 한 번 죽고, 다시 켜면 정상으로 돕니다. 「사용자가 앱을 아예 못 쓰나」의 답이 이것이었습니다.
이 답들이 나오면 결정이 따라 섭니다. 모든 앱이 같은 응답을 받았다면 우리 쪽 변경을 뒤질 이유가 없습니다. 전 버전이 해당한다면 SDK 버전을 올린 긴급 빌드도 소용이 없습니다. 크래시 루프가 아니라면 사용자에게 알리는 수위도 달라집니다. 실장이 아침에 팀별로 나눠 물었던 질문들이 채워져야 나오는 판단들인데, 그 재료가 전부 이슈 댓글에 있었습니다.
구글 쪽 첫 댓글은 오전 10시 54분에 달렸습니다. 첫 크래시에서 73분 뒤입니다. 내용은 팀이 인지하고 조사 중이라는 「the team is aware and investigating」 한 줄이었습니다. 그 시점에 외부 개발자들은 이미 문제의 필드를 찾아 두고 재현까지 올려 둔 상태였습니다. 뒤이어 11시 24분에 원인을 찾아 롤백했고 배포 중이라는 댓글이, 12시 16분에 롤아웃이 끝났다는 댓글이 달렸습니다.
구글이 느렸다고 탓하려는 게 아닙니다. 서버 쪽을 되돌리는 일은 구글만 할 수 있고, 롤백 소식은 첫 댓글 30분 뒤에 나왔습니다. 제가 본 것은 역할이 갈린 모양입니다. 고치는 일은 벤더가 했고, 「어디까지냐」에 답하는 일은 벤더 바깥의 개발자들이 했습니다. 그리고 그 답은 공식 채널이 아니라 이슈 댓글에 흩어져 있었습니다.
dispatch_async 큐에서 터진 크래시에는 앱 코드가 한 줄도 없었습니다
돌아보면 이 장애는 우리 쪽에서 범위를 판단하기가 유난히 어려운 모양이었습니다.
크래시 스택부터 그렇습니다. 문제의 딕셔너리는 Firebase 가 쓰는 공용 라이브러리 GoogleUtilities 의 GULMutableDictionary 인데, 이 클래스는 쓰기를 dispatch_async 로 내부 큐에 넘깁니다. 그래서 예외가 SDK 내부 큐에서 터지고, 누가 그 쓰기를 불렀는지는 스택에 남지 않습니다. 크래시 리포트를 열어도 앱 코드 프레임이 하나도 없습니다. 보통 크래시를 보면 제일 먼저 우리 코드가 몇 번째 줄에서 죽었는지를 찾는데, 찾을 줄이 없습니다.
앱 업데이트도 없었습니다. 첫 크래시는 이미 배포돼 있던 빌드 전부에서 동시에 시작됐습니다. 어제 잘 돌던 바이너리가 오늘 아침 죽습니다. 최근 배포를 되돌리자는 선택지가 처음부터 없습니다.
그리고 Analytics 는 앱이 직접 부르는 SDK 가 아닌 경우가 많습니다. 초기화 한 번 해 두면 알아서 이벤트를 모으고 알아서 서버와 통신합니다. 댓글 중에 「우리가 직접 부르지도 않는 SDK 가 백그라운드 큐에서 앱 전체를 내렸다」는 문장이 있었는데, 오늘 장애의 성격을 가장 짧게 적은 문장이라고 봅니다.
이 셋이 겹치면 평소의 추적 순서가 전부 헛돕니다. 장애가 나면 우리는 보통 「우리가 뭘 바꿨나」로 시작합니다. 최근 배포를 보고, 설정 변경을 보고, 크래시 리포트에서 우리 코드가 등장하는 첫 줄을 찾습니다. 오늘 같은 장애에서는 배포 이력에 걸리는 것이 없고, 스택에는 우리 코드가 없습니다. 그렇게 다 뒤져서 얻는 것은 「우리 쪽 문제가 아니다」 정도인데, 그건 범위에 대한 답이 아니라 원인 후보 하나를 지운 것일 뿐입니다. 안드로이드는 괜찮은지, 다른 서비스도 그런지는 여전히 모릅니다.
이런 모양이 처음은 아닙니다. 2020년 7월에 Facebook SDK 가 서버 쪽 변경 하나로 Spotify 와 Pinterest 와 Waze 앱을 한꺼번에 죽인 일이 있었습니다(TechCrunch). 앱은 아무것도 안 바꿨는데 SDK 가 받아 온 서버 응답이 앱을 내렸다는 점에서 오늘과 같은 모양입니다. 오늘 그 모양을 가장 먼저 알아본 곳도 벤더 채널이 아니라 같은 증상을 겪는 사람들이 모인 GitHub 이슈였습니다.
firebase-android-sdk 저장소에는 오늘 올라온 이슈가 없었습니다
처음 질문으로 돌아가면, 안드로이드 팀이 무엇을 보고 어떻게 답했는지는 제가 이 글에 적을 수 없습니다. 제가 확인할 수 있는 것은 공개된 기록뿐입니다.
공개 기록으로는 오늘 크래시는 iOS 에서만 났습니다. firebase-android-sdk 저장소에는 9월 28일 이후 등록된 이슈가 없습니다. 죽은 자리가 iOS 쪽 GoogleUtilities 의 딕셔너리였다는 점과도 앞뒤가 맞습니다.
그런데 이 답도 결국 GitHub 에서 나옵니다. 「iOS 저장소에는 400개 넘는 댓글이 달린 이슈가 있고 안드로이드 저장소에는 아무것도 없다」는 비교가, 오늘 「안드로이드도?」에 댈 수 있는 가장 빠른 근거였습니다. 벤더가 「안드로이드는 영향 없음」이라고 적어 준 곳은 없습니다. 상태 페이지는 애초에 iOS 도 영향이 있다고 말하지 않았으니까요.
다만 이건 없는 것을 근거로 삼는 답입니다. 안드로이드 저장소가 조용한 것은 안드로이드가 멀쩡해서일 수도 있고, 아직 아무도 안 올려서일 수도 있습니다. 오전 9시 59분의 iOS 이슈도 9시 41분에는 없었습니다. 그래서 안드로이드 팀에 이슈가 없는지 확인해 달라는 요청이 간 것이 맞는 순서였다고 봅니다. 공개 기록이 조용한 것과 우리 안드로이드 앱의 크래시 지표가 조용한 것은 별개로 확인해야 하는 사실입니다.
Firebase Analytics 도 서버에서 플래그를 받아 와 동작을 바꾸는 SDK 였습니다
오늘 하루를 따라가고 나서 제가 가져가려는 것은 하나입니다. 범위 질문의 답을 벤더 상태 페이지에 맡겨 두지 않는 것입니다.
상태 페이지는 벤더 서비스가 멈췄는지를 알리는 물건이고, 그 기준으로는 오늘 멈춘 것이 없었습니다. 문제는 거기에 「우리 앱이 괜찮은가」까지 기대하게 된다는 점입니다. 그 기대가 초록 불과 겹치면 원인을 엉뚱한 데서 찾게 됩니다.
범위의 답을 어디에 둬야 하나는, 오늘 댓글에서 나온 답들이 무엇을 알아야 나오는 답이었는지를 거꾸로 보면 보입니다.
「모든 iOS 앱」이라는 답은 그 요청에 앱별 정보가 실리지 않는다는 사실에서 나왔습니다. 요청이 앱마다 다르면 응답도 앱마다 다르고, 잘못된 응답을 받는 앱은 일부에 그칩니다. 요청이 전부 같으면 잘못된 응답 하나가 그 SDK 를 쓰는 앱 전부에 갑니다. 한 번에 누구까지 맞는지가 요청의 모양에 달려 있습니다.
「앱 업데이트 없이 죽는다」는 모양은 그 SDK 가 서버 응답을 받아 동작을 바꾼다는 사실에서 나옵니다. Remote Config 나 A/B Testing 은 이름부터 그런 SDK 라서 다들 알고 있습니다. 오늘 죽은 것은 Analytics 였습니다. 흔히 이벤트를 모아 보내는 SDK 로만 생각하는데, 그 SDK 가 sdk-exp 엔드포인트에서 플래그를 받아 오고 있었습니다. 우리 앱에 들어 있는 SDK 중에 서버에서 무언가를 받아 와 동작을 바꾸는 것이 무엇인지, 그 목록이 평소에 있으면 이런 날 첫 추적을 「우리가 뭘 바꿨나」가 아니라 「누가 우리 앱에 무엇을 내려보냈나」에서 시작할 수 있습니다.
그리고 끌 수 있는 스위치가 있는지입니다. 댓글에 올라온 임시 대응은 둘이었습니다. 원격으로 설정을 바꿀 수 있는 앱이면 Analytics.setAnalyticsCollectionEnabled(false) 로 수집을 끄는 것, 아니면 GULMutableDictionary 를 스위즐링해서 nil 키를 무시하게 만드는 것입니다. 앞의 것은 원격 설정 경로를 미리 만들어 둔 앱에서만 됩니다. 뒤의 것은 결국 새 빌드를 내야 하니 오늘 같은 날에는 늦습니다.
수집을 끄는 쪽도 공짜는 아닙니다. 끄는 동안의 이벤트는 그대로 비고, 그 구간의 지표는 나중에 채울 수 없습니다. 구글이 곧 롤백할 거라면 기다리는 편이 낫고, 롤백이 언제 될지 모르면 끄는 편이 낫습니다. 그 판단도 결국 「이게 어디까지고 언제까지냐」를 알아야 설 수 있습니다. 오전 10시 54분까지는 벤더 쪽에서 나온 말이 없었으니, 그 한 시간 동안 이 스위치를 내릴지는 댓글을 읽은 사람이 정해야 했을 것입니다. GoogleUtilities 쪽에도 nil 키를 무시하게 고치는 방어 PR이 올라와 있는데, 이 글을 쓰는 시점에는 아직 열려 있습니다.
범위에는 시간 축도 있습니다. 구글이 롤아웃 완료를 알린 12시 16분 댓글에는 캐시 때문에 다음 갱신 주기까지는 일부 크래시가 날 수 있다는 말이 붙어 있었습니다. fetch 간격이 4시간이라 그 주기마다 반복될 수 있다는 분석도 댓글에 있는데, 이건 SDK 코드를 읽고 나온 분석이지 구글이 확인해 준 내용은 아닙니다. 앞에서 본 성공 시각을 먼저 저장하는 동작이 여기서 다시 걸립니다. 그 덕에 한 번 죽은 앱은 곧바로 다시 죽지 않았지만, 같은 이유로 그 앱이 올바른 응답을 받는 시점도 다음 fetch 까지 미뤄집니다. 서버가 고쳐진 시각과 각 기기가 고쳐진 응답을 받는 시각 사이에 간격이 생기고, 그 간격은 기기마다 다릅니다. 「롤아웃 끝」이 「우리 앱에서 끝」과 같은 뜻이 아니라는 것은 분명합니다. 그 차이도 벤더 채널이 아니라 우리 쪽 크래시 지표로 확인해야 합니다.
서버 응답으로 동작이 바뀌는 SDK 가 무엇이고, 그 요청이 앱별 정보를 싣는지, 끌 스위치가 있는지. 이걸 평소에 알고 있어야 「안드로이드도?」라는 질문에 벤더를 기다리지 않고 답할 수 있다고 지금은 보고 있습니다. 오늘 아침 그 질문에 가장 먼저 답을 준 것은 상태 페이지가 아니라 GitHub 이슈 번호 하나였습니다.
이 글을 쓰는 오후에도 이슈는 열려 있고 댓글은 계속 늘고 있습니다. 장애 기록에는 여전히 오늘 날짜가 없습니다.
댓글
댓글 쓰기