OCR 서비스를 실전에서 운영하기: 큰 이미지 전처리와 실패 응답 계약

OCR demo는 보통 이미지를 올리고 text가 나오는 화면으로 끝납니다. 실전 서비스는 거기서 끝나지 않습니다. 이미지가 압축된 byte로는 작아 보여도 decode하면 너무 클 수 있고, content type이 거짓일 수 있고, Tesseract native runtime이 host에 없을 수 있고, OCR call이 timeout될 수 있습니다.
그래서 bluetape4k-workshop/image-processing/ocr-api 예제는 “인식 결과가 잘 나오는가”보다 먼저
입력 경계와 실패 응답을 모델링합니다. 잘못된 요청은 BAD_REQUEST로 끊고, 유효한 이미지인데 현재
host에서 OCR을 수행할 수 없는 경우만 fallback 응답으로 돌려줍니다. 이 차이를 섞으면 client도,
운영자도, 장애 알림도 같이 헷갈립니다.

BAD_REQUEST는 fallback이 아니라 입력 경계에서 끝나는 요청입니다.예제 API 모양
섹션 제목: “예제 API 모양”글에서 사용할 예제 이미지는 아래처럼 읽기 쉬운 문서형 이미지라고 가정합니다.

workshop API는 multipart upload를 받습니다.
curl -F "file=@sample.png;type=image/png" \ -F "language=eng" \ http://localhost:8080/api/images/ocrController는 file과 선택적 language query parameter를 service request로 넘깁니다.
Full source:
ImageOcrController.kt
응답 모델은 text만 반환하지 않습니다.
data class ImageOcrResponse( // 요청을 log, trace, client 문의와 연결하기 위한 correlation id입니다. val requestId: String,
// client가 가장 먼저 봐야 하는 처리 결과입니다. // COMPLETED, UNAVAILABLE, FAILED는 같은 response shape 안에서 구분됩니다. val status: OcrStatus,
// 실제로 사용한 OCR engine 이름입니다. native OCR을 끈 경우에는 disabled처럼 표시됩니다. val engine: String,
// 요청에서 선택했거나 service가 정규화한 OCR language 목록입니다. val languages: List<String>,
// OCR engine이 confidence를 제공하지 못하면 null입니다. val confidence: Double?,
// 최종 인식 문자열입니다. UNAVAILABLE/FAILED에서는 빈 문자열일 수 있습니다. val text: String,
// line 단위로 쪼갠 구조화 결과입니다. UI highlighting이나 후처리에 사용합니다. val blocks: List<OcrTextBlock>,
// client와 operator가 볼 수 있는 주의 사항입니다. exception parsing을 대신합니다. val warnings: List<String>,)Full source: ImageOcrModels.kt
status가 먼저 나오기 때문에 client는 text가 빈 문자열인지 아닌지를 추측하지 않아도 됩니다.
입력 경계: byte 크기만 보면 늦다
섹션 제목: “입력 경계: byte 크기만 보면 늦다”fallback 응답은 “아무 이미지나 받아도 된다”는 뜻이 아닙니다. ImageOcrServiceImpl은 OCR을 시도하기 전에
다음 조건을 먼저 검사합니다.
| Guard | 이유 |
|---|---|
| Empty byte 거부 | 빈 multipart field를 정상 OCR 요청으로 처리하지 않는다 |
maxUploadBytes | 요청 하나가 heap과 native runtime을 압박하지 않게 한다 |
| JPEG/PNG/WebP content type | API contract를 좁혀서 client와 server의 기대를 맞춘다 |
| Magic byte와 declared type 비교 | image/png라고 보낸 JPEG 같은 spoofing을 막는다 |
| Decoded pixel budget | width * height 기준으로 decode 이후 비용을 제한한다 |
여기서 제한하는 값은 결국 decoded pixel budget입니다. 예를 들어 4096x3860 이미지는 업로드 byte가 작아도
decode하면 약 15.8M pixels입니다. 실전에서는 “가로/세로가 몇이냐”를 따로 분리해서 보기보다
width * height가 API worker와 preprocessing pipeline을 얼마나 압박하는지를 제한 값으로 삼는 편이 직접적입니다.
예제도 maxImagePixels로 이 값을 끊습니다.
Full source: ImageOcrServiceImpl.kt, ImageOcrProperties.kt
중요한 점은 BAD_REQUEST가 fallback 상태가 아니라는 것입니다. 빈 파일, unsupported content type,
magic byte mismatch, corrupt image, pixel budget 초과는 입력 경계에서 실패합니다. fallback 응답은
잘못된 요청을 정상화하는 장치가 아닙니다. BAD_REQUEST는 입력 경계에서 끊고, fallback은 유효한 이미지가
OCR runtime 문제로 처리되지 못했을 때만 사용합니다.
같은 응답 shape 안에서 상태를 구분한다
섹션 제목: “같은 응답 shape 안에서 상태를 구분한다”Native OCR이 정상 실행되면 COMPLETED 응답은 text와 line-based block을 함께 돌려줍니다.
{ "requestId": "ocr-20260630-0001", "status": "COMPLETED", "engine": "tesseract", "languages": ["eng"], "confidence": null, "text": "BLUETAPE OCR\nLine 1: Upload image\nLine 2: Validate native OCR\nLine 3: Return fallback safely", "blocks": [ { "index": 0, "text": "BLUETAPE OCR", "confidence": null }, { "index": 1, "text": "Line 1: Upload image", "confidence": null }, { "index": 2, "text": "Line 2: Validate native OCR", "confidence": null }, { "index": 3, "text": "Line 3: Return fallback safely", "confidence": null } ], "warnings": ["Confidence is not available from the current OCR engine."]}Native OCR을 끈 상태라면 요청 image는 여전히 검증하지만 OCR은 수행하지 않습니다. 이때 응답은
UNAVAILABLE입니다.
{ "requestId": "ocr-20260630-0002", "status": "UNAVAILABLE", "engine": "disabled", "languages": ["eng"], "confidence": null, "text": "", "blocks": [], "warnings": [ "Native OCR is disabled. Enable workshop.ocr.native-enabled=true or -Docr.enabled=true." ]}Tesseract 설정이 빠졌거나 language pack을 찾지 못하는 경우도 client에게는 UNAVAILABLE로 표현합니다.
반면 OCR 실행 중 timeout이나 OCR exception이 나면 FAILED입니다.
| Status | text/blocks | 대표 원인 | Client 해석 |
|---|---|---|---|
COMPLETED | 채워짐 | OCR 완료 | text와 blocks를 사용한다 |
UNAVAILABLE | 비어 있음 | native disabled, Tesseract/tessdata/language pack 미준비 | 같은 request를 나중에 재시도하거나 OCR 없는 UX로 처리한다 |
FAILED | 비어 있음 | timeout, OCR runtime exception | 입력 품질 또는 OCR 처리 실패로 보고 재시도 정책을 별도로 둔다 |
세 상태가 같은 response shape를 가진다는 점이 중요합니다. Client는 예외 문자열을 parsing하지 않고
status, warnings, text, blocks만 보면 됩니다.
Native OCR은 host 계약이다
섹션 제목: “Native OCR은 host 계약이다”bluetape4k-image/images-ocr는 Tess4J/Tesseract 기반 engine을 제공합니다. 하지만 Tesseract binary와
traineddata는 library 안에 bundling하지 않습니다. 운영 host나 container가 native runtime을 준비해야 합니다.
brew install tesseractls /opt/homebrew/share/tessdata/eng.traineddata
sudo apt-get install -y tesseract-ocr tesseract-ocr-engworkshop에서는 이 사실을 property로 드러냅니다.
| Property | 역할 |
|---|---|
workshop.ocr.native-enabled | native OCR path를 켤지 결정한다 |
workshop.ocr.tessdata-path | host별 traineddata 위치를 지정한다 |
workshop.ocr.timeout | native call이 API worker를 오래 붙잡지 않게 한다 |
workshop.ocr.max-upload-bytes | upload byte budget을 제한한다 |
workshop.ocr.max-image-pixels | decoded image pixel budget을 제한한다 |
ImageOcrServiceImpl은 native OCR 구간을 Semaphore(permits = 1), withTimeout,
runInterruptible(Dispatchers.IO)로 감쌉니다. 이것은 coroutine API라는 이유로 native call을 가볍게
보지 않겠다는 신호입니다. Native OCR은 CPU와 native memory를 쓰는 host-level 작업이므로 concurrency와
timeout을 명시적으로 잡아야 합니다.
Full source: TesseractOcrEngine.kt, images-ocr README
큰 이미지는 OCR 전에 이미 비용이 든다
섹션 제목: “큰 이미지는 OCR 전에 이미 비용이 든다”OCR API에서 memory pressure는 Tesseract에 들어간 뒤에만 생기지 않습니다. Upload byte는 작아도 decoded image가 크면 JVM heap과 native pipeline이 먼저 일을 합니다.
images-benchmark의 large streaming 결과에는 document/OCR-like workload가 있습니다. 2480x3508 문서형 입력을
1240x1754로 resize하고 grayscale/JPEG encoding까지 수행한 측정입니다. OCR 자체를 잰 benchmark는 아니지만,
“OCR 전에 이미지 전처리 비용이 어느 정도인가”를 판단하는 데는 충분히 유용합니다.

| Boundary | Latency | Managed heap allocation | 해석 |
|---|---|---|---|
Scrimage ByteArray | 143.64 ms/op | 164.46 MiB/op | 편하지만 compressed input byte를 먼저 staging한다 |
Scrimage Path | 145.13 ms/op | 164.34 MiB/op | blocking Scrimage boundary에서는 비슷한 수준 |
Scrimage InputStream / OutputStream | 148.39 ms/op | 165.33 MiB/op | stream boundary여도 decoded heap 비용은 남는다 |
Scrimage Okio Source / Sink | 145.59 ms/op | 165.34 MiB/op | lifecycle/integration 장점이지 latency win은 아니다 |
| Scrimage suspended source/sink | 170.69 ms/op | 166.10 MiB/op | blocking stream bridge overhead가 보인다 |
vips ByteArray | 15.38 ms/op | 0.34 MiB/op | 빠르지만 compressed input byte staging은 남는다 |
vips Path | 5.47 ms/op | 0.34 MiB/op | 이 snapshot에서 가장 강한 row |
vips InputStream / OutputStream | 15.59 ms/op | 1.40 MiB/op | 낮은 managed allocation, stream boundary 비용은 존재 |
Source: large-streaming-2026-06-05.md
이 결과가 API 설계로 이어지는 지점은 단순합니다. OCR API는 maxUploadBytes만 두면 부족합니다. 압축 byte가
작은 큰 이미지가 들어올 수 있으므로 decoded pixel budget도 함께 둬야 합니다. 그리고 전처리 비용이 중요한 서비스라면
pure JVM path와 libvips path를 같은 말로 취급하면 안 됩니다. vips는 OCR engine이 아니라 이미지 전처리 비용을 줄이는
선택지입니다. 글자를 읽는 일은 여전히 Tesseract 같은 OCR runtime이 맡습니다.
Ktor와 Spring Boot 차이는 adapter에 머문다
섹션 제목: “Ktor와 Spring Boot 차이는 adapter에 머문다”bluetape4k-image에는 OCR quickstart가 두 개 있습니다.
| Example | Route | Upload 처리 | Error shape |
|---|---|---|---|
| Ktor OCR API | /api/ocr | streamed multipart part를 maxInputBytes + 1까지만 읽는다 | error, message, status, path |
| Spring Boot OCR API | /api/ocr | MultipartFile.bytes를 Dispatchers.IO에서 읽는다 | error, message |
둘 다 결국 immutableImageOf(uploadBytes).suspendExtractText(...)를 호출합니다. 차이는 HTTP stack이 multipart를
어떻게 읽고 error payload를 어떻게 만드는지에 있습니다. OCR engine contract 자체는 images-ocr에 남아 있습니다.
Full source: KtorOcrApiApplication.kt, SpringBootOcrApiApplication.kt
마무리
섹션 제목: “마무리”OCR 서비스는 검출된 문자열만 반환하는 endpoint로 시작하면 금방 막힙니다. 실전에서는 upload byte 크기, decoded pixel budget, preprocessing 비용, native runtime 준비 상태, timeout, 실패 응답, client retry 정책이 같이 움직입니다.
Coroutines, Ktor, Spring Boot 중 무엇을 쓰더라도 기본 계약은 크게 달라지지 않습니다. HTTP adapter는 file을 받고
service request를 만들 뿐입니다. 조심할 지점은 native OCR을 “suspend 함수 안에서 호출했으니 가볍다”고 착각하지
않는 것입니다. blocking/native call은 concurrency와 timeout으로 감싸고, client에게는 COMPLETED,
UNAVAILABLE, FAILED를 같은 response shape 안에서 돌려줘야 합니다.
이미지 detection은 OCR과 비슷한 실패 응답 계약을 갖게 됩니다. 다만 detection은 object bounding box, label, score 같은 결과 shape가 다르므로 별도 예제로 다루도록 할 예정입니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.