시작하며;
작년 9월과 10월, 두 달에 걸쳐 푸시 서버와 관련된 문제를 집중적으로 들여다봤다. 당시엔 따로 정리할 여유가 없었지만, 지금에서야 늦은 회고를 남겨본다.
모바일 서비스에서 푸시 알림은 단순한 메시지를 넘어, 사용자와의 접점을 가장 빠르고 직접적으로 만들어내는 중요한 수단이라고 생각한다. 실시간으로 정보를 전달하고, 사용자 행동을 유도하며, 서비스의 존재감을 각인시키는 데 핵심적인 역할을 하기 때문이다. 그런데.. 우리 서비스의 운영 과정에서 푸시 누락에 대한 제보가 지속적으로 발생했다. 사내 공유 목적으로 장애 보고서를 쓰며 로그를 역추적해보니, 이 문제는 2024년 10월 15일까지 계속해서 반복되고 있었다. 즉, 내가 합류하기 전부터 존재했던, 꽤 오래된 장애였다.


그래서 한동안 푸시 대상자들에게 알림이 정상적으로 도달하지 않는 문제가 반복적으로 발생했다. 어떤 경우에는 전체 대상에게 모두 정상적으로 발송되었지만, 일부 케이스에서는 발송이 중간에 멈추거나, 로그상으론 성공으로 표시되지만 실제로는 수신되지 않는 등 일관되지 않은 패턴이 확인되었다.
푸시는 단순한 편의 기능이 아니다. 사용자의 재방문을 유도하고, 커뮤니티의 온도를 유지하며, 나아가 서비스의 매출에도 직결되는 핵심 채널이다. 그래서 이슈가 반복될수록 가볍게 넘길 수 없었다. 언젠가는 반드시 해결해야 할 문제였다. 나는 원인을 찾기 위해 로그를 뒤지고, 시스템 구조를 뜯어보며 하나씩 파고들기 시작했다.
이 글에서는 그렇게 찾아낸 푸시 발송 누락의 원인과, 그것을 어떻게 해결했는지 그 과정을 정리해보려 한다.
삽질의 여정;
- 2024년 9월 11일
>> Promise.all 에서 Promise.allSettled 로 변경 후 배포 .. 여전히 재시작 이슈 발생
- 2024년 9월 13일
>> catch 핸들러가 없어서 Promise 내에서 발생한 에러가 처리되지 않고 프로세스가 강제 종료가 된다고 추측
>> catch 핸들러 추가 후 배포 .. 여전히 재시작 이슈 발생
- 2024년 9월 26일
>> 메모리 부족 이슈로 인해서 프로세스가 종료되는 것이 아닐까 추측
>> process.memoryUsage() 적용 후 모니터링
- 2024년 10월 10일
>> 클러스터 모드에서 인스턴스 개수 최대 2개로 고정 .. 여전히 재시작 이슈 발생
- ★ 24년 10월 15일
>> 로그 상으로 특이점 발견 (주로 예약 발송 타입에서 자주 발생)
- ★ 24년 10월 16일
>> 프로세스가 죽기 직전 헬스 체크 응답 시점에 차이가 있음을 확인
>> 정상적일 때는 10초-20초 내로 정상 응답 수신
>> 비정상적일 때는 1분-2분 정도 지연 후 응답 수신
>> 헬스 체크 설정 확인해보니 조건 불충분 시 exit 1 로 응답하도록 설정되어 있음
>> exit 1 수신 시 process killed 로 로그를 남김
>> 헬스 체크 설정을 좀 더 유연하게 설정
>> 푸시 서버 재 시작 이슈 박멸 🐛
메모리 사용량, 클러스터 모드 설정 등 하나하나 점검해가며 가능한 모든 원인을 추적했다. 로그를 까보고, 메모리를 찍어보고, 실행 환경을 조정하고, 쿼리를 뜯어보고… 그렇게 며칠이 지나고 나서야 패턴이 하나 보이기 시작했다. 유독 예약 푸시나 푸시가 겹칠 때, 서버가 비정상적으로 종료된다? 이게 실마리였다. 헬스 체크 로직이 너무 빡빡했던 것.

당시 푸시 서버의 헬스 체크는 "푸시 테이블 쿼리가 일정 시간 내 응답되면 정상"이라는 조건이었다. 그런데 예약 푸시나 대용량 푸시가 몰릴 경우, 테이블 조회 시간이 평소보다 늘어나면서 헬스 체크 조건을 만족하지 못하고 exit 1 을 반환하게 된 것이었다. 서버는 이걸 보고 "아, 나 지금 힘들어!" 하며 스스로 종료해버린다. 실제로는 멀쩡한데도. 문제의 원인은 복잡한 로직이나 코드 버그가 아니라, 헬스 체크 타이밍과 조건 설정의 미묘한 차이였다.
조건을 완화하고, 헬스 체크 기준을 더 유연하게 조정하자 서버는 더 이상 이유 없이 죽지 않았다. 해결책은 단순했지만, 그 단순함에 도달하기까지는 꽤나 길고 집요한 디버깅이 필요했다.
🚧 마개조 (w/ 워커 스레드);
사실 단순히 헬스 체크 옵션만 수정해도 푸시 중단 문제는 대부분 해결될 수 있었다. 하지만 이왕 손을 대는 김에, 구조적으로도 더 탄탄하고 확장 가능한 방향으로 개선하고 싶었다.
기존에는 전체 회원을 회원번호 기준으로 정렬한 뒤, 이중 반복문을 사용해 푸시를 발송했다. 먼저 1만 단위로 청크를 나누고, 그 안에서 다시 500개씩 잘라 FCM 제약에 맞춰 전송하는 구조이다.
문제는 이 구조가 한 번 멈추면 끝이라는 점이었다. 중간에 예기치 않은 에러나 서버 이슈가 발생하면, 그 지점에서 로직이 멈춰버리고 이후의 푸시는 전혀 발송되지 않았다. 재시도 로직도 없고, 멈춘 지점부터 다시 이어갈 방법도 없었다. 게다가 회원 수는 시간이 지날수록 계속 늘어나기 마련이고, 그에 따라 처리 속도는 점점 더 느려질 수 있다고 판단했다.
그래서 구조를 마개조했다. 회원 번호 범위를 1만 단위로 나누고 바로 큐에 전송하도록 변경했다. 이어서 자식 스레드는 부모 스레드로부터 1만 단위 사용자 범위 내 푸시 토큰 배열을 전달받으면, 이를 다시 사용자 푸시 동의 등을 확인하여 최대 500개 단위로 청크하여 처리하도록 변경했다. (이렇게 생성된 데이터는 parentPort.postMessage() 를 통해 메인 스레드로 결과를 전달한다.)
이 방식은 로직 단위가 작고 독립적이기 때문에, 특정 청크에서 실패하더라도 전체 흐름이 중단되지는 않았다. 실패 구간만 다시 큐잉하거나, 유실된 구간만 재처리하면 되기 때문에 안정성과 복원력 모두 크게 개선되었다.
효과는?;
그 결과, 최대 CPU 사용량은 35.1%, 평균 CPU 사용량은 58.2% 절감되었고, 무엇보다도 2024년 10월 16일 개선 배포 이후로는 푸시 서버 재시작 이슈도 발생하지 않았다.


return;
'회고' 카테고리의 다른 글
| AI 모델 학습부터 MLOps 운영까지: 직접 개발한 프로젝트 회고 (0) | 2025.05.25 |
|---|