이 글은 Tech-Verse 2025에서 발표된 Pushsphere: LINE의 신뢰성 있고 신속한 대량 푸시 알림 비법 세션을 글로 옮긴 것입니다.
안녕하세요. LINE Plus Corporation의 Developer eXperience 팀 이희승(Trustin), 엄익훈이라고 합니다. Developer eXperience 팀은 LINE 앱의 메시징 시스템을 개발하는 데 필요한 마이크로 서비스 프레임워크 Armeria와 고가용성 분산 설정 저장소 Central Dogma를 개발 및 운영하고 있습니다.
이번 글에서는 Developer eXperience 팀과 LINE 메시징 시스템 개발 팀이 협업해 개발한 푸시 알림 게이트웨이 서버, Pushsphere를 소개하려고 합니다. 먼저 메시징 시스템에서 푸시 알림 전송을 구현하면서 맞닥뜨렸던 이슈를 공유하고 전반적인 관점에서 디자인과 아키텍처를 소개한 뒤 구체적인 구현 방식과 그 효과를 공유하겠습니다.
LINE 앱의 푸시 알림 전송 시스템이 고려해야 할 점
모바일 환경에서의 ‘푸시 알림 전송’이라고 하면 많은 분들이 이벤트나 프로모션 홍보와 같은 용도를 떠올릴 것입니다. 물론 그런 용도로도 많이 사용되는데요. LINE 앱과 같은 인스턴트 메신저에서는 메시지 전달 및 음성/화상 통화에서 핵심 역할을 맡고 있다는 점이 다른 서비스와의 차이라고 할 수 있을 것 같습니다.
이 차이를 전송되는 내용 관점에서 한 번 살펴보겠습니다. 예를 들어 할인 이벤트 알림 같은 경우 놓치거나 제대로 전달받지 못했더라도 보통 좀 아쉬운 정도로 끝날 것입니다. 하지만 만약 사랑하는 아내가 보낸 중요한 연락이 제대로 전달되지 않았다면 어떨까요? 내용에 따라서 누군가 '생명'에 위협을 느낄 가능성도 배제할 수 없을 것 같습니다. 그 외에도 나라에 따라서 지진이나 화산 분화와 같은 중대한 재해 관련 알림을 LINE 앱으로 전달하는 경우도 있습니다. 따라서 푸시 알림이 제대로 전달되지 않는다면 LINE 앱의 신뢰에 중대한 영향을 끼칠 것입니다. 인스턴트 메시징 시스템에서 안정적이고 신속한 푸시 알림 전달은 말 그대로 삶과 죽음의 문제라고 할 수 있을 것 같습니다.
안정성과 속도라는 두 마리 토끼를 잡는 것만으로도 어려운 일인데요. 여기 세 번째 토끼가 있습니다. 바로 때에 따라 극적으로 증가하는 메시지 발송량입니다. 다수의 사용자가 짧은 시간에 같은 경험을 공유하는 어떤 사건이 발생하면 LINE 앱으로 발송되는 메시지가 평소의 수배에서 수십배까지 치솟는 상황이 발생합니다. 이런 상황에서도 푸시 알림이 안정적으로 빠르게 전달되도록 하려면 생각보다 고려할 것이 많습니다.
본격적으로 어떤 점을 고려해야 하는지 이야기하기 전에 간단한 퀴즈를 하나 풀어보겠습니다. 아래 세 가지 상황 중 어느 상황에서 메시지 발송량이 가장 많을까요?
- 새해 0시 0분(자정)
- 지진이 발생했을 때
- 상공을 통과하는 인공위성 발사체를 미사일로 착각했을 때
정답은 ‘지진이 발생했을 때’입니다.
그런데 저희가 안정적으로 푸시 알림을 전달하고 싶어도 통제할 수 없는 부분이 있습니다. 바로 Apple의 iOS 통지 시스템인 APNs(Apple Push Notification service)와 Google의 Android 통지 시스템인 FCM(Firebase Cloud Message)입니다. 이 서비스들은 대체로 꽤 안정적인 것으로 보이지만, 실제로 안을 살펴보면 꼭 그렇지만은 않다는 것을 알 수 있습니다.
이 서비스들은 다수의 인스턴스로 구성된 거대한 클러스터로 운영되는데요. 클러스터의 전반적인 상태가 양호할지라도 일부 인스턴스가 문제를 일으키는 경우가 상당히 자주 발생합니다. 따라서 운이 나쁘게도 응답이 늦거나 타임아웃이 발생하는 인스턴스에 걸렸다면 푸시 알림이 제대로 전달되지 않을 것입니다. 그 밖에도 갑작스럽게 연결이 끊어지는 경우도 있기 때문에 이에 대한 대비도 필요합니다.
보통 이런 상황에 대처하기 위한 방법으로 푸시 알림을 다시 전송하는 재시도(retry) 로직을 많이 사용하는데요. 재시도 로직을 사용하기 위해서는 다음과 같이 고 민해야 할 것들이 많습니다.
- 재시도를 몇 번 해야 하는가?
- 재시도 간 지연 시간을 어느 정도로 설정할 것인가?
- 이전에 타임아웃이 발생했던 인스턴스에 재시도할 것인가? 아니면 다른 인스턴스로 전환할 것인가? 전환한다면 수백 개의 인스턴스 중 어느 인스턴스로 전환할 것인가?
- 이전에 문제가 발생했던 인스턴스의 문제가 시간이 지나면서 해결된 경우 언제 다시 후보군에 넣을 것인가? 인스턴스 재평가를 언제 해야 하는가?
살펴보면 항목도 많고 각 항목의 내용도 까다롭다는 것을 알 수가 있습니다. 또한 이 외에도 고려해야 할 중요한 문제가 더 있습니다. 바로 푸시 알림 서비스에서 강제하고 있는 할당량 제한(quota limit)입니다. 만약 제한값을 초과하면 푸시 발송이 막혀 한동안 모든 푸시 알림 전송에 실패하는 사태가 발생할 수 있습니다. 따라서 알림 도달률을 높이겠다는 생각으로 무작정 계속 재시도하면 오히려 도달률이 급감하는 사태가 발생할 것입니다.
할당량 제한값을 높이는 것이 가장 좋은 방법이겠지만, 각 시스템은 각자의 사정이 있기 때문에 제한값을 높이는 것은 생각보다 어렵습니다. 아래 그래프처럼 새해 0시 0분과 같은 상황에서는 여전히 이 제한값을 초과할 가능성이 있기 때문에 푸시 알림을 발송하는 시점의 할당량을 고려하는 것이 중요합니다.
이와 같이 안정적으로 신속하게 푸시 알림을 전달하기 위해서는 고려해야 할 것들이 많습니다. 이를 해결하기 위해 Pushsphere를 개발해 도입했습니다.
Pushsphere 전체 기술 스택 및 구조
먼저 아래 그림과 함께 전반적인 기술 스택과 개념 수준에서의 흐름을 살펴보며 어떻게 Pushsphere를 설계하고 구성했는지 소개하겠습니다.
배포와 운영은 쿠버네티스 환경에서 하고 있고, JVM 기반 언어인 Kotlin으로 개발했습니다. 프레임워크로는 오픈소스로 개발해서 LINE 메시징 시스템뿐 아니라 실리콘밸리나 유럽 등 세계 유수 회사들에서 적극적으로 사용하고 있는 마이크로서비스 프레임워크인 Armeria를 사용했습니다. Armeria의 네트워킹 스택으로는 보다 높은 성능을 제공하기 위해 Netty를 직접 활용하고 있습니다.
위와 같은 기술 스택으로 'Pushsphere'라고 이름 지은 푸시 알림 전송 시스템을 개발했습니다. Pushsphere는 크게 두 가지 컴포넌트로 구성됩니다. 하나는 LINE 메시징 서버의 푸시 알림 전송 요청을 받는 역할을 담당하는 게이트웨이 서버이고, 또 하나는 전달받은 푸시 알림을 APNs나 FCM으로 전송하는 클라이언트 라이브러리입니다. 특히 이 클라이언트 라이브러리는 앞서 말씀드렸던 문제들을 해결하기 위해 복잡한 로직을 탑재하고 있는데요. 기본적으로는 Armeria가 제공하고 있는 클라이언트 측 로드 밸런싱이나 자동 재시도 같은 기능을 커스터마이징해서 구현해 놓았습니다.
배포 관점에서도 가볍게 살펴보겠습니다.
손쉽게 스케일아웃할 수 있도록 구현해 놓았기 때문에 다수의 데이터 센터에서 운용할 수 있습니다. 실제로 저희도 위와 같이 현재 두 개의 데이터 센터에서 운영 중인데요. LINE 메시징 서버와 Pushsphere 서버 사이에는 각 서버가 '자신이 속한 데이터 센터가 어디인지'와 '서로의 가용성 상태'에 따라 자율적으로 트래픽의 흐름을 조정하는 '존 인식 라우팅(zone-aware routing)'을 적용했습니다. 보통 존 인식 라우팅을 구현 할 때 Envoy와 같은 사이드카를 활용하는 경우가 대부분이지만 저희는 이 부분도 직접 구현했습니다(이와 관련해서는 뒤에서 다시 한 번 살펴보겠습니다). 또한 Pushsphere는 APNs와 FCM 구간에서 문제가 발생한 인스턴스, 즉 타임아웃이 발생하는 인스턴스들을 즉각 걸러낼 수 있는 '로드 밸런싱 로직(outlier-detecting load balancer)'이 구현돼 있습니다.
지금까지 LINE 메시징 서비스에서 푸시 알림을 안정적으로 빠르게 전달하기 위해서 고려해야 할 것들이 무엇이고, 어떤 방식으로 문제를 해결했는지 거시적인 관점에서 설명했습니다. 다음으로 Pushsphere 클라이언트를 보다 상세히 살펴보겠습니다.
Pushsphere 클라이언트의 내부 구조 및 특징
Pushsphere 클라이언트의 내부 구조를 살펴보며 Pushsphere 클라이언트의 특징을 소개하겠습니다.
사용 편의를 위해 통합한 인터페이스
클라이언트의 최상단에는 사용자의 요청을 받아주는 통합 인터페이스가 위치합니다. 이 인터페이스를 통해 들어온 메시지는 FCM이나 APNs의 형식에 맞게 변형되는 과정을 거치며, 이 과정에서 입력된 데이터 검증(validation)도 진행합니다.
이후 로드밸런서의 엔드포인트 매니저에게 엔드포인트 목록을 전달받아 메시지를 전송할 엔드포인트를 선택합니다. 엔드포인트로 메시지를 전송하기 위해서는 인증 단계가 필요한데요. Pushsphere 클라이언트에서는 mTLS와 OAuth 2.0을 지원하고 있습니다. mTLS는 APNs에서, OAuth 2.0은 FCM에서 사용합니다. 이제 푸시 요청을 보낼 모든 준비를 마쳤습니다. 생성된 요청은 Armeria를 통해 Netty로 전달됩니다. 이 과정은 비동기로 구현돼 있는데 높은 성능을 보장합니다. 전송된 후에는 후처리 작업이 진행됩니다(재시도 모듈과 서킷 브레이커에 대해서는 추후 자세히 다루도록 하겠습니다).
Pushsphere 클라이언트는 하나의 엔트리 포인트를 이용해 모든 푸시 플랫폼에 메시지를 전송할 수 있도록 디자인돼 있습니다. 동일한 API를 이용해 푸시 메시지를 작성해서 Apple의 iOS와 Google의 Android에 모두 전송할 수 있습니다.
Profile은 PushProvider 정보를 담고 있고 푸시 메시지가 어디로 전송될지 결정합니다. 푸시 메시지를 전송할 때에는 두 가지 단계를 거치면 됩니다. 먼저 푸시 클래스의 팩토리 메서드를 이용해서 내용을 작성한 뒤, 기기 토큰과 함께 푸시 요청 객체를 만들어 전송하면 됩 니다. 이와 같이 단순하게 통합한 API 설계 덕분에 어떤 플랫폼으로든 손쉽게 푸시 알림을 전달할 수 있습니다.
높은 푸시 메시지 도달률을 달성하기 위해 도입한 세 가지
다음으로 Pushsphere 클라이언트의 주요 컴포넌트 중 푸시 알림의 도달률을 높이기 위한 새로운 기술을 적용한 컴포넌트들을 살펴보겠습니다.
첫 번째: 재시도 인식 로드 밸런서
Pushsphere 클라이언트는 내부에 자체 로드 밸런서를 두고 있습니다. 이를 이용해 부하를 여러 엔드포인트에 균등하게 전달합니다. 부하 배분에는 기본적으로 라운드 로빈 전략을 사용합니다. 라운드 로빈은 단순하면서도 공정하며 빠르고 효율적인 알고리즘인데요. 단순한 방식인 만큼 단점도 있습니다. 예를 들어 아래와 같이 8개의 엔드포인트가 있다면 재시도 시 8분의 1 확률로 이전에 사용했던 엔드포인트를 다시 사용하게 될 수 있습니다. 이전에 사용하다가 실패했던 엔드포인트가 선택된다면 이는 좋은 결과가 아니겠죠.
이런 문제를 방지하기 위해 Pushsphere에서는 로드 밸런서의 재시도 컨텍스트를 서로 연결해 이전에 사용했던 엔드포인트를 제외하는 로직을 넣었습니다. 아주 간단한 아이디어이지만 특정 서버에서 실패가 발생했을 때 탄력적으로 대응해 도달률을 높일 수 있는 방법 중 하나였습니다.
두 번째: 할당량을 인식하는 자동 재시도 로직
할당량 제한값과 재시도 로직을 연결하는 것입니다. 이 로직은 Firebase와 연동하면서 발생한 문제점들을 해결하기 위해 고안했습니다. Firebase는 1분당 전송할 수 있는 최대 메시지량을 각 앱에 할당해 줍니다. 이 값은 무한대가 아니며, Google에 요청한다고 쉽게 올려주지 않습니다. 따라서 할당량은 귀중한 자원이기 때문에 푸시 메시지 전송 요청을 남발하면 안됩니다.
할당량을 고려하기 전에는 '최대 가능한 재시도 횟수 = 3'과 같이 재시도 횟수가 하드코딩돼 있었습니다. 이에 따라 만약 Firebase의 특정 리전 서버에 일시적으로 장애가 발생해서 모든 요청이 3번씩 재시도하게 되면 전체적으로 할당량을 모두 소진해 초과해 버려 정상적인 요청 전송도 실패할 수 있습니다. ‘Google에서 이런 장애가?’라고 생각하실 수도 있는데요. 실제로 LINE 메신저에서는 이런 현상이 발생한 적이 있습니다. 특정 리전 서버 장애로 재시도가 대거 발생하면서 할당량을 초과해 전체 푸시 요청이 일시적으로 '429 too many request' 응답을 받았었습니다. 추가로 다수의 요청이 재시도된다면 푸시 프로바이더 시스템에 과부하가 발생해 장애가 더욱 길어질 수도 있을 것입니다.
이 문제를 해결하기 위해서 재시도하기 전에 현재 할당량이 얼마나 남아 있는지 확인하는 로직을 추가했습니다.
이 로직 덕분에 예를 들어 새해 첫날 너무 많은 요청이 들어와서 할당량을 다 사용한다면 재시도는 작동하지 않을 것입니다. 트래픽이 너무 많을 때에는 실패한 요청을 재시도하는 것보다는 제한된 할당량 안에서 새로운 요청을 처리하는 것이 더 합리적인 선택일 것입니다. 각 서버는 할당량을 분배해서 가지고 있고, 이를 기반으로 현재 재시도할 수 있는 양을 정밀하게 계산합니다. 이를 통해 높은 도달률을 유지하면서 할당량을 초과하는 것을 방지해 균형을 맞출 수 있었습니다.
세 번째: 엔드포인트 매니저와 서킷 브레이커 연동
세 번째는 엔드포인트 매니저와 서킷 브레이커를 연동해 건강한 엔드포인트를 지속적으로 로드밸런서에 제공하는 것입니다.
이 알고리즘은 APNs의 DNS 특성을 고려해 설계한 것입니다. APNs와 Firebase는 둘 다 헬스 체크를 제공하고 있지 않기 때문에 클라이언트는 모든 엔드포인트가 잘 작동하고 있다 고 가정해야 합니다. Pushsphere에서는 특정 엔드포인트에 문제가 있다는 것을 판단하기 위해서 각 엔드포인트마다 서킷 브레이커를 할당했습니다.
만약 실패가 누적돼 특정 임계치를 넘어서서 서킷 브레이커가 작동한다면 이 사실이 엔드포인트 매니저로 전달됩니다. 엔드포인트 매니저는 문제가 발생한 엔드포인트를 엔드포인트 풀에서 즉시 제거하고, 후보 풀에서 새로운 엔드포인트를 하나 선정해 엔드포인트 풀에 추가합니다.
이때 Firebase와 다르게 APNs는 고정된 IP가 아닌 매번 다른 IP를 DNS 서버를 통해서 반환하도록 설정돼 있는데요. 이런 특성을 고려해서 주기적으로 DNS 쿼리를 실행해서 후보 풀의 IP 목록을 업데이트합니다. 서킷 브레이커가 작동했을 때 가장 최신 IP 중 하나를 엔드포인트로 사용하도록 만들기 위함입니다. 오래된 IP는 APNs에서 더 이상 사용하지 않을 수 있기에 피하는 것이 좋습니다.
예를 들어 서킷 브레이커를 각 엔드포인트가 아닌 전체 엔드포인트 풀에 하나만 할당한다면 일부 서버 장애에도 서킷브레이커가 작동해 메시지를 전송할 수 없게 될 수 있습니다. Pushsphere에서는 각 엔드포인트마다 서킷 브레이커를 할당해 일부 서버의 장애에 탄력적으로 대응하면서 높은 도달률을 유지하고 있습니다.