매일공부

발송 이력과 장애 격리: "부가 기능의 실패가 핵심 기능을 끌고 내려가지 않게" 본문

IT/작업기록

발송 이력과 장애 격리: "부가 기능의 실패가 핵심 기능을 끌고 내려가지 않게"

aram 2026. 8. 31. 15:07

이메일 캠페인 도구 개발기 시리즈 (8/9). 전체 개요

왜 필요했나

send_messages(발송 함수)는 원래 성공 시 카운터만 올리고, 실패는 print()로 콘솔에만 남겼다. 캠페인이 sent로 바뀐 뒤에는 "실제로 누구에게 성공적으로 발송됐는지"를 나중에 조회할 방법이 전혀 없었다 — 수신자 목록 자체가 발송 시점에 동적으로 계산될 뿐 저장되지 않기 때문. 최소 기능으로 "누구한테 보낸 게 성공/실패했는지" 이력을 남기고, 캠페인 상세 화면에서 조회할 수 있게 했다.

스키마

CREATE TABLE campaign_send_logs (
  id INT NOT NULL AUTO_INCREMENT,
  campaign_id INT NOT NULL,
  subscriber_id INT NULL,
  email VARCHAR(255) NOT NULL,
  status ENUM('success','failed') NOT NULL,
  error_message VARCHAR(500) NULL,
  is_test TINYINT(1) NOT NULL DEFAULT 0,
  sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  INDEX (campaign_id),
  CONSTRAINT FOREIGN KEY (campaign_id) REFERENCES campaigns(id)
    ON UPDATE RESTRICT ON DELETE CASCADE,
  CONSTRAINT FOREIGN KEY (subscriber_id) REFERENCES subscribers(id)
    ON UPDATE RESTRICT ON DELETE SET NULL
);

설계 포인트:

  • email을 subscriber_id와 별도로 스냅샷 저장한다. 나중에 그 구독자가 이메일을 수정하거나 삭제되어도 "그때 이 주소로 보냈다"는 사실 자체는 변하지 않아야 하기 때문. subscriber_id는 삭제 시 SET NULL로 두어 구독자 삭제를 막지 않는다(FK 제약으로 다른 기능이 막히는 상황을 피함 — 4편의 CASCADE/RESTRICT 이슈와 같은 맥락).
  • is_test 플래그로 테스트 발송 이력과 실제 캠페인 이력을 분리. 캠페인 상세 화면의 기본 이력 표는 is_test=0만 보여준다.

가장 중요했던 버그: DB 순단으로 발송 전체가 중단되던 문제

증상

테스트 발송 중 pymysql.err.OperationalError: (2003, "Can't connect to MySQL server ... (timed out)")가 발생하며 발송 자체가 실패로 끝났다. 그런데 그 직전 같은 캠페인의 다른 수신자들은 campaign_send_logs에 이미 success로 정상 기록돼 있었다 — 즉 SMTP 발송 자체는 계속 되고 있었는데, DB 쪽 순단 때문에 전체가 죽은 것.

원인

처음 구현은 이런 구조였다:

try:
    ...
    server.sendmail(...)
    success_count += 1
    log_send_result(cur, ..., 'success', ...)      # DB INSERT
except Exception as e:
    fail_count += 1
    log_send_result(cur, ..., 'failed', str(e), ...)  # 이것도 DB INSERT

DB 커넥션이 일시적으로 끊기면 log_send_result 내부의 INSERT/commit()이 실패한다. 문제는:

  1. 방금 메일 발송에는 성공했는데도, 뒤이은 로그 INSERT 실패가 예외를 던져서 except로 떨어져 실패로 오집계된다.
  2. except 블록 안에서 다시 호출한 log_send_result도 (같은 DB 문제로) 실패 하면 이번엔 어디서도 잡히지 않는 예외가 되어 send_messages 함수 전체가 죽는다 — 남은 수신자 전원에게 발송 자체가 시도조차 안 되고 중단된다.

즉 "이력을 남기는 부가 기능"의 DB 오류가 "메일을 실제로 보내는 핵심 기능"까지 끌고 내려간 것이 문제였다.

수정

메일 발송(server.sendmail)과 이력 기록(log_send_result)의 성패를 완전히 분리했다. 메일 발송 성공/실패를 먼저 확정해 카운터에 반영한 다음, 이력 기록은 별도의 try/except로 감싸서 DB 쪽 예외가 나도 콘솔에 경고만 출력하고 (발송 이력 기록 실패: ...) 절대 상위로 전파되지 않게 했다.

try:
    server.sendmail(...)
    success_count += 1
    result_status, error_message = 'success', None
except Exception as e:
    fail_count += 1
    result_status, error_message = 'failed', str(e)

try:
    log_send_result(cur, campaign_id, subscriber_id, email,
                     result_status, error_message, is_test)
except Exception as log_err:
    print(f"발송 이력 기록 실패: {log_err}")

이제 DB가 일시적으로 끊겨도: 그 순간의 이력 한 건만 유실될 뿐, 메일 발송 성공/실패 판정과 다음 수신자로의 진행에는 전혀 영향을 주지 않는다.

또한 각 로그는 전체 발송이 끝날 때 한 번에 커밋하지 않고, 행 하나마다 즉시 commit한다. 그렇지 않으면 수백 건 중간에 SMTP 연결이 끊겨 함수가 중단됐을 때 이미 보낸 메일들의 로그까지 전부 유실되기 때문 — 실제 발송 여부와 로그 존재 여부가 어긋나면 이력의 의미가 없어진다.

교훈: 기존 핵심 로직(발송) 사이에 새 부가 기능(이력 기록)을 끼워 넣을 때는, 부가 기능의 실패가 핵심 로직의 실패로 오인되거나 핵심 로직을 중단시키지 않도록 반드시 별도 예외 처리로 격리해야 한다. "로깅이 실패했다고 본 작업까지 실패해서는 안 된다"는 원칙은 당연해 보이지만, try/except를 어디에 겹쳐 놓느냐에 따라 쉽게 깨진다.

캠페인 전체가 시작도 못 하고 죽는 경우 (failure_reason)

campaign_send_logs는 수신자별 발송 루프에 진입한 이후의 실패만 잡는다. 만약 루프에 들어가기 전 단계(SMTP 서버 접속/로그인 등)에서 예외가 나면 send_scheduled_campaigns.py의 바깥쪽 except로 바로 떨어지고, 로그가 하나도 안 남은 채 캠페인이 failed로 끝난다. 이 경우 기존에는 콘솔(NAS Task Scheduler 로그)에만 원인이 남고 앱 화면에서는 그냥 "실패"로만 보였다.

campaigns.failure_reason TEXT NULL 컬럼을 추가해서, 바깥쪽 except에서 예외 메시지를 여기 저장하도록 했다. campaign_send_logs.error_message(수신자별 개별 실패 사유)와는 역할이 다르다 — 이건 "발송이 시작도 못 하고 캠페인 전체가 죽은" 경우 전용이다. 정상 발송이 끝나거나 재시도할 때는 failure_reason=NULL로 같이 초기화해서, 예전 실패 이력이 재시도 후 성공했는데도 화면에 남아있는 혼란을 막았다.

범위에서 의도적으로 제외한 것

  • 개별 실패 건 재발송(특정 수신자만 골라 재시도)은 구현하지 않았다. 현재 campaign_retry는 캠페인 전체를 다시 pending으로 돌려 전체 대상자에게 다시 발송하는 방식이라(성공한 사람도 다시 받음), 별개 이슈로 남겨뒀다 — campaign_send_logs에 이미 success로 남은 이메일을 향후 get_recipients 에서 제외하도록 개선할 수 있는 기반은 마련해둔 상태.
  • 발송 이력 CSV 내보내기, 검색/필터 UI도 포함하지 않았다. "이력이 남고 볼 수 있으면 된다"는 최소 요구 수준이었기 때문 — 1회성 사내 도구에 필요 이상의 기능을 미리 만들지 않는다는 원칙을 지켰다.
Comments