아티클/개발

대량 알림톡 발송 API의 응답 지연을 줄인 방법 — Node.js와 MongoDB

프로필

오웬

2026. 3. 22.·조회수 1회·

안경원 고객 관리 프로그램에는 고객에게 알림톡을 보내는 기능이 있다.

서비스를 운영하면서 한 번에 3,000건, 많게는 5,000건을 발송하는 센터가 늘었다. 그러다 어느 순간부터 발송 버튼을 누르면 로딩이 수십 초씩 이어졌고, 타임아웃이 발생하기도 했다.

이 문제를 해결하면서 고민한 건 응답 속도만이 아니었다. 사용자에게는 빠르게 응답하면서도, 뒤에서 처리하는 작업에 문제가 생겼을 때 그 흔적을 남길 수 있어야 했다.

모든 작업을 끝낸 뒤에 응답하던 구조

기존에는 하나의 요청 안에서 다음 작업을 순서대로 기다렸다.

  • 외부 메시지 API에 대량 발송을 요청한다.
  • 개별 발송 이력을 MongoDB에 insertMany로 저장한다.
  • 모든 작업이 끝나면 클라이언트에 응답한다.

문제는 상세 이력 저장까지 끝나야 사용자에게 응답할 수 있다는 점이었다.

외부 API 요청이 끝났어도 수천 건의 이력을 저장하는 동안 사용자는 계속 로딩 화면을 봐야 했다. 대량 발송이 진행될 때 다른 사용자의 요청까지 느려지는 현상도 있었다.

다만 이 두 문제는 구분할 필요가 있었다. 비동기 DB 작업을 await로 기다리는 것 자체가 이벤트 루프를 막는다는 뜻은 아니다. 다른 요청까지 느려진 정확한 원인은 별도 측정이 필요했다.

우선 명확한 것은, 사용자 응답이 상세 이력 저장 시간에 묶여 있다는 점이었다.

응답을 빠르게 만드는 것만으로는 부족했다

먼저 Redis와 BullMQ 같은 작업 큐를 검토했다.

요청 처리와 백그라운드 작업을 분리하고, 재시도나 작업 상태 관리까지 체계적으로 다룰 수 있는 방법이었다. 다만 당시에는 개발과 운영을 사실상 혼자 맡고 있었다. Redis를 추가하면 관리해야 할 인프라도 늘어난다.

현재 규모에서 그 비용까지 감수해야 하는지 고민이 됐다.

다른 방법은 상세 이력 저장을 기다리지 않고 먼저 응답하는 것이었다. 이른바 Fire & Forget 방식이다.

응답 시간은 줄일 수 있지만, 저장 도중 프로세스가 종료되면 문제가 생긴다. 외부 API에는 발송을 요청했는데 우리 DB에는 이력이 일부만 남거나, 아무것도 남지 않을 수 있었다.

빠른 응답을 얻는 대신, 실패 여부조차 추적하기 어려워지는 구조는 피하고 싶었다.

필요한 것은 별도 인프라를 추가하지 않으면서, 미완료 작업을 나중에라도 확인할 수 있는 방법이었다.

상세 이력보다 먼저 작업 상태를 남겼다

그래서 발송 묶음의 상태를 기록하는 SmsBatch 문서를 추가했다.

변경한 흐름은 다음과 같다.

  • 외부 API에 발송을 요청한다.
  • SmsBatch 문서를 status: 'processing' 상태로 저장한다.
  • 배치 문서 저장이 끝나면 클라이언트에 응답한다.
  • 상세 이력은 같은 서버 프로세스에서 백그라운드로 저장한다.
  • 상세 이력 저장이 정상적으로 끝나면 배치 상태를 completed로 변경한다.

핵심은 사용자에게 응답하기 전에 저장해야 할 데이터를 줄이는 것이었다.

기존에는 수천 건의 상세 이력을 모두 저장해야 했다. 변경 후에는 배치 상태 문서를 먼저 남기고, 상세 이력 저장을 응답 이후로 옮겼다.

여기서 응답이 끝났다는 것은 외부 API 발송 요청과 배치 기록을 마쳤다는 의미다. 고객에게 메시지가 실제로 전달됐거나, 상세 이력 저장까지 모두 끝났다는 의미는 아니다.

상세 이력은 청크로 나눠 저장했다

백그라운드 저장에서는 5,000건의 이력을 500건 단위로 나눴다.

각 청크는 insertMany로 저장하고, ordered: false 옵션을 사용했다. 일부 문서에서 쓰기 오류가 발생하더라도 나머지 문서의 삽입을 계속 시도하도록 하기 위해서였다.

다만 이 옵션이 모든 문서의 저장 성공을 보장하는 것은 아니다. 부분 실패는 별도로 확인해야 하며, 단순히 삽입을 시도했다는 이유만으로 배치를 완료 처리해서는 안 된다.

청크 사이에는 await sleep(50)으로 50ms의 비동기 대기를 넣었다. 대량 쓰기를 쉬지 않고 연속으로 실행하는 대신, 청크 사이에 간격을 둔 것이다.

이 변경은 사용자가 기다려야 하는 작업을 줄이는 것과 백그라운드 저장의 처리 속도를 조절하는 것을 나눠 접근한 결과였다.

유실을 막는 구조와 흔적을 남기는 구조는 다르다

이 방식은 영속적인 작업 큐를 대체하지는 못한다.

백그라운드 저장 도중 프로세스가 종료되면, 실행 중이던 작업이 자동으로 재개되는 것은 아니다. 대신 이미 저장된 SmsBatch가 processing 상태로 남아 있으므로, 완료되지 않은 작업을 확인할 단서는 생긴다.

물론 processing 상태라는 이유만으로 실패라고 단정할 수는 없다. 아직 실행 중인 작업일 수도 있기 때문이다. 실제 점검 시에는 작업이 시작된 시각과 경과 시간도 함께 봐야 한다.

사각지대도 남아 있다. 외부 API 요청은 끝났지만 SmsBatch를 저장하기 전에 프로세스가 종료되면, 이 방식으로도 작업 흔적을 남기지 못한다.

따라서 이 설계가 해결한 범위는 분명하다.

작업 유실을 완전히 방지한 것이 아니라, 배치 기록 이후에 발생한 미완료 작업을 추적할 수 있게 만든 것이다.

결과와 남은 기준

3,200건 발송 기준으로, 기존에는 십수 초 이상 걸리거나 타임아웃이 발생하던 API의 응답 시간이 개선 후 평균 약 3.5초로 줄었다.

여기서 줄어든 시간은 상세 이력 저장까지 모두 끝나는 시간이 아니라, 클라이언트가 응답을 받을 때까지의 시간이다.

Redis나 별도의 작업 큐 서버를 추가하지 않고, 기존 Node.js 서버 안에서 응답 처리와 상세 이력 저장을 분리했다.

이번 경험에서 배운 건, 무조건 큐를 도입하거나 반대로 끝까지 도입하지 않는 것이 정답은 아니라는 점이다.

현재 규모에서 감당할 수 있는 복잡도를 선택하되, 그 선택이 무엇을 해결하고 무엇을 보장하지 못하는지는 알고 있어야 했다.

앞으로 자동 재시도나 장애 이후 복구가 중요해지면, 영속적인 작업 큐를 도입할 이유도 더 분명해질 것이다. 그때도 SmsBatch로 구분해둔 작업 단위와 상태 개념은 설계를 확장하는 출발점이 될 수 있다.

지금 필요한 만큼 단순하게 만들되, 남아 있는 한계까지 설명할 수 있는 구조를 선택하는 것. 이번 작업에서 얻은 기준이다.

댓글 0개

댓글은 로그인 후 작성할 수 있어요.

로그인하기

아직 작성된 댓글이 없습니다.