HWP 파일은 문단 텍스트, 글자 모양, 표 같은 정보를 작은 레코드 단위로 저장합니다. 이 사례의 다음 레코드인 태그 68은 글자 모양 정보이며, 앞 레코드의 이어진 본문이 아닙니다.
GPTPilots · HWP Parser Incident Brief
HWP 파싱 타임아웃:
원인·수정·검증
케빈랩 HWP 1건을 계기로 확인한 범용 파서 결함과, 팀 적용 전 알아야 할 검증 결과를 정리한 보고서입니다.
01 문제점
케빈랩 HWP는 한글에서 정상적으로 열리고, 원본의 문제 구간도 HWP 구조상 정상입니다. 그런데 hwp_hwpx_parser의 하이퍼링크 사전 탐색 코드가 특정 제어 문자를 14바이트만 소비해, 남은 바이트를 다시 같은 제어 문자로 오인했습니다. 그 뒤 읽을 수 있는 바이트가 부족해도 현재 위치를 옮기지 않아 무한 반복이 발생했고, 추출 작업이 600초 후 타임아웃되었습니다.
원본 문서가 깨진 문제가 아니라 파서의 제어 문자 길이 처리 문제입니다. 케빈랩만 예외 처리하면 다음 유사 문서에서 다시 멈출 수 있으므로, 제어 문자 길이를 명세에 맞게 고쳐야 합니다.
02 본문 레코드에서 필요한 설명
HWP 본문은 글자만 이어진 텍스트가 아닙니다. 문단 안에는 글자와 함께 “여기부터 목차 필드가 시작된다”, “문단이 끝났다”처럼 문서 구조를 알리는 제어 문자가 들어 있습니다. 케빈랩의 문제 레코드는 태그 67 (문단 텍스트)이고, 본문 길이는 정확히 18바이트입니다.
0x0003은 표 6에서 ‘필드 시작’ 확장 컨트롤이므로, WCHAR가 2바이트인 문단 텍스트에서는 8 × 2 = 16바이트를 한 단위로 읽어야 합니다.03 00 63 6f 74 25 00 00 00 00 00 00 00 00 03 00 0d 00
0x0003 Field Start. 63 6f 74 25는 바이트 순서를 뒤집어 읽으면 %toc인 목차 관련 제어 ID입니다.
2바이트: 0x000d 문단 끝
03 00 63 6f 74 25 00 00 00 00 00 00 00 00 | 03 00 0d 00
기존 코드가 소비한 14바이트 i += 14 | 남아 버린 4바이트 03 00 0d 00
따라서 남은 03 00을 새 Field Start로 다시 오인합니다. 하지만 뒤에는 0d 00만 남아 있어 제어 ID를 읽을 수 없고, 기존 분기는 i를 이동시키지 않아 같은 위치를 반복합니다.
화면에 표시할 글자가 아니라 문서의 구조·기능을 표현하는 값입니다. 0x0003은 Field Start, 즉 필드가 시작됨을 뜻하는 확장 제어 문자입니다.
필드의 종류 또는 짝을 식별하는 번호입니다. 여기서는 %toc가 들어 있어 목차 관련 필드임을 알 수 있습니다. ID가 있다고 해서 화면에 그대로 글자로 나타나는 것은 아닙니다.
03 00이 또 있나
이 03 00은 새 제어 문자의 시작이 아닙니다. 앞의 Field Start 전체가 16바이트이므로 그 안에 포함된 값입니다. 뒤의 0d 00만 별도의 문단 끝 제어 문자입니다.
03 00 0d 00만 잘린 데이터처럼 보였던 이유는 파서가 앞 16바이트 중 14바이트만 건너뛰었기 때문입니다. 실제 원본 레코드는 18바이트이고 구조가 맞습니다.03 해결방안
0x0003Field Start를 발견하면 14바이트가 아니라 16바이트를 하나의 확장 제어 문자로 처리합니다.- 남은 길이가 16바이트보다 짧으면, 불완전한 제어 문자를 억지로 해석하지 말고 해당 문단의 사전 탐색을 안전하게 끝냅니다.
- 어떤 제어 문자 분기에서도 반복문 위치
i가 이동하거나 종료되는지 보장합니다. 이 안전장치가 있어야 새 RFP에서 예외적인 데이터가 와도 무한 루프가 나지 않습니다. - 케빈랩 원본을 회귀 테스트로 추가하고, 전체 HWP 코퍼스의 결과가 바뀌지 않는지 비교합니다.
.venv 수정은 원인 검증용입니다. 팀 코드에는 라이브러리를 포크하거나 의존성 패치 절차로 반영해야 하며, 가상환경을 다시 만들 때 사라지는 로컬 수정에 의존하면 안 됩니다.04 다른 HWP 파일들은 왜 됐었나
다른 HWP가 모두 “제어 문자가 없어서” 성공한 것은 아닙니다. 96개 HWP 중 83개에 0x0003 Field Start가 있었고, 총 677회 등장했습니다. 다만 케빈랩은 그 Field Start가 문단의 거의 끝에 있어서, 잘못 14바이트를 건너뛴 뒤 남은 03 00 0d 00을 다시 읽는 순간 이동할 여지가 없어졌습니다.
03 00은 어떻게 되나버려지는 값이 아닙니다. 다음 반복에서 파서는 이 03 00을 새로운 Field Start의 시작이라고 잘못 읽습니다. 그 뒤에 무엇이 남아 있느냐가 케빈랩과 다른 HWP의 차이입니다.
케빈랩: 뒤에 2바이트만 남음
03 00 | 0d 00
03 00을 새 Field Start로 오인했지만, 제어 ID를 읽으려면 뒤에 4바이트가 필요합니다. 실제로는 0d 00 2바이트뿐이라 읽기에 실패하고 i도 이동하지 않습니다.
결과: 같은 03 00을 계속 다시 읽음 → 무한 루프
다른 HWP: 뒤에 데이터가 더 남음
03 00 | xx xx xx xx ···
남은 03 00 뒤에 4바이트 이상이 있어, 파서는 그것들을 임시 제어 ID처럼 잘못 읽습니다. 이후 비하이퍼링크 분기에서 i += 14가 실행됩니다.
결과: 잘못 읽었지만 위치는 앞으로 이동 → 타임아웃은 없음
xx xx xx xx은 실제 값이 아니라 “문단 뒤에 계속 남아 있던 데이터”를 표시한 것입니다. 문서마다 값은 다릅니다.
| 상황 | 잘못된 14바이트 처리 뒤의 결과 |
|---|---|
| 대부분의 다른 HWP | 뒤에 읽을 바이트가 더 남아 있어, 파서가 비록 정확하지 않은 위치에서 시작했더라도 다음 위치로 진행하여 멈추지는 않았습니다. |
| 케빈랩 HWP | Field Start 끝 뒤에 문단 끝 0d 00만 남아, 재해석한 03 00 분기에서 위치가 전혀 이동하지 않았습니다. |
05 소스 수정해야 되는 부분
문제가 있는 함수는 _extract_hyperlink_texts_from_para입니다. 현재 로컬 검증은 설치된 라이브러리 파일에서 했지만, 팀 적용 시에는 해당 라이브러리의 관리 가능한 포크 또는 패치된 버전으로 반영해야 합니다.
기존
Field Start를 14바이트로 취급해, 마지막 2바이트를 남긴 채 다음 반복으로 넘어갑니다.
if code == 0x03:
text_start = i + 14
...
i += 14
수정
HWP Field Start의 정확한 길이인 16바이트를 사용하고, 부족하면 안전하게 종료합니다.
if code == 0x03:
field_start_size = 16
if i + field_start_size > len(para_data):
break
text_start = i + field_start_size
...
i += field_start_size
GPTPilots_Project\.venv\Lib\site-packages\hwp_hwpx_parser\hwp5.py. 이 경로는 프로젝트 소스가 아니라 설치된 의존성입니다. 여기만 고치면 팀원·배포 서버·가상환경 재생성 시에는 적용되지 않습니다.06 Test 결과
| 검증 항목 | 결과 | 의미 |
|---|---|---|
| 수정 전 전체 추출 | 99 / 100 성공 케빈랩 600초 타임아웃 | 문제 재현 완료 |
| 수정 후 케빈랩 원본 HWP | 성공 · 56,914자 | 동일 원본이 타임아웃 없이 본문 추출됨 |
| 수정 후 전체 100건 | 100 / 100 성공 | 케빈랩이 성공 상태로 전환됨 |
| 케빈랩 제외 다른 HWP 95건 | 상태·글자 수 변경 0건 | 이번 길이 수정이 기존 추출 결과를 바꾸지 않았음 |