콘텐츠로 이동

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

OCR 서비스 작업대에 이미지 업로드, 검증, 네이티브 OCR 실행, 완료와 대체 응답 패널이 배치된 3D 일러스트
OCR 서비스는 인식할 수 없는 상황에서도 클라이언트가 해석할 수 있는 응답을 먼저 정의해야 합니다.

OCR 데모는 보통 이미지를 올리고 텍스트가 나오는 화면으로 끝납니다. 실전 서비스에는 더 많은 경계가 필요합니다. 압축 바이트가 작아도 디코딩한 이미지는 매우 클 수 있고, 선언한 콘텐츠 유형이 실제 형식과 다를 수 있습니다. 호스트에 Tesseract 네이티브 런타임이 없거나 OCR 호출이 타임아웃될 수도 있습니다.

그래서 bluetape4k-workshop/image-processing/ocr-api 예제는 “인식 결과가 잘 나오는가”보다 먼저 입력 경계와 실패 응답을 모델링합니다. 잘못된 요청은 BAD_REQUEST로 끊고, 유효한 이미지인데 현재 호스트에서 OCR을 수행할 수 없는 경우만 대체 응답으로 돌려줍니다. 이 차이를 섞으면 클라이언트와 운영자 모두 입력 오류와 런타임 장애를 구분하기 어렵습니다.

HTTP 업로드, 이미지 전처리, 네이티브 OCR, 응답 계약의 경계를 수직 흐름으로 보여주는 OCR 서비스 구조도
업로드 검사, 이미지 전처리, 네이티브 OCR, 응답 계약은 서로 다른 책임입니다. BAD_REQUEST는 대체 응답이 아니라 입력 경계에서 끝나는 요청입니다.

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

BLUETAPE OCR이라는 제목과 세 줄의 문장이 들어 있는 OCR API 예제 입력 이미지
실제 OCR 결과는 글꼴, 해상도, 학습 데이터, 전처리에 따라 달라집니다. 아래 JSON은 워크숍의 응답 계약을 현재 소스에 맞춰 설명한 예시입니다.

워크숍 API는 멀티파트 업로드를 받습니다.

Terminal window
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>,
)

소스: ImageOcrModels.kt

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 완료textblocks를 사용한다
UNAVAILABLE비어 있음네이티브 OCR 비활성화, Tesseract·tessdata·언어 팩 미준비같은 요청을 나중에 재시도하거나 OCR 없는 사용자 흐름으로 처리한다
FAILED비어 있음타임아웃, OCR 런타임 예외OCR 처리 실패로 보고 별도 재시도 정책을 적용한다

세 상태는 같은 응답 구조를 사용합니다. 클라이언트는 예외 문자열을 분석하지 않고 status, warnings, text, blocks만 보면 됩니다.

네이티브 OCR은 호스트 계약이다

섹션 제목: “네이티브 OCR은 호스트 계약이다”

bluetape4k-image/images-ocr는 Tess4J/Tesseract 기반 엔진을 제공합니다. 그러나 Tesseract 실행 파일과 학습 데이터는 라이브러리에 포함하지 않습니다. 운영 호스트나 컨테이너가 네이티브 런타임을 준비해야 합니다.

Terminal window
brew install tesseract
ls /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와 libvips의 지연 시간과 관리 힙 할당량을 비교한 OCR 문서 전처리 벤치마크 차트
이 문서형 전처리 워크로드에서는 libvips Java 25 FFM 경로의 지연 시간과 관리 힙 할당량이 더 작았습니다. 수치는 OCR 인식 시간이 아니라 디코딩·크기 조정·회색조 변환·인코딩 비용입니다.
입력 경계지연 시간관리 힙 할당량해석
Scrimage ByteArray143.64 ms/op164.46 MiB/op사용은 편하지만 압축 입력 바이트를 먼저 임시 저장한다
Scrimage Path145.13 ms/op164.34 MiB/op블로킹 Scrimage 경계에서는 비슷한 수준이다
Scrimage InputStream / OutputStream148.39 ms/op165.33 MiB/op스트림 경계여도 디코딩한 이미지의 힙 비용은 남는다
Scrimage Okio Source / Sink145.59 ms/op165.34 MiB/op생명주기와 통합에는 유리하지만 지연 시간을 줄이지는 않는다
Scrimage 일시 중단 Source / Sink170.69 ms/op166.10 MiB/op블로킹 스트림 연결 비용이 드러난다
vips ByteArray15.38 ms/op0.34 MiB/op빠르지만 압축 입력 바이트의 임시 저장은 남는다
vips Path5.47 ms/op0.34 MiB/op이 측정에서 지연 시간이 가장 짧다
vips InputStream / OutputStream15.59 ms/op1.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/ocrMultipartFile.bytesDispatchers.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을 남길 수 있습니다.