배치 처리
Office Oxide는 충분히 빨라서 대부분의 배치 작업에서 병목은 디스크 I/O이지 파싱이 아닙니다. 일반적인 Word 문서는 0.8ms에 추출됩니다 — 즉 단일 스레드가 초당 ~1,000개 파일을 처리합니다.
이 가이드는 확장되는 패턴을 다룹니다: 작은 작업엔 순차 루프, 중간 규모엔 스레드 풀, S3나 HTTP에서 스트리밍할 땐 비동기 I/O.
순차 루프 — 올바른 기본값
로컬 디스크에 수천 파일까지라면 평범한 순차 루프가 가장 단순하고 종종 가장 빠른 선택입니다. 워커 스폰 오버헤드와 병렬 디스크 읽기 경쟁을 피할 수 있습니다.
Python
from pathlib import Path
from office_oxide import Document
for src in Path("corpus").rglob("*"):
if src.suffix.lower() in {".docx", ".xlsx", ".pptx", ".doc", ".xls", ".ppt"}:
with Document.open(src) as doc:
text = doc.plain_text()
src.with_suffix(".txt").write_text(text)
Rust
use std::path::Path;
use office_oxide::Document;
use walkdir::WalkDir;
for entry in WalkDir::new("corpus") {
let entry = entry?;
let path = entry.path();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if matches!(ext.to_ascii_lowercase().as_str(),
"docx" | "xlsx" | "pptx" | "doc" | "xls" | "ppt") {
let doc = Document::open(path)?;
std::fs::write(path.with_extension("txt"), doc.plain_text())?;
}
}
}
JavaScript
import { readdirSync, statSync, writeFileSync } from 'node:fs';
import { join, extname } from 'node:path';
import { Document } from 'office-oxide';
const exts = new Set(['.docx', '.xlsx', '.pptx', '.doc', '.xls', '.ppt']);
function* walk(dir) {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) yield* walk(full);
else yield full;
}
}
for (const src of walk('corpus')) {
if (!exts.has(extname(src).toLowerCase())) continue;
using doc = Document.open(src);
writeFileSync(src.replace(/\.\w+$/, '.txt'), doc.plainText());
}
WASM (브라우저, 사용자 업로드 파일)
import { WasmDocument } from 'office-oxide-wasm';
// <input type="file" multiple accept=".docx,.xlsx,.pptx,.doc,.xls,.ppt">
async function extractAll(fileList) {
const results = [];
for (const file of fileList) {
const data = new Uint8Array(await file.arrayBuffer());
const fmt = file.name.split('.').pop().toLowerCase();
const doc = new WasmDocument(data, fmt);
try {
results.push({ name: file.name, text: doc.plainText() });
} finally {
doc.free();
}
}
return results;
}
완전히 클라이언트 측에서 실행됩니다 — 파일이 브라우저를 떠나지 않으므로, 프라이버시가 중요한 배치 작업에 이상적입니다.
Go
package main
import (
"os"
"path/filepath"
"strings"
officeoxide "github.com/yfedoseev/office_oxide/go"
)
var exts = map[string]bool{
".docx": true, ".xlsx": true, ".pptx": true,
".doc": true, ".xls": true, ".ppt": true,
}
func main() {
filepath.Walk("corpus", func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() { return err }
if !exts[strings.ToLower(filepath.Ext(path))] { return nil }
doc, err := officeoxide.Open(path)
if err != nil { return nil } // 읽을 수 없는 파일은 스킵
defer doc.Close()
text, _ := doc.PlainText()
return os.WriteFile(strings.TrimSuffix(path, filepath.Ext(path))+".txt", []byte(text), 0644)
})
}
C#
using OfficeOxide;
var exts = new HashSet<string> { ".docx", ".xlsx", ".pptx", ".doc", ".xls", ".ppt" };
foreach (var src in Directory.EnumerateFiles("corpus", "*", SearchOption.AllDirectories))
{
if (!exts.Contains(Path.GetExtension(src).ToLowerInvariant())) continue;
using var doc = Document.Open(src);
File.WriteAllText(Path.ChangeExtension(src, ".txt"), doc.PlainText());
}
병렬 — 큰 코퍼스용
수만 개의 파일과 빠른 SSD가 있을 때 병렬화가 도움이 됩니다. 주의: 워커가 너무 많으면 디스크가 포화돼 처리량이 떨어집니다.
Python (ProcessPoolExecutor)
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
from office_oxide import Document
def process(path: Path) -> None:
with Document.open(path) as doc:
path.with_suffix(".md").write_text(doc.to_markdown())
paths = [p for p in Path("corpus").rglob("*")
if p.suffix.lower() in {".docx", ".xlsx", ".pptx", ".doc", ".xls", ".ppt"}]
with ProcessPoolExecutor(max_workers=8) as ex:
for _ in ex.map(process, paths):
pass
Python 바인딩은 네이티브 파싱 중 GIL을 해제하므로 ThreadPoolExecutor도 동작합니다 — 다만 단일 문서에서 패닉이 발생할 경우 프로세스 격리가 더 안전합니다.
Rust (rayon)
use rayon::prelude::*;
use office_oxide::Document;
paths.par_iter().for_each(|path| {
if let Ok(doc) = Document::open(path) {
let _ = std::fs::write(path.with_extension("md"), doc.to_markdown());
}
});
rayon의 기본 스레드 수는 CPU 수와 일치합니다 — 거의 항상 옳은 설정입니다.
Go (goroutine pool)
package main
import (
"os"
"path/filepath"
"runtime"
"strings"
"sync"
officeoxide "github.com/yfedoseev/office_oxide/go"
)
func main() {
var paths []string
filepath.Walk("corpus", func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() { return err }
ext := strings.ToLower(filepath.Ext(p))
if ext == ".docx" || ext == ".xlsx" || ext == ".pptx" { paths = append(paths, p) }
return nil
})
jobs := make(chan string)
var wg sync.WaitGroup
for i := 0; i < runtime.NumCPU(); i++ {
wg.Add(1)
go func() {
defer wg.Done()
for path := range jobs {
doc, err := officeoxide.Open(path)
if err != nil { continue }
md, _ := doc.ToMarkdown()
os.WriteFile(strings.TrimSuffix(path, filepath.Ext(path))+".md", []byte(md), 0644)
doc.Close()
}
}()
}
for _, p := range paths { jobs <- p }
close(jobs)
wg.Wait()
}
C# (Parallel.ForEach)
using OfficeOxide;
var exts = new HashSet<string> { ".docx", ".xlsx", ".pptx" };
var paths = Directory.EnumerateFiles("corpus", "*", SearchOption.AllDirectories)
.Where(p => exts.Contains(Path.GetExtension(p).ToLowerInvariant()))
.ToList();
Parallel.ForEach(paths, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, path =>
{
try
{
using var doc = Document.Open(path);
File.WriteAllText(Path.ChangeExtension(path, ".md"), doc.ToMarkdown());
}
catch (OfficeOxideException) { /* 읽을 수 없는 파일은 스킵 */ }
});
JavaScript (Promise.all)
import { readdirSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';
import { Document } from 'office-oxide';
const exts = new Set(['.docx', '.xlsx', '.pptx', '.doc', '.xls', '.ppt']);
function* walk(dir) {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) yield* walk(full);
else yield full;
}
}
const paths = [...walk('corpus')].filter(p => exts.has(extname(p).toLowerCase()));
// 동시성 제한
const CONCURRENCY = 8;
let i = 0;
async function worker() {
while (i < paths.length) {
const path = paths[i++];
using doc = Document.open(path);
// doc.toMarkdown() 처리...
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
비동기 — 입력이 다른 곳에서 올 때
입력이 HTTP, S3, 큐에서 온다면 비동기 I/O가 이깁니다. 네트워크가 파싱 시간을 지배하기 때문이죠. 파일 경로 대신 바이트에서 여세요.
Python (asyncio + aiohttp)
import asyncio, aiohttp
from office_oxide import Document
async def fetch_and_extract(session, url):
async with session.get(url) as r:
data = await r.read()
fmt = url.rsplit(".", 1)[-1].lower()
with Document.from_bytes(data, fmt) as doc:
return doc.plain_text()
async def main(urls):
async with aiohttp.ClientSession() as session:
return await asyncio.gather(*(fetch_and_extract(session, u) for u in urls))
Rust (tokio)
use office_oxide::{Document, DocumentFormat};
use std::io::Cursor;
let bytes = reqwest::get(url).await?.bytes().await?;
let fmt = DocumentFormat::Docx;
// 파싱은 blocking 태스크로 옮김 — 추출은 CPU 바운드
let text = tokio::task::spawn_blocking(move || -> office_oxide::Result<String> {
let doc = Document::from_reader(Cursor::new(bytes.to_vec()), fmt)?;
Ok(doc.plain_text())
}).await??;
JavaScript (fetch + 동시성 제한)
import { Document } from 'office-oxide';
async function fetchAndExtract(url) {
const res = await fetch(url);
const buf = Buffer.from(await res.arrayBuffer());
const fmt = url.split('.').pop().toLowerCase();
using doc = Document.fromBytes(buf, fmt);
return doc.plainText();
}
const CONCURRENCY = 16;
const queue = [...urls];
const results = [];
await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
while (queue.length) {
const url = queue.shift();
results.push(await fetchAndExtract(url));
}
}));
Go (HTTP fan-out)
package main
import (
"io"
"net/http"
"strings"
"sync"
officeoxide "github.com/yfedoseev/office_oxide/go"
)
func fetchAndExtract(url string) (string, error) {
resp, err := http.Get(url)
if err != nil { return "", err }
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil { return "", err }
fmt := url[strings.LastIndex(url, ".")+1:]
doc, err := officeoxide.OpenFromBytes(data, fmt)
if err != nil { return "", err }
defer doc.Close()
return doc.PlainText()
}
func main() {
urls := []string{ /* ... */ }
sem := make(chan struct{}, 16) // 동시성 상한
var wg sync.WaitGroup
for _, u := range urls {
wg.Add(1)
sem <- struct{}{}
go func(url string) {
defer wg.Done()
defer func() { <-sem }()
text, _ := fetchAndExtract(url)
_ = text // 처리...
}(u)
}
wg.Wait()
}
C# (HttpClient + async)
using OfficeOxide;
using var http = new HttpClient();
async Task<string> FetchAndExtract(string url)
{
var data = await http.GetByteArrayAsync(url);
var fmt = url[(url.LastIndexOf('.') + 1)..].ToLowerInvariant();
using var doc = Document.FromBytes(data, fmt);
return doc.PlainText();
}
// SemaphoreSlim으로 동시성 제한
var sem = new SemaphoreSlim(16);
var tasks = urls.Select(async url =>
{
await sem.WaitAsync();
try { return await FetchAndExtract(url); }
finally { sem.Release(); }
});
var results = await Task.WhenAll(tasks);
메모리 팁
- 매우 큰 XLSX의 경우 Rust에서
mmapfeature(features = ["mmap"])로 빌드하고Document::open_mmap을 호출해 아카이브 전체를 힙에 복사하지 마세요. - 워커당 한 번에
Document하나만 열어두세요. 각 핸들은 파싱된 구조를 메모리에 보관하고, 닫으면(Rust drop, Pythonwith블록 종료, JSclose()/using) 해제됩니다. - 대규모 LLM 인제스트에서는
to_html()보다to_markdown()을 선호하세요 — Markdown이 더 작은 출력과 더 나은 다운스트림 LLM 처리량을 만듭니다.
더 보기
- 성능 벤치마크 — p99 포함 전체 수치
- RAG용 Office — RAG 전용 패턴