- r
- PY4E
- 데이터베이스
- 정보처리기사
- 빅분기
- SQL
- 코딩테스트
- boostcoures
- 빅데이터분석기사
- 네이버부스트캠프
- 파이썬
- 부스트코스
- boostcourse
- Django
- AI 플랫폼을 활용한 데이터 분석
- 기초다지기
- 이것이 취업을 위한 코딩테스트다 with 파이썬
- DB
- 인공지능기초다지기
- 프로그래머스
- unsub
- python
- 빅데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- 데이터 분석 기반 에너지 운영 관리자 양성 및 취업과정
- 이기적
- 코딩테스트 python
- Oracle
- 오라클
- Ai
- [멀티잇]데이터 시각화&분석 취업캠프(Python)
- Today
- Total
매일공부
이메일 캠페인에 참조(CC) 기능 추가하기 — 그리고 놓치기 쉬운 함정 하나 본문
사내에서 운영 중인 이메일 발송(수신거부/캠페인 관리) 앱에 "발송하는 메일마다 참조(CC)를 걸 수 있게 해달라"는 기능 요청이 들어왔다. 간단해 보이는 요청이었지만, 구현하면서 실제로 한 번은 겪어봐야 아는 함정을 만나서 기록해둔다.
요구사항
- 캠페인(메일 발송 단위)마다 CC 수신자를 지정할 수 있어야 한다
- 한 명이 아니라 여러 명을 지정할 수 있어야 한다
- 실제 발송뿐 아니라 테스트 발송에서도 동일하게 동작해야 한다
설계: 캠페인 단위 CC
CC를 어느 레벨에서 관리할지부터 정해야 했다. 후보는 두 가지였다.
- 시스템 전역 설정으로 고정 CC 주소를 두는 방식
- 캠페인(발송 건)마다 개별적으로 CC를 지정하는 방식
캠페인마다 성격이 다르고(부서별, 대상별로 다른 담당자가 참조로 들어가야 하는 경우가 있음), 매번 관리자가 판단해서 넣고 빼는 유연성이 필요했기 때문에 2번으로 결정했다. 구현은 단순하게 campaigns 테이블에 cc_emails 컬럼(콤마로 구분된 문자열)을 추가하고, 캠페인 작성/수정 화면에 입력란을 하나 추가하는 정도였다.
ALTER TABLE campaigns
ADD COLUMN cc_emails VARCHAR(1000) NULL DEFAULT NULL AFTER body;
진짜 함정: Cc 헤더는 "표시"일 뿐, 배달을 보장하지 않는다
여기서부터가 이번에 기록해두고 싶은 부분이다.
메일을 파이썬 smtplib로 보낼 때, 흔히 이렇게 생각하기 쉽다.
"메일 헤더에 Cc 필드를 넣으면 그 사람한테도 메일이 가겠지."
틀렸다. msg['Cc']는 메일 클라이언트가 "이 메일이 누구에게 참조로 갔는지" 보여주기 위한 표시용 헤더일 뿐이다. 실제로 어떤 주소로 메일이 배달될지는 전적으로 smtplib.sendmail()의 두 번째 인자(envelope recipients, 즉 SMTP RCPT TO 목록)가 결정한다.
즉, 아래처럼 헤더만 넣고 끝내면:
msg['Cc'] = 'cc1@example.com, cc2@example.com'
server.sendmail(from_email, [to_email], msg.as_string())
받는 사람의 메일 클라이언트에는 "참조: cc1@example.com, cc2@example.com"이 버젓이 표시되지만, cc1, cc2는 실제로 메일을 받지 못한다. 헤더에 적힌 내용과 실제 SMTP 봉투(envelope) 수신자 목록이 서로 다른 개념이라는 걸 모르면 그냥 "됐네" 하고 넘어가기 쉽고, 나중에 "왜 참조로 넣었는데 못 받았지"라는 문의가 들어와야 발견되는 종류의 버그다.
해결은 간단하다. 헤더에 넣은 CC 주소를 실제 수신자 목록에도 그대로 합쳐주면 된다.
cc_list = parse_cc_emails(campaign.get('cc_emails'))
if cc_list:
msg['Cc'] = ', '.join(cc_list)
server.sendmail(smtp['from_email'], [recipient_email] + cc_list, msg.as_string())
이렇게 하면 헤더에 표시되는 내용과 실제 배달 대상이 일치하게 된다.
배포 프로세스: 스키마 변경은 항상 마이그레이션 스크립트로
이번 작업에서 하나 더 지킨 원칙이 있다. 운영 DB에 ALTER TABLE이 필요한 변경이었는데, AI 어시스턴트에게 스키마 변경 작업을 맡기더라도 운영 DB에 직접 실행하게 하지 않고, 마이그레이션 SQL 파일만 생성하도록 한 뒤 직접 검토 후 적용했다. 스키마 변경 이력을 파일로 남겨두면 나중에 "언제 어떤 이유로 이 컬럼이 추가됐는지" 추적하기 쉽고, 실수로 프로덕션에 잘못된 변경이 바로 반영되는 위험도 막을 수 있다.
여기에 이름까지 붙이려다 보니
기능을 다 붙이고 나서, "참조로 이메일 주소만 뜨는데 누군지 이름도 보이면 좋겠다"는 생각이 들었다. 그냥 ,로 자르던 방식(split(','))을 계속 쓰면서 이름을 붙이려면, "홍길동 <hong@x.com>, kim@y.com" 같은 문자열을 직접 정규식이나 문자열 처리로 파싱해야 하는데, 이건 하다 보면 반드시 엣지 케이스에서 깨진다. 예를 들어 이름 안에 콤마가 들어간 "Hong, Gil Dong" <hong@x.com> 같은 경우다.
파이썬 표준 라이브러리에 이걸 위한 함수가 이미 있었다. email.utils.getaddresses(). 메일 헤더의 To:, Cc:처럼 "이름 있는 사람과 없는 사람이 콤마로 섞여서 나열된" 형식(RFC 5322 mailbox-list)을 파싱하도록 설계된 함수다.
from email.utils import getaddresses
getaddresses(['홍길동 <hong@x.com>, 김철수 <kim@y.com>, park@z.com'])
# → [('홍길동', 'hong@x.com'), ('김철수', 'kim@y.com'), ('', 'park@z.com')]
이름이 있으면 이름을, 없으면 빈 문자열을 채워서 (이름, 이메일) 튜플 리스트를 돌려준다. 직접 짠 파서보다 훨씬 견고하다.
다시 만난 naver.com 버그
이름을 다시 헤더 문자열로 조립할 때는 email.utils.formataddr()를 썼다.
from email.utils import formataddr
cc_list = parse_cc_emails(campaign.get('cc_emails'))
if cc_list:
msg['Cc'] = ', '.join(formataddr(addr) for addr in cc_list)
왜 그냥 f"{name} <{email}>"처럼 문자열로 이어 붙이지 않았냐면, 예전에 발신자(From) 헤더에서 겪었던 문제가 그대로 재발할 수 있기 때문이다. 이름에 한글이 섞인 상태로 이름+이메일을 통째로 이어 붙이면, 그 문자열 전체가 base64로 인코딩되면서 이메일 주소까지 인코딩에 말려드는 경우가 있다. 그러면 일부 메일 서버(특히 naver.com)가 주소를 제대로 못 읽고 반송시켜버린다.
formataddr()는 이름 부분만 필요할 때 base64로 인코딩하고, <이메일> 부분은 항상 평문으로 남긴다. 실제로 확인해보면:
formataddr(('홍길동', 'hong@example.com'))
# → '=?utf-8?b?7ZmN6ri464+Z?= <hong@example.com>'
이름만 인코딩되고, 이메일 주소는 그대로 읽을 수 있는 상태로 남는 걸 볼 수 있다.
세 단계로 정리하면
- 파싱: DB에 저장된 원본 문자열을 getaddresses()로 [(이름, 이메일), ...]로 변환
- 헤더 표시용: 각 튜플을 formataddr()로 다시 조립해 Cc: 헤더 문자열로 — 사람이 메일 클라이언트에서 보는 부분
- 실제 배달용: 튜플에서 이메일만 뽑아 sendmail()의 envelope recipient 목록에 — 실제로 메일이 가는 부분
헤더(2번)와 실제 배달(3번)이 서로 다른 데이터에서 나온다는 게, 앞서 다룬 "Cc 헤더는 표시일 뿐 배달을 보장하지 않는다"는 문제와 정확히 같은 축이다. 표준 라이브러리 함수 하나를 표준 라이브러리 함수로 교체했을 뿐인데, 콤마로 여러 명을 지정하는 기존 기능은 그대로 유지되면서 이름 표시까지 자연스럽게 얹혔다.
정리
- 기능 자체는 "캠페인마다 CC 입력란 하나 추가"로 단순했지만, 메일 헤더와 SMTP 실제 수신자는 별개라는 걸 놓치면 "화면엔 보이는데 실제로는 안 가는" 조용한 버그가 생긴다
- Cc/Bcc 헤더를 다룰 때는 항상 sendmail()의 envelope recipient 목록에도 반영됐는지 같이 확인하는 습관을 들이는 게 좋다
- 이메일 주소 목록(이름 포함/미포함 혼합)을 직접 파싱하지 말고 email.utils.getaddresses()를, 다시 조립할 때는 formataddr()를 쓰면 한글 이름이 섞여도 안전하게 처리된다 — 직접 짠 문자열 처리보다 표준 라이브러리가 이런 RFC 형식을 훨씬 잘 다룬다
- 운영 DB 스키마 변경은 자동화 도구를 쓰더라도 마이그레이션 스크립트로 남기고 직접 적용하는 프로세스를 유지하는 게 안전하다
'IT > 작업기록' 카테고리의 다른 글
| [메일 발송] 700명한테 보내는데 갑자기 느려진 이유 — 진단부터 자동화까지 (0) | 2026.09.07 |
|---|---|
| "빈 DB에만 터지는" Django 마이그레이션 버그 — 트리거 SQL 파일을 잘못 읽고 있었다 (0) | 2026.09.03 |
| 포커스 도둑 — 첫 클릭만 씹히던 이상한 버그 (0) | 2026.09.02 |
| [Django/pytest] 테스트 작성할 때 다시 볼 정리 — mock의 함정과 뭘 테스트해야 하나 (0) | 2026.09.01 |
| Django(Python)와 Node.js/NestJS, 뭐가 다를까 — 스택 전환을 고민하다 미룬 이유 (0) | 2026.09.01 |