파이썬 업무 자동화#11개인화 대량 메일 자동 발송! 명단별 첨부파일·발송 로그·재시도까지 (smtplib 실전편) 📬
목차12개 항목
안녕하세요! Mojangcam입니다 😎
지난 파이썬 업무 자동화 #10 - 워드(Word) 문서 자동 생성 (python-docx)에서는 엑셀 명단으로 안내문 100장을 몇 초 만에 뽑아봤어요. 그런데 여기서 딱 멈추면 반쪽이죠. 그 100장을 100명에게 하나씩 보내는 일이 남아있거든요 😇
오늘은 그 마지막 조각을 채웁니다. 명단을 읽어서 → 그 사람 파일만 골라 첨부하고 → 이름을 넣은 개인화 메일을 자동 발송하는 스크립트예요.
메일 발송 기초(앱 비밀번호 발급, SMTP 접속)는 예전에 쓴 파이썬 이메일 자동 발송하기 편에서 다뤘으니, 오늘은 실전에서 진짜 필요한 것들 위주로 갈게요. 발송 간격, 실패 재시도, 발송 로그, 테스트 모드 같은 것들이요. 이거 없이 100통 돌렸다가 계정 잠기면 진짜 눈물납니다 😭
원래 실물 이미지로 하려 했으나 실물 이미지보다는 직접 이미지를 만들어서 사용하는게 더 깔끔하게 보여 제작한 이미지를 사용합니다!

📌 준비물
- Python 3.8 이상 (작성 기준 3.14) —
smtplib,email,csv는 표준 라이브러리라 설치가 필요 없어요 - openpyxl 3.1.x — 엑셀 명단 읽기 (업무 자동화 #1에서 다뤘어요)
- python-dotenv 1.x — 계정·앱 비밀번호를
.env로 빼두기 위해서예요 - 지메일 계정 + 2단계 인증 + 앱 비밀번호 16자리
pip install openpyxl python-dotenv💡
smtplib은 옛날부터 있던 표준 모듈이라 지금도 그대로 살아있어요. 가끔 "smtplib 없어진다"는 얘기가 도는데, 사라진 건 메일 서버 쪽 모듈인smtpd(3.12에서 제거)예요. 보내는 쪽인 smtplib은 멀쩡합니다!
🧠 왜 "그냥 반복문"으로는 안 될까요?
메일 발송을 처음 자동화하면 보통 이렇게 짜요.
for row in rows:
smtp.send_message(make_mail(row)) # 😱돌아가긴 합니다. 그런데 실무에서는 이런 일이 생겨요.
| 상황 | 그냥 반복문일 때 | 오늘 만들 스크립트 |
|---|---|---|
| 30번째에서 연결이 끊김 | 전체 중단, 누가 받았는지 모름 | 로그 CSV로 확인 + 실패분만 재발송 |
| 1초에 수십 통 발송 | 스팸 판정·일시 차단 | 발송 간격(sleep)으로 완화 |
| 주소 오타 1건 | 예외로 프로그램 종료 | 해당 건만 실패 기록 후 계속 진행 |
| 실수로 진짜 발송 | 되돌릴 수 없음 💀 | DRY_RUN 테스트 모드로 예방 |
특히 마지막 줄이 중요해요. 메일은 Ctrl+Z가 없습니다. 그래서 오늘 코드에는 실제로 안 보내고 로그만 찍어보는 DRY_RUN 모드를 기본값으로 넣었어요.
📌 1단계 - 계정 정보는 .env로 빼기
코드에 아이디·앱 비밀번호를 박아두면 깃허브에 올리는 순간 끝이에요. 항상 .env로 분리합니다.
같은 폴더에 .env 파일을 만들어주세요.
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_USER=본인주소@gmail.com
SMTP_PASSWORD=앱비밀번호16자리
SENDER_NAME=Mojangcam 교육팀
EXCEL_PATH=명단.xlsx
ATTACH_DIR=output
SEND_INTERVAL=2
DRY_RUN=1파이썬에서는 이렇게 읽어와요.
import os
from dotenv import load_dotenv
load_dotenv()
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
DRY_RUN = os.getenv("DRY_RUN", "1") == "1" # 기본값은 안전하게 '테스트'하나씩 뜯어볼게요 💡
- load_dotenv() : 같은 폴더의
.env를 읽어서 환경변수로 올려줘요. 이 줄이 없으면os.getenv가 전부 None을 돌려줍니다. - os.getenv("키", "기본값") : 값이 없을 때 쓸 기본값을 정할 수 있어요.
- int(...) : 환경변수는 무조건 문자열로 들어와요. 포트 번호처럼 숫자로 써야 하면 꼭 형변환해주세요.
- DRY_RUN 기본값 "1" :
.env를 깜빡해도 실제 발송은 안 되게 막는 안전장치예요.
💡
.env는 반드시 .gitignore에 추가하세요. 이 파일에는 앱 비밀번호가 들어있으니 실수로 올리면 바로 계정 탈취로 이어집니다!
📌 2단계 - 명단 + 첨부파일 짝지어 읽기
엑셀(명단.xlsx)은 이런 모양이라고 할게요. 이메일 열이 추가된 것 말고는 #10편과 같아요.
| 이름 | 부서 | 이메일 |
|---|---|---|
| 김철수 | 개발팀 | chulsoo@example.com |
| 이영희 | 디자인팀 | younghee@example.com |
from pathlib import Path
from openpyxl import load_workbook
def read_rows(xlsx_path):
wb = load_workbook(xlsx_path, data_only=True)
ws = wb.active
headers = [c.value for c in ws[1]]
rows = []
for row in ws.iter_rows(min_row=2, values_only=True):
if not row[0]:
break
rows.append(dict(zip(headers, row)))
wb.close()
return rows
def find_attachment(attach_dir, name):
"""output 폴더에서 '이름'으로 시작하는 파일을 찾아요."""
matches = sorted(Path(attach_dir).glob(f"{name}_*"))
return matches[0] if matches else None- glob(f"{name}_*") : #10편에서
김철수_안내문.docx형태로 저장했으니, 이름으로 시작하는 파일을 그대로 찾아낼 수 있어요. - 파일이 없으면
None을 돌려주고, 뒤에서 첨부 없이 보낼지 / 건너뛸지를 결정할 거예요. - 동명이인이 있다면 이름 대신 사번·이메일 아이디를 파일명에 쓰는 게 안전해요.
📌 3단계 - 개인화 메일 만들기
본문에 {이름}, {부서} 같은 자리를 만들어두고 format으로 채우면 끝이에요.
from email.message import EmailMessage
from email.utils import formataddr
import mimetypes
SUBJECT_TMPL = "[{부서}] {이름} 님 교육 수료 안내드립니다"
BODY_TMPL = """{이름} 님, 안녕하세요.
{부서} 소속 {이름} 님의 교육 수료 안내문을 첨부드립니다.
내용 확인 후 문의사항은 본 메일에 회신해 주세요.
감사합니다.
"""
def build_message(row, attach_path, sender_name, sender_addr):
msg = EmailMessage()
msg["Subject"] = SUBJECT_TMPL.format(**row)
msg["From"] = formataddr((sender_name, sender_addr))
msg["To"] = row["이메일"]
msg.set_content(BODY_TMPL.format(**row))
if attach_path:
ctype, _ = mimetypes.guess_type(attach_path.name)
maintype, subtype = (ctype or "application/octet-stream").split("/", 1)
with open(attach_path, "rb") as f:
msg.add_attachment(
f.read(),
maintype=maintype,
subtype=subtype,
filename=attach_path.name,
)
return msg하나씩 뜯어볼게요 💡
- EmailMessage : 제목·주소·본문·첨부를 담는 표준 메일 객체예요. 한글 인코딩(UTF-8)도 알아서 처리해줍니다.
- formataddr((이름, 주소)) : 받는 사람 화면에
Mojangcam 교육팀 <주소>처럼 보내는 사람 이름이 보이게 해줘요. 한글 이름도 안전하게 인코딩됩니다. - format(**row) :
{"이름": "김철수"}딕셔너리를 그대로 풀어서{이름}자리에 넣어줘요. 엑셀에 열을 추가하면 템플릿에서 바로 쓸 수 있어요. - mimetypes.guess_type : 파일 확장자로 종류를 자동 판별해요.
.docx,.pdf,.xlsx뭐가 오든 신경 안 써도 됩니다. - add_attachment : 파일은 반드시
rb(바이너리) 모드로 읽어야 해요.r로 열면 UnicodeDecodeError가 납니다.
📌 4단계 - 발송 루프에 안전장치 붙이기
핵심은 세 가지예요. 개별 실패를 삼키고, 간격을 두고, 끝나면 로그를 남기는 것.
import time, smtplib
def send_all(rows, smtp, ...):
results = []
for i, row in enumerate(rows, start=1):
try:
msg = build_message(...)
if not DRY_RUN:
smtp.send_message(msg)
results.append((row["이름"], row["이메일"], "성공", ""))
print(f"[{i}/{len(rows)}] {row['이름']} ✅")
except Exception as e:
results.append((row["이름"], row["이메일"], "실패", str(e)))
print(f"[{i}/{len(rows)}] {row['이름']} ❌ {e}")
time.sleep(SEND_INTERVAL)
return results- try / except를 반복문 안에 : 이게 제일 중요해요. 밖에 두면 한 명 실패에 나머지 99명이 통째로 멈춥니다.
- time.sleep(간격) : 1~3초를 권장해요. 지메일은 짧은 시간에 몰아치면 일시적으로 발송을 막습니다. 일반 지메일 계정은 하루 약 500통이 한도예요(정책은 바뀔 수 있으니 대량 발송 전엔 확인하세요).
- results 리스트 : 성공·실패를 전부 모아뒀다가 마지막에 CSV로 저장할 거예요. "누구한테 갔는지"를 증명할 수 있어야 실무에서 씁니다.
📌 전체 코드 (복붙 OK)
# send_mails.py
# Python 3.8+ (작성 기준 3.14) / smtplib·email·csv는 표준 라이브러리
# openpyxl 3.1.x, python-dotenv 1.x
import csv
import mimetypes
import os
import smtplib
import ssl
import time
from datetime import datetime
from email.message import EmailMessage
from email.utils import formataddr
from pathlib import Path
from dotenv import load_dotenv
from openpyxl import load_workbook
load_dotenv()
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
SENDER_NAME = os.getenv("SENDER_NAME", "보내는사람")
EXCEL_PATH = os.getenv("EXCEL_PATH", "명단.xlsx")
ATTACH_DIR = os.getenv("ATTACH_DIR", "output")
SEND_INTERVAL = float(os.getenv("SEND_INTERVAL", "2"))
DRY_RUN = os.getenv("DRY_RUN", "1") == "1"
MAX_RETRY = 2
SUBJECT_TMPL = "[{부서}] {이름} 님 교육 수료 안내드립니다"
BODY_TMPL = """{이름} 님, 안녕하세요.
{부서} 소속 {이름} 님의 교육 수료 안내문을 첨부드립니다.
내용 확인 후 문의사항은 본 메일에 회신해 주세요.
감사합니다.
"""
def read_rows(xlsx_path):
"""엑셀 1행을 헤더로 삼아 dict 리스트로 읽어와요."""
wb = load_workbook(xlsx_path, data_only=True)
ws = wb.active
headers = [c.value for c in ws[1]]
rows = []
for row in ws.iter_rows(min_row=2, values_only=True):
if not row[0]:
break
rows.append(dict(zip(headers, row)))
wb.close()
return rows
def find_attachment(attach_dir, name):
"""'이름_'으로 시작하는 첨부파일을 찾아요. 없으면 None."""
matches = sorted(Path(attach_dir).glob(f"{name}_*"))
return matches[0] if matches else None
def build_message(row, attach_path):
msg = EmailMessage()
msg["Subject"] = SUBJECT_TMPL.format(**row)
msg["From"] = formataddr((SENDER_NAME, SMTP_USER))
msg["To"] = row["이메일"]
msg.set_content(BODY_TMPL.format(**row))
if attach_path:
ctype, _ = mimetypes.guess_type(attach_path.name)
maintype, subtype = (ctype or "application/octet-stream").split("/", 1)
with open(attach_path, "rb") as f:
msg.add_attachment(
f.read(),
maintype=maintype,
subtype=subtype,
filename=attach_path.name,
)
return msg
def save_log(results):
"""발송 결과를 CSV로 남겨요. 엑셀에서 바로 열 수 있게 utf-8-sig."""
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
path = Path(f"send_log_{stamp}.csv")
with open(path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(["이름", "이메일", "첨부파일", "결과", "메시지"])
writer.writerows(results)
return path
def main():
if not SMTP_USER or not SMTP_PASSWORD:
raise SystemExit(".env에 SMTP_USER / SMTP_PASSWORD를 설정해주세요!")
rows = read_rows(EXCEL_PATH)
print(f"명단 {len(rows)}건을 읽었어요. (DRY_RUN={DRY_RUN})")
context = ssl.create_default_context()
results = []
smtp = None
if not DRY_RUN:
smtp = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context)
smtp.login(SMTP_USER, SMTP_PASSWORD)
try:
for i, row in enumerate(rows, start=1):
name = str(row.get("이름", "")).strip()
to_addr = str(row.get("이메일", "")).strip()
attach = find_attachment(ATTACH_DIR, name)
attach_name = attach.name if attach else "(없음)"
if "@" not in to_addr:
results.append([name, to_addr, attach_name, "실패", "이메일 주소 형식 오류"])
print(f"[{i}/{len(rows)}] {name} ❌ 주소 오류")
continue
for attempt in range(1, MAX_RETRY + 1):
try:
msg = build_message(row, attach)
if not DRY_RUN:
smtp.send_message(msg)
results.append([name, to_addr, attach_name, "성공", ""])
tag = "(테스트)" if DRY_RUN else ""
print(f"[{i}/{len(rows)}] {name} ✅ {attach_name} {tag}")
break
except (smtplib.SMTPServerDisconnected, smtplib.SMTPConnectError) as e:
# 연결이 끊긴 경우엔 재접속 후 한 번 더 시도해요
print(f" ↻ 재연결 시도 {attempt}/{MAX_RETRY} ({e})")
if not DRY_RUN:
smtp = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context)
smtp.login(SMTP_USER, SMTP_PASSWORD)
if attempt == MAX_RETRY:
results.append([name, to_addr, attach_name, "실패", str(e)])
except Exception as e:
results.append([name, to_addr, attach_name, "실패", str(e)])
print(f"[{i}/{len(rows)}] {name} ❌ {e}")
break
time.sleep(SEND_INTERVAL)
finally:
if smtp is not None:
smtp.quit()
log_path = save_log(results)
ok = sum(1 for r in results if r[3] == "성공")
print(f"\n완료! 성공 {ok}건 / 실패 {len(results) - ok}건")
print(f"로그: {log_path.resolve()}")
if DRY_RUN:
print("⚠️ 테스트 모드였어요. 실제 발송하려면 .env의 DRY_RUN=0 으로 바꾸세요.")
if __name__ == "__main__":
main()📌 실행 결과
폴더는 이렇게 준비해주세요. output 폴더는 #10편에서 만든 그 폴더예요.
mail_sender/
├── send_mails.py
├── .env
├── 명단.xlsx
└── output/
├── 김철수_안내문.docx
└── 이영희_안내문.docx먼저 테스트 모드 그대로 돌려봅니다.
python send_mails.py명단 2건을 읽었어요. (DRY_RUN=True)
[1/2] 김철수 ✅ 김철수_안내문.docx (테스트)
[2/2] 이영희 ✅ 이영희_안내문.docx (테스트)
완료! 성공 2건 / 실패 0건
로그: C:\...\mail_sender\send_log_20260910_101530.csv
⚠️ 테스트 모드였어요. 실제 발송하려면 .env의 DRY_RUN=0 으로 바꾸세요.첨부파일이 전부 제대로 매칭됐는지 로그 CSV로 확인하고, 본인 주소 한 줄만 남긴 명단으로 한 번 실제 발송해본 뒤에 전체를 돌리세요. 이 순서만 지켜도 사고의 90%는 막힙니다 🙌


📌 자주 나는 에러
1. SMTPAuthenticationError (535 / 534-5.7.9)
- 원인: 지메일 일반 비밀번호를 넣었거나, 2단계 인증이 꺼져 있는 경우예요. 예전의 "보안 수준이 낮은 앱 허용" 방식은 이미 종료됐습니다.
- 해결: 2단계 인증을 켜고 앱 비밀번호 16자리를 발급해
.env의SMTP_PASSWORD에 넣으세요. 붙여넣을 때 공백이 섞이지 않게 주의!
2. KeyError: '부서'
- 원인: 엑셀 헤더 이름과 템플릿의
{부서}가 다른 경우예요. 앞뒤 공백(부서)도 다른 키로 취급됩니다. - 해결: 헤더를 읽을 때
headers = [str(c.value).strip() for c in ws[1]]처럼 공백을 제거하거나, 템플릿 이름을 엑셀에 맞춰 고치세요.
3. 첨부가 안 붙고 (없음)으로만 나와요
- 원인:
ATTACH_DIR경로가 틀렸거나, 파일명이김철수_안내문.docx가 아니라안내문_김철수.docx형태인 경우예요. - 해결:
find_attachment의 glob 패턴을 파일명 규칙에 맞게 바꾸세요. 이름이 뒤에 오면f"*{name}*"로요.
4. SMTPDataError: 552 message size exceeds limit
- 원인: 첨부파일이 너무 커요. 지메일은 첨부 포함 25MB 정도가 한계입니다.
- 해결: PDF로 변환해 용량을 줄이거나, 큰 파일은 드라이브 링크로 대체하세요. PDF 처리는 업무 자동화 #8 - PDF 자동 처리 (pypdf) 편을 참고하세요.
5. 몇 통 보내다가 SMTPServerDisconnected로 끊겨요
- 원인: 한 연결로 너무 오래·너무 빠르게 보내면 서버가 연결을 닫아버려요.
- 해결:
SEND_INTERVAL을 2~3초로 늘리고, 위 코드처럼 재연결 후 재시도 로직을 두세요. 그래도 반복되면 100통 단위로 나눠 돌리는 게 안전해요.
6. 발송은 되는데 전부 스팸함으로 들어가요
- 원인: 같은 본문을 짧은 시간에 대량 발송하면 스팸 필터에 걸려요.
- 해결: 제목·본문에 받는 사람 이름을 넣어 개인화하고(오늘 코드가 그렇죠!), 발송 간격을 늘리세요. 업무용 대량 발송이 잦다면 회사 도메인 메일 + 전용 발송 서비스를 쓰는 게 정석입니다.
💡 보너스 - 매일 아침 자동으로 보내기
스크립트가 완성됐다면 예약 실행만 붙이면 끝이에요. 윈도우는 작업 스케줄러, 리눅스는 cron, 파이썬 안에서 해결하고 싶다면 schedule·APScheduler를 쓰면 됩니다. 이건 업무 자동화 #6 - 예약 실행 자동화 편에서 다뤘어요.
"엑셀 명단 → 워드 문서 생성(#10) → 개인화 메일 발송(#11) → 매일 아침 예약 실행(#6)". 여기까지 오면 진짜 손 안 대는 자동화가 완성됩니다 😎
📌 마무리
오늘은 개인화 대량 메일 자동 발송을 만들어봤어요. 정리하면,
- 계정·앱 비밀번호는
.env로 빼고,.gitignore는 필수예요. EmailMessage+add_attachment면 한글 제목·한글 파일명도 안 깨져요.- 실무 코드의 핵심은 예외 처리 · 발송 간격 · 로그 · DRY_RUN, 이 네 가지예요.
- 실제 발송 전엔 테스트 모드 → 본인 주소 1건 → 전체 순서를 꼭 지키세요!
다음 편에서는 반대로 메일을 자동으로 받아서 첨부파일을 폴더에 정리하는 방법(imaplib)을 다뤄볼게요. 매일 오는 거래명세서·정산 파일을 손으로 내려받고 계셨다면 아마 제일 반가운 편이 될 거예요. 기대해주세요! 🙌
궁금한 점이나 막히는 부분은 댓글로 남겨주세요 💬
📚 파이썬 업무 자동화 시리즈
- #1 - 엑셀 자동화 입문 (openpyxl)
- #2 - 웹 크롤링 입문 (requests + BeautifulSoup)
- #3 - 파일 정리 자동화 (이름 일괄 변경)
- #4 - 디스코드 웹훅(Webhook)으로 알림 보내기
- #5 - 로컬 LLM(LM Studio)으로 고객 문의 자동 분류·요약
- #6 - 예약 실행 자동화 (schedule·APScheduler)
- #7 - 동적 페이지·로그인 자동화 (Selenium)
- #8 - PDF 자동 처리 (pypdf)
- #9 - 마우스·키보드 GUI 자동화 (PyAutoGUI)
- #10 - 워드 문서 자동 생성 (python-docx)
- #11 - 개인화 대량 메일 자동 발송 (smtplib 실전편) ← 지금 이 글!
연재 · 14편 중 11번째
파이썬 업무 자동화
- #1엑셀 자동화 입문 (openpyxl)!
- #2웹 크롤링 입문 (requests + BeautifulSoup)!
- #3파일 정리 자동화 (이름 일괄 변경)!
- #4디스코드 웹훅(Webhook)으로 알림 보내기 (requests)!
- #5로컬 LLM(LM Studio)으로 고객 문의 자동 분류·요약하기! (무료 AI)
- #6예약 실행 자동화! 매일 정해진 시간에 스크립트 돌리기 (schedule·APScheduler)
- #7동적 페이지·로그인 자동화! requests로 안 되는 사이트 긁기 (Selenium)
- #8PDF 자동 처리! 여러 파일 병합·분할부터 비밀번호까지 (pypdf) 📄
- #9마우스·키보드 GUI 자동화! 클릭·타이핑을 파이썬이 대신하게 하기 (PyAutoGUI)
- #10워드(Word) 문서 자동 생성! 엑셀 명단으로 계약서·안내문 100장 뽑기 (python-docx) 📄
- #11개인화 대량 메일 자동 발송! 명단별 첨부파일·발송 로그·재시도까지 (smtplib 실전편) 📬지금 읽는 글
- #12구글 시트 연동하기! 팀원과 같이 보는 자동 시트 (gspread) 📗
- #13파이썬 자동화 로그·재시도·디스코드 실패 알림 만들기 - 조용히 멈추는 스크립트 잡기
- #14파워포인트(PowerPoint) 보고서 자동 생성! 엑셀 숫자만 바꾸면 발표자료 완성 (python-pptx) 📊
