HWP 파일 하나 열자고 한컴오피스를 깔기는 싫고, 문서를 아무 온라인 변환 사이트에 올리기는 더 싫었다. 그래서 집 NAS에 문서 웹앱을 하나 만들었다. 오피스·PDF·HWP를 편집하고, CAD 도면과 3D 모델을 보고, 휴대폰으로 찍은 종이를 검색 가능한 PDF로 바꾸는 것까지 브라우저 하나로 한다. 이름은 Sejong Office다.
기능보다 먼저 정한 원칙이 하나 있었다. 서버에 문서를 남기지 않는다. 이 글은 그 원칙을 지키려고 무엇을 어디서 처리하게 나눴는지, 그리고 Jenkins로 NAS까지 자동 배포하는 과정을 정리한 기록이다.
한 화면에 모은 것
직접 만든 엔진은 없다. 형식마다 잘 만든 오픈소스를 골라 하나의 화면에 붙였다. 파일을 끌어다 놓으면 확장자를 보고 알맞은 편집기로 보낸다.
| 형식 | 엔진 | 처리 위치 |
|---|---|---|
| docx · xlsx · pptx · pdf | Euro-Office Document Server | 서버 (임시) |
| hwp · hwpx | rhwp | 브라우저 |
| dxf · dwg | MLightCAD | 브라우저 |
| glTF · OBJ · STEP · IFC · STL 등 | Online 3D Viewer | 브라우저 |
| 다이어그램 | Excalidraw · Mermaid · draw.io | 브라우저 |
| 사진 · PDF → 스캔 PDF | OpenCV.js · PP-OCRv5 | 브라우저 |
프론트는 Vue 3 + Vite, 백엔드는 Express(TypeScript)다. 처음에는 OnlyOffice Document Server를 썼다가 유럽 쪽 포크인 Euro-Office로 옮겼다. 뷰어 라이브러리, OCR 모델, PDF.js 워커는 모두 앱이 직접 서빙한다(/viewer-assets/). 실행 중에 외부 CDN에서 받아 오는 파일이 없으니, 어떤 문서를 열었는지 밖으로 새어 나갈 경로도 없다.
원칙: 서버에 남기지 않는다
표의 마지막 열이 핵심이다. 브라우저에서 처리할 수 있는 형식은 아예 서버로 보내지 않는다. HWP는 rhwp가 WebAssembly로 파싱하고, 도면·3D 모델·스캔도 전부 브라우저 메모리 안에서 끝난다. 결과는 다운로드로만 가져간다.
문제는 오피스 문서다. Document Server는 서버에서 파일을 받아 편집하고, 저장할 때 결과 파일을 다시 서버에 돌려주는 구조라 서버를 거칠 수밖에 없다. 그래서 이 경로는 "잠깐만 두고 확실히 지운다"로 설계했다.
- 디스크가 아니라 메모리에 둔다. 업로드한 파일은 컨테이너의 tmpfs(
/tmp/sejong)에세션/문서디렉터리로 들어간다. 컨테이너가 내려가면 같이 사라지고, 백엔드는 시작할 때 이 디렉터리를 한 번 더 비운다. - 세션은 로그인이 아니라 탭이다. 페이지를 열면 세션 쿠키가 생기고, 30초마다 하트비트를 보낸다. 마지막 신호 뒤 15분이 지나면 1분마다 도는 청소 루프가 세션 디렉터리를 통째로 지운다.
- 탭을 닫으면 바로 지운다.
pagehide이벤트에서sendBeacon과keepalivefetch로 세션 삭제를 요청한다. 이 요청이 유실되더라도 TTL이 뒤를 받친다.
export async function sweepExpired(): Promise<number> {
const cutoff = now() - config.sessionTtlMs;
let removed = 0;
for (const [id, session] of sessions) {
if (session.lastSeenAt < cutoff) {
await deleteSession(id); // 세션 디렉터리를 통째로 rm -rf
removed += 1;
}
}
return removed;
}Document Server와 백엔드 사이도 막아 두었다. 편집기가 원본을 받아 갈 때는 문서마다 따로 발급한 임의 토큰이 필요하다. 저장 콜백은 공유 비밀키로 서명한 JWT만 받는다. 저장이 끝나면 문서 키를 새로 만들어, 편집기가 예전 캐시를 다시 쓰지 않게 했다.
문서 스캐너: 사진을 "스캔한 것처럼"
가장 손이 많이 간 기능이다. 종이를 휴대폰으로 찍어 올리면 문서 테두리를 찾아 반듯하게 펴고, 한국어 OCR을 돌려 글자를 검색할 수 있는 PDF로 만든다. 이것도 전부 브라우저에서 돈다.
처음 버전은 결과가 어딘가 어색했다. 네 꼭짓점을 찾아 펴는 것까지는 됐는데, 비스듬히 찍은 사진은 늘어난 것처럼 보였다. 원인은 출력 비율을 변의 길이로 정했다는 데 있었다. 비스듬히 찍으면 카메라에서 먼 쪽 변이 짧게 찍히니, 변 길이만 보면 원래 종이 비율이 틀어진다.
그래서 네 꼭짓점의 원근 왜곡에서 카메라 초점거리를 역산해 실제 종이의 가로세로 비를 구하는 방법(Zhang & He, Whiteboard scanning and image enhancement)을 넣었다. 구한 비율이 A 계열(√2)이나 Letter 비율과 4% 안쪽으로 가까우면 그 값으로 맞춘다. 거의 정면에서 찍어 계산이 불안정할 때는 변 길이 평균으로 돌아간다.
/** 흔한 용지 비율(A 계열, Letter)과 4% 안쪽이면 그 비율로 맞춘다 */
function snapAspect(ratio: number) {
for (const target of [Math.SQRT2, 11 / 8.5]) {
for (const t of [target, 1 / target]) {
if (Math.abs(ratio / t - 1) < 0.04) return t;
}
}
return ratio;
}보정 단계도 바꿨다. 전체 대비를 올리기만 하면 그림자 진 쪽은 회색으로 뭉개진다. 지금은 글자를 지운 배경(팽창 연산으로 글자를 메운 이미지)을 추정해 원본을 그 값으로 나누는 방식(조명 평탄화)으로 종이를 고르게 하얗게 만든 다음, 글자 진하기와 선명도를 따로 조절한다. 흑백 모드는 적응형 이진화를 쓴다.
OCR은 PP-OCRv5 한국어 모델을 ONNX Runtime Web으로 돌린다. 인식한 단어는 페이지 이미지 위에 투명한 텍스트 레이어로 얹는다. 그래서 PDF에서 검색하고 복사할 수 있다. 여기서도 버그가 하나 있었다. 이미지 픽셀 수를 그대로 PDF 포인트로 쓰는 바람에 페이지가 수십 cm짜리 종이가 됐다. 지금은 긴 변을 A4(841.89pt)에 맞추고, OCR 좌표도 같은 비율로 환산한다.
배포: push하면 NAS까지
소스는 NAS의 Gitea에 있다. main에 push하면 Jenkins가 빌드해 개인 레지스트리(docker.sejong.dev)에 이미지를 올리고, NAS에 SSH로 접속해 docker compose pull && up -d를 실행한다. PR 빌드는 타입 체크와 빌드만 하고, 레지스트리 자격 증명은 쓰지 않는다.
git push (Gitea)
→ Jenkins: npm ci · npm run build
→ docker build · push docker.sejong.dev/sejong-office:{latest, sha-…}
→ Publish Over SSH → NAS: docker compose pull && docker compose up -d처음부터 이렇게 정리된 건 아니다. 커밋 기록을 보면 시행착오가 그대로 남아 있다.
- Vite가 메모리 부족으로 죽었다. CAD 뷰어, 3D 뷰어, Mermaid, Excalidraw를 한 번에 번들링하니 Node 기본 힙(약 2GB)을 넘었다.
NODE_OPTIONS=--max-old-space-size=8192로 해결했다. - Gitea Actions에서 이미지를 빌드하려다 DinD(Docker-in-Docker)를 거쳤다. 결국 DinD를 빼고 호스트 Docker 소켓을 쓰는 쪽으로 바꿨고, 이후 CI 전체를 이미 쓰고 있던 Jenkins로 옮겼다.
- SSH로 접속했는데
docker를 못 찾았다. 시놀로지는 비대화형 SSH 세션의 PATH에/usr/local/bin이 빠져 있다. 배포 명령에서 PATH를 직접 지정했다.
앱이 쓰는 비밀값은 Document Server와 공유하는 JWT 키 하나뿐이다. 이 키는 Git에도 이미지에도 Jenkins에도 넣지 않고 NAS의 .env에만 둔다. 레지스트리 토큰도 CI용(push)과 서버용(pull)을 나눴다.
남은 것
rhwp는 아직 0.7 버전대라, 표가 복잡한 공공문서는 한컴오피스와 조판이 조금 다르게 나온다. 오피스 편집은 구조상 서버를 거치니, "남기지 않는다"는 약속도 결국 TTL과 tmpfs를 믿는 수준이다. 브라우저만으로 처리하는 형식을 늘려 가는 게 다음 목표다.
소스는 git.sejong.dev/ysj7705/sejong-office에 GPL-3.0으로 공개되어 있다.