매일공부

[메일 발송] 700명한테 보내는데 갑자기 느려진 이유 — 진단부터 자동화까지 본문

IT/작업기록

[메일 발송] 700명한테 보내는데 갑자기 느려진 이유 — 진단부터 자동화까지

julian 2026. 9. 7. 13:29

배경

홍보 메일을 두 건 연달아 발송했다. 앞의 메일은 13명한테 보내는 거라 순식간에 전체 성공했고, 뒤이어 보낸 약 650~700명짜리 메일이 문제였다 — 초반 50~100명까지는 정상 속도로 나가다가, 그 지점을 넘기고 나서부터 갑자기 1분에 5~6건으로 뚝 떨어졌다. 원인을 찾다가 두 번이나 진단을 정정하게 된 이야기.

1차 의심 — 첨부파일 (이번엔 해당 없음)

이전에 다른 캠페인에서 4MB짜리 PDF 첨부파일 때문에 느려진 적이 있었다. 수신자마다 첨부파일을 매번 다시 읽어서 base64로 인코딩하고 SMTP로 업로드하는 구조였는데, 4MB → base64 인코딩 시 약 5.4MB, 수백 명이면 총 수 GB를 업로드하는 셈이었다. 이번엔 첨부파일이 없어서 이 원인은 아니었다.

2차 의심 — 본문에 박힌 이미지 (틀린 진단)

이번엔 첨부파일 대신 이번 캠페인 본문에 298KB짜리 이미지가 에디터를 통해 base64로 삽입되어 있다는 걸 발견했다. 확인 과정에서 서명(signature) 설정도 같이 들여다봤는데:

SELECT LENGTH(signature_html) l, LEFT(signature_html,80) sample FROM smtp_settings LIMIT 1
-- 결과: signature_html 안에도 <img src="data:image/png;base64,...">

서명에도 base64 이미지가 박혀있다는 걸 확인했다. 메일 본문 생성 코드가 캠페인 본문 + 서명 HTML을 그대로 합쳐서 매 수신자에게 보내는 구조라, 본문의 298KB 이미지가 base64로 인코딩되면 텍스트로는 약 400KB가 되고, 이게 수신자 전원에게 매번 통째로 재전송된다는 계산이 나왔다.

업로드 속도(약 30.9KB/s)와 발송 로그(분당 정확히 56건씩)를 대조해보니 숫자가 딱 맞아떨어져서, "이거다" 싶었다.

서버 측 이미지 업로드 기능을 먼저 구현

원인이 확정된 것처럼 보여서, 근본적인 해결책부터 만들었다. 문제는 에디터(Quill)의 기본 이미지 삽입 동작 자체였다 — 툴바의 이미지 버튼으로 삽입하거나 이미지를 복사해서 붙여넣으면, 에디터가 파일을 자동으로 base64로 읽어서 HTML 안에 그대로 박아넣는다. 이 동작을 바꾸지 않으면 나중에 또 같은 문제가 재발할 게 뻔했다.

구현 방향:

  1. 서버에 이미지 업로드 API 추가 — 업로드된 파일을 저장하고 URL을 반환
  2. Quill 에디터에 커스텀 이미지 핸들러 등록 — 이미지 버튼 클릭 시 base64 대신 이 API로 업로드하고, 반환받은 URL로 <img src="..."> 삽입
  3. 이미지 붙여넣기(paste)도 동일하게 가로채서 서버 업로드로 처리
  4. 서명 에디터 + 캠페인 본문 에디터 둘 다 적용
  5. 기존에 서명에 박혀있던 base64 이미지도 파일로 추출해서 URL로 교체
// Quill 툴바의 기본 이미지 삽입 동작을 가로채서, base64 대신 서버 업로드로 대체
function useServerImageUpload(quill) {
    var toolbar = quill.getModule('toolbar');
    toolbar.addHandler('image', function () {
        var input = document.createElement('input');
        input.setAttribute('type', 'file');
        input.setAttribute('accept', 'image/*');
        input.click();
        input.onchange = function () {
            var file = input.files[0];
            if (file) uploadImageAndInsert(quill, file);
        };
    });

    // 복사-붙여넣기로 들어오는 이미지도 기본은 base64라서 동일하게 가로챈다
    quill.clipboard.addMatcher('IMG', function (node, delta) {
        var src = node.getAttribute('src') || '';
        if (src.indexOf('data:image') !== 0) return delta;
        // base64 데이터를 파일로 변환해서 서버에 업로드
        ...
    });
}

정정 — 확인한 이미지가 진짜 용의자가 아니었다

구현을 마치고 기존 서명의 base64 이미지를 실제로 파일로 추출해봤더니, 각각 7KB, 9KB 수준으로 아주 작았다. 이건 처음부터 서명에 있던 로고 이미지들이라 이번 대량 발송 지연과는 무관했다 — 애초에 의심했던 건 서명이 아니라 이번 캠페인 본문에 들어간 298KB 이미지였는데, 서명 쪽만 추출/확인하다 보니 "이미지가 작아서 이론이 틀렸다"는 결론으로 잘못 흘러갔다.

실제로 짚어봐야 했던 건 본문의 298KB 이미지 자체였다. 하지만 이미지 크기와 무관하게, 아래의 발송 패턴이 이미지 크기 이론 자체를 기각하는 더 결정적인 증거였다.

결정적 증거 1 — 이미지가 있어도 소량 발송은 멀쩡했다

가장 확실한 반증은 같은 이미지가 들어간 메일을 적은 인원에게 보냈을 때였다.

  • 13명 발송 (298KB 이미지 포함) → 1분도 안 돼서 완료
  • 371명 발송 (같은 시기 이미지 없는 메일) → 5~6건/분으로 1시간 넘게 소요

이미지 유무와 무관하게, 수신자 수가 많을 때만 느려진다는 게 명확해졌다. 이미지가 있든 없든 소량 발송은 항상 빨랐다는 것 자체가, 이미지 크기 이론을 확실하게 기각하는 증거였다.

결정적 증거 2 — "초반엔 정상, 특정 지점부터 갑자기 느려짐"

실제 발송 로그를 다시 들여다보니 결정적인 패턴이 하나 더 있었다.

  • 초반 2분 — 약 93건이 빠르게 처리됨 (초당 거의 1건 수준)
  • 그 이후 — 계속 분당 5~6건으로 뚝 떨어져서 끝까지 그 속도로 고정됨

이 패턴이 이미지 크기 이론을 완전히 기각하고, SMTP 스로틀링 이론을 확정 짓는 증거였다.

  • 이미지/본문 크기가 병목이었다면: 메일 하나하나의 크기는 처음부터 끝까지 항상 똑같다. 그렇다면 느려지는 것도 처음부터 일정하게 느려야 한다 — "초반 90여 건은 빠르고 그다음부터 갑자기 느려진다"는 패턴은 크기 이론으로는 설명이 안 된다
  • SMTP 서버의 버스트 제한(rate limit)이라면: 많은 메일 서비스가 "짧은 시간 안에 일정 개수까지는 빠르게 허용하고, 그 이상부터는 강제로 늦춘다"는 방식을 쓴다. 초반 정상 속도 → 특정 지점부터 갑자기 뚝 떨어지는 패턴과 정확히 일치한다

두 증거를 합치면: 13명처럼 소량이면 제한 threshold(약 90건 안팎으로 추정)에 도달하기 전에 끝나니까 빠르고, 371명 이상처럼 많으면 중간부터 스로틀링이 걸리는 것으로 설명이 딱 맞아떨어진다. 이 근거로 메일 서비스 쪽에 "계정/시간당 발송량 제한이 있는지, 대량 발송 시 속도 제한 정책이 있는지" 문의했다.

다음 실험 — 세션을 끊었다 다시 여는 방식

메일 서비스의 답변을 기다리는 동안 시도해볼 만한 실험도 하나 정리해뒀다: 일정 건수(예: 80~90건)마다 SMTP 연결을 끊고(server.quit()) 다시 로그인해서 새 세션을 여는 방식으로 코드를 바꿔보는 것.

  • 만약 세션 단위 제한이라면 → 새 세션을 열 때마다 카운터가 리셋되어 스로틀링을 우회할 수 있을 것
  • 만약 계정 전체 단위 제한이라면 → 세션을 새로 열어도 효과가 없을 것

이 실험 자체가 "제한이 세션 기준인지 계정 기준인지"를 구분해주는 값싼 테스트라, 답변을 받기 전에 미리 해볼 가치가 있었다.

도중에 발견한 것 — 계정이 하루 발송 한도를 넘겨서 멈춰있었다

실험을 준비하던 중, 이런 알림 메일을 발견했다.

고객님 계정은 하루에 보낼 수 있는 발송건수를 모두 사용하였습니다. ※ 1일 최대 발송 건수: 500건

이걸로 그동안의 증상이 완전히 설명됐다. 초반에 빠르게 나가다가 5~6건/분으로 뚝 떨어진 건, 하루 500건 한도에 가까워지면서 메일 서비스가 자체적으로 속도를 줄이다가, 결국 한도를 넘긴 시점부터는 전부 실패 처리된 것이었다. 그리고 그 실패 상태는 사람이 이 알림 메일을 보고 수동으로 "발송 초기화"를 눌러야만 풀리는 구조였다 — 자동으로 복구되지 않았다.

메일 서비스의 공식 답변 — 정확한 제한 수치 확인

문의한 결과, 메일 서비스 쪽에서 정확한 정책을 답변받았다.

단시간대에 많은 수신자에게 메일을 발송하실 경우 발신 서버에 부하가 생기거나 제한이 있을 수 있는 점 안내드립니다. 발신 용량(텍스트 본문+첨부파일) 10MB 초과 시, 10분당 50통 / 10MB 미만일 경우 10분당 100통으로 설정하여 대용량 발송을 진행해주시길 권장드립니다.

정리하면 두 가지 규칙이 겹쳐 있었다.

  1. 10분당 발송 한도 — 메일 크기가 10MB 미만이면 10분당 100통, 10MB 이상이면 10분당 50통. 이건 초반에 너무 빨리 몰아서 보내면 걸리는 규칙
  2. 하루 총 발송 한도(500건) — 이건 위 규칙과 별개로, 하루 전체 발송량을 누적해서 카운트하는 규칙

구현 — 발송 속도 자동 조절 + 하루 한도 자동 이월

두 규칙을 코드에 반영했다.

발송 간격 자동 조절: 메일 크기(첨부파일 포함)를 계산해서, 10MB 미만이면 6.7초 간격(10분당 90통, 여유 10통), 10MB 이상이면 13.3초 간격(10분당 45통, 여유 5통)으로 자동으로 페이싱을 조절한다.

일일 한도(450건, 여유를 둔 값) 및 자동 이어보내기:

  • 그날 실제 성공 발송 건수(테스트 발송 포함 — 메일 서비스 쪽에서는 구분 없이 다 카운트하기 때문)를 DB에서 세서, 450건에 도달하면 그 시점에서 멈추고 남은 수신자는 건드리지 않는다
  • 남은 수신자가 있으면 캠페인 상태를 "대기중"으로 유지 → 다음날 한도가 풀리면 예약 발송 스크립트가 자동으로 이어서 발송한다
  • 이미 성공한 사람에게 중복 발송되지 않도록, 다음 발송 대상을 조회할 때 이미 보낸 사람은 제외 처리한다 (사실 이건 기존 "재시도" 기능에도 있던 잠재적 중복발송 버그였는데 겸사겸사 같이 고쳤다)

이렇게 하면 "실패 알림 메일을 보고 사람이 수동으로 초기화하는" 과정 자체가 없어진다 — 한도에 도달하기 전에 앱이 스스로 멈추고, 다음날 자동으로 이어받기 때문이다.

예비 계정을 추가할지는 별개의 선택

한도에 걸리면 무조건 다음날까지 기다려야 하니, 만약 큰 캠페인을 하루 안에 다 보내야 하는 경우가 자주 있다면 예비 SMTP 계정을 추가로 등록해서 한도에 다다르면 자동으로 전환하는 방법도 있다. 다만:

  • 계정을 하나 더 만들어야 하고(서비스 쪽 비용/신청이 필요할 수 있음)
  • 발신 주소가 계정마다 다를 수 있어 수신자 입장에서 헷갈릴 수 있음

이번 경우엔 홍보 메일이라 "며칠에 걸쳐 나눠 보내도 상관없다"는 확인을 받아서, 예비 계정 없이 하루 한도 내에서 자동으로 끊고 다음날 이어서 보내는 방식만 구현하기로 했다. 캠페인 성격(긴급도)에 따라 이 부분은 판단이 달라질 수 있다.

참고 — 페이싱을 적용하면 체감상 느려 보일 수 있다

450명 캠페인을 하루치 한도 안에서 보내도, 페이싱(6.7초 간격) 때문에 실제로는 약 50분 정도 걸린다. 이건 버그가 아니라 의도한 정상 동작이다 — "왜 이렇게 느려졌지"라고 놀랄 필요는 없다.

그래도 이미지 → 링크 방식 개선은 유효했다

진짜 병목의 원인은 아니었지만, base64 인라인 이미지 자체는 여전히 고쳐야 할 나쁜 습관이었다.

  • base64로 이미지를 박으면 메일 크기가 불필요하게 커진다
  • 스팸 필터가 대용량 인라인 이미지를 의심스럽게 볼 수 있다
  • 이미지 하나 바꾸려면 모든 발송 메일의 본문 크기에 다 영향을 준다

그래서 이번 진단이 틀렸더라도, 서버 업로드 방식으로 바꾼 작업 자체는 그대로 유지하기로 했다.

배포하고 나니 이미지가 깨졌다 — 로컬/운영 파일시스템은 별개다

기능을 로컬 개발 환경에서 완성하고 배포했는데, 정작 운영 서버에서 이미지가 깨졌다. 원인은 간단했다 — 업로드 폴더가 .gitignore에 등록되어 있어서, 로컬에서 만든 이미지 파일이 git에 올라가지 않았다. 배포할 때 코드는 반영됐지만, 로컬에만 저장되어 있던 실제 이미지 파일은 운영 서버로 넘어가지 않았던 것 — 그래서 이미지 URL 요청이 404가 났다.

더 근본적인 문제: 이 업로드 기능 자체가 "로컬 개발 서버에서 올린 이미지는 로컬에만 저장되고, 운영 서버에서 올린 이미지는 운영 서버에만 저장되는" 구조였다. 로컬과 운영은 파일시스템을 공유하지 않으니, "로컬에서 테스트하고 운영에 반영"하는 일반적인 개발 흐름이 파일 업로드 기능에는 그대로 적용되지 않는다.

해결: 이미 만들어진 이미지 파일 2개는 직접 서버로 옮기거나(SCP), 운영 서버에서 다시 업로드하는 방법으로 처리했다. 그리고 앞으로는 "실제로 사용할 환경(운영 서버)에서 직접 업로드"하는 걸 원칙으로 삼기로 했다.

정리

  1. 같은 조건을 소량으로 재현해보면 원인을 빠르게 좁힐 수 있다 — "이미지가 있는데도 13명은 순식간에 끝났다"는 대조 실험 하나가, 긴 계산이나 로그 분석보다 더 확실하게 이미지 이론을 기각시켰다
  2. "언제부터 느려지기 시작했는가"가 원인의 성격을 알려준다 — 처음부터 일정하게 느리면 메일 하나하나의 속성(크기 등)을 의심하고, 특정 지점부터 갑자기 느려지면 누적량에 반응하는 서버 쪽 정책(속도 제한)을 의심해야 한다
  3. 네트워크/외부 서비스 쪽 병목은 코드로 못 고친다 — 하지만 정책을 알면 코드로 대응은 할 수 있다 — 서비스 제공자에게 정확한 제한 수치(10분당 발송량, 하루 총량)를 확인받고 나면, 그 수치에 맞춰 발송 속도를 조절하고 한도 도달 전에 스스로 멈췄다가 자동으로 이어받는 로직을 코드로 구현할 수 있다. 문의 자체가 다음 구현의 설계도가 된 셈
  4. "사람이 알림을 보고 수동으로 처리해야 하는 지점"은 자동화 대상이다 — 하루 한도 초과로 계정이 막히고, 그걸 사람이 메일 보고 수동으로 초기화해야 했던 구조 자체가 근본 문제였다. 한도에 도달하기 전에 앱이 스스로 멈추고 다음 주기에 자동으로 이어받게 만들면, 그 수동 개입 지점 자체가 사라진다
  5. 에디터의 기본 이미지 삽입 동작(base64)은 원인이 아니어도 고칠 가치가 있다 — 메일 크기, 스팸 필터 관점에서 base64 인라인 이미지는 어차피 좋은 습관이 아니다
  6. 파일 업로드 기능은 "로컬에서 개발 → 운영에 배포"라는 일반적인 흐름이 그대로 안 통할 수 있다 — 코드는 git으로 배포되지만, 실제 업로드된 파일은 그 서버의 로컬 디스크에만 있다. 이런 기능은 실제 사용 환경에서 직접 데이터를 만들거나, 배포 시 업로드 폴더도 함께 동기화하는 절차가 필요하다
Comments