매일공부

이메일 캠페인에 참조(CC) 기능 추가하기 — 그리고 놓치기 쉬운 함정 하나 본문

IT/작업기록

이메일 캠페인에 참조(CC) 기능 추가하기 — 그리고 놓치기 쉬운 함정 하나

julian 2026. 9. 7. 20:11

사내에서 운영 중인 이메일 발송(수신거부/캠페인 관리) 앱에 "발송하는 메일마다 참조(CC)를 걸 수 있게 해달라"는 기능 요청이 들어왔다. 간단해 보이는 요청이었지만, 구현하면서 실제로 한 번은 겪어봐야 아는 함정을 만나서 기록해둔다.

요구사항

  • 캠페인(메일 발송 단위)마다 CC 수신자를 지정할 수 있어야 한다
  • 한 명이 아니라 여러 명을 지정할 수 있어야 한다
  • 실제 발송뿐 아니라 테스트 발송에서도 동일하게 동작해야 한다

설계: 캠페인 단위 CC

CC를 어느 레벨에서 관리할지부터 정해야 했다. 후보는 두 가지였다.

  1. 시스템 전역 설정으로 고정 CC 주소를 두는 방식
  2. 캠페인(발송 건)마다 개별적으로 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>'

이름만 인코딩되고, 이메일 주소는 그대로 읽을 수 있는 상태로 남는 걸 볼 수 있다.

세 단계로 정리하면

  1. 파싱: DB에 저장된 원본 문자열을 getaddresses()로 [(이름, 이메일), ...]로 변환
  2. 헤더 표시용: 각 튜플을 formataddr()로 다시 조립해 Cc: 헤더 문자열로 — 사람이 메일 클라이언트에서 보는 부분
  3. 실제 배달용: 튜플에서 이메일만 뽑아 sendmail()의 envelope recipient 목록에 — 실제로 메일이 가는 부분

헤더(2번)와 실제 배달(3번)이 서로 다른 데이터에서 나온다는 게, 앞서 다룬 "Cc 헤더는 표시일 뿐 배달을 보장하지 않는다"는 문제와 정확히 같은 축이다. 표준 라이브러리 함수 하나를 표준 라이브러리 함수로 교체했을 뿐인데, 콤마로 여러 명을 지정하는 기존 기능은 그대로 유지되면서 이름 표시까지 자연스럽게 얹혔다.

정리

  • 기능 자체는 "캠페인마다 CC 입력란 하나 추가"로 단순했지만, 메일 헤더와 SMTP 실제 수신자는 별개라는 걸 놓치면 "화면엔 보이는데 실제로는 안 가는" 조용한 버그가 생긴다
  • Cc/Bcc 헤더를 다룰 때는 항상 sendmail()의 envelope recipient 목록에도 반영됐는지 같이 확인하는 습관을 들이는 게 좋다
  • 이메일 주소 목록(이름 포함/미포함 혼합)을 직접 파싱하지 말고 email.utils.getaddresses()를, 다시 조립할 때는 formataddr()를 쓰면 한글 이름이 섞여도 안전하게 처리된다 — 직접 짠 문자열 처리보다 표준 라이브러리가 이런 RFC 형식을 훨씬 잘 다룬다
  • 운영 DB 스키마 변경은 자동화 도구를 쓰더라도 마이그레이션 스크립트로 남기고 직접 적용하는 프로세스를 유지하는 게 안전하다
Comments