- 빅데이터분석기사
- 이것이 취업을 위한 코딩테스트다 with 파이썬
- 빅데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- 기초다지기
- 프로그래머스
- unsub
- PY4E
- 데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- 빅분기
- 코딩테스트
- SQL
- 정보처리기사
- DB
- Oracle
- 코딩테스트 python
- 데이터베이스
- 오라클
- Ai
- boostcoures
- [멀티잇]데이터 시각화&분석 취업캠프(Python)
- 인공지능기초다지기
- 네이버부스트캠프
- 이기적
- 부스트코스
- 파이썬
- boostcourse
- python
- Django
- r
- AI 플랫폼을 활용한 데이터 분석
- Today
- Total
매일공부
발송 이력과 장애 격리: "부가 기능의 실패가 핵심 기능을 끌고 내려가지 않게" 본문
이메일 캠페인 도구 개발기 시리즈 (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()이 실패한다. 문제는:
- 방금 메일 발송에는 성공했는데도, 뒤이은 로그 INSERT 실패가 예외를 던져서 except로 떨어져 실패로 오집계된다.
- 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회성 사내 도구에 필요 이상의 기능을 미리 만들지 않는다는 원칙을 지켰다.
'IT > 작업기록' 카테고리의 다른 글
| Django(Python)와 Node.js/NestJS, 뭐가 다를까 — 스택 전환을 고민하다 미룬 이유 (0) | 2026.09.01 |
|---|---|
| black은 왜 훅에 없을까 — 포매터와 검증 도구는 다르다 (0) | 2026.08.31 |
| 서로 다른 두 고객 데이터소스를 하나의 구독자 목록으로 통합하기 (0) | 2026.08.31 |
| HTML 이메일 렌더링 트러블슈팅: Quill 에디터, 표 스타일, 그리고 naver.com만 반송되던 버그 (0) | 2026.08.31 |
| 캠페인 상태 머신: 테스트 없이 실제 메일이 나가지 않도록 만들기 (0) | 2026.08.31 |