카테고리: 업무 자동화

파이썬 업무 자동화#10워드(Word) 문서 자동 생성! 엑셀 명단으로 계약서·안내문 100장 뽑기 (python-docx) 📄

목차12개 항목

안녕하세요! Mojangcam입니다 😎

지난 파이썬 업무 자동화 #9에서는 PyAutoGUI로 마우스·키보드 클릭까지 파이썬에게 시켜봤어요. 화면을 직접 조작하는, 좀 극단적인 자동화였죠.

이번 #10편은 다시 얌전한(?) 자동화로 돌아옵니다. 바로 워드(Word) 문서 자동 생성이에요.

회사에서 이런 일 해보신 적 있으실 거예요. 엑셀에 이름·부서·날짜가 100줄 들어있고, 워드 양식에 그걸 하나씩 넣어서 100개 파일로 저장하는 일이요. 수료증, 계약서, 안내문, 급여명세서... 이름만 다르고 나머지는 똑같은데 손으로 100번 복붙하고 계셨다면, 오늘 글이 딱입니다.

오늘 만들 건 이거예요. 엑셀 명단(.xlsx)을 읽어서 → 워드 문서(.docx)를 사람 수만큼 자동으로 뽑아내는 스크립트. 실행 한 번에 폴더가 문서로 꽉 찹니다 🙌


📌 준비물

  • Python 3.10 이상 (최신 python-docx·python-dotenv 기준이에요)
  • python-docx 1.2.0 — 워드 문서를 만들고 편집하는 라이브러리
  • openpyxl 3.1.x — 엑셀 명단을 읽는 라이브러리 (업무 자동화 #1에서 다뤘어요)
  • python-dotenv — 경로·회사명 같은 설정값을 .env로 빼두기 위해서예요
터미널
pip install python-docx openpyxl python-dotenv

💡 설치는 python-docx인데 import는 import docx 예요. 이거 헷갈려서 pip install docx 치는 분들 정말 많은데, 그건 전혀 다른 옛날 패키지니까 절대 설치하지 마세요! (아래 에러 코너에서 다시 짚을게요)


🧠 왜 python-docx인가요?

"워드 자동화"라고 하면 보통 두 가지 방법이 떠올라요.

방법 장점 단점
python-docx MS Word 설치 불필요, 리눅스·서버에서도 동작, 빠름 인쇄·PDF 변환 같은 워드 고유 기능은 못 씀
pywin32 (COM) 워드가 할 수 있는 건 다 됨 윈도우 + 워드 설치 필수, 느리고 잘 죽음

우리는 "양식에 값 채워서 대량 생성"이 목적이니까 python-docx가 압도적으로 편해요. 서버에서 돌려도 되고요.


📌 1단계 - 워드 문서 하나 만들어보기

일단 감을 잡기 위해 문서 한 장부터 만들어볼게요.

Python
from docx import Document
from docx.shared import Pt, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH

doc = Document()                      # 빈 문서 생성

title = doc.add_heading("교육 수료 안내문", level=1)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER

p = doc.add_paragraph()
run = p.add_run("김철수 님, 수고하셨습니다.")
run.bold = True
run.font.size = Pt(12)

doc.add_paragraph("위 사람은 2026년 사내 교육 과정을 성실히 이수하였음을 증명합니다.")

doc.save("sample.docx")
print("저장 완료!")

하나씩 뜯어볼게요 💡

  • Document() : 빈 워드 문서 객체예요. 괄호 안에 기존 파일 경로를 넣으면 그 문서를 열어서 편집하게 됩니다. 이게 뒤에서 '양식 파일 재활용'의 핵심이 돼요.
  • add_heading(text, level) : 제목 스타일 문단을 추가해요. level=0은 Title, 1~9는 Heading 1~9입니다.
  • add_paragraph() : 일반 문단을 추가해요.
  • run : 워드에서 서식이 같은 글자 덩어리 단위예요. 한 문단 안에서 '이 단어만 굵게' 하려면 문단을 여러 run으로 쪼개야 합니다. python-docx를 이해하는 데 가장 중요한 개념이에요!
  • Pt / Cm : 글자 크기·길이 단위예요. 숫자만 넣으면 안 되고 꼭 이 단위 객체로 감싸줘야 합니다.


📌 2단계 - 한글 폰트 깨짐 잡기

여기서 거의 100% 만나는 문제가 있어요. 한글이 이상한 폰트로 나오는 현상입니다.

워드의 기본 스타일은 서양 문자용 폰트만 지정돼 있어서, 한글(East Asian 영역)에는 적용이 안 돼요. 그래서 rFonts의 eastAsia 속성을 직접 건드려줘야 합니다.

Python
from docx.oxml.ns import qn

def set_korean_font(doc, font_name="맑은 고딕", size=11):
    """문서 기본 스타일에 한글 폰트를 적용해요."""
    style = doc.styles["Normal"]
    style.font.name = font_name
    style.font.size = Pt(size)
    # 한글(동아시아 문자) 폰트는 XML 속성을 직접 지정해야 적용돼요
    style.element.rPr.rFonts.set(qn("w:eastAsia"), font_name)
  • qn("w:eastAsia") : OOXML 네임스페이스를 붙여주는 헬퍼예요. 이 한 줄이 없으면 한글만 폰트가 안 먹습니다.
  • 개별 run에도 적용하고 싶다면 run._element.rPr.rFonts.set(qn("w:eastAsia"), "맑은 고딕") 형태로 똑같이 해주면 돼요.

💡 이게 귀찮다면 3단계 방식(양식 파일 재활용)을 쓰세요. 워드에서 직접 폰트를 예쁘게 잡아둔 파일을 열어서 글자만 바꾸면, 폰트 문제 자체가 안 생겨요. 실무에서는 이 방법이 훨씬 낫습니다!


📌 3단계 - 양식 파일에서 자리표시자 치환하기

실무 자동화의 정석은 이거예요.

  1. 워드에서 양식 파일(template.docx)을 예쁘게 하나 만들어요. 바꿀 자리는 {{이름}}, {{부서}}, {{날짜}} 처럼 표시해둡니다.
  2. 파이썬이 그 파일을 열어서 자리표시자만 실제 값으로 갈아끼우고 다른 이름으로 저장해요.

이러면 디자인은 워드가, 반복은 파이썬이 담당하게 됩니다 👍

Python
def replace_text(doc, mapping):
    """문서 전체(본문 문단 + 표 셀)에서 자리표시자를 치환해요."""
    def _replace_in_paragraph(para):
        # run이 쪼개져 있으면 치환이 안 되므로 문단 텍스트를 통째로 확인
        full_text = para.text
        if not any(key in full_text for key in mapping):
            return
        for key, value in mapping.items():
            full_text = full_text.replace(key, str(value))
        # 첫 run에 몰아넣고 나머지는 비움 (서식은 첫 run 것을 유지)
        for i, run in enumerate(para.runs):
            run.text = full_text if i == 0 else ""

    for para in doc.paragraphs:
        _replace_in_paragraph(para)

    for table in doc.tables:
        for row in table.rows:
            for cell in row.cells:
                for para in cell.paragraphs:
                    _replace_in_paragraph(para)

하나씩 뜯어볼게요 💡

  • 왜 run을 합치나요? : 워드는 사용자가 타이핑하다 멈추거나 맞춤법 검사가 돌면 {{이름}} 한 덩어리를 {{이, 름}} 처럼 여러 run으로 쪼개서 저장해버려요. 그래서 run 하나씩 보면서 replace 하면 아무것도 안 바뀝니다. 이게 python-docx 치환이 안 된다는 질문의 90% 원인이에요!
  • doc.tables : 표 안의 글자는 doc.paragraphs에 안 잡혀요. 표는 따로 순회해야 합니다. 실무 양식은 표가 많으니 꼭 넣어주세요.
  • 머리글·바닥글까지 바꾸고 싶다면 doc.sections[0].header.paragraphs도 같은 방식으로 돌려주면 됩니다.

📌 4단계 - 엑셀 명단 읽어서 한 방에 대량 생성

이제 openpyxl로 명단을 읽어서 반복만 돌리면 끝이에요.

엑셀(명단.xlsx)은 이런 모양이라고 할게요.

이름 부서 수료일
김철수 개발팀 2026-09-09
이영희 디자인팀 2026-09-09
Python
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]]              # 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
  • data_only=True : 셀에 수식이 있어도 계산된 값을 가져와요. 이게 없으면 =A1&B1 같은 수식 문자열이 그대로 들어옵니다.
  • values_only=True : Cell 객체 대신 값만 튜플로 받아서 다루기 편해요.
  • dict(zip(headers, row)) : {"이름": "김철수", "부서": "개발팀", ...} 형태로 만들어둬서, 뒤에서 자리표시자와 바로 매칭시킬 거예요.

📌 전체 코드 (복붙 OK)

Python
# make_docs.py
# python-docx 1.2.0 / openpyxl 3.1.x / Python 3.10+

import os
import re
from pathlib import Path

from dotenv import load_dotenv
from docx import Document
from openpyxl import load_workbook

load_dotenv()

# 경로·설정값은 코드에 박지 말고 .env에서 불러와요
TEMPLATE_PATH = os.getenv("TEMPLATE_PATH", "template.docx")
EXCEL_PATH = os.getenv("EXCEL_PATH", "명단.xlsx")
OUTPUT_DIR = os.getenv("OUTPUT_DIR", "output")
FILENAME_FORMAT = os.getenv("FILENAME_FORMAT", "{이름}_안내문.docx")

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 replace_text(doc, mapping):
    """본문 문단 + 표 셀 + 머리글에서 자리표시자를 치환해요."""

    def _replace_in_paragraph(para):
        full_text = para.text
        if not any(key in full_text for key in mapping):
            return
        for key, value in mapping.items():
            full_text = full_text.replace(key, str(value))
        for i, run in enumerate(para.runs):
            run.text = full_text if i == 0 else ""

    def _walk(container):
        for para in container.paragraphs:
            _replace_in_paragraph(para)
        for table in container.tables:
            for row in table.rows:
                for cell in row.cells:
                    _walk(cell)

    _walk(doc)
    for section in doc.sections:
        _walk(section.header)
        _walk(section.footer)

def safe_filename(name):
    """윈도우에서 파일명에 못 쓰는 문자를 _ 로 바꿔요."""
    return re.sub(r'[\\/:*?"<>|]', "_", str(name)).strip()

def main():
    template = Path(TEMPLATE_PATH)
    if not template.exists():
        raise FileNotFoundError(f"양식 파일이 없어요: {template.resolve()}")

    out_dir = Path(OUTPUT_DIR)
    out_dir.mkdir(parents=True, exist_ok=True)

    rows = read_rows(EXCEL_PATH)
    print(f"명단 {len(rows)}건을 읽었어요.")

    for i, row in enumerate(rows, start=1):
        # {"이름": "김철수"} -> {"{{이름}}": "김철수"}
        mapping = {f"{{{{{k}}}}}": (v if v is not None else "") for k, v in row.items()}

        doc = Document(str(template))       # 매번 양식을 새로 열어야 값이 안 섞여요!
        replace_text(doc, mapping)

        filename = safe_filename(FILENAME_FORMAT.format(**row))
        doc.save(out_dir / filename)
        print(f"[{i}/{len(rows)}] {filename} 저장 완료")

    print(f"\n끝! '{out_dir.resolve()}' 폴더를 확인해보세요 🎉")

if __name__ == "__main__":
    main()

같은 폴더에 .env 파일을 만들어주세요.

터미널
TEMPLATE_PATH=template.docx
EXCEL_PATH=명단.xlsx
OUTPUT_DIR=output
FILENAME_FORMAT={이름}_안내문.docx

💡 .env는 꼭 .gitignore에 넣어주세요! 오늘 예제엔 비밀값이 없지만, 나중에 메일 발송이나 API 키가 붙는 순간 그대로 유출됩니다. 처음부터 습관을 들이는 게 좋아요.


📌 실행 결과

폴더를 이렇게 준비하고,

텍스트
my_docx_tool/
├── make_docs.py
├── .env
├── template.docx      ← {{이름}} {{부서}} {{수료일}} 이 들어있는 워드 양식
└── 명단.xlsx

실행해볼게요.

터미널
python make_docs.py
텍스트
명단 2건을 읽었어요.
[1/2] 김철수_안내문.docx 저장 완료
[2/2] 이영희_안내문.docx 저장 완료

끝! 'C:\...\my_docx_tool\output' 폴더를 확인해보세요 🎉

명단이 100줄이면 100개 파일이 몇 초 만에 나와요. 이거 처음 돌려보면 진짜 짜릿합니다 😆


📌 자주 나는 에러

1. ModuleNotFoundError: No module named 'docx'

  • 원인: pip install docx를 설치했거나, 아예 설치가 안 된 경우예요. docx는 파이썬 2 시절의 전혀 다른 패키지라 동작하지 않아요.
  • 해결: 잘못 깐 걸 지우고 정식 패키지를 설치하세요.
터미널
pip uninstall docx
pip install python-docx

2. 치환이 하나도 안 돼요 (자리표시자가 그대로 남아요)

  • 원인: 워드가 {{이름}}을 여러 run으로 쪼개 저장했기 때문이에요. run 단위로 replace 하면 절대 안 걸립니다.
  • 해결: 위 코드처럼 문단 텍스트 전체(para.text)를 기준으로 치환하세요. 그래도 안 되면 양식 파일에서 자리표시자를 지우고 한 번에 쭉 다시 타이핑해보세요. 서식이 섞이면 쪼개지기 쉬워요.

3. 표 안의 글자만 안 바뀌어요

  • 원인: doc.paragraphs에는 표 안의 문단이 포함되지 않아요.
  • 해결: doc.tables를 따로 순회해야 합니다. 위 전체 코드의 _walk() 함수가 표·머리글·바닥글까지 재귀로 처리해줘요.

4. PackageNotFoundError: Package not found at 'template.docx'

  • 원인: 경로가 틀렸거나, 파일이 .doc(구버전)이거나, 워드에서 열어둔 채 생긴 임시 파일(~$template.docx)을 가리킨 경우예요.
  • 해결: 절대경로로 바꿔보고, .doc라면 워드에서 다른 이름으로 저장 → .docx로 변환하세요. python-docx는 .docx만 읽습니다.

5. 한글이 이상한 폰트로 나와요

  • 원인: 워드 기본 스타일에 동아시아 폰트가 지정돼 있지 않아서예요.
  • 해결: 2단계의 set_korean_font()로 w:eastAsia를 지정하거나, 애초에 폰트가 잡힌 양식 파일을 재활용하세요.

6. 모든 파일 내용이 마지막 사람으로 똑같이 나와요

  • 원인: Document()를 반복문 밖에서 한 번만 열고 재사용한 경우예요. 이미 치환된 문서에는 자리표시자가 없으니 그다음부터 아무것도 안 바뀝니다.
  • 해결: 반복문 안에서 매번 새로 열어주세요. 전체 코드처럼요.

💡 보너스 - PDF로도 뽑고 싶다면

python-docx는 PDF 변환 기능이 없어요. 방법은 두 가지예요.

  • 윈도우 + 워드 설치됨 : pip install docx2pdf 후 from docx2pdf import convert; convert("output") — 폴더째 변환됩니다. 내부적으로 워드를 띄우는 방식이에요.
  • 리눅스·서버 : LibreOffice를 설치하고 soffice --headless --convert-to pdf *.docx를 돌리면 됩니다.

만들어진 PDF를 병합·분할하고 싶다면 파이썬 업무 자동화 #8 - PDF 자동 처리 (pypdf) 편을 이어서 보시면 딱 맞아요 📄


📌 마무리

오늘은 python-docx로 워드 문서를 자동 생성해봤어요. 정리하면,

  • Document()로 새 문서를 만들거나 기존 양식을 열어서 편집할 수 있어요.
  • 실무에서는 양식 파일 + 자리표시자 치환이 정답이에요. 디자인은 워드가, 반복은 파이썬이!
  • 치환이 안 되는 건 대부분 run 쪼개짐 때문이고, 표는 따로 순회해야 해요.
  • openpyxl로 엑셀 명단을 읽어 붙이면 문서 100장도 몇 초면 끝납니다.

다음 편에서는 이렇게 만든 문서를 자동으로 메일에 첨부해서 보내는 방법을 다뤄볼게요. 문서 생성 → 발송까지 이어지면 진짜 '손 안 대는 자동화'가 완성됩니다. 기대해주세요! 🙌

궁금한 점이나 막히는 부분은 댓글로 남겨주세요 💬


📚 파이썬 업무 자동화 시리즈

  • #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) ← 지금 이 글!

연재 · 14편 중 10번째

파이썬 업무 자동화

  1. #1엑셀 자동화 입문 (openpyxl)!
  2. #2웹 크롤링 입문 (requests + BeautifulSoup)!
  3. #3파일 정리 자동화 (이름 일괄 변경)!
  4. #4디스코드 웹훅(Webhook)으로 알림 보내기 (requests)!
  5. #5로컬 LLM(LM Studio)으로 고객 문의 자동 분류·요약하기! (무료 AI)
  6. #6예약 실행 자동화! 매일 정해진 시간에 스크립트 돌리기 (schedule·APScheduler)
  7. #7동적 페이지·로그인 자동화! requests로 안 되는 사이트 긁기 (Selenium)
  8. #8PDF 자동 처리! 여러 파일 병합·분할부터 비밀번호까지 (pypdf) 📄
  9. #9마우스·키보드 GUI 자동화! 클릭·타이핑을 파이썬이 대신하게 하기 (PyAutoGUI)
  10. #10워드(Word) 문서 자동 생성! 엑셀 명단으로 계약서·안내문 100장 뽑기 (python-docx) 📄지금 읽는 글
  11. #11개인화 대량 메일 자동 발송! 명단별 첨부파일·발송 로그·재시도까지 (smtplib 실전편) 📬
  12. #12구글 시트 연동하기! 팀원과 같이 보는 자동 시트 (gspread) 📗
  13. #13파이썬 자동화 로그·재시도·디스코드 실패 알림 만들기 - 조용히 멈추는 스크립트 잡기
  14. #14파워포인트(PowerPoint) 보고서 자동 생성! 엑셀 숫자만 바꾸면 발표자료 완성 (python-pptx) 📊
전체 14편 보기접기

글쓴이

Mojangcam

직접 코드를 짜고, 서버를 돌리고, 막힌 에러를 해결하며 배운 것들을 기록하는 개발·IT 실전 기록 블로그