Contents

천만 명의 사용자에게 1분 내로 알림 보내기 (병렬프로세스의 최적화)

만약 1번부터 10번까지 번호표가 있는 사람들 총 열명에게 혼자서 동일한 내용의 메일을 보낸다고 가정해보자. 그리고 메일 발송시 한번에 한명에게만 보내야 하는 제한사항이 있을때 과연 당신은 어떤식으로 보내겠는가? 이어서 읽지말고 한번 생각해보자. 아무것도 고려하지 않고 단순하게 생각한다면 1번 보내고 > 2번 보내고 … 9번 보내고 > 10번 보내는 방법이 먼저 떠오르게 된다. (for loop 1 to 10 … ) 하지만 보내야 할 사람들이 많아져서 백명, 천명 많게는 천만명에게 보내야 할 경우 방금과 같은 순차적인 방법을 사용하면 너무 늦게 발송된다는건 코드를 작성하지 않아도 알 수있는 문제… 그렇다면 어떤 방법으로 보내야 보다 빨리 보낼수 있을까? 이번 포스팅에서는 필자가 운영하고 있는 서비스에서 기존에 있던 병렬프로세스를 어떤식으로 최적화 했는지, 그래서 결국 얼마나 빨라졌는지에 대한 과정을 정리해 보고자 한다. 비단 메일 발송이나 앱 푸시 등 특정 도메인에 국한되지는 않고 전반적인 프로세스에 대해 이해를 한다면 다른 곳에서도 비슷한 방법으로 활용할 수 있을꺼라 기대 해본다.


상황파악 및 목표

(원할한 이해를 돕기 위하여) 먼저 필자가 운영하고있는 서비스를 간략히 소개부터 해야겠다. (그렇다고 필자 혼자 다 하는건 아님^^;…) 셀럽의 방송이 시작되면 구독한 사용자에게 각 모바일 기기에 설치되어있는 앱으로 알림을 보내어 예정에 없던 깜짝 라이브 방송이나 VOD 영상 오픈을 보다 빠르게 확인할 수 있도록 제공하고 있다. 여기서, 알림이 늦게 발송되면 셀럽은 방송을 시작하고 팬들이 들어오기까지 기다려야 한다거나 반대로 팬들은 방송 시작하고 뒤늦게 방송을 보게되는 불편함이 생기게 된다. 그리고 중복으로 알림이 발송되거나 특정 사용자들에게 발송이 누락되면 안 되는 등 “알림” 이란 기능은 서비스에 있어서 중요한 기능 중에 하나라고 할수 있다.

여기서 “발송 시간"은 처음 발송작업 시작부터 마지막 사용자에 대해 사내 발송 플랫폼으로 발송 요청을 하기까지의 시간을 의미

그리고 “채널” 이라는 샐럽단위의 그룹이 있는데 영상과 채널의 관계는 1:N이다. 즉, 하나의 영상을 여러 채널에 연결시킬수 있어서 하나의 영상에 대해 여러 채널들에게 연결을 시켜놓으면 채널을 구독하고있는 각각의 사용자에게 모두 알림을 발송 할수가 있게 된다.

우선, 알람이 사용자에게 전달되기까지의 큰 흐름은 다음과 같다.

/images/faster-parallel-processes/push_process.jpg
알림 프로세스
  1. 서비스에서 보낼 대상과 보낼 정보를 조합하여
  2. 사내 푸시 발송 플랫폼인 사내 발송 플랫폼에게 전달을 하면 플랫폼에 따라 발송이 되고
  3. 최종적으로는 사용자의 모바일 기기에 노출이 됨

간단하게 “병렬로 발송하면 되지 않을까?“라는 필자의 생각이 부끄러워질 정도로 이미 redis, rabbitMQ 를 활용해서 아래 그림처럼 병렬 프로세스로 구성되어 있었다.

/images/faster-parallel-processes/legacy_structure.jpg
기존 구조
  1. 라이브가 시작되거나 VOD가 오픈될 경우 api가 호출이 되고 다시 배치 서버에게 영상의 고유번호를 전달
  2. 전달받은 영상의 고유번호를 rabbitMQ의 수신자 조회 Queue에 produce
  3. 수신자 조회 Queue의 consumer인 수신자 조회 모듈에서 영상의 고유번호를 consume 후 아래 작업을 진행 3-1. 영상:채널 은 1:N 구조이기 때문에 여러 채널의 사용자들에게 알림을 발송할 수 있고, 영상에 연결된 채널들의 user를 db에서 가져온다. 3-2. 가져온 user를 (중복으로 알림이 발송되지 않기 위해) java set에 담고 모든 채널을 조회했다면 redis에 sorted set으로 담는다. 3-3. 적당한 크기로 분할하고 이 분할정보를 발송 Queue에 produce
  4. 발송 모듈에서 분할 정보를 consume 하고 아래 작업을 진행 (병렬처리) 4-1. redis 에서 user 모음을 가져오고 4-2. 조회한 user에 해당하는 deviceId를 db에서 가져옴
  5. deviceId와 컨텐츠 정보를 활용하여 적절한 payload를 구성 후 사내 발송 플랫폼 에게 전달

기존 구조에서 발송 시간은 서비스에서 구독자 수가 가장 많은 채널 기준으로 약 1.1천만 명에게 최종 11분 정도 소요되고 있었다. (맨 처음에 이야기 한 순차적인 방법이였다면… 훨씬더 오래 걸렸을꺼라 예상해본다…)

기존에 구성하셨던 분들도 수많은 시행착오와 고민을 하시며 구성하셨을 텐데 더 이상 어떻게 더 빠르게 보낼 수 있을까 하는 부담감과 자칫 알림이 잘못 발송되기라도 한다면(장애가 발생한다면) 그 수많은 사용자들의 불만 화살 과녁이 필자가 되어야 한다는 압박감이 개선 시작 전부터 머릿속을 휘감고 있었던 찰나에

/images/faster-parallel-processes/goal.jpg
답정너

라는 불가능할 것만 같은 목표가 (답정너 마냥) 정해지며 그렇게 푸시 개선 프로젝트가 시작되었다. 결국 사내 발송 플랫폼에게 얼마나 더 빨리 보낼수 있는가 가 개선 포인트 라고 할수 있겠다.


1차 개선 : AsyncRestTemplate 적용

사내 발송 플랫폼에 요청을 한 뒤 응답의 종류(성공/실패)에 따라 발송 시간 로깅만 하기 때문에 응답을 기다리지 않고 AsyncRestTemplate를 사용해서 비동기 호출로 변경하였다. 사내 발송 플랫폼에 요청에 따른 응답은 1~2초 내외였지만 발송 대상이 많을수록 기다리는 시간을 모아보면 무시 못 할 시간이었기 때문이다.

구조가 크게 변경된 건 없었고 발송하는 부분에서 약간의 로직만 변경하였는데 나름의 큰 효과를 볼 수 있었다.

▶ 개선 결과

항목기존1차2차3차
발송 대상수약 10,530,000명약 10,570,000명
첫발송약 6분약 6분
마지막 발송약 11분약 7분

2차 개선 : 발송대상 구하는 즉시 병렬 발송처리, 불필요 프로세스 제거

  • 발송대상 구하는 즉시 병렬 발송처리 기존 구조에서는 발송 대상을 전부다 구한 뒤에 발송이 시작되었다. 왜냐하면 영상에 연결된 채널이 여러 개가 될 수 있다 보니 중복 사용자 제거를 해야 하기 때문이었다. 예로 들어 영상 하나에 A, B, C 채널이 연결되어있고 어느 사용자가 A, B 채널을 구독하고 있는 상황에서 중복제거를 하지 않고 보낸다면 해당 사용자는 같은 내용의 알림을 두 번 받는 상황이 된다. 영상에 연결된 채널이 한 개라면 문제가 없지만 두 개 이상일 경우부터 중복알림 문제가 발생했기 때문에, 그리고 이 중복제거 프로세스가 다 되어야지만 첫 번째 발송이 되는 구조였기 때문에 어떻게든 다른 방법으로 중복 발송을 해결해야만 했다. 그래서 여러 시행착오 끝에 결정된 방법은 “이 사용자는 발송이 되었다"라는 정보를 redis에 담는 식으로 중복체크하는 방법을 바꾸는 것이었다. 또한 첫 번째 채널(구독자 수가 가장 많은 채널)은 중복체크를 할 필요가 없기 때문에 db에서 조회하는 즉시 발송해서 방송 시작 1초 내에 사용자에게 알림을 발송할 수가 있었다.
  • 불필요 프로세스 제거 발송 triggering 을 배치(jenkins)에서 하고 있었다. api에서 jenkins remote api로 호출이 되면 기본적으로 약 20~30초가량의 인스턴스 구동시간이 존재하게 되는데 이 시간 또한 불필요한 프로세스라고 생각되어 api가 바로 수신자 조회 Queue에 produce 하는 식으로 구조를 변경하였다.
/images/faster-parallel-processes/improvement_2.jpg
2차 개선
  1. api에서 바로 수신자 조회 분할 Queue로 produce
  2. 수신자 조회 분할 모듈에서 consume을 하고 적당한 크기로 start index, end index를 구분하여 다시 수신자 조회 Queue로 produce
  3. 수신자 조회 모듈이 병렬로 consume을 하며 아래 작업을 수행합니다. 3-1. 발송 대상 user를 db에서 가져옴 3-2. 첫 번째 채널일 경우(구독자 수가 가장 많은 채널) 중복제거키에 담고 발송대상 key에 담은 뒤 발송 Queue에 produce 3-3. 첫 번째 채널이 아닐 경우 중복제거를 해야 하기 때문에 중복제거키에서 redis의 zscore 연산 (시간 복잡도 O(1) )을 활용하여 발송되지 않은 user만 간추려서 발송 대상 key에 담은 뒤 발송 Queue에 produce
  4. 기존과 동일

이렇게 개선한 결과 사용자들이 방송이 시작되자마자 알림을 받기 시작할 수 있었고, 발송 대상을 구하자마자 발송하기 때문에 발송 속도도 개선이 됬음을 확인할 수 있었다.

▶ 개선 결과

항목기존1차2차3차
발송 대상수약 10,530,000명약 10,570,000명약 11,120,000명
첫발송약 6분약 6분약 1초
마지막 발송약 11분약 7분약 5분 30초

3차 개선 : 발송대상 병렬x병렬조회, redis 파티셔닝, 채널간의 발송 타이밍 해소

  • 발송대상 병렬x병렬조회 몇 차례 속도 개선을 하는 필자를 보고 팀원 분들이 짠하게(?) 느끼셨는지 아이디어를 하나 건네주셨다. 그건 바로 db에서 user를 조회할 때 병렬 조회하는 것을 다시 병렬 조회하는 것. db에 채널별 구독자 테이블에는 user가 오름차순으로 정렬되어 있다 보니 큰 단위로 나눌수가 있고, 다시 이를 작은 단위로 분할하여 조회가 가능했던 것이었다. 대신, 나누는 단위가 적당해야 하고(테스트를 통해서 찾아내야…) user가 꽉 찬(?) 그룹이 있는가 반면 비어있는 그룹이 있을 수가 있다. 그림으로 그려보면 다음과 같다.

    /images/faster-parallel-processes/user.jpg
    • 1단계 : 첫 번째 user가 1, 마지막 user가 300만이라고 가정할 때 큰 단위(10만)로 분할합니다. 예 ) 0~100,000 / 100,000~200,000 / … / 2,900,000~3,000,000
    • 2단계 (병렬) : 1단계에서 나눈 단위를 다시 작은 단위(1,000)로 분할하여 db에서 조회를 하고 그다음 단계를 진행합니다. 예) 0~1,000 / 1,000~2,000 / … / 99,000~100,000

    이렇게 하고서 반영을 해보니 속도가 빨라진 대신 redis 가 부하를 많이 받게 되어 다른 모듈에서 redis 를 사용하는 곳에서 지연이 발생하게 되었습니다. 모니터링 툴인 pinpoint에 롯데타워가 뙇..

    /images/faster-parallel-processes/pinpoint.jpg

    결국 알림 속도를 빠르게 한답시고 서비스 전체가 사용하는 공용 redis에 지연이 발생하게 되어버린 것이었다. 개선을 함에 있어 서비스 영향도를 리스트업 하고, 조금이라도 문제가 생길것 같은 부분을 고려해야 하는 교훈을 얻을수 있었다.

  • redis 파티셔닝 알림 발송만을 위한 별도 redis 클러스터를 구축하기에는 장비 발급부터 간단한 작업이 아니었기에 어떻게든 로직에서 해결점을 찾아야 했다. 고민의 고민을 한 결과 redis 는 Single thread 방식으로 처리하기 때문에 key 하나에 연산이 끝날 때까지 해당 key가 속한 redis는 다른 연산을 처리할 수가 없게 되는 부분을 인지하고 중복 발송을 막기 위한 redis 키를 기존에는 하나를 사용하고 있었는데 이를 user 값 기준으로 여러 개의 키로 파티셔닝 하게 되었다. 즉, user가 천만 개라고 가정했을 때 기존에는 한 개의 키에 천만 개가 들어가던 구조에서 user 값을 10,000으로 나누어 결과적으로는 하나의 키에 1,000개씩 총 10,000개의 키에 파티셔닝되어 들어가게 되는 구조로 변경하게 되었다. 그랬더니 발송 속도도 더 빨라지고 pinpoint에 응답 그래프도 전혀 문제가 없는 수치인 것을 확인할 수 있었다.

  • 채널간의 발송 타이밍 해소 여러 채널을 동시에 보내다 보니 아주 간헐적으로 중복 알림이 발생하게 되었다. 이유는 지금까지 프로세스를 보면 여러 단계의 병렬 프로세스가 있는데 각 프로세스별 순서 보장이 안되고 각자 진행되기 때문에 중복체크 키에 들어가기 전에 다른 프로세스에서 먼저 중복체크를 하고 발송을 해버리면 중복으로 발송이 되어버리던 것이었다. 간단히 그림으로 설명해보면…

    /images/faster-parallel-processes/rabbitmq.jpg

    위 그림에서 1,2,3,4,5 가 동시에 발송을 시작한다고 가정했을 때 그 다음은 2번이 먼저 진행될 수도 있고 5번이 먼저 진행될 수도 있게 된니다. 그렇기 때문에 매번 중복 알림이 발생되는 건 아니었지만 아주아주 간헐적으로 발생하게 되었다. 이 문제는 간단히 채널별로 병렬 조회 하는 부분에서, 1초마다 발송 대상수(redis 를 활용하여 로깅 목적으로 발송 대상수를 트래킹하고 있다.)가 변하지 않을 경우 한 채널에 대해 발송이 완료되었다고 간주하고 그 다음 채널을 발송하는 방법으로 해결하였다.

이렇게 거듭된 개선을 거쳐 정리된 최종 구조는 다음과 같다.

/images/faster-parallel-processes/improvement_3.jpg
3차 개선. a.k.a. 최종 구조
  1. 채널별로 큰 단위로 index를 파티셔닝 하여 병렬 조회할 수 있도록 한다. 1-1. 첫 consumer는 채널을 채널 간의 알림 발송 진행을 담당해주고, 1-2. redis에 발송 대상수가 변함이 없을 경우 다음 채널을 발송하도록 한다.
  2. 1에서보다 더 작은 단위로 파티셔닝하여 아래 작업을 수행한다. 2-1. db에서 user를 조회하고 2-2. user를 중복제거 key에 10만단위로 파티셔닝 하여 담는다. ex ) user가 105872 인경우 push:overlapCheck:100000 에, user가 3409572 인 경우 push:overlapCheck:3400000 2-3. 2-1에서 가져온 user를 임의 redis key에 담는다.
  3. 중복체크 작업을 수행합니다. 3-1. 첫 번째 채널일 경우 중복체크를 하지 않고 바로 발송을 한다. 3-2. 첫 번째 채널이 아닐 경우 2-3에서 저장한 redis key의 값을 조회하여 2-2에서 저장한 중복제거 key에 있는지 확인 후 발송 여부를 결정한다.

▶ 개선 결과

항목기존1차2차3차
발송 대상수약 10,530,000명약 10,570,000명약 11,120,000명약 11,240,000명
첫발송약 6분약 6분약 1초약 1초
마지막 발송약 11분약 7분약 5분 30초51초

마치며

결국 처음에 개선 프로젝트 시작 시 정해졌던 목표에 도달할 수 있었다.(발송 대상이 약 100만 명이 더 늘었지만 1분내로 발송 성공) 또한 무조건 좋다고 사용하다간 오히려 독이 될 수 있고, 반대로 돌아가는 원리를 잘 알아보고 사용한다면 본인이 원하는 가장 이상적인 결과를 만들 수 있다는 좋은 경험을 얻을 수 있었다.


Buy me a coffeeBuy me a coffee