# 문서 구조를 보존하는 DOCX 에디터 만들기

Source: https://www.deea.dev/posts/docx-editor/

ProseMirror와 OOXML로 문서 구조를 보존하는 오픈소스 DOCX 에디터를 만든 과정

[https://docx-editor.portone.io](https://docx-editor.portone.io)

[GitHub](https://github.com/portone-io/docx-editor)

[npm](https://www.npmjs.com/package/@portone/docx-editor)

## 들어가며

회사에서 계약서 생성 워크플로우를 만들면서 브라우저에서 사용할 수 있는 DOCX 에디터가 필요했습니다.

당시 만들던 기능은 Word 문서나 Google Docs로 만든 기존 계약서를 템플릿 삼아 새 문서를 생성하고, 외부 강사가 브라우저에서 자신의 정보만 확인하고 수정하도록 하는 워크플로우였습니다. 계약 조항처럼 사용자가 건드리면 안 되는 부분은 잠겨 있어야 했습니다. 문서에는 표가 많았고 한국어, 중국어, 일본어, 영어 입력을 지원해야 했으며, 민감한 계약서를 외부 편집 서비스로 보내지 않는 것도 중요한 조건이었습니다.

처음에는 당연히 이런 라이브러리가 이미 있을 거라고 생각했습니다. 하지만 직접 찾아보니 단순하게 React에 임베딩할 수 있으면서 DOCX를 다시 온전히 내보내고, 문서 일부를 잠글 수 있는 선택지를 찾기 어려웠습니다. 상용 서비스까지 범위를 넓혀도 비용과 데이터 처리 방식이 우리 요구사항에 맞지 않았습니다.

결국 직접 만들기로 했습니다. 그렇게 시작한 프로젝트가 [@portone/docx-editor](https://github.com/portone-io/docx-editor)입니다. 텍스트 서식과 목록, 표, 이미지, 링크, 댓글을 지원하고, 콘텐츠 컨트롤로 지정한 영역을 잠글 수 있는 React 에디터입니다.

현재 회사에서 사용하는 계약서 템플릿은 원하는 형태로 렌더링되고 수정 후에도 원래 구조를 유지합니다. 하지만 더 다양한 DOCX를 만나야 호환성을 높일 수 있다고 생각해 내부 도구로만 두지 않고 Apache-2.0으로 공개했습니다.

```tsx
import "@portone/docx-editor/styles.css"

import { DocxEditor } from "@portone/docx-editor"

export function Editor({ file }: { file: File }) {
  return <DocxEditor document={file} />
}
```

React 컴포넌트로 사용하는 방법은 단순하지만, 가장 오래 고민한 문제는 **DOCX를 손상하지 않고 다시 내보내는 방법**이었습니다.

## DOCX를 HTML로 바꾸지 않기로 했다

DOCX 파일은 하나의 문서처럼 보이지만 실제로는 여러 XML과 이미지, 관계 정보가 들어 있는 ZIP 패키지입니다.

```text
contract.docx
├── [Content_Types].xml
├── _rels/.rels
└── word/
    ├── document.xml
    ├── styles.xml
    ├── numbering.xml
    ├── _rels/document.xml.rels
    └── media/
```

브라우저 에디터를 만들 때 흔히 떠올릴 수 있는 방법은 DOCX를 HTML로 변환하고, 편집이 끝나면 HTML을 다시 DOCX로 만드는 것입니다. 화면에 표시하는 것만 생각하면 간단하지만, 왕복 변환 과정에서 HTML이 표현하지 못하는 Word의 구조나 속성이 사라질 수 있습니다.

우리에게는 에디터가 이해하지 못하는 정보라도 사용자가 건드리지 않았다면 그대로 남아 있어야 했습니다. 그래서 `@portone/docx-editor`는 HTML을 중간 포맷으로 사용하지 않고 **OOXML을 직접 읽고 씁니다.**

핵심은 import할 때 편집 가능한 ProseMirror 문서와 원본 DOCX를 보관하는 세션을 함께 만드는 것입니다.

```ts
import { exportDocx, importDocx } from "@portone/docx-editor/core"

const { doc, session } = importDocx(source)
const bytes = exportDocx(doc, session)
```

세션에는 DOCX 패키지의 원본 파트와 각 문서 블록의 XML이 들어 있습니다. 내보낼 때는 현재 ProseMirror 노드를 가져올 당시의 노드와 비교합니다.

- 수정되지 않은 블록은 원본 XML을 그대로 사용합니다.
- 수정된 문단이나 표만 OOXML로 다시 직렬화합니다.
- 이미지, 댓글, 목록처럼 변경이 필요한 패키지 파트만 교체합니다.
- 에디터가 모델링할 수 없는 블록은 원본 XML을 가진 플레이스홀더로 보존합니다.

즉, 문서 전체를 새로 만드는 대신 편집이 일어난 범위만 다시 쓰는 방식입니다. 지원하지 않는 구조를 안전하게 보존할 수 없다면 손상된 파일을 조용히 반환하지 않고 `unsupported-content` 같은 명시적인 에러로 가져오기나 내보내기를 중단합니다.

이 원칙 덕분에 에디터의 기능 범위를 점진적으로 늘리면서도, 아직 지원하지 않는 Word 기능까지 우연히 지워버리는 문제를 피할 수 있었습니다.

## 편집 모델은 ProseMirror로

커서 이동과 실행 취소·다시 실행까지 갖춘 브라우저 편집 엔진을 직접 만들 수도 있습니다. 하지만 우리에게는 편집 엔진 자체보다 필요한 기능을 빠르게 완성해 실제 계약서 워크플로우에서 검증하는 일이 중요했습니다. [ProseMirror](https://prosemirror.net/)가 제공하는 편집 기능이면 요구사항을 충분히 만족한다고 판단해, 사용자가 실제로 편집하는 문서 모델로 선택했습니다.

ProseMirror는 완성된 UI를 강제하지 않으면서 필요한 기능을 모듈로 조합할 수 있고, 여러 웹 에디터의 기반으로 널리 사용되고 있습니다. 문서 구조를 스키마로 제한하고 모든 편집을 트랜잭션으로 기록하며, 표와 편집 이력 같은 기반 기능도 제공합니다. 덕분에 편집 엔진을 처음부터 만드는 대신 DOCX의 문단, run, 표, 이미지 등을 ProseMirror 모델과 OOXML 사이에서 안전하게 변환하는 일에 집중할 수 있었습니다.

전체 흐름은 다음과 같습니다.

```text
DOCX bytes
  → ZIP package와 OOXML 파싱
  → ProseMirror document + 원본을 가진 session
  → 브라우저에서 transaction으로 편집
  → 변경된 block만 OOXML로 직렬화
  → 원본 package에 필요한 part만 교체
  → DOCX bytes
```

물론 OOXML과 ProseMirror의 모델이 정확히 일치하지는 않습니다. Word의 서식은 문서 기본값, 스타일, 문단 속성, run 속성이 계층적으로 겹쳐 적용되고, 표는 병합 셀과 grid 정보까지 함께 봐야 합니다. 화면에는 계산된 최종값을 보여주되 export할 때는 원본 XML의 구조를 최대한 유지하도록 읽기용 값과 쓰기용 원본을 분리했습니다.

ProseMirror의 트랜잭션 모델은 콘텐츠 잠금을 구현할 때도 유용했습니다. DOCX의 콘텐츠 컨트롤을 에디터 스키마에 포함하고, 잠긴 범위 안에서 트랜잭션이 문서를 바꾸지 못하도록 플러그인과 명령 단계에서 막습니다. 툴바에서는 비활성 상태인데 단축키로는 수정되는 식의 불일치가 생기지 않도록, 명령의 실행 가능 여부와 실제 실행 결과가 일치하는지도 테스트합니다.

## 한중일 입력을 어떻게 지원했을까?

계약서에서는 한국어, 중국어, 일본어 입력이 모두 중요했습니다. 일반적인 영문 키 입력과 달리 IME 입력 중 브라우저가 받는 것은 완성된 글자가 아니라 계속 교체되는 composition buffer입니다. 일본어는 `にほんご`가 후보 선택을 거쳐 `日本語`로 바뀌고, 중국어는 pinyin buffer가 한자로 교체됩니다. 한글은 `ㅇ → 아 → 안`처럼 하나의 음절이 조합되고, 다음 입력에 따라 `간`의 받침 `ㄴ`이 다음 음절의 초성으로 이동해 `가나`가 되기도 합니다.

ProseMirror 자체는 브라우저의 composition을 처리하지만, 에디터에 추가한 기능들이 그 흐름을 깨뜨릴 수 있었습니다. 새 문단에 Word 스타일을 적용하는 transaction은 문단 DOM을 다시 그리고, 페이지 계산은 입력할 때마다 새로운 decoration을 만듭니다. 이 작업들이 조합 중인 문단에 끼어들면 아직 확정되지 않은 문자열이 중간에 사라지거나 두 번 입력되고, 커서 아래의 페이지 경계가 계속 움직일 수 있습니다.

해결 원칙은 단순했습니다. **`view.composing`이 true인 동안에는 ProseMirror의 조합 처리 외에, 에디터가 조합 중인 문단을 추가로 다시 그리지 않습니다.**

- 새 문단의 스타일 계산은 조합 중인 문단만 건너뛰고 composition이 끝난 다음 transaction에서 적용합니다.
- 페이지 높이가 달라져도 측정과 decoration 갱신을 미루고, composition이 끝나면 누적된 요청을 한 번만 실행합니다.
- 링크 카드처럼 선택 영역 주변에 나타나는 UI도 조합 중에는 열지 않습니다.
- 일반 keymap은 IME가 소비한 keydown을 별도의 편집 명령으로 처리하지 않습니다.

잠긴 content control에서는 한 단계가 더 필요했습니다. 잠긴 범위에 들어온 transaction을 거부하면 문서는 보호되지만, 브라우저는 DOM에서 조합 문자열이 사라진 뒤 `compositionend`를 보내지 않는 경우가 있었습니다. 그러면 ProseMirror의 `view.composing`이 계속 `true`로 남아 페이지 측정처럼 미뤄둔 작업도 영원히 실행되지 않습니다.

그래서 잠금 플러그인은 트랜잭션을 거부한 다음 animation frame에서 문서 내용은 바꾸지 않지만 ProseMirror가 DOM 상태를 다시 동기화하도록 하는 트랜잭션을 보냅니다. 그 과정에서 조합 상태도 함께 종료됩니다. 결과적으로 잠긴 내용은 바뀌지 않고, 화면에 조합 중이던 글자도 남지 않으며, 에디터는 다음 입력을 정상적으로 받을 수 있습니다.

이 동작은 jsdom만으로 확인하기 어려워 Playwright와 Chrome DevTools Protocol의 `Input.imeSetComposition`으로 실제 브라우저가 받는 조합 buffer를 재현했습니다. 일본어 후보 교체, 중국어 pinyin 변환, 한글 음절 조합과 받침 이동, 자모가 하나씩 지워질 때의 buffer 변화를 거치는 동안 문서와 화면이 계속 일치하는지 확인합니다. 이 브라우저 테스트는 **IME 조합 중에는 해당 문단을 추가로 다시 그리지 않는다**는 원칙을 회귀 테스트로 고정합니다. 이후 페이지 계산이나 스타일 처리 방식이 바뀌어도 한중일 입력이 다시 깨지지 않도록 하는 안전장치입니다.

## 코딩 에이전트와 품질 기준 만들기

초기 버전은 코딩 에이전트와 함께 개발했습니다. 저는 아키텍처와 기대 동작을 정의하고, 에이전트가 작성한 구현과 테스트, 문서를 리뷰하며 다음 작업의 방향을 잡았습니다.

에이전트는 범위와 완료 조건이 분명한 작업을 빠르게 구현했습니다. 반면 DOCX 호환성처럼 이후의 모든 변경에서도 지켜야 하는 조건은 매번 프롬프트로 설명하는 것만으로 유지하기 어려웠습니다. 그래서 중요한 판단을 설명에만 남기지 않고, 저장소가 자동으로 검사하는 제약으로 옮겼습니다.

### 아키텍처 경계를 자동으로 검사하기

OOXML과 문서 처리 계층은 에디터 UI에 의존하지 않고, UI가 그 아래 계층을 조합합니다. 이 의존성 규칙과 각 폴더의 계층을 문서화한 뒤 `folderBoundaries.test.ts`가 역방향 import, 진입점에서 도달할 수 없는 파일, 분류되지 않은 새 폴더를 검사하도록 했습니다.

에이전트가 기능을 추가하면서 당장 편한 위치에 코드를 넣더라도 전체 구조가 서서히 무너지지 않게 하기 위한 장치였습니다.

### DOCX 유효성을 완료 조건으로 만들기

DOCX는 브라우저에서 그럴듯하게 보이는 것만으로 충분하지 않습니다. 내보낸 WordprocessingML이 OOXML 스키마를 만족하는지도 확인해야 합니다.

테스트용 DOCX를 가져온 뒤 그대로 내보내는 경우뿐 아니라, 실제로 문단을 수정해 내보낸 결과도 검사했습니다. 생성된 WordprocessingML은 `xmllint`로 [ECMA-376 Transitional 스키마](https://ecma-international.org/publications-and-standards/standards/ecma-376/)에 맞는지 검증합니다. 검증 도구가 누락되거나 테스트 문서가 비었을 때 검사가 건너뛰어진 채 통과하지 않도록 했고, 의도적으로 잘못된 XML이 반드시 실패하는 음성 대조군도 두었습니다.

### 결정사항을 다음 작업의 컨텍스트로 남기기

OOXML 명세에서 확인한 근거, 보존해야 하는 문서 구조, 아직 지원하지 않는 동작을 문서에 남겼습니다. 테스트는 완료 조건이 되었고 문서는 다음 작업의 출발점이 되었습니다.

제가 반복한 작업은 대체로 다음과 같았습니다.

1. 원하는 동작과 실패하면 안 되는 조건을 정의합니다.
2. 에이전트가 구현과 테스트를 작성하면 실제 DOCX와 브라우저에서 결과를 확인합니다.
3. 예상과 다른 부분은 설계와 테스트, 문서에 반영해 다음 작업의 기준으로 남깁니다.

에이전트 덕분에 짧은 시간 안에 다양한 기능을 구현할 수 있었지만, 속도를 만든 것은 프롬프트의 길이보다 검증 가능한 기준이었습니다. 스키마 검증, round-trip 테스트, 실제 브라우저 테스트, 아키텍처 경계 검사가 없었다면 코드의 양은 비슷해도 공개할 수 있는 라이브러리가 되지는 못했을 것 같습니다.

## 지금 지원하는 것과 남은 것

v0.1에서는 다음 기능을 지원합니다.

- 텍스트 서식과 문단 스타일, 정렬, 들여쓰기, 줄 간격
- 글머리 기호와 번호 목록
- 행과 열 추가·삭제, 셀 병합·분할·크기 조절을 포함한 표 편집
- 이미지 삽입·붙여넣기·크기 조절
- 링크와 댓글 스레드
- 읽기 전용 모드와 콘텐츠 컨트롤 잠금
- DOCX 가져오기와 내보내기

처음부터 Word의 모든 기능을 구현하는 것이 목표는 아니었습니다. 계약서 워크플로우에 필요한 기능을 제공하면서, 에디터가 아직 이해하지 못하는 문서 구조를 훼손하지 않는 것이 더 중요했습니다. 지원하는 범위에서는 실제 계약서에 사용할 수 있고, 그 밖의 구조는 조용히 망가뜨리지 않는 것이 v0.1의 기준이었습니다.

페이지 경계는 브라우저에서 측정한 근삿값이라 Word나 인쇄 결과와 다를 수 있습니다. 각주와 미주는 참조와 일반 텍스트 형태의 노트 내용을 확인할 수 있지만 편집은 지원하지 않고, 흔하지 않은 목록 형식도 다르게 표시될 수 있습니다. 기존 변경 내용 추적(tracked changes)은 원본에 보존하지만 화면에 표시하거나 편집하지 않습니다.

특히 가져오기, 렌더링, round-trip 문제를 드러내는 DOCX 예제와 중국어나 일본어를 모국어로 사용하는 분들의 피드백을 기다립니다. 페이지네이션, 각주와 미주 편집, 문서 호환성을 넓히는 기여도 환영합니다.
