import()로 지연 로딩해서, 안 쓰는 형식의 무거운 코드는 번들에 실리지 않는다. "라이브러리를 어떻게 프레임워크에 안 묶이게 설계하는가"를 배우기에 교과서 같은 레포다.
xushanpei/open-file-viewer · 언어 TypeScript · 라이선스 MIT · 버전 0.1.32 · 구조 pnpm 모노레포(core + react/vue/svelte 바인딩) · 코어 소스 ~5만 줄 · 최신 커밋 2026-07-27 · npm @open-file-viewer/core · 제작 xushanpei · 성숙도 0.1.x 초기 단계)
open-file-viewer는 "브라우저 안에서 아무 파일이나 미리보기 창을 띄워주는 부품(SDK)"이다. 네가 만드는 웹 앱에 이 라이브러리를 붙이면, 사용자가 올린 .pdf·.docx·.xlsx·.png·.mp4·.zip·.eml·.glb(3D)·.dwg(CAD)·.geojson(지도) 파일을 새 탭으로 나가지 않고 페이지 안 지정된 자리에 그려준다.
공항의 만능 X-ray 검색대를 떠올려 보자. 가방(파일)이 어떤 종류든 — 캐리어든, 배낭이든, 악기 케이스든 — 같은 벨트에 올리면 안이 화면에 뜬다. open-file-viewer가 하는 일이 딱 이것이다. "이 파일이 무슨 형식이지?"를 먼저 판별하고(detect), 형식에 맞는 전용 렌더러(플러그인)에게 넘겨 화면에 그린다.
핵심은 벨트는 하나라는 점이다. 형식이 50개여도 사용자가 쓰는 API는 createViewer({ container, file }) 딱 하나다.
기술적으로 정리하면 세 층이다. ① 코어(@open-file-viewer/core) — 순수 DOM만 다루는 프레임워크 무관 엔진. ② 플러그인 20종 — 형식별 렌더링 로직(image / pdf / office / archive / 3d / cad / gis …). ③ 프레임워크 바인딩 3종(react / vue / svelte) — 코어를 각 프레임워크의 컴포넌트로 감싼 얇은 래퍼. 순수 JS만 쓸 거면 바인딩 없이 코어를 직접 부르면 된다.
파일 미리보기는 "쉬워 보이지만 실제로는 지옥"인 대표적 문제다. 웹 브라우저가 기본으로 보여줄 수 있는 건 이미지·영상·PDF 정도뿐이다. 워드·엑셀·PPT·한글 문서·CAD·3D 모델은 브라우저가 전혀 모른다. 그래서 대부분의 서비스는 이렇게 때운다:
open-file-viewer의 매력은 이 세 번째 길을 한 번만 잘 배선해서 재사용 가능하게 포장했다는 것이다. 게다가 최근 흐름과 정확히 맞물린다:
대부분의 뷰어는 window.open()으로 새 탭을 열거나 전체화면 모달을 강제한다. open-file-viewer는 네가 준 <div> 안에 그린다. 덕분에 "왼쪽엔 파일 목록, 오른쪽엔 미리보기" 같은 앱 레이아웃을 자연스럽게 짤 수 있다. 이건 문서에만 있는 약속이 아니라 코드로 강제된다 — 플러그인은 ctx.viewport(주어진 자리)에만 그리도록 규약이 잡혀 있다.
React·Vue·Svelte·순수 JS를 같은 코어 엔진으로 지원한다. 회사가 Vue를 쓰든 React를 쓰든 같은 기능을 쓴다. 라이브러리 설계 관점에서 "로직은 프레임워크 밖에, 프레임워크는 얇은 껍데기"라는 이상적 구조의 실물 예제다.
3D 렌더링용 three.js는 수백 KB다. 하지만 사용자가 3D 파일을 열기 전까지는 다운로드되지 않는다 — 코드가 await import("three")로 필요한 순간에만 불러오기 때문이다. 형식별 플러그인이 독립적이라 이런 코드 분할이 깔끔하게 된다.
"파일 미리보기"라는, 모두가 겪지만 아무도 깔끔하게 못 푸는 문제를 컨테이너 우선 + 플러그인 + 프레임워크 중립이라는 세 원칙으로 정리한 게 인기 이유다. AI 코딩 에이전트가 파일을 다루는 앱을 많이 만들면서 "만능 뷰어" 수요도 커졌다.
버전이 0.1.32다. 로드맵상 CAD·3D·고급 오피스는 "인식 + 기본 미리보기" 수준이고, 고정밀 렌더링은 서버 변환이나 상용 SDK로 넘긴다고 제작자가 스스로 명시한다. 프로덕션에 넣기 전엔 네가 쓸 형식이 실제로 얼마나 잘 그려지는지 반드시 직접 테스트해야 한다. 별(star) 수도 아직 적다 — 신생 프로젝트다.
| 역할 | 기술 | 메모 |
|---|---|---|
| 패키지 매니저 | pnpm 11.7 (workspaces) | turbo/nx 없이 순수 pnpm 워크스페이스. packages/* + examples/* + doc |
| 번들러 | tsup (esbuild 기반) | core/react/vue를 ESM+CJS 동시 출력 + .d.ts 타입 + 소스맵 |
| Svelte 패키징 | @sveltejs/package | Svelte만 svelte-package로 별도 빌드 |
| 예제·문서 앱 | Vite 6 | 4개 예제 + 문서 사이트 모두 Vite dev 서버 |
| 테스트 | Vitest 4 + jsdom | render-smoke.test.ts가 5,900줄 — 형식별 렌더 스모크 테스트가 두껍다 |
| 언어 | TypeScript 5.7 | strict, moduleResolution: Bundler, target ES2021 |
이 표가 이 레포의 진짜 알맹이다. "어떤 형식을 무슨 라이브러리로 그리는가"를 한눈에 보여준다. 거의 전부 import()로 지연 로딩된다.
| 형식군 | 플러그인 | 렌더링 라이브러리 |
|---|---|---|
| 이미지 | image | native <img>, TIFF=utif, HEIC=heic2any |
| 영상/오디오 | video / audio | native <video>/<audio>, HLS(m3u8)=hls.js |
| 텍스트/코드 | text | 하이라이트=prismjs(언어별 청크), 마크다운=marked, 다이어그램=mermaid, 살균=dompurify |
| PDF/전자책 | pdf / epub / xps | pdfjs-dist(선택적 peer), epub/xps는 jszip 해제 후 자체 렌더 |
| 오피스 | office / msdoc | docx=docx-preview/mammoth, 엑셀=xlsx(SheetJS), pptx=@aiden0z/pptx-renderer, 구형 .doc/.xls=자체 파서 |
| 압축 | archive | jszip, gzip=pako, bz2=seek-bzip, xz=xz-decompress |
| 이메일 | eml/mbox=postal-mime, Outlook .msg=@kenjiuno/msgreader | |
| 3D | model3d | three + 예제 로더들(GLTF/OBJ/STL/FBX/PLY/3MF/USD) + OrbitControls |
| CAD | cad / cad-dwg | DXF=자체 파서+pako, DWG=@mlightcad/libredwg-web(WASM, 선택적) |
| 지도(GIS) | gis | 지도=leaflet, kml/gpx=@mapbox/togeojson, topojson, 셰이프파일=shpjs |
| 기타 자산 | asset | PSD=ag-psd, Parquet=hyparquet, 그 외 메타데이터 인식 |
pdfjs-dist와 DWG용 WASM은 optional peer라, PDF나 CAD를 안 쓰는 사용자는 이 무거운 패키지를 설치할 필요가 없다. 라이브러리 용량을 사용자 선택에 맡기는 영리한 방식.<script>나 악성 속성을 제거해 XSS 공격을 막는 라이브러리. 워드·마크다운·이메일은 결국 HTML로 변환되는데, 남이 준 파일을 그대로 화면에 넣으면 위험하다. 그래서 렌더 전에 반드시 DOMPurify로 걸러낸다.레지스트리라고 하면 복잡한 등록 시스템을 상상하지만, 여기선 그냥 배열을 순서대로 훑는 for 루프다. 놀랍도록 단순하다.
// viewer.ts — 사용자가 준 플러그인 + 항상 마지막에 fallback
const plugins = [...(options.plugins || []), fallbackPlugin()];
const plugin = await findPlugin(plugins, file);
async function findPlugin(plugins, file) {
for (const plugin of plugins) {
if (await plugin.match(file)) { // 이 형식 내가 그릴 수 있어?
return plugin; // 응 → 채택하고 종료
}
}
return fallbackPlugin(); // 아무도 못 그림 → 다운로드 안내
}
"첫 매치가 이긴다"이므로 배열에 넣는 순서가 곧 우선순위다. 예를 들어 .csv를 표로 보고 싶으면 officePlugin()을 textPlugin()보다 앞에 놔야 한다. 순서가 반대면 텍스트 플러그인이 먼저 낚아채 그냥 글자로 보여준다. README도 이 점을 명시적으로 경고한다.
플러그인이 지켜야 할 규약은 types.ts에 있고, 요구하는 건 단 둘 — "이 파일 네가 맡을래?(match)"와 "그럼 그려줘(render)"뿐이다.
interface PreviewPlugin {
name: string;
match: (file) => boolean | Promise<boolean>; // 담당 판별
render: (ctx) => PreviewInstance | Promise<…>; // 실제 그리기
}
interface PreviewInstance {
resize?: (size) => void; // 컨테이너 크기 변하면
command?: (cmd) => void; // 확대·회전 같은 툴바 명령
destroy: () => void; // 정리(메모리 해제)
}
이렇게 계약을 좁게 잡으면 누구나 새 형식 플러그인을 쉽게 추가할 수 있다. "한글(.hwp) 플러그인"을 만들고 싶다면 match에서 확장자를 검사하고 render에서 그려 배열에 끼우면 끝이다.
여러 파일을 빠르게 넘길 때 문제가 생긴다. A파일 렌더링(비동기)이 끝나기 전에 사용자가 B로 넘기면, 뒤늦게 끝난 A가 B 위에 덮어 그려버린다. 해결책은 단조 증가 토큰이다.
let renderToken = 0;
async function renderFile(file) {
const token = ++renderToken; // 이번 렌더의 번호표
const instance = await plugin.render(ctx);
if (destroyed || token !== renderToken) return; // 번호표 낡음 → 버림
// 여기 왔으면 "내가 최신" → 화면에 반영
}
React·Vue·Svelte 바인딩이 하는 일은 똑같다: ① <div> ref 만들고 ② 마운트되면 createViewer() 호출 ③ 언마운트/prop 변경 시 destroy(). React 예시(툴바 배선 생략):
function FileViewer({ width="100%", height="600px", ...options }) {
const containerRef = useRef(null);
const viewerRef = useRef(null);
useEffect(() => {
if (!containerRef.current) return;
viewerRef.current?.destroy();
viewerRef.current = createViewer({ // ← 코어 호출
...options, container: containerRef.current, width, height,
});
return () => { viewerRef.current?.destroy(); }; // 정리
}, [options.file, options.files, width, height /* …모든 prop… */]);
return <div ref={containerRef} />; // 코어가 그릴 빈 자리
}
코어는 발전기, 프레임워크 바인딩은 콘센트 어댑터다. 발전기(로직)는 하나뿐이고, 나라(프레임워크)마다 플러그 모양만 다르니 얇은 어댑터만 갈아 끼운다. 그래서 React 어댑터가 20~30줄로 끝난다.
이 레포에서 가장 "실전 냄새 나는" 코드가 sandbox-compat.ts다. qiankun·micro-app·wujie 같은 마이크로 프론트엔드 환경에선 zip 기반 미리보기(docx/xlsx/pptx/epub)가 영원히 "로딩 중"에 멈추는 악명 높은 버그가 있다.
원인: JSZip이 내부적으로 쓰는 setImmediate 폴리필은 window의 message 이벤트에 의존한다. 그런데 마이크로 프론트엔드 sandbox가 앱을 정리하면서 이 리스너를 떼어버려, JSZip.loadAsync가 절대 완료 콜백을 안 부른다.
해결: sandbox를 감지하면(__POWERED_BY_QIANKUN__ 등) MessageChannel 기반의 자체 setImmediate를 심는다. 이 채널의 포트는 모듈 내부에 숨어 있어 sandbox가 건드릴 수 없다. 남이 겪은 삽질을 라이브러리가 대신 막아주는, 프로덕션 라이브러리다운 배려다.
코드 무게중심이 어디인지 줄 수가 말해준다. office.ts가 5,536줄로 압도적이다 — 오피스 문서가 그만큼 지옥이라는 방증이다. 그다음이 asset.ts(3,223), cad.ts(2,714). 테스트도 두껍다 — render-smoke.test.ts가 5,900줄로, "형식별로 실제 렌더가 깨지지 않는가"를 집요하게 검증한다.
① types.ts(계약이 뭔지) → ② viewer.ts의 createViewer·findPlugin(엔진이 어떻게 도는지) → ③ 가장 단순한 plugins/image.ts(플러그인 한 개가 어떻게 생겼는지) → ④ packages/react/src/index.tsx(바인딩이 얼마나 얇은지). 이 4개만 읽으면 전체 설계가 손에 잡힌다. office.ts는 나중에.
가장 값진 교훈. 로직을 순수 DOM/함수로 짜고(createViewer), 프레임워크는 얇게 감싼다. 이러면 React가 죽어도 코어는 산다. 오픈소스 라이브러리를 만들 계획이 있다면 이 3층 구조가 표준 답안이다.
match/render 딱 두 메서드로 확장 포인트를 잡는 법. "인터페이스를 좁게 잡을수록 확장은 쉬워진다"를 코드로 체득한다.
import() 기반 코드 분할three, pdfjs-dist 같은 무거운 의존성을 쓰는 순간에만 불러오는 실전 패턴. 초기 번들을 작게 유지하는 프론트엔드 성능 최적화의 핵심 기술.
renderToken으로 "낡은 작업 버리기". 빠른 사용자 입력 + 비동기 작업이 만나는 모든 UI에서 재사용 가능한 패턴이다(검색 자동완성, 탭 전환 등).
docx·xlsx·pptx·epub·ofd·kmz가 사실은 전부 zip 껍데기라는 것, PDF는 pdf.js가 canvas에 그린다는 것, 3D는 WebGL이라는 것 — 파일 포맷의 내부를 들여다보는 드문 기회.
남이 준 문서를 화면에 넣기 전 dompurify로 거르는 습관. 사용자 콘텐츠를 다루는 모든 웹 개발자의 필수 반사신경.
examples/vanilla를 띄우고 아무 파일이나 드래그해 어떤 플러그인이 잡는지 콘솔로 추적해 보기.plugins/image.ts를 복사해 나만의 형식 플러그인(예: .log를 색칠해 보여주기)을 하나 추가.three 청크가 그제서야 다운로드되는지 확인.| 항목 | 요구 |
|---|---|
| 런타임 | 모던 브라우저(ES2021+). 서버가 필요 없는 100% 클라이언트 라이브러리 — 파일이 서버로 안 나간다(프라이버시 강점) |
| 개발 환경 | Node.js + pnpm 11. pnpm i && pnpm dev:react 로 예제 실행 |
| 프레임워크 | React ≥18, Vue ≥3, Svelte ≥4, 또는 순수 JS(바인딩 불필요) |
| 선택 설치 | PDF 보려면 pdfjs-dist, DWG(CAD) 보려면 LibreDWG WASM — 안 쓰면 설치 불필요 |
| 무거운 형식 | 3D(three.js)·CAD는 WebGL/WASM을 쓰므로 저사양 기기에선 버벅일 수 있음 |
서버 변환도, 외부 뷰어도 없다. 사내 기밀 문서를 미리보기 해도 파일이 사용자 브라우저를 떠나지 않는다. 이게 구글/MS 뷰어 대비 결정적 차별점이다.
pnpm i 후 dev:vanilla, dev:react, dev:vue, dev:svelte를 차례로 실행. 같은 파일을 네 앱에 넣어 결과가 동일한지 확인. "코어 하나, 껍데기 넷"을 눈으로 체감하는 것이 목표.
.csv 파일 하나를 넣고, officePlugin과 textPlugin 순서를 바꿔가며 결과가 표↔글자로 달라지는지 실험. "첫 매치가 이긴다"를 손으로 검증.
image.ts를 템플릿 삼아 .env나 .ini 파일을 키=값 표로 예쁘게 보여주는 플러그인을 작성. match(확장자 검사)와 render(ctx.viewport에 테이블 생성)만 채우면 됨. 플러그인 계약을 진짜로 이해했는지 확인하는 과제.
빌드 후 번들 분석. 3D/CAD/오피스 청크가 초기 로드에 없다가 해당 파일을 열 때만 네트워크 요청되는 걸 캡처. 지연 로딩이 실제로 작동함을 데이터로 보이기.
files 배열로 여러 파일을 넣고 next()/previous()/goTo()로 넘기는 갤러리 UI를 구현. 각 프레임워크의 툴바 슬롯에 자체 버튼(예: 즐겨찾기)을 꽂아 코어↔프레임워크 브리지가 어떻게 동작하는지 파헤치기.
| 주차 | 주제 | 할 것 |
|---|---|---|
| 1주 | TypeScript 라이브러리 기초 | tsup로 ESM+CJS+타입을 동시 출력하는 법, exports 맵, peerDependency 개념 |
| 2주 | 플러그인/전략 패턴 | 이 레포의 레지스트리를 모방해 작은 "만능 파서" 직접 설계 |
| 3주 | 브라우저 파일 API | File/Blob/ArrayBuffer, FileReader, MIME 판별, JSZip으로 zip 해제 |
| 4주 | 렌더링 라이브러리 | pdf.js(canvas), SheetJS(엑셀 파싱), mammoth(docx→html) 각각 단독 실습 |
| 5주 | three.js / WebGL | GLTF 로더로 3D 모델 띄우기, OrbitControls |
| 6주 | 성능 & 코드 분할 | 동적 import, 번들 분석, 지연 로딩 전략 |
| 7주 | 프론트엔드 보안 | XSS, DOMPurify, CSP, 신뢰할 수 없는 콘텐츠 렌더링 |
| 8주 | 마이크로 프론트엔드 | qiankun/wujie sandbox 원리, sandbox-compat.ts가 푼 버그 재현·이해 |
await import("three")처럼 필요할 때만 코드를 불러와 초기 번들을 작게 유지하는 기법.renderToken으로 해결.README.ko.md 포함)doc/ 앱 (Vite로 로컬 실행 pnpm dev:doc)@open-file-viewer/core, /react, /vue, /svelte (v0.1.32)