콘텐츠로 이동

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

OCR 서비스 작업대에서 이미지 업로드, 검증, native OCR 실행, 완료와 fallback 응답 패널이 배치된 3D 일러스트
OCR 서비스는 글자를 뽑는 기능보다, OCR을 못 할 때도 client가 해석할 수 있는 응답을 먼저 약속해야 합니다.

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도, 운영자도, 장애 알림도 같이 헷갈립니다.

HTTP upload boundary, image preprocessing boundary, native OCR boundary, response contract boundary를 세로 lane으로 나누어 보여주는 OCR 서비스 workflow diagram
실전 OCR 서비스는 upload guard, image preprocessing, native OCR, response contract를 분리해서 봐야 합니다. BAD_REQUEST는 fallback이 아니라 입력 경계에서 끝나는 요청입니다.

글에서 사용할 예제 이미지는 아래처럼 읽기 쉬운 문서형 이미지라고 가정합니다.

BLUETAPE OCR이라는 제목과 세 줄의 문장이 들어 있는 OCR API 예제 입력 이미지
실제 OCR 결과는 font, 해상도, traineddata, preprocessing에 따라 달라집니다. 글의 JSON은 workshop contract를 설명하기 위한 source-backed 예시입니다.

workshop API는 multipart upload를 받습니다.

Terminal window
curl -F "file=@sample.png;type=image/png" \
-F "language=eng" \
http://localhost:8080/api/images/ocr

Controller는 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 typeAPI contract를 좁혀서 client와 server의 기대를 맞춘다
Magic byte와 declared type 비교image/png라고 보낸 JPEG 같은 spoofing을 막는다
Decoded pixel budgetwidth * 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입니다.

Statustext/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만 보면 됩니다.

bluetape4k-image/images-ocr는 Tess4J/Tesseract 기반 engine을 제공합니다. 하지만 Tesseract binary와 traineddata는 library 안에 bundling하지 않습니다. 운영 host나 container가 native runtime을 준비해야 합니다.

Terminal window
brew install tesseract
ls /opt/homebrew/share/tessdata/eng.traineddata
sudo apt-get install -y tesseract-ocr tesseract-ocr-eng

workshop에서는 이 사실을 property로 드러냅니다.

Property역할
workshop.ocr.native-enablednative OCR path를 켤지 결정한다
workshop.ocr.tessdata-pathhost별 traineddata 위치를 지정한다
workshop.ocr.timeoutnative call이 API worker를 오래 붙잡지 않게 한다
workshop.ocr.max-upload-bytesupload byte budget을 제한한다
workshop.ocr.max-image-pixelsdecoded 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 전에 이미지 전처리 비용이 어느 정도인가”를 판단하는 데는 충분히 유용합니다.

OCR document preprocessing benchmark chart comparing Scrimage and libvips latency and managed heap allocation
문서형 OCR 전처리 workload에서는 libvips Java 25 FFM 경로가 latency와 managed heap allocation 모두에서 훨씬 작았습니다. 이 숫자는 OCR 인식 시간이 아니라 decode/resize/grayscale/encode pipeline의 비용입니다.
BoundaryLatencyManaged heap allocation해석
Scrimage ByteArray143.64 ms/op164.46 MiB/op편하지만 compressed input byte를 먼저 staging한다
Scrimage Path145.13 ms/op164.34 MiB/opblocking Scrimage boundary에서는 비슷한 수준
Scrimage InputStream / OutputStream148.39 ms/op165.33 MiB/opstream boundary여도 decoded heap 비용은 남는다
Scrimage Okio Source / Sink145.59 ms/op165.34 MiB/oplifecycle/integration 장점이지 latency win은 아니다
Scrimage suspended source/sink170.69 ms/op166.10 MiB/opblocking stream bridge overhead가 보인다
vips ByteArray15.38 ms/op0.34 MiB/op빠르지만 compressed input byte staging은 남는다
vips Path5.47 ms/op0.34 MiB/op이 snapshot에서 가장 강한 row
vips InputStream / OutputStream15.59 ms/op1.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가 두 개 있습니다.

ExampleRouteUpload 처리Error shape
Ktor OCR API/api/ocrstreamed multipart part를 maxInputBytes + 1까지만 읽는다error, message, status, path
Spring Boot OCR API/api/ocrMultipartFile.bytesDispatchers.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을 남길 수 있습니다.