
우리 앱이 스스로에게 DDoS를 건 날
기동 시 한꺼번에 풀려난 밀린 업로드 더미가, 모든 GeekBye 클라이언트를 우리 자신의 서버에 대한 작은 서비스 거부 공격으로 바꿔놓았습니다. 그 수정 — 그리고 그것이 우리에게 짓게 만든 연결 생존성 사다리 — 은 v2가 가르쳐준 가장 쓸모 있는 것 중 하나입니다.
분산 시스템 엔지니어라면 언젠가는 몰림(thundering herd)을 만납니다: 한 무리의 클라이언트가 같은 순간에 같은 일을 하고, 그 뒤에 있는 공유 자원이 무너지는 것 말입니다. 대개는 남의 클라이언트입니다. GeekBye v2.0.1에서는 그게 우리 것이었고, 공격자는 우리 자신의 앱이었습니다.
노트테이커는 어떻게 스스로를 공격하는가
GeekBye는 녹음을 당신의 Google Drive로 업로드할 수 있습니다. 녹음이 즉시 업로드되지 못하면 — 오프라인이었거나, 앱이 닫혔거나, Drive가 잠깐 삐끗했거나 — 나중에 재시도하도록 밀림에 들어갑니다. 합리적입니다.
실패는 "나중에"가 언제 일어나는가에 있었습니다. 다음 기동 때, 앱은 밀림 전체를 한꺼번에 비우려 했습니다. 일주일치 보류 녹음을 가진 한 명의 사용자가, 앱을 여는 그 순간 동시 업로드 요청의 폭발로 변합니다. 그걸 아침에 기동하는 모든 사용자로 곱해 보세요. 그러면 우리 백엔드는 인증하고, 레이트를 확인하고, 트래픽의 벽을 처리하는 일을 같은 몇 초 안에 요구받게 됩니다 — 게다가 기술적으로는 전부 얌전하게 행동하는 클라이언트로부터요.
우리 백엔드는 옳은 일을 했고, 그 홍수를 레이트 리밋했습니다. 그리고 거기서 2차 피해가 시작됐습니다. 레이트 리밋 응답은 HTTP 429이고, 429는 조심하지 않으면 다른 실패들과 아주 비슷해 보입니다:
- 기동 시 프로필 가져오기가
429를 받고, 앱은 그걸 인증 실패로 취급했습니다 — 완전히 유효한 세션에서 사용자를 잠깐 로그아웃시키면서요. - 전사 연결이 일반적인
429를 받고 오디오 한도 도달 업그레이드 안내를 띄웠습니다 — 유료 사용자에게, 도달하지도 않은 쿼터에 도달했다고 알리면서요.
이렇게 하나의 근본 원인 — 페이싱 안 된 밀림 — 이 세 가지 눈에 보이는 증상을 낳았습니다: 서버 부담, 거짓 로그아웃, 거짓 업셀. 자체 DoS의 전형적인 모양입니다: 부하 자체도 나쁘지만, 사용자가 실제로 느끼는 건 그 부하의 증상에 대한 오독입니다.
수정, 두 개의 층으로
몰림을 멈춰라. 업로드 밀림은 이제 페이싱됩니다 — 요청은 백오프로 분산되고, 어떤 429 뒤에도 쿨다운이 붙어, 클라이언트는 부담받는 서버에 더 세게 기대는 대신 물러섭니다. 몰림(thundering herd)이 질서 있는 대기줄이 됩니다.
오독을 멈춰라. 기동 중의 일시적인 429나 5xx는 이제 당신을 로그아웃시키지 않습니다 — 클라이언트는 "서버가 잠깐 바쁘다"를 "당신의 세션이 무효다"와 구분합니다. 그리고 전사 경로의 일반적인 레이트 리밋 429는 이제 오디오 쿼터 오류인 척하지 않습니다. 진짜 쿼터 응답만이 업그레이드 안내를 띄웁니다. 남은 교훈은 이렇습니다: 에러 코드는 에러의 의미가 아니다. 429는 "속도를 늦춰라"를 뜻하지, "당신은 미인증이다"도 "당신은 쿼터가 다 됐다"도 아닙니다 — 그리고 클라이언트는 그 차이를 알아야 합니다.
그 뒤에 이어진 생존성 사다리
몰림을 페이싱하자 더 조용한 문제가 드러났습니다: 부하 아래에서 연결이 정말로 나빠졌을 때, 우리는 얼마나 빨리 알아챘는가? 우리가 바란 것보다 느렸습니다. 그래서 v2.0.4는 실시간 전사를 위한 연결 생존성 사다리를 지어 올렸습니다:
- 하트비트 — 전사 소켓은 고정 간격으로 ping되어, 조용히 죽은 연결이 다음 오디오 청크가 실패하기를 기다리는 대신 몇 초 만에 감지됩니다.
- 네트워크 복구 시 재접속 킥 — OS가 네트워크가 돌아왔다고 알리면, 앱은 그 발견에 걸려 넘어지기를 기다리는 대신 능동적으로 연결을 다시 세웁니다.
- 제어용 ping/pong 생존성 — 애플리케이션 수준의 왕복으로, "소켓이 열려 있다"뿐 아니라 "상대편이 실제로 답한다"까지 확인합니다.
- 서비스가 소유하는 재접속 게이트 — 재접속할지 말지를 한 곳이 정하므로, 앱의 여러 부분이 서로 경쟁하며 밟고 넘어지지 않습니다.
어느 것도 화려하지 않습니다. 그 모두가, v2 세션이 그냥… 계속 연결돼 있는 것처럼 느껴지는 이유입니다.
이번 주에 또 일어났다 — 한 단계 아래에서
여기가 이걸 단순한 무용담 이상으로 만드는 부분입니다. 이 같은 실패 부류가 며칠 전, 우리보다 한 단계 아래에서 다시 떠올랐습니다. 하나의 클라이언트가 400밀리초 미만에 21번의 전사 세션 시작을 발사했고 — 우리 음성 프로바이더의 계정 전체 15 동시 요청 한도에 걸렸습니다. 공유 인프라에 대한 몰림, 바로 그 업로드 밀림과 똑같이, 다만 우리 백엔드가 아니라 의존 대상을 겨냥했을 뿐입니다.
모양은 동일하고, 수정도 동일합니다: 몰리는 클라이언트는 하나의 의도로 스무 번의 시작을 발사할 수 없도록 싱글 플라이트 가드가 필요하고, 공유 자원은 한 클라이언트가 풀을 다 먹어치울 수 없도록 사용자별 상한이 필요합니다. 우리는 이 클라이언트 측 버전을 전에 만든 적이 있습니다 — 업로드 페이서가 바로 이 패턴입니다. 이제 그걸 세션 시작에 적용합니다. 몰림(thundering herd)은 한 번 고치면 끝나는 버그가 아닙니다. 그건 클라이언트가 공유 한도를 만나는 모든 곳에서 알아보게 되는 하나의 모양입니다.
가져갈 세 가지
- 당신 자신의 클라이언트는 당신이 잡지 않은 부하 테스트다. 기동 시 묶어서 처리하는 것, 기동 시 재시도하는 것, 깨어날 때 재접속하는 것 — 그 무엇이든 충분한 사용자를 기다리는 몰림(thundering herd)입니다. 사용자를 갖기 전에 페이싱하세요.
- 코드를 의미와 구분하라.
429는 존재하는 상태 중 가장 많이 오독되는 것입니다. "속도를 늦춰라"는 "로그아웃하라"도 "우리에게 돈을 내라"도 아닙니다. 각 실패를 그것이 실제로 의미하는 것으로 보내세요. - 생존성은 플래그가 아니라 사다리다. "연결이 살아 있는가?"에는 서로 다른 층에서 여러 개의 정직한 답이 있습니다 — 소켓이 열려 있다, 바이트가 흐른다, 상대편이 답한다, 네트워크가 있다. 튼튼한 앱은 하나 이상을 확인합니다.
이것이 GeekBye v2의 차분함 아래 있는, 화려하지 않은 기계 장치입니다. 그것이 가능케 한 신뢰성 기능은 AI 노트테이커는 왜 나쁜 Wi-Fi에서 멈추는가와 방화벽이 WebSocket을 막을 때의 실시간 전사(v2.0.8)를 보세요. 이 시리즈의 이웃 릴리스는 AI 노트테이커는 왜 회의 중간에 녹음을 멈추는가(v2.0.9)를 보세요.