ADR-003: Multi-document redesign β€” xlwings-inspired App/Docs/Document

Published

April 29, 2026

Tip채택 (2026-04-29)

이 ADR 은 채택 λ˜μ–΄ v3.0 으둜 κ΅¬ν˜„λ¨. - 버전: v3.0 (semver 정직, clean break) - Document.activate(): λͺ¨λ“  doc-scoped λ©”μ†Œλ“œ μ‹œμž‘ μ‹œ μžλ™ 호좜 - v2 ν˜Έν™˜ shim: μ—†μŒ β€” App.open/save/save_as/close/doc μ¦‰μ‹œ 제거 - μ‚¬μš©μž λ§ˆμ΄κ·Έλ ˆμ΄μ…˜: v2β†’v3 κ°€μ΄λ“œ

λ§₯락 (Context)

v2.0 의 슬림 facade λŠ” ν•œ λ²ˆμ— ν•œ λ¬Έμ„œλ§Œ λ‹€λ£¨λŠ” λͺ¨λΈλ‘œ μΆœλ°œν–ˆμŠ΅λ‹ˆλ‹€. App 이 lifecycle κ³Ό 단일 ν™œμ„± λ¬Έμ„œ (app.doc) λ₯Ό λͺ¨λ‘ μ±…μž„μ§€κ³ , λŒ€λΆ€λΆ„μ˜ μž‘μ—…μ΄ app.open() / app.save() / app.close() 와 같이 App λ©”μ†Œλ“œ 둜 λ…ΈμΆœλ©λ‹ˆλ‹€. 이 λͺ¨λΈμ€ λ‹€μŒκ³Ό 같은 본질적 ν•œκ³„λ₯Ό λ“œλŸ¬λƒˆμŠ΅λ‹ˆλ‹€:

  1. 닀쀑 λ¬Έμ„œ λΆ€μž¬. HWP λŠ” ν•œ ν”„λ‘œμ„ΈμŠ€μ— μ—¬λŸ¬ λ¬Έμ„œλ₯Ό λ™μ‹œμ— μ—΄ 수 μžˆμ§€λ§Œ (XHwpDocuments), v2 surface λŠ” κ·Έ collection 을 λ…ΈμΆœν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. app.documents λŠ” v1 μ—μ„œ μ‚¬μš©λ˜μ—ˆμœΌλ‚˜ v2 redesign μ‹œ β€œPhase 3 으둜 연기” 둜 ν‘œμ‹œλ˜λ©° μ œκ±°λ˜μ—ˆμŠ΅λ‹ˆλ‹€ (migration guide μ°Έμ‘°). 결과적으둜 μ‚¬μš©μžκ°€ ν•œ λ²ˆμ— 두 νŒŒμΌμ„ 비ꡐ/병합/볡사 ν•˜λ €λ©΄ raw COM (app.api.XHwpDocuments) 으둜 λ‚΄λ €κ°€μ•Ό ν•©λ‹ˆλ‹€.

  2. lifecycle μ±…μž„μ˜ 잘λͺ»λœ μœ„μΉ˜. app.save() λŠ” β€œμ–΄λŠ λ¬Έμ„œλ₯Ό?” κ°€ λͺ¨ν˜Έν•©λ‹ˆλ‹€ β€” ν˜„μž¬λŠ” ν™œμ„± λ¬Έμ„œλ₯Ό μ €μž₯ν•˜μ§€λ§Œ, μ‚¬μš©μžκ°€ λ¬Έμ„œ A λ₯Ό μ²˜λ¦¬ν•˜λ‹€ background μ—μ„œ λ¬Έμ„œ B κ°€ ν™œμ„±ν™”λ˜λ©΄ μ˜λ„μ™€ λ‹€λ₯Έ λ¬Έμ„œκ°€ μ €μž₯λ©λ‹ˆλ‹€. μ €μž₯/λ‹«κΈ°λŠ” λ¬Έμ„œ λ‹¨μœ„ μž‘μ—… 이 μžμ—°μŠ€λŸ½μŠ΅ λ‹ˆλ‹€.

  3. Document λ©”μ†Œλ“œκ°€ λΉ„μ–΄ 있음. app.doc 은 7개 collection (fields/bookmarks/hyperlinks/images/paragraphs/styles/tables) 만 κ°€μ§€κ³  있고, migration κ°€μ΄λ“œκ°€ μ•½μ†ν•˜λŠ” insert_text / text / cursor / find_text / replace_all 등은 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. μ‚¬μš©μžκ°€ λ¬Έμ„œλŒ€λ‘œ 따라 μ½”λ”©ν•˜λ©΄ μ¦‰μ‹œ AttributeError. μ΄λŠ” λ‹¨μˆœ λˆ„λ½μ΄ μ•„λ‹ˆλΌ Document 의 μ±…μž„ λ²”μœ„κ°€ μ •ν•΄μ§€μ§€ μ•Šμ€ κ²°κ³Ό μž…λ‹ˆλ‹€ β€” App μ—μ„œ λΉΌκΈ΄ ν–ˆμ§€λ§Œ 어디에도 두지 μ•ŠμŒ.

  4. ν…ŒμŠ€νŠΈ 격리 어렀움. v2 의 App() 은 resident HWP μΈμŠ€ν„΄μŠ€ 에 attach ν•©λ‹ˆλ‹€ β€” λ¬Έμ„œ 1개만 μ—΄λ¦¬λŠ” λͺ¨λΈμ΄λΌ ν…ŒμŠ€νŠΈ μ‚¬μ΄μ˜ λ¬Έμ„œ λˆ„μ μ΄ 잘 μ•ˆ λ³΄μ˜€μ§€λ§Œ, 닀쀑 λ¬Έμ„œ μ›Œν¬ν”Œλ‘œμš° μΆ”κ°€ μ‹œ β€œμ–΄λŠ λ¬Έμ„œκ°€ ν™œμ„±?” 의 race κ°€ μ¦‰μ‹œ λ“œλŸ¬λ‚  κ²ƒμž…λ‹ˆλ‹€.

영감 (Inspiration) β€” xlwings

xlwings λŠ” Excel μžλ™ν™”μ— λ™μΌν•œ deep COM μžλ™ν™” 문제λ₯Ό ν’€μ—ˆκ³ , λ‹€μŒκ³Ό 같은 3-tier ꡬ쑰λ₯Ό μ •μ°©μ‹œμΌ°μŠ΅λ‹ˆλ‹€:

xw.App                       # Excel application instance
β”œβ”€β”€ books                    # Books collection (workbook λ“€)
β”‚   β”œβ”€β”€ open(path) β†’ Book    # 파일 μ—΄κΈ° β†’ Book λ°˜ν™˜
β”‚   β”œβ”€β”€ add() β†’ Book         # μƒˆ 빈 workbook
β”‚   β”œβ”€β”€ active β†’ Book        # ν˜„μž¬ ν™œμ„± workbook
β”‚   β”œβ”€β”€ [i] / [name]         # index λ˜λŠ” 이름 μ ‘κ·Ό
β”‚   └── for wb in books      # 순회 κ°€λŠ₯
β”œβ”€β”€ apps                     # 사이블링 β€” λ‹€λ₯Έ Excel ν”„λ‘œμ„ΈμŠ€
└── quit()                   # Excel 자체 μ’…λ£Œ

Book (= κ°œλ³„ workbook μΈμŠ€ν„΄μŠ€)
β”œβ”€β”€ name / fullname / path
β”œβ”€β”€ sheets                   # Sheet collection (per-Book)
β”œβ”€β”€ save() / save_as(path)   # λ¬Έμ„œ λ‹¨μœ„ μ €μž₯
β”œβ”€β”€ close(save=False)        # λ¬Έμ„œ λ‹¨μœ„ λ‹«κΈ°
β”œβ”€β”€ activate()               # 이 workbook 을 ν™œμ„±ν™”
└── selection                # ν˜„μž¬ 선택

핡심 원칙:

  • App = ν”„λ‘œμ„ΈμŠ€ lifecycle 만 (start, quit). λ¬Έμ„œ μžμ²΄μ— λŒ€ν•œ λ©”μ†Œλ“œ μ—†μŒ.
  • App.books = workbook collection. 이 collection 의 λ©”μ†Œλ“œ (open, add) κ°€ Book μΈμŠ€ν„΄μŠ€λ₯Ό λ°˜ν™˜ ν•˜κ³ , μ‚¬μš©μžλŠ” κ·Έ μΈμŠ€ν„΄μŠ€λ₯Ό λ³€μˆ˜λ‘œ μž‘μ•„μ„œ μž‘μ—…ν•©λ‹ˆλ‹€.
  • Book = ν•œ workbook 의 λͺ¨λ“  μ±…μž„ (μ €μž₯, λ‹«κΈ°, sheet μ ‘κ·Ό, μ…€ μ‘°μž‘). 같은 App μ•ˆμ— μ—¬λŸ¬ Book 이 λ™μ‹œμ— μžˆμ„ 수 있고 μ„œλ‘œ 독립적.

κ²°μ • (Decision)

hwpapi v3 (λ˜λŠ” v2.1) 은 xlwings 의 3-tier ꡬ쑰λ₯Ό μ°¨μš©ν•©λ‹ˆλ‹€.

hwpapi.App                   # HWP COM μ—”μ§„ lifecycle 만
β”œβ”€β”€ docs                     # Documents collection
β”‚   β”œβ”€β”€ open(path) β†’ Document
β”‚   β”œβ”€β”€ add() β†’ Document         # μƒˆ 빈 λ¬Έμ„œ
β”‚   β”œβ”€β”€ active β†’ Document        # ν˜„μž¬ ν™œμ„± λ¬Έμ„œ
β”‚   β”œβ”€β”€ [i] / [name]
β”‚   └── for d in docs
β”œβ”€β”€ visible (property)
β”œβ”€β”€ version (property)
β”œβ”€β”€ engine                   # raw escape hatch
└── quit()                   # HWP ν”„λ‘œμ„ΈμŠ€ μ’…λ£Œ

hwpapi.Document              # ν•œ .hwp νŒŒμΌμ— λŒ€ν•œ λͺ¨λ“  μ±…μž„
β”œβ”€β”€ name / path / saved      # 메타 (μˆ˜μ • μ—¬λΆ€ 포함)
β”œβ”€β”€ text (r/w property)
β”œβ”€β”€ insert_text(...)
β”œβ”€β”€ insert_picture(...) / insert_table(...) / ...
β”œβ”€β”€ find_text(...) / replace_all(...)
β”œβ”€β”€ select_all() / get_selected_text()
β”œβ”€β”€ fields / bookmarks / hyperlinks / images /
β”‚   paragraphs / styles / tables / controls
β”œβ”€β”€ cursor                   # per-Document μ»€μ„œ
β”œβ”€β”€ page                     # per-Document νŽ˜μ΄μ§€ μ„€μ •
β”œβ”€β”€ save() / save_as(path)   # λ¬Έμ„œ λ‹¨μœ„ μ €μž₯
β”œβ”€β”€ close(save=False)        # λ¬Έμ„œ λ‹¨μœ„ λ‹«κΈ° β€” App 영ν–₯ μ—†μŒ
β”œβ”€β”€ activate()               # 이 λ¬Έμ„œλ₯Ό HWP μ°½μ—μ„œ ν™œμ„±ν™”
└── api / engine             # per-doc 의 COM ν•Έλ“€ (XHwpDocument)

Process-level μžμ› binding μ •μ±… (Actions / ParameterSets / API)

HWP 의 raw HAction, HParameterSet, XHwpDocuments 같은 μžμ›μ€ ν”„λ‘œμ„ΈμŠ€ μ „μ—­ 이고 항상 ν™œμ„± λ¬Έμ„œ 에 μž‘λ™ν•©λ‹ˆλ‹€. 닀쀑 λ¬Έμ„œ μƒν™©μ—μ„œ μ–΄λŠ doc 에 λͺ…령이 κ°ˆμ§€ λͺ¨ν˜Έν•΄μ§€λ―€λ‘œ, λ‹€μŒ 정책을 μ •ν•©λ‹ˆλ‹€:

같은 μžμ›μ„ 두 μœ„μΉ˜μ— λ…ΈμΆœ

μžμ› App 레벨 (raw) Document 레벨 (doc-scoped)
actions app.actions.X doc.actions.X β€” run() 직전 doc.activate() μžλ™
parametersets app.engine.parametersets (raw 클래슀) doc.create_parameterset(...) β€” auto-activate
api (COM ν•Έλ“€) app.api β€” IHwpObject (process) doc.api β€” IXHwpDocument (per-doc)
engine app.engine β€” Engine 객체 (κ·ΈλŒ€λ‘œ β€” process μžμ›)

binding 의미

μ§„μž…μ  ν™œμ„±ν™” μ±…μž„ μΆ”μ²œ μ‚¬μš©μ²˜
app.actions.X μ‚¬μš©μž (doc.activate() μˆ˜λ™) escape hatch / 단일 doc
doc.actions.X μžλ™ (run μ‹œμ ) 일반 μ‚¬μš© / 닀쀑 doc

κ΅¬ν˜„ β€” 얇은 proxy

class _DocActions:
    """Document-scoped actions proxy."""
    def __init__(self, doc):
        self._doc = doc
        self._app = doc._app

    def __getattr__(self, name):
        action = getattr(self._app.actions, name)
        return _ScopedAction(action, self._doc)


class _ScopedAction:
    def __init__(self, action, doc):
        self._action = action
        self._doc = doc

    @property
    def pset(self):
        return self._action.pset

    def run(self, *a, **kw):
        self._doc.activate()              # 핡심
        return self._action.run(*a, **kw)


class Document:
    @property
    def actions(self):
        return _DocActions(self)

μ‚¬μš©μž λ©˜νƒˆ λͺ¨λΈ

doc.actions.X.run()    β†’  "doc 에 X 적용" (μ•ˆμ „)
app.actions.X.run()    β†’  "ν˜„μž¬ ν™œμ„± doc 에 X 적용 (μ‚¬μš©μž μ±…μž„)"

xlwings 의 book.api vs app.api 와 의미 동일.

μ˜€λ²„ν—€λ“œ

Document.activate() = COM 호좜 1 회 (~Β΅s). 단일 doc 일 땐 noop (이미 ν™œμ„±). 닀쀑 doc 일 λ•Œλ§Œ μ‹€μ§ˆ λΉ„μš© λ°œμƒ, μ΄λ§ˆμ €λ„ μ‚¬μš©μžκ°€ μ—°μ†ν•΄μ„œ 같은 doc 을 λ‹€λ£¨λŠ” 경우 μΊμ‹±μœΌλ‘œ 0 으둜 μΆ•μ†Œ κ°€λŠ₯.

μ‚¬μš© 예 (Examples)

단일 λ¬Έμ„œ β€” v2 와 거의 동일

from hwpapi import App

with App() as app:
    doc = app.docs.open("report.hwp")
    doc.insert_text("μƒˆ 쀄\n")
    doc.save()
    doc.close()

두 λ¬Έμ„œλ₯Ό λ™μ‹œμ— 닀루기 (ν˜„μž¬ λΆˆκ°€λŠ₯ν–ˆλ˜ 것)

from hwpapi import App

with App() as app:
    src = app.docs.open("source.hwp")
    dst = app.docs.add()                    # 빈 μƒˆ λ¬Έμ„œ

    for para in src.paragraphs:
        dst.insert_text(para.text + "\n")

    dst.save_as("merged.hwp")
    src.close(save=False)                   # 원본은 κ·ΈλŒ€λ‘œ
    dst.close()

일괄 λ³€ν™˜ β€” ν•œ App μ—μ„œ N개 파일

from hwpapi import App
from pathlib import Path

with App() as app:
    for hwp in Path("inbox").glob("*.hwp"):
        doc = app.docs.open(hwp)
        doc.save_as(hwp.with_suffix(".pdf"))
        doc.close()                         # λ©”λͺ¨λ¦¬ ν•΄μ œ, λ‹€μŒ 파일둜

ν™œμ„± λ¬Έμ„œ μ „ν™˜

docs = app.docs                              # collection
print(len(docs))                             # μ—΄λ¦° λ¬Έμ„œ 개수
for d in docs:
    print(d.name)
docs[0].activate()                           # 첫 λ¬Έμ„œλ₯Ό forefront 둜

λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ 단계 (Phases)

Phase 0 β€” λͺ…μ„Έ 동결

이 ADR 채택 ν›„ v2.x 라인은 frozen. v2 μ‚¬μš©μžλŠ” hwpapi==2.* pin. v3 (λ˜λŠ” v2.1) 은 μƒˆ minor 둜 μΆœμ‹œ.

Phase 1 β€” Documents collection μ‹ μ„€

  • hwpapi.collections.documents.DocumentCollection μž‘μ„±
  • App 에 app.docs property μΆ”κ°€, app.doc 은 app.docs.active 둜 λ‚΄λΆ€ alias (v2.x ν˜Έν™˜ μž„μ‹œ).
  • app.docs.open(path) κ°€ Document μΈμŠ€ν„΄μŠ€λ₯Ό λ°˜ν™˜ (ν˜„μž¬λŠ” Document κ°€ 빈 facade 라도 collection 만 일단 λ…ΈμΆœ).

Phase 2 β€” Document 에 λ©”μ†Œλ“œ 이식

  • App.open/save/save_as/close β†’ Document.open/save/save_as/close 둜 이동. App λ©”μ†Œλ“œλŠ” 일단 deprecation shim 으둜 남겨 active doc 으둜 μœ„μž„ (app.save() β†’ app.docs.active.save()).
  • text I/O β€” insert_text, find_text, replace_all, select_all, get_selected_text, text (property), clear λ₯Ό Document 에 μ‹ μ„€. λ‚΄λΆ€ κ΅¬ν˜„μ€ raw actions.InsertText 등을 μ‚¬μš©.
  • cursor β€” Document.cursor.goto_page(...), move_to_field, in_table λ“±.
  • 7 개 collection accessor λŠ” κ·ΈλŒ€λ‘œ (이미 Document 에 있음).

Phase 3 β€” App 의 lifecycle 정리

  • App 에 λ‚¨λŠ” λ©”μ†Œλ“œ: quit, visible, version, docs, engine, api. κ·Έ μ™ΈλŠ” deprecated β†’ v3.1 μ—μ„œ 제거.
  • App() __enter__/__exit__ κ°€ quit() 만 μ±…μž„. μ—΄λ¦° λ¬Έμ„œλŠ” μžλ™ λ‹«μ§€ μ•ŠμŒ (μ‚¬μš©μžκ°€ λͺ…μ‹œμ μœΌλ‘œ close λ˜λŠ” with-Document μ‚¬μš©).

Phase 4 β€” Document 의 context manager

with app.docs.open("a.hwp") as doc:
    doc.insert_text("...")
    # exit μ‹œ μžλ™ close (μ €μž₯ μ—¬λΆ€λŠ” docs.open(..., save_on_close=) 둜 μ œμ–΄)

Phase 5 β€” ν˜Έν™˜ shim 제거

app.open/save/close/doc λ“± v2 ν˜Έν™˜ alias λ₯Ό μ œκ±°ν•˜κ³  v3.0 stable release. v2 μ‚¬μš©μžλŠ” migration κ°€μ΄λ“œ (이 ADR 의 뢀둝) 따라 마이그레 μ΄μ…˜.

v2 β†’ v3 λ³€ν™˜ ν‘œ (μš”μ•½)

v2 v3
app.open(path) doc = app.docs.open(path)
app.new() doc = app.docs.add()
app.save() doc.save()
app.save_as(path) doc.save_as(path)
app.close() doc.close()
app.doc app.docs.active
(μ—†μŒ β€” multi-doc) for d in app.docs:
(μ—†μŒ β€” 닀쀑 λ¬Έμ„œκ°„ 비ꡐ) app.docs["a.hwp"], app.docs[0]

ν˜Έν™˜μ„± μ •μ±… (Compatibility)

  • v2.x: 동결 (bug fix 만)
  • v3.0: deprecation shim 포함, v2 μ½”λ“œ λŒ€λΆ€λΆ„ μž‘λ™
  • v3.1: shim 제거, clean cut

v2 β†’ v3 λ§ˆμ΄κ·Έλ ˆμ΄μ…˜μ€ v1 β†’ v2 보닀 훨씬 μž‘μŒ β€” 주둜 app.X λ₯Ό doc = app.docs.open(...) + doc.X 둜 λ°”κΎΈλŠ” 것. find/replace κ°€λŠ₯ν•œ νŒ¨ν„΄.

κ²°κ³Ό (Consequences)

긍정

  • 닀쀑 λ¬Έμ„œ μ›Œν¬ν”Œλ‘œμš° κ°€λŠ₯ β€” 일괄 λ³€ν™˜, 병합, 비ꡐ λ“±
  • μ €μž₯/λ‹«κΈ° μ˜λ„ λͺ…ν™• β€” doc.save() λŠ” β€œμ΄ λ¬Έμ„œβ€ κ°€ λͺ…ν™•
  • Document μ±…μž„ λ²”μœ„ λͺ…ν™• β€” 7 collection + text I/O + cursor + save/close. App 은 process lifecycle 만.
  • xlwings μ‚¬μš©μž μΉœμˆ™ β€” Python 데이터 λΆ„μ„κ°€μ—κ²Œ mental model 동일
  • ν…ŒμŠ€νŠΈ 격리 깔끔 β€” 각 ν…ŒμŠ€νŠΈκ°€ 자기 Document λ₯Ό λͺ…μ‹œμ μœΌλ‘œ μ†Œμœ , race μ—†μŒ

λΆ€μ • / μœ„ν—˜

  • API ν‘œλ©΄μ΄ 컀짐 β€” Document κ°€ v2 의 거의 λͺ¨λ“  App λ©”μ†Œλ“œλ₯Ό 흑수. 슬림 facade 정신은 일뢀 후퇴 (λ‹€λ§Œ μŠ¬λ¦Όν•œ 것은 App 만).
  • win32com 의 active doc μΆ”μ κ³Όμ˜ 좩돌 κ°€λŠ₯μ„± β€” HWP COM 은 λͺ…μ‹œμ μΈ doc ν•Έλ“€ 전달이 μΌκ΄€λ˜μ§€ μ•ŠμŒ (HAction.Run 은 항상 ν™œμ„± λ¬Έμ„œ λŒ€μƒ). 이 뢀뢄은 Document.activate() λ₯Ό λͺ¨λ“  doc-scoped λ©”μ†Œ λ“œ μ‹œμž‘ μ‹œ μžλ™ ν˜ΈμΆœν•˜λŠ” λ°©μ‹μœΌλ‘œ 우회 (xlwings 도 동일 νŒ¨ν„΄).
  • λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ λΉ„μš© β€” v2 μ‚¬μš©μžμ—κ²Œ 두 번째 breaking change (v1β†’v2β†’v3). 이 λΉ„μš©μ„ 쀄이렀고 v3.0 에 deprecation shim 포함.
  • HWP 의 닀쀑 λ¬Έμ„œ 지원이 μ™„μ „ν•˜μ§€ μ•Šμ„ 수 있음 β€” XHwpDocuments μžμ²΄λŠ” μžˆμ§€λ§Œ 일뢀 action 은 ν™œμ„± λ¬Έμ„œμ—λ§Œ 적용됨. Document.api λ₯Ό ν†΅ν•œ per-doc COM 호좜이 κ°€λŠ₯ν•œμ§€ 검증 ν•„μš” (Phase 1 spike).

κ²°μ • (이전 λ―Έν•΄κ²° 질문)

  1. βœ… 버전 β€” v3.0 (semver 정직, clean break)
  2. βœ… Document.activate() μžλ™ 호좜 β€” λͺ¨λ“  doc-scoped λ©”μ†Œλ“œ μ§„μž… μ‹œ μžλ™ (_DocActions.__getattr__ μ‹œμ μ—λ„ μžλ™). xlwings 와 동일 νŒ¨ν„΄. COM 호좜 1회/λ©”μ†Œλ“œ β€” 단일 doc 일 땐 noop λΉ„μš©.
  3. βœ… app.docs.add() β€” μƒˆ 빈 λ¬Έμ„œλ₯Ό λ§Œλ“€κ³  active 둜 μ„€μ • ν›„ ν•΄λ‹Ή Document λ°˜ν™˜.
  4. βœ… app.doc shim 제거 μ‹œμ  β€” μ¦‰μ‹œ (v3.0). μ‚¬μš©μž λͺ…μ‹œ κ²°μ • β€” 무엇이 doc 객체둜 κ°€λŠ”μ§€ λͺ…ν™•ν•˜λ―€λ‘œ deprecation 사이클 λΆˆν•„μš”.

μ°Έκ³ 

Back to top