이번에 42만명의 회원들에게 알림톡을 보내게 되면서 180명의 유저들에게 중복 메시지를 최소 100개 이상 보냈다.
또한 OPEN API의 TPS를 고려하지 않아 TPS 이상의 요청이 들어오면서 OPEN API 서버도 함께 뻗어버렸다.
이 문제를 어떻게 해결해 나갔는지를 정리해보려고 한다.

전체 호출 과정은 위와 같다.
7번 과정에서 보충 설명이 필요한데 인포뱅크에게 우리의 open api url을 미리 등록해줬기 때문에 가능한 과정이다.
알림톡이란
카카오톡을 통해 보내는 정보성 메시지이다.
해당 메시지는 "정보성" 메시지이기 때문에 광고성 문구를 넣을 수 없으며 따라서 미리 인포뱅크로부터 해당 메시지 내용을 보내도 되는지 검수를 받아야 한다. 검수를 넣고 승인을 받으면 해당 메시지에 대한 식별자인 템플릿 코드가 생기게 된다. 개발자는 승인된 템플릿 메시지에 대해서 토시 하나 틀리지 않고 회원들에게 알림톡을 보내야 한다. 만약에 실제로 승인 받은 메시지에 쉼표,문자를 추가하면 "전체 호출 과정" 그림의 2번 과정에서 실패가 발생한다.

중복 메시지가 발생된 원인은 CompletableFuture 설정의 대기열 큐가 터져 TaskRejectedException이 발생하였고 UncheckedException이기 때문에 알림톡이 발송된 사람을 저장하는 member_recipient 테이블이 롤백되었다. 해당 배치는 1초마다 다시 실행되었고 상위에 위치한 180명에게 똑같은 알림톡이 발송되는 상황이 반복되었다. 로그는 아래와 같다.
org.springframework.core.task.TaskRejectedException: Executor [java.util.concurrent.ThreadPoolExecutor@7098e80b[Running, pool size = 3, active threads = 3, queued tasks = 100, completed tasks = 5578]] did not accept task: java.util.concurrent.CompletableFuture$AsyncRun@2c8d08c7
at org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor.execute(ThreadPoolTaskExecutor.java:363)
at java.base/java.util.concurrent.CompletableFuture.asyncRunStage(CompletableFuture.java:1750)
at java.base/java.util.concurrent.CompletableFuture.runAsync(CompletableFuture.java:1965)
at kr.co.bomapp.domain.rds.alimtalk.recipient.service.AlimTalkRecipientSendService.lambda$sendAlimTalkMessageSetting$4(AlimTalkRecipientSendService.java:134)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1541)
Caused by: java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.CompletableFuture$AsyncRun@2c8d08c7 rejected from java.util.concurrent.ThreadPoolExecutor@7098e80b[Running, pool size = 3, active threads = 3, queued tasks = 100, completed tasks = 5578]
at java.base/java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2055)
at java.base/java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:825)
at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1355)
at org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor.execute(ThreadPoolTaskExecutor.java:360)
대기열 큐 초과의 원인은 아래와 같다.
마지막으로 가장 큰 원인은 대규모 발송에도 불구하고 테스트 없이 진행되었고 CompletableFuture의 설정을 제대로 확인하지 않았다는 점이다.

일단 먼저 member_recipient 테이블의 저장 방식을 바꿔 중복 메시지가 발생하지 않는 안정적인 방식으로 바꾸었다.
[AS-IS]
[TO-BE]
알림톡 발송 방식은 보다 안전하게 개선되었다.
외부 서비스의 TPS 제한 및 Open API의 TPS 제한, 그리고 스레드풀의 기본/최대 개수를 고려하여 비동기 작업 처리에 사용되는 대기열 큐의 크기를 200으로 설정하였다.
발송 작업은 페이징 단위(Pagination Size)로 분할해 비동기로 병렬 처리되며, 각 작업이 모두 완료될 때까지 다음 단계로 진행되지 않도록 하기 위해 다음과 같이 CompletableFuture를 사용하였다:
CompletableFuture.allOf(currentBatchTasks.toArray(new CompletableFuture[0])).join();
이를 통해 외부 API의 TPS를 초과하지 않도록 제어하면서도 안정적인 대량 발송 처리를 구현할 수 있었다.
한편, 해당 작업은 I/O Bound 성격이기 때문에 기존에 설정되어 있던 전용 스레드풀의 크기(4개)로는 4개 작업이 모두 완료될 때까지 다음 작업이 대기해야 했다.
이를 개선하기 위해 스레드풀의 최대 스레드 수를 100개로 증가시켰고, 실행 중 CPU 사용률은 최대 38.7% 수준으로 관찰되었다.
CPU 리소스를 고려하면 더 많은 스레드를 설정할 여유가 있었지만, 결국 Open API의 TPS 제한을 초과하지 않기 위해 스레드 수를 100개로 유지하였다.
알림톡 발송에는 비용이 발생하기 때문에, 실제 발송 대신 외부 서비스와 동일한 요청/응답 구조를 갖는 Mock Controller를 만들어 테스트를 진행했다.
Mock Controller에서 1초마다 TPS를 측정하여 로그로 출력하고, TPS를 초과하는 순간 예외를 발생시키도록 했다.
중복 메시지 여부 확인 방식

7번 과정은 1번 과정(알림톡을 인포뱅크에 전송하는 과정)의 TPS에 따라 비슷한 수준으로 발생하는 요청이다.
그리고 7번 과정에서는 log성 테이블에 업테이트가 발생한다.

1번 호출에서 1번의 업데이트가 발생한다. 이에 따라 RDS 과부하가 발생했고 CPU 사용량이 90퍼센트를 넘게 되었다.
왜 업데이트 하나만으로 CPU 사용량이 급증했을까? 아래와 같다고 생각한다.
1. InnoDB는 UPDATE마다 undo/redo 로그 생성
따라서, TPS를 조절하며 RDS의 CPU를 안정적으로 유지될 수 있는 적절한 TPS를 찾아갔다.

알림톡 전송 로직에서 message_id를 통해 alimtalk_notification_log 테이블에서 해당 로그를 찾는(find) 과정과, 해당 멤버에게 보낸 메시지의 성공 여부 칼럼을 갱신(update)하는 과정은 트랜잭션이 분리되어 있다.
따라서, 해당 과정이 장시간 커넥션을 점유하지는 않았다.
하지만 테스트를 진행하던 중 예상치 못한 문제를 발견했다.
나는 코드를 작성할 때, 정합성이 반드시 보장될 필요가 없다면 트랜잭션을 짧게 유지하려고 한다.
이는 트랜잭션이 길어질 경우, 커넥션이 장시간 점유되고, 언두 로그(Undo Log) 메모리에 오랜 시간 머물러 RDBMS 성능에 부정적인 영향을 미치기 때문이다.
그러나 이 과정에서 트레이드 오프(Trade-off)가 발생한다는 점은 간과했다.
해당 작업은 CPU를 소모하는 복잡한 연산이 아닌 단순한 네트워크 I/O 작업임에도 불구하고, CPU 성능이 기대보다 낮았다.
이에 대해 오랜 시간 고민한 결과, 트랜잭션을 생성하고 종료하는 과정 자체가 CPU 자원을 소모한다는 사실을 깨달았다.
트랜잭션을 하나로 묶고 부하 테스트를 다시 진행한 결과, CPU 최대 부하 상황에서 약 10%의 성능 개선이 이루어졌다.
해당 작업은 정합성을 보장할 필요가 없는 작업이지만, 트랜잭션을 하나로 묶는 것이 적절한지 고민했다.
그 이유는 코드를 읽는 다른 개발자가 요구사항을 오해하여, 정합성이 중요한 작업으로 잘못 해석할 가능성이 있기 때문이다.
하지만 아래와 같은 이유로 트랜잭션을 하나로 묶는 것이 합리적이라고 판단했다.
트랜잭션을 하나로 묶고, 해당 기능의 트랜잭션이 필요한 이유를 명확하게 주석으로 추가하기로 했다. 이를 통해, CPU 성능을 최적화하고, 코드를 읽는 개발자가 오해하지 않도록 예방할 수 있다.
Artillery라는 도구를 사용하여 테스트를 진행하였다. dev 환경에서 진행했으며 해당 dev의 서버 사양은 t4g.large이다. dev 환경에서 진행한 이유는 prod 혹은 stg에서 진행하면 실제 서비스에 영향이 가기 때문이었다.

Artillery라는 도구를 사용해 시나리로를 작성했고 해당 시나리오는 인포뱅크에서 요구하는 TPS 200을 시작으로 OPEN API 서버가 터지지 않은 적절한 TPS를 찾아갔다.
시나리오 예시는 아래와 같다.
config:
target: "https://dev-openapi.co.kr" # 요청을 보낼 대상 URL
processor : "./messageId.js"
phases:
- duration: 50 # 테스트 지속 시간 (초)
arrivalRate: 1
rampTo : 100
name : Ramp 1 to 100
- duration : 150
arrivalRate : 100
rampTo : 180
name : Ramp 100 to 180
- duration : 100
arrivalRate : 180
name : maintain 180
- duration : 100
arrivalRate : 180
rampTo : 0
name : Ramp 180 to 0
scenarios:
- name: "Example Scenario"
flow:
- function: "generateMessageId" # messageId 생성
- post:
url: "/dev_only/reception-result" # API 엔드포인트
headers:
authorization: "Bearer accessToken"
json: # 요청 바디 (JSON 형식)
messageId : "{{ messageId }}"
resultCode : "SUC"
errorText: ""
reportTime: "2024-11-25 11:12:12"
brandtalkType : "F"
ref : ""
DEV 환경에서 테스트 진행 결과, TPS 80에서 CPU 53%를 사용하였다.
그 외의 TPS에서는 CPU 사용률이 저조하거나 혹은 80%를 이상을 사용하거나 실패 응답이 포함되어 있었기에 TPS 90에 맞게 1번 요청을 진행하기로 결정했다. 따라서 아래와 같은 순서도가 완성되었다. 하지만 DEV 환경은 PROD 환경보다 서버 사양이 많이 낮기 때문에 측정한 TPS에서는 완전히 안정적으로 운영될 것이라고 확신했다.


중복 알림톡 문제를 해결하기 위해 서비스를 재정비한 후, 정확히 일주일 뒤 40만 명을 대상으로 알림톡을 다시 발송했다.
그 결과, 중복 메시지 없이 알림톡이 정상적으로 발송되었다.
이번 경험을 통해 나는 다음과 같은 중요한 교훈을 얻었다.
이 과정은 단순한 문제 해결을 넘어, 시스템 설계와 운영에 대한 깊은 인사이트를 제공하는 값진 경험이 되었다.