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

OCR 데모는 보통 이미지를 올리고 텍스트가 나오는 화면으로 끝납니다. 실전 서비스에는 더 많은 경계가 필요합니다. 압축 바이트가 작아도 디코딩한 이미지는 매우 클 수 있고, 선언한 콘텐츠 유형이 실제 형식과 다를 수 있습니다. 호스트에 Tesseract 네이티브 런타임이 없거나 OCR 호출이 타임아웃될 수도 있습니다.
그래서 bluetape4k-workshop/image-processing/ocr-api 예제는 “인식 결과가 잘 나오는가”보다 먼저
입력 경계와 실패 응답을 모델링합니다. 잘못된 요청은 BAD_REQUEST로 끊고, 유효한 이미지인데 현재
호스트에서 OCR을 수행할 수 없는 경우만 대체 응답으로 돌려줍니다. 이 차이를 섞으면 클라이언트와
운영자 모두 입력 오류와 런타임 장애를 구분하기 어렵습니다.

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

워크숍 API는 멀티파트 업로드를 받습니다.
curl -F "file=@sample.png;type=image/png" \ -F "language=eng" \ http://localhost:8080/api/images/ocr컨트롤러는 file과 선택적 language 쿼리 매개변수를 서비스 요청으로 넘깁니다.
소스:
ImageOcrController.kt
응답 모델은 인식한 텍스트만 반환하지 않습니다.
data class ImageOcrResponse( // 요청을 로그, 추적, 클라이언트 문의와 연결하는 상관관계 ID입니다. val requestId: String,
// 클라이언트가 가장 먼저 확인할 처리 결과입니다. // COMPLETED, UNAVAILABLE, FAILED는 같은 응답 구조에서 구분됩니다. val status: OcrStatus,
// 실제로 사용한 OCR 엔진 이름입니다. 네이티브 OCR을 끈 경우에는 disabled처럼 표시됩니다. val engine: String,
// 요청에서 선택했거나 서비스가 정규화한 OCR 언어 목록입니다. val languages: List<String>,
// OCR 엔진이 신뢰도를 제공하지 못하면 null입니다. val confidence: Double?,
// 최종 인식 문자열입니다. UNAVAILABLE/FAILED에서는 빈 문자열일 수 있습니다. val text: String,
// 행 단위로 나눈 구조화 결과입니다. UI 강조 표시나 후처리에 사용합니다. val blocks: List<OcrTextBlock>,
// 클라이언트와 운영자가 확인할 주의 사항입니다. 예외 문자열 분석을 대신합니다. val warnings: List<String>,)status가 명시되어 있으므로 클라이언트는 빈 문자열의 의미를 추측하지 않아도 됩니다.
입력 경계: 업로드 바이트만 제한해서는 부족하다
섹션 제목: “입력 경계: 업로드 바이트만 제한해서는 부족하다”대체 응답은 “아무 이미지나 받아도 된다”는 뜻이 아닙니다. ImageOcrServiceImpl은 OCR을 시도하기 전에
다음 조건을 먼저 검사합니다.
| 검사 | 이유 |
|---|---|
| 빈 바이트 거부 | 빈 멀티파트 필드를 정상 OCR 요청으로 처리하지 않는다 |
maxUploadBytes | 요청 하나가 힙과 네이티브 런타임을 압박하지 않게 한다 |
| JPEG/PNG/WebP 콘텐츠 유형 | 클라이언트와 서버가 허용 형식을 동일하게 해석하도록 계약을 좁힌다 |
| 매직 바이트와 선언 유형 비교 | JPEG를 image/png로 위장한 업로드를 거부한다 |
| 디코딩 픽셀 예산 | width * height를 기준으로 디코딩 이후 비용을 제한한다 |
핵심 제한값은 디코딩 픽셀 예산입니다. 예를 들어 4096x3860 이미지는 업로드 바이트가 작아도
디코딩하면 약 1,580만 픽셀이 됩니다. 가로와 세로를 따로 제한하기보다
width * height가 API 작업자와 전처리 파이프라인에 주는 부하를 직접 제한하는 편이 명확합니다.
예제도 maxImagePixels로 이 값을 끊습니다.
전체 소스: ImageOcrServiceImpl.kt, ImageOcrProperties.kt
BAD_REQUEST는 대체 상태가 아닙니다. 빈 파일, 지원하지 않는 콘텐츠 유형, 매직 바이트 불일치,
손상된 이미지, 픽셀 예산 초과는 입력 경계에서 실패합니다. 대체 응답은 잘못된 요청을 정상화하는 장치가
아니며, 유효한 이미지를 OCR 런타임 문제로 처리하지 못했을 때만 사용합니다.
같은 응답 구조에서 상태를 구분한다
섹션 제목: “같은 응답 구조에서 상태를 구분한다”네이티브 OCR이 정상 실행되면 COMPLETED 응답은 텍스트와 행 단위 블록을 함께 반환합니다.
{ "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."]}네이티브 OCR을 끈 상태라면 요청 이미지는 계속 검증하지만 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 설정이 빠졌거나 언어 팩을 찾지 못하는 경우도 클라이언트에는 UNAVAILABLE로 표현합니다.
반면 OCR 실행 중 타임아웃이나 런타임 예외가 발생하면 FAILED입니다.
| 상태 | text/blocks | 대표 원인 | 클라이언트 해석 |
|---|---|---|---|
COMPLETED | 채워짐 | OCR 완료 | text와 blocks를 사용한다 |
UNAVAILABLE | 비어 있음 | 네이티브 OCR 비활성화, Tesseract·tessdata·언어 팩 미준비 | 같은 요청을 나중에 재시도하거나 OCR 없는 사용자 흐름으로 처리한다 |
FAILED | 비어 있음 | 타임아웃, OCR 런타임 예외 | OCR 처리 실패로 보고 별도 재시도 정책을 적용한다 |
세 상태는 같은 응답 구조를 사용합니다. 클라이언트는 예외 문자열을 분석하지 않고
status, warnings, text, blocks만 보면 됩니다.
네이티브 OCR은 호스트 계약이다
섹션 제목: “네이티브 OCR은 호스트 계약이다”bluetape4k-image/images-ocr는 Tess4J/Tesseract 기반 엔진을 제공합니다. 그러나 Tesseract 실행 파일과
학습 데이터는 라이브러리에 포함하지 않습니다. 운영 호스트나 컨테이너가 네이티브 런타임을 준비해야 합니다.
brew install tesseractls /opt/homebrew/share/tessdata/eng.traineddata
sudo apt-get install -y tesseract-ocr tesseract-ocr-eng워크숍에서는 이 사실을 설정 속성으로 드러냅니다.
| 설정 속성 | 역할 |
|---|---|
workshop.ocr.native-enabled | 네이티브 OCR 경로를 활성화할지 결정한다 |
workshop.ocr.tessdata-path | 호스트별 학습 데이터 위치를 지정한다 |
workshop.ocr.timeout | 네이티브 호출이 API 작업자를 오래 점유하지 않게 한다 |
workshop.ocr.max-upload-bytes | 업로드 바이트 예산을 제한한다 |
workshop.ocr.max-image-pixels | 디코딩한 이미지의 픽셀 예산을 제한한다 |
ImageOcrServiceImpl은 네이티브 OCR 구간을 Semaphore(permits = 1), withTimeout,
runInterruptible(Dispatchers.IO)로 감쌉니다. 코루틴 API가 네이티브 호출의 실행 비용을 없애지는 않습니다.
네이티브 OCR은 CPU와 네이티브 메모리를 사용하는 호스트 작업이므로 동시 실행 수와 타임아웃을 명시해야 합니다.
소스: TesseractOcrEngine.kt, images-ocr README
큰 이미지는 OCR 전에 이미 비용이 든다
섹션 제목: “큰 이미지는 OCR 전에 이미 비용이 든다”OCR API의 메모리 압력은 Tesseract에 진입하기 전부터 발생합니다. 업로드 바이트는 작아도 디코딩한 이미지가 크면 JVM 힙과 네이티브 파이프라인이 먼저 자원을 사용합니다.
images-benchmark의 대용량 스트리밍 결과에는 문서형 OCR과 유사한 워크로드가 있습니다. 2480x3508 입력을
1240x1754로 축소하고 회색조 변환과 JPEG 인코딩까지 수행한 측정입니다. OCR 자체를 측정한 벤치마크는 아니며,
인식 전에 발생하는 이미지 전처리 비용만 비교합니다.

| 입력 경계 | 지연 시간 | 관리 힙 할당량 | 해석 |
|---|---|---|---|
Scrimage ByteArray | 143.64 ms/op | 164.46 MiB/op | 사용은 편하지만 압축 입력 바이트를 먼저 임시 저장한다 |
Scrimage Path | 145.13 ms/op | 164.34 MiB/op | 블로킹 Scrimage 경계에서는 비슷한 수준이다 |
Scrimage InputStream / OutputStream | 148.39 ms/op | 165.33 MiB/op | 스트림 경계여도 디코딩한 이미지의 힙 비용은 남는다 |
Scrimage Okio Source / Sink | 145.59 ms/op | 165.34 MiB/op | 생명주기와 통합에는 유리하지만 지연 시간을 줄이지는 않는다 |
Scrimage 일시 중단 Source / Sink | 170.69 ms/op | 166.10 MiB/op | 블로킹 스트림 연결 비용이 드러난다 |
vips ByteArray | 15.38 ms/op | 0.34 MiB/op | 빠르지만 압축 입력 바이트의 임시 저장은 남는다 |
vips Path | 5.47 ms/op | 0.34 MiB/op | 이 측정에서 지연 시간이 가장 짧다 |
vips InputStream / OutputStream | 15.59 ms/op | 1.40 MiB/op | 관리 힙 할당량은 낮지만 스트림 경계 비용은 존재한다 |
자료: large-streaming-2026-06-05.md
이 결과가 API 설계로 이어지는 지점은 단순합니다. OCR API는 maxUploadBytes만 두면 부족합니다. 압축 바이트가
작더라도 디코딩하면 큰 이미지가 될 수 있으므로 픽셀 예산도 함께 둬야 합니다. 전처리 비용이 중요한 서비스라면
순수 JVM 경로와 libvips 경로를 같은 방식으로 취급해서는 안 됩니다. vips는 OCR 엔진이 아니라 이미지 전처리 비용을 줄이는
선택지입니다. 글자를 읽는 일은 여전히 Tesseract 같은 OCR 런타임이 맡습니다.
Ktor와 Spring Boot의 차이는 어댑터에 머문다
섹션 제목: “Ktor와 Spring Boot의 차이는 어댑터에 머문다”bluetape4k-image에는 OCR 빠른 시작 예제가 두 개 있습니다.
| 예제 | 경로 | 업로드 처리 | 오류 구조 |
|---|---|---|---|
| Ktor OCR API | /api/ocr | 스트리밍 멀티파트 항목을 maxInputBytes + 1까지만 읽는다 | error, message, status, path |
| Spring Boot OCR API | /api/ocr | MultipartFile.bytes를 Dispatchers.IO에서 읽는다 | error, message |
둘 다 결국 immutableImageOf(uploadBytes).suspendExtractText(...)를 호출합니다. 차이는 HTTP 기술 스택이 멀티파트를
읽고 오류 페이로드를 만드는 방식입니다. OCR 엔진 계약 자체는 images-ocr에 남아 있습니다.
소스: KtorOcrApiApplication.kt, SpringBootOcrApiApplication.kt
마무리
섹션 제목: “마무리”OCR 서비스를 검출 문자열만 반환하는 엔드포인트로 설계하면 입력과 런타임 실패를 표현하기 어렵습니다. 실전에서는 업로드 바이트 제한, 디코딩 픽셀 예산, 전처리 비용, 네이티브 런타임 준비 상태, 타임아웃, 실패 응답, 클라이언트 재시도 정책이 함께 움직입니다.
코루틴, Ktor, Spring Boot 중 무엇을 쓰더라도 기본 계약은 크게 달라지지 않습니다. HTTP 어댑터는 파일을 받고
서비스 요청을 만듭니다. 네이티브 OCR을 일시 중단 함수에서 호출한다고 실행 비용이 줄어들지는 않습니다.
블로킹·네이티브 호출은 동시 실행 수와 타임아웃으로 제한하고, 클라이언트에는 COMPLETED,
UNAVAILABLE, FAILED를 같은 응답 구조로 반환해야 합니다.
여러 이미지 분석기를 하나의 API로 묶는 방법은 「이미지 한 장에서 여러 정보를 추출하는 API」에서 이어집니다. OCR과 객체 검출은 검증·병렬 처리·부분 실패라는 기본 구조를 공유하지만, 결과 형식과 정책은 각각 분리해야 합니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.