매주 월요일 아침, 엑셀에서 숫자를 뽑아 정리한 뒤 팀원 열두 명에게 같은 형식의 메일을 하나씩 보내는 일. 한 통에 3분씩만 잡아도 36분이고, 한 달이면 두 시간이 넘습니다. 게다가 손으로 보내다 보면 수신자를 빠뜨리거나 지난주 첨부파일을 그대로 보내는 사고가 꼭 한 번은 납니다. 파이썬 이메일 자동 발송 스크립트를 한 번 만들어두면 이 작업이 명령어 한 줄로 끝납니다. 이 글에서는 앱 비밀번호 발급부터 첨부파일, 명단 기반 개인화 발송, 그리고 실제로 자주 막히는 오류까지 순서대로 다룹니다.
준비물: 앱 비밀번호와 SMTP 주소
파이썬 표준 라이브러리인 smtplib만 있으면 되므로 따로 설치할 패키지는 없습니다. 대신 메일 계정의 로그인 비밀번호를 그대로 쓸 수 없다는 점을 먼저 알아야 합니다. 구글은 2022년 5월부터 “보안 수준이 낮은 앱”의 접속을 차단했고, 그 이후로는 앱 전용 비밀번호를 따로 발급받아야 합니다.
지메일 기준 발급 순서입니다.
- 구글 계정 관리 → 보안 메뉴로 들어갑니다.
- 2단계 인증을 먼저 켭니다. 이게 켜져 있지 않으면 앱 비밀번호 메뉴 자체가 보이지 않습니다.
- 검색창에 “앱 비밀번호”를 입력해 해당 페이지로 이동합니다.
- 앱 이름을 적당히 입력하면 공백으로 구분된 16자리 문자열이 나옵니다.
- 공백을 뺀 16글자를 복사해 둡니다. 이 화면을 닫으면 다시 볼 수 없으니 바로 저장하세요.
네이버 메일을 쓴다면 메일 화면의 환경설정 → POP3/IMAP 설정에서 사용함으로 바꿔야 SMTP 접속이 열립니다. 2단계 인증을 켜둔 계정이라면 네이버도 애플리케이션 비밀번호를 따로 발급받아 써야 합니다.
서버 주소와 포트는 아래를 참고하세요. 포트에 따라 코드가 조금 달라지므로 이 표를 기준으로 삼으면 됩니다.
| 메일 서비스 | SMTP 주소 | 포트 | 접속 방식 |
|---|---|---|---|
| 지메일 | smtp.gmail.com | 587 | STARTTLS (SMTP + starttls()) |
| 지메일 | smtp.gmail.com | 465 | SSL (SMTP_SSL) |
| 네이버 | smtp.naver.com | 465 | SSL (SMTP_SSL) |
| 다음 | smtp.daum.net | 465 | SSL (SMTP_SSL) |
| 회사 / 호스팅 | 관리자에게 문의 | 587 또는 465 | 대개 STARTTLS |
최소 코드 20줄로 첫 메일 보내기
아래 스크립트를 send_mail.py로 저장하면 바로 돌아갑니다. 계정 정보는 코드가 아니라 환경변수에서 읽어오는데, 이유는 다음 섹션에서 설명합니다.
import os
import smtplib
from email.message import EmailMessage
SMTP_HOST = "smtp.gmail.com"
SMTP_PORT = 587
def send_mail(to_addr, subject, body):
msg = EmailMessage()
msg["From"] = os.environ["MAIL_USER"]
msg["To"] = to_addr
msg["Subject"] = subject
msg.set_content(body)
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as smtp:
smtp.starttls()
smtp.login(os.environ["MAIL_USER"], os.environ["MAIL_PASS"])
smtp.send_message(msg)
if __name__ == "__main__":
send_mail("받는사람@example.com", "테스트 메일", "파이썬에서 보낸 첫 메일입니다.")

핵심은 세 줄입니다. starttls()로 암호화 연결을 열고, login()으로 인증하고, send_message()로 보냅니다. with 블록을 쓰면 끝날 때 연결이 자동으로 닫히므로 quit()을 따로 부를 필요가 없습니다.
465 포트를 쓰는 네이버나 다음이라면 접속 부분만 이렇게 바꿉니다.
# 네이버 메일은 465 포트에 SSL로 바로 접속합니다.
# starttls()를 부르면 안 되고, SMTP 대신 SMTP_SSL을 씁니다.
with smtplib.SMTP_SSL("smtp.naver.com", 465) as smtp:
smtp.login(os.environ["MAIL_USER"], os.environ["MAIL_PASS"])
smtp.send_message(msg)
오래된 예제에서 자주 보이는
MIMEText,MIMEMultipart조합은 지금도 동작하지만 한글 제목을 넣을 때 인코딩을 직접 처리해야 합니다. 파이썬 3.6 이상에서는EmailMessage를 쓰세요. 한글 헤더와 첨부파일 인코딩을 알아서 처리해 줍니다.
비밀번호를 코드에서 분리하기
앱 비밀번호를 스크립트 안에 그대로 적어두면, 그 파일을 깃허브에 올리거나 동료에게 보내는 순간 계정이 통째로 넘어갑니다. 공개 저장소에 올라간 자격 증명은 자동 수집 봇이 몇 분 안에 찾아냅니다. 환경변수로 빼두는 습관을 들이세요.
# 윈도우 PowerShell — 지금 열려 있는 창에서만 유효
$env:MAIL_USER = "내계정@gmail.com"
$env:MAIL_PASS = "abcdefghijklmnop"
# 윈도우 — 계정에 영구 등록 (등록 후 새 터미널을 열어야 반영됨)
setx MAIL_USER "내계정@gmail.com"
setx MAIL_PASS "abcdefghijklmnop"
# 맥 / 리눅스 — ~/.zshrc 또는 ~/.bashrc 에 추가
export MAIL_USER="내계정@gmail.com"
export MAIL_PASS="abcdefghijklmnop"
여러 프로젝트를 오간다면 python-dotenv 패키지로 프로젝트마다 .env 파일을 두는 방법도 있습니다. 이때는 .gitignore에 .env를 반드시 추가해야 합니다.
HTML 본문과 파일 첨부하기
실무에서 보내는 메일은 대개 표나 강조가 들어간 HTML 본문에 엑셀이나 PDF가 붙습니다. add_alternative()로 HTML 버전을, add_attachment()로 파일을 붙입니다.
import mimetypes
import os
import smtplib
from email.message import EmailMessage
from pathlib import Path
def send_report(to_addr, subject, html_body, attach_path=None):
msg = EmailMessage()
msg["From"] = os.environ["MAIL_USER"]
msg["To"] = to_addr
msg["Subject"] = subject
# 텍스트 버전을 먼저 넣고 HTML을 대체 본문으로 추가합니다.
msg.set_content("HTML을 지원하지 않는 환경에서는 첨부파일을 확인해 주세요.")
msg.add_alternative(html_body, subtype="html")
if attach_path:
path = Path(attach_path)
ctype, encoding = mimetypes.guess_type(path.name)
if ctype is None or encoding is not None:
ctype = "application/octet-stream"
maintype, subtype = ctype.split("/", 1)
msg.add_attachment(
path.read_bytes(),
maintype=maintype,
subtype=subtype,
filename=path.name,
)
with smtplib.SMTP("smtp.gmail.com", 587) as smtp:
smtp.starttls()
smtp.login(os.environ["MAIL_USER"], os.environ["MAIL_PASS"])
smtp.send_message(msg)
if __name__ == "__main__":
html = """
<h2>8월 매출 리포트</h2>
<p>지난달 대비 <strong>12% 증가</strong>했습니다.</p>
<p>상세 내역은 첨부된 엑셀 파일을 확인해 주세요.</p>
"""
send_report("팀장님@example.com", "8월 매출 리포트", html, "report_08.xlsx")
mimetypes.guess_type()은 확장자를 보고 파일 종류를 추측합니다. 판단하지 못하면 application/octet-stream으로 처리되는데, 이렇게 보내도 수신자가 다운로드하는 데는 문제가 없습니다.
첨부 용량은 지메일 기준 25MB가 한도입니다. 넘어가면 발송 자체가 거부되므로, 큰 파일은 클라우드에 올리고 본문에 링크를 넣는 편이 안전합니다.
CSV 명단으로 개인화 메일 보내기
자동화의 효과가 가장 큰 지점입니다. 명단을 CSV로 준비해 두면 수신자마다 이름과 숫자가 다른 메일을 한 번에 돌릴 수 있습니다. 먼저 recipients.csv를 이렇게 만듭니다.
name,email,amount
김철수,chulsoo@example.com,120000
이영희,younghee@example.com,85000
박민수,minsoo@example.com,240000
그다음 스크립트입니다. 실패한 건을 따로 모아 두는 부분이 중요합니다. 스무 명에게 보내다 세 번째에서 멈춰버리면 누구까지 보냈는지 알 수 없기 때문입니다.
import csv
import os
import smtplib
import time
from email.message import EmailMessage
SUBJECT_TPL = "{name}님, 8월 정산 내역 안내드립니다"
BODY_TPL = """{name}님 안녕하세요.
8월 정산 금액은 {amount}원입니다.
확인 후 회신 부탁드립니다.
감사합니다."""
def send_bulk(csv_path):
user = os.environ["MAIL_USER"]
password = os.environ["MAIL_PASS"]
sent, failed = 0, []
# 연결을 한 번만 맺고 재사용합니다. 매번 접속하면 훨씬 느리고 차단당하기 쉽습니다.
with smtplib.SMTP("smtp.gmail.com", 587) as smtp:
smtp.starttls()
smtp.login(user, password)
# 엑셀로 저장한 CSV는 앞에 BOM이 붙으므로 utf-8-sig로 읽습니다.
with open(csv_path, encoding="utf-8-sig", newline="") as f:
for row in csv.DictReader(f):
try:
row["amount"] = format(int(row["amount"]), ",")
msg = EmailMessage()
msg["From"] = user
msg["To"] = row["email"]
msg["Subject"] = SUBJECT_TPL.format(**row)
msg.set_content(BODY_TPL.format(**row))
smtp.send_message(msg)
sent += 1
print("보냄:", row["email"])
except Exception as e:
failed.append((row.get("email"), str(e)))
print("실패:", row.get("email"), e)
time.sleep(1) # 연속 발송 간격. 스팸 차단을 피하는 최소한의 배려
print("총 {}건 발송, {}건 실패".format(sent, len(failed)))
return failed
if __name__ == "__main__":
send_bulk("recipients.csv")
실행하면 어느 주소로 보냈는지 한 줄씩 출력되고, 마지막에 성공·실패 건수가 정리됩니다. 실패 목록을 받아서 주소를 고친 뒤 그 부분만 다시 돌리면 됩니다.
자주 만나는 오류와 해결법

처음 돌릴 때 열에 아홉은 아래 다섯 가지 중 하나에 걸립니다.
| 오류 메시지 | 원인 | 해결 |
|---|---|---|
SMTPAuthenticationError (535) | 계정 비밀번호를 그대로 썼거나 2단계 인증이 꺼져 있음 | 2단계 인증을 켜고 앱 비밀번호를 재발급받아 공백 없이 16자리 입력 |
SMTPServerDisconnected | 465 포트에 starttls()를 호출했거나 반대로 587에 SMTP_SSL 사용 | 위 표의 포트·접속 방식 조합을 그대로 맞추기 |
TimeoutError / 응답 없음 | 회사 방화벽이나 백신이 SMTP 포트를 막고 있음 | 휴대폰 핫스팟으로 바꿔 테스트해 보고, 막힌 게 맞으면 사내 SMTP 서버 주소를 문의 |
SMTPRecipientsRefused | 수신자 주소 오타 또는 존재하지 않는 도메인 | CSV 명단에서 공백·오타 확인. strip()으로 앞뒤 공백 제거 |
| 보냈는데 안 옴 | 수신자의 스팸함으로 분류됨 | 제목의 과한 특수문자와 대문자를 줄이고, 처음에는 본인 계정으로 테스트 |
원인이 잡히지 않으면 디버그 모드를 켜세요. 서버와 주고받는 응답 코드가 그대로 보여서 어느 단계에서 끊겼는지 바로 드러납니다.
# 서버와 주고받는 대화를 그대로 출력합니다. 원인 파악할 때 가장 확실한 방법입니다.
with smtplib.SMTP("smtp.gmail.com", 587) as smtp:
smtp.set_debuglevel(1)
smtp.starttls()
smtp.login(user, password)
smtp.send_message(msg)
매일 자동 실행하고, 사고를 피하는 법
스크립트가 완성됐다면 마지막은 스케줄 등록입니다. 윈도우라면 작업 스케줄러에 등록해 매일 정해진 시각에 돌리면 되고, 맥이나 리눅스라면 cron을 쓰면 됩니다. 등록 방법은 이 블로그의 윈도우 작업 스케줄러로 스크립트 자동 실행 설정하기 글에 단계별로 정리해 두었습니다.
다만 자동 발송은 되돌릴 수 없는 작업입니다. 아래 항목은 실제로 사고가 나는 지점이니 한 번씩 확인하세요.
- 테스트는 반드시 본인 주소로. 명단 파일의 이메일 열을 전부 본인 주소로 바꿔 한 번 돌려보고, 형식이 맞으면 진짜 명단으로 교체하세요.
- 발송량 한도를 넘기지 마세요. 무료 지메일 계정은 하루 수신자 500명이 상한이고, 넘기면 계정 발송이 일시 정지됩니다. 그 이상이 필요하면 전용 발송 서비스를 쓰는 편이 맞습니다.
- 여러 명에게 보낼 때 To에 몰아넣지 마세요. 수신자 목록이 서로에게 그대로 노출됩니다. 개인정보 유출로 이어질 수 있으니 위 예제처럼 한 명씩 따로 보내거나
Bcc를 쓰세요. - 광고성 메일은 법적 요건이 있습니다. 국내에서 광고 목적으로 메일을 보내려면 수신자의 사전 동의가 필요하고, 제목 맨 앞에 (광고) 표기와 본문에 수신거부 방법을 넣어야 합니다. 사내 공지나 거래 관계 안내는 여기 해당하지 않지만, 마케팅 발송이라면 반드시 확인하세요.
- 실패 목록을 남기세요. 로그 없이 돌리면 몇 명에게 갔는지 아무도 모릅니다. 위 스크립트처럼 실패 건을 모아 출력하거나 파일로 저장해 두세요.
마무리
가장 작게 시작하는 방법은 이렇습니다. 앱 비밀번호를 발급받고, 두 번째 섹션의 20줄짜리 스크립트를 그대로 복사해 수신자를 본인 주소로 넣고 한 번 실행해 보세요. 메일함에 도착하는 것만 확인하면 나머지는 그 위에 붙여나가면 됩니다. 첨부파일이든 명단 발송이든 결국 같은 send_message() 한 줄을 감싸는 일입니다.
다음 글에서는 협업할 때 가장 자주 지적받는 부분인 깃 커밋 메시지 쓰는 법을, 실제로 쓸 수 있는 예제와 함께 정리하겠습니다.