Python 업무 자동화 실전 가이드 — 엑셀, 이메일, 파일 관리 스크립트

목차

smtplib.SMTPAuthenticationError: (535, b'5.7.8 Username and Password not accepted.
Learn more at 5.7.8 https://support.google.com/mail/?p=BadCredentials')

Gmail로 자동 메일 발송 스크립트를 짜다가 이 에러를 만났다. 분명 비밀번호를 정확히 입력했는데 계속 인증 실패가 떴다. 원인은 단순했다. 2단계 인증이 켜진 Gmail 계정에서는 일반 비밀번호가 아니라 앱 비밀번호를 따로 발급받아야 한다. 이걸 모르고 2시간을 날렸다. Python 업무 자동화를 하겠다고 마음먹은 첫날의 일이다.

프론트엔드에서 백엔드로 전환한 지 2년 정도 됐다. JavaScript 시절에는 반복 작업이 생기면 Node.js로 스크립트를 짰는데, Python으로 넘어오고 나서는 생태계가 훨씬 넓다는 걸 체감한다. 특히 엑셀 처리, 메일 발송, 파일 시스템 조작 같은 업무 자동화 영역에서 Python 라이브러리의 완성도가 높다. 이 글에서는 실제로 쓰고 있는 스크립트 3종 — 엑셀 처리, 이메일 전송, 파일 관리 — 을 다룬다.

왜 Python인가 — Node.js와 비교한 선택 기준

프론트 출신이라 Node.js로도 충분히 자동화할 수 있었다. 실제로 처음에는 xlsx npm 패키지로 엑셀을 다뤘다. 돌아가긴 했는데, 셀 스타일 지정이나 차트 삽입 같은 세부 기능에서 막히는 부분이 많았다. Python의 openpyxl은 이런 부분이 거의 다 지원된다.

비교 항목 Python (openpyxl + smtplib) Node.js (xlsx + nodemailer)
엑셀 읽기/쓰기 셀 스타일, 차트, 수식 모두 지원 읽기 위주, 스타일 제한적
이메일 발송 표준 라이브러리 내장 (smtplib) nodemailer 설치 필요
파일 시스템 pathlib 내장, glob 패턴 지원 fs/path 내장, glob은 외부 패키지
스케줄링 crontab + 스크립트 조합 pm2 또는 node-cron
러닝커브 낮음 (스크립트 성격에 적합) 비동기 패턴 신경 써야 함

결정적으로 Python을 고른 이유는 비동기를 신경 쓸 필요가 없다는 점이었다. 업무 자동화 스크립트는 순차 실행이면 충분하다. Node.js의 async/await 패턴이 이런 단순 스크립트에서는 오히려 군더더기가 된다.

엑셀 처리 — openpyxl 실전 사용법

라이브러리 선택: openpyxl vs pandas

엑셀을 다루는 Python 라이브러리는 크게 두 갈래다. 데이터 분석 목적이면 pandas가 맞고, 엑셀 파일 자체를 가공하는 목적이면 openpyxl이 맞다. 내 경우는 매주 받는 매출 리포트 엑셀에서 특정 시트의 데이터를 뽑아 다른 양식으로 재구성하는 작업이었다. 셀 병합, 색상 지정, 테두리 설정까지 필요했기 때문에 openpyxl 3.1.5를 선택했다(출처: openpyxl 공식 문서, 2026-04-10 기준).

pandasread_excel도 내부적으로 openpyxl을 엔진으로 쓴다. 데이터만 읽을 거면 pandas가 편하지만, 서식을 건드려야 하면 결국 openpyxl을 직접 써야 한다.

실제 스크립트: 매출 리포트 재구성

from openpyxl import load_workbook
from openpyxl.styles import Font, PatternFill, Border, Side
from datetime import datetime

# 원본 엑셀 로드
wb = load_workbook("weekly_sales_raw.xlsx")
ws = wb["Sheet1"]

# 헤더 행 스타일 지정
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font = Font(color="FFFFFF", bold=True, size=11)

# 데이터 추출 — 2행부터 실제 데이터
sales_data = []
for row in ws.iter_rows(min_row=2, values_only=True):
    if row[0] is None:  # 빈 행 건너뛰기
        break
    sales_data.append({
        "date": row[0],
        "product": row[1],
        "amount": row[2],
        "region": row[3]
    })

# 새 워크북에 정리된 형태로 쓰기
new_wb = load_workbook("report_template.xlsx")  # 미리 만든 템플릿
new_ws = new_wb["리포트"]

for i, item in enumerate(sales_data, start=3):  # 템플릿 3행부터 데이터 시작
    new_ws[f"A{i}"] = item["date"]
    new_ws[f"B{i}"] = item["product"]
    new_ws[f"C{i}"] = f'{item["amount"]:,}'  # 천단위 콤마
    new_ws[f"D{i}"] = item["region"]

# 파일명에 날짜 포함해서 저장
today = datetime.now().strftime("%Y%m%d")
new_wb.save(f"sales_report_{today}.xlsx")

여기서 주의할 점이 하나 있다. load_workbook으로 .xls 파일(구형 엑셀 포맷)을 열면 에러가 난다. openpyxl.xlsx만 지원한다. .xls를 다뤄야 하면 xlrd 라이브러리를 써야 하는데, xlrd 2.0.1 이후로는 .xls 지원도 보안 이유로 제거됐다(출처: xlrd GitHub, 2024-01 기준). 결국 .xls 파일은 먼저 .xlsx로 변환하는 게 깔끔하다.

자주 쓰는 패턴: 여러 시트 합치기

import glob
from openpyxl import load_workbook, Workbook

# 폴더 안의 모든 엑셀 파일 합치기
files = glob.glob("monthly_reports/*.xlsx")
merged = Workbook()
merged_ws = merged.active
merged_ws.title = "통합"

row_offset = 1
for f in sorted(files):
    wb = load_workbook(f)
    ws = wb.active
    for row in ws.iter_rows(min_row=2, values_only=True):  # 헤더 제외
        for col, val in enumerate(row, start=1):
            merged_ws.cell(row=row_offset, column=col, value=val)
        row_offset += 1

merged.save("merged_all.xlsx")
print(f"{len(files)}개 파일, {row_offset - 1}행 병합 완료")

이 스크립트를 매월 초에 돌리면 전월 리포트 10여 개가 하나로 합쳐진다. 수작업으로 복사-붙여넣기 하던 시절에는 30분 이상 걸렸는데, 스크립트로는 체감상 3초면 끝난다.

이메일 자동 발송 — smtplib과 앱 비밀번호

글 서두에서 만났던 SMTPAuthenticationError의 원인은 Gmail의 보안 정책이다. 2022년 5월 이후 Google은 "보안 수준이 낮은 앱"의 접근을 완전히 차단했다(출처: Google 지원 문서). 이제는 2단계 인증을 활성화한 뒤, 앱 비밀번호를 발급받아야 한다.

앱 비밀번호 발급 경로: Google 계정 → 보안 → 2단계 인증 → 앱 비밀번호. 여기서 생성된 16자리 코드를 비밀번호 대신 쓰면 된다. 이걸 몰라서 "파이썬 smtp 인증 실패"로 검색하면 절반은 2019년 답변이라 "보안 수준 낮은 앱 허용"을 켜라고 안내하는데, 그 옵션은 이미 사라졌다.

import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
import os

def send_report(to_email: str, subject: str, body: str, attachment_path: str = None):
    msg = MIMEMultipart()
    msg["From"] = os.environ["GMAIL_USER"]  # 환경변수에서 읽기
    msg["To"] = to_email
    msg["Subject"] = subject

    msg.attach(MIMEText(body, "html", "utf-8"))  # HTML 본문

    # 첨부파일 처리
    if attachment_path and os.path.exists(attachment_path):
        with open(attachment_path, "rb") as f:
            part = MIMEBase("application", "octet-stream")
            part.set_payload(f.read())
        encoders.encode_base64(part)
        filename = os.path.basename(attachment_path)
        part.add_header("Content-Disposition", f"attachment; filename={filename}")
        msg.attach(part)

    # SMTP 연결 및 전송
    with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
        server.login(
            os.environ["GMAIL_USER"],
            os.environ["GMAIL_APP_PASSWORD"]  # 앱 비밀번호
        )
        server.send_message(msg)
        print(f"메일 전송 완료: {to_email}")

비밀번호를 코드에 직접 넣는 건 당연히 안 된다. .env 파일에 저장하고 python-dotenv로 불러오거나, 위 코드처럼 환경변수로 관리하는 게 기본이다. 프론트엔드 시절 .env.local에 API 키 넣던 것과 같은 패턴이라 익숙했다.

한 가지 더. MIMEText의 두 번째 인자를 "html"로 주면 HTML 메일을 보낼 수 있다. 매출 리포트를 테이블 형태로 메일 본문에 넣을 때 유용하다. "plain"으로 보내면 텍스트만 간다.

파일 관리 — pathlib으로 정리하기

os.pathpathlib 중 뭘 쓸지 고민한 적이 있을 것이다. 결론부터 말하면, Python 3.6 이상이면 pathlib을 쓰는 게 맞다. os.path.join(base, "sub", "file.txt") 대신 Path(base) / "sub" / "file.txt"로 쓸 수 있어서 가독성이 훨씬 좋다.

from pathlib import Path
from datetime import datetime, timedelta
import shutil

# 다운로드 폴더 정리 — 확장자별 분류
download_dir = Path.home() / "Downloads"
rules = {
    ".xlsx": "엑셀",
    ".xls": "엑셀",
    ".csv": "엑셀",
    ".pdf": "PDF",
    ".jpg": "이미지",
    ".png": "이미지",
    ".zip": "압축파일",
}

moved_count = 0
for file in download_dir.iterdir():
    if not file.is_file():
        continue
    suffix = file.suffix.lower()
    if suffix in rules:
        target_dir = download_dir / rules[suffix]
        target_dir.mkdir(exist_ok=True)  # 폴더 없으면 생성
        shutil.move(str(file), str(target_dir / file.name))
        moved_count += 1

print(f"{moved_count}개 파일 정리 완료")

이 스크립트를 매일 자동 실행하면 다운로드 폴더가 쌓이는 걸 방지할 수 있다. 한 달만 방치해도 파일이 200개 넘게 쌓이는 경험이 있을 것이다.

오래된 파일 자동 삭제

# 30일 이상 된 파일 삭제
threshold = datetime.now() - timedelta(days=30)
deleted = 0

for file in (download_dir / "압축파일").glob("*"):
    if file.is_file():
        mtime = datetime.fromtimestamp(file.stat().st_mtime)
        if mtime < threshold:
            file.unlink()  # 파일 삭제
            deleted += 1

print(f"오래된 파일 {deleted}개 삭제")

file.unlink()은 휴지통이 아니라 영구 삭제다. 처음에 이걸 몰라서 중요한 파일을 날릴 뻔했다. 안전하게 하려면 send2trash 라이브러리를 쓰는 것도 방법이다. pip install send2trash로 설치하고 send2trash(str(file)) 하면 OS 휴지통으로 보내준다.

세 스크립트 연결 — 하나의 자동화 파이프라인

여기까지 만든 스크립트 3개를 하나로 묶으면 이런 흐름이 된다.

1. 엑셀 처리 (raw 데이터 → 정리된 리포트)
     ↓
2. 메일 발송 (리포트를 첨부해서 전송)
     ↓
3. 파일 정리 (처리 완료된 raw 파일을 archive 폴더로 이동)
from pathlib import Path
from datetime import datetime

def daily_automation():
    today = datetime.now().strftime("%Y%m%d")

    # 1단계: 엑셀 처리
    report_path = process_excel("weekly_sales_raw.xlsx", today)

    # 2단계: 메일 발송
    send_report(
        to_email="team@company.com",
        subject=f"[{today}] 매출 리포트",
        body="<h3>금주 매출 리포트 첨부</h3>",
        attachment_path=str(report_path)
    )

    # 3단계: 원본 파일 아카이브
    archive = Path("archive") / today[:6]  # 월별 폴더
    archive.mkdir(parents=True, exist_ok=True)
    Path("weekly_sales_raw.xlsx").rename(archive / f"raw_{today}.xlsx")

    print(f"[{today}] 자동화 완료")

if __name__ == "__main__":
    daily_automation()

이걸 crontab에 등록하면 매일 지정 시간에 자동 실행된다. crontab -e로 편집기를 열고 한 줄 추가하면 끝이다.

# 매일 오전 9시에 실행 (서버 시간 기준)
0 9 * * * /usr/bin/python3 /home/user/scripts/daily_automation.py >> /home/user/logs/auto.log 2>&1

로그를 파일로 리다이렉트하는 건 꼭 해두는 게 좋다. 에러가 나도 cron은 아무 말 없이 실패하기 때문이다. 나중에 "왜 메일이 안 갔지?" 하고 찾아보면 로그에 traceback이 남아있다.

실무에서 부딪힌 예외 처리

스크립트가 동작하는 것까지는 쉽다. 문제는 예외 상황이다.

엑셀 파일이 열려 있으면 PermissionError가 난다. Windows에서 엑셀 프로그램이 해당 파일을 잡고 있으면 Python이 접근하지 못한다. 이건 try-except로 잡아서 재시도하거나, 사용자에게 알림을 보내는 식으로 처리해야 한다.

메일 발송이 실패하면 스크립트 전체가 멈춘다. 이게 제일 곤란한데, 네트워크 문제로 SMTP 연결이 안 되면 그 뒤의 파일 정리도 안 된다. 각 단계를 독립적으로 try-except로 감싸고, 실패 로그를 남기는 게 기본이다.

import logging

logging.basicConfig(
    filename="automation.log",
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s"
)

def safe_run(func, name):
    try:
        func()
        logging.info(f"{name} 성공")
    except Exception as e:
        logging.error(f"{name} 실패: {e}")
        # 슬랙 웹훅이나 별도 알림 추가 가능

이 정도만 해도 "왜 오늘 메일이 안 왔지?"라는 질문에 바로 답할 수 있다. 프론트엔드에서 window.onerror로 에러를 추적하던 것과 비슷한 맥락이다. 자동화 스크립트도 모니터링이 없으면 죽은 스크립트가 된다.

Python 업무 자동화를 시작하려면

당장 실행할 수 있는 액션 3가지를 정리한다.

첫째, openpyxlpathlib만 먼저 익혀라. 이 두 가지면 엑셀 처리와 파일 관리를 커버할 수 있다. pip install openpyxl이면 설치 끝이고, pathlib은 Python 3.4 이상에서 내장이다.

둘째, 메일 발송은 Gmail 앱 비밀번호 발급부터 해두자. 이게 선행 조건인데 막상 코드 짜놓고 나서 발급받으려면 흐름이 끊긴다. 회사 메일이 Google Workspace면 관리자 정책에 따라 앱 비밀번호가 막혀있을 수도 있다. 그 경우는 OAuth2 인증으로 가야 하는데, 이건 설정이 좀 복잡하다.

셋째, 스크립트 하나 만들 때마다 로깅을 붙여라. print로 디버깅하는 건 개발 단계까지만이다. cron으로 자동 실행하는 순간부터는 logging 모듈이 필수가 된다.

개인적으로는 Python 업무 자동화의 진짜 가치는 코드 자체가 아니라, 반복 작업에서 해방된 시간에 있는 것 같다.

관련 글

Chiko IT
Chiko IT

Platform Engineer. Python, AI, Infra에 관심이 많습니다.