이미지 분석 전에 입력부터 판정하라: 공통 자격 판정과 단일 디코딩

방문증 이미지 한 장을 OCR, 객체 검출, QR 판독기가 각각 다시 읽는다면 분석기도 세 개지만 입력 경계도 세 개가 됩니다. OCR은 JPEG라고 받아들이는데 QR 판독기는 손상된 파일이라고 거부하거나, 세 경로가 같은 바이트를 각각 디코딩해 CPU와 메모리를 반복해서 사용할 수 있습니다.
검증 코드를 공통 함수로 옮기는 것만으로는 충분하지 않습니다. 이 입력으로 분석을 시작해도 되는지 판정하는 단계와 분석기가 어떤 정보를 찾았는지 판단하는 단계를 분리해야 합니다. 입력 판정이 성공하면 해당 단계에서 만든 이미지 객체를 모든 분석기가 공유합니다.
이 글은 bluetape4k-image의 Spring Boot 이미지 인텔리전스 API를
바탕으로 공통 입력 자격 판정과 단일 디코딩 경계를 살펴봅니다. 바이러스 검사나 콘텐츠 무해화까지 포괄하는
종합 업로드 보안 계층을 만들려는 것이 아니라, 분석을 시작하기 위한 최소 조건을 일관되게 판정하고 고비용
작업의 중복을 방지하는 구조에 초점을 맞춥니다.
입력 자격과 분석 결과는 다른 질문이다
섹션 제목: “입력 자격과 분석 결과는 다른 질문이다”“유효한 이미지인가?”라는 질문은 범위가 너무 넓습니다. 방문증에 이름이 선명하게 보이지 않거나 QR 코드가
없다고 해서 파일 자체가 잘못된 것은 아닙니다. 반대로 .jpg라는 파일 이름이 있고
Content-Type: image/jpeg로 전송됐다고 해서 실제 JPEG 이미지라고 단정할 수도 없습니다.
공통 입력 판정과 개별 분석은 다음처럼 서로 다른 질문에 답합니다.
| 입력 자격 판정 | 개별 분석 결과 |
|---|---|
| 지원하는 이미지 형식인가? | 읽을 수 있는 글자가 있는가? |
| 압축된 바이트 크기가 제한 안에 있는가? | 얼굴이나 민감 영역이 있는가? |
| 이미지 크기와 전체 픽셀 수가 예산 안에 있는가? | QR 코드와 값이 있는가? |
| 전체 이미지를 정상적으로 디코딩할 수 있는가? | 결과가 비었는가, 분석기가 실패했는가? |
입력 자격 판정에 합격했다는 말은 “OCR과 객체 검출, QR 판독을 실행해도 되는 이미지”라는 뜻입니다. 글자, 얼굴, QR 코드가 반드시 존재하거나 분석 품질이 충분하다는 보장은 아닙니다. 이 구분을 지켜야 “QR이 없는 정상 이미지”를 입력 오류로 잘못 처리하지 않습니다.
세 분석기 앞에 하나의 관문을 둔다
섹션 제목: “세 분석기 앞에 하나의 관문을 둔다”ImageUploadQualifier는
멀티파트 파일을 받으면 저비용 검사부터 수행합니다. 앞 단계가 실패하면 이후의 고비용 검사와
세 분석기는 실행하지 않습니다.

실제 구현의 핵심 순서를 의사코드로 줄이면 다음과 같습니다.
suspend fun qualify(file: MultipartFile): QualifiedImage { requireNotEmpty(file) val declaredType = requireSupportedMediaType(file.contentType) requireEncodedSize(file.size)
val bytes = readOnce(file) requireEncodedSize(bytes.size) requireMatchingSignature(declaredType, bytes)
val dimensions = probeDimensions(bytes) requireDecodedBudget(dimensions)
return QualifiedImage( mediaType = declaredType, dimensions = dimensions, image = decodeOnce(bytes), )}이 코드는 설명을 위해 함수 이름과 세부 처리를 단순화한 의사코드입니다. 중요한 것은 특정 함수명이 아니라 검사 순서와 실행 횟수입니다.
- 빈 입력인지 확인합니다.
- 클라이언트가 선언한 MIME 형식이 PNG, JPEG, WebP 중 하나인지 확인합니다.
- 멀티파트 어댑터가 보고한 압축 크기가 제한 안에 있는지 확인합니다.
- 업로드 바이트를 한 번 읽습니다.
- 실제로 읽은 바이트 크기도 같은 제한으로 확인합니다.
- 파일 시그니처로 실제 이미지 형식을 확인하고 선언한 MIME 형식과 비교합니다.
- 이미지의 가로·세로를 확인한 뒤 한 변의 길이와 전체 픽셀 수를 검증합니다.
- 마지막으로 전체 이미지를 한 번 디코딩해
QualifiedImage를 만듭니다.
보고된 크기와 실제 크기를 모두 확인한다
섹션 제목: “보고된 크기와 실제 크기를 모두 확인한다”MultipartFile.size는 전체 내용을 읽기 전에 제한을 초과한 요청을 조기에 거부하는 데 유용합니다. 하지만
서비스의 메모리 사용량을 보호하는 최종 근거로 삼아서는 안 됩니다. 어댑터나 테스트 대역이 보고한 값과
실제로 읽힌 바이트 수가 다를 수 있기 때문입니다.
따라서 예제는 두 번의 크기 판정을 서로 다른 목적으로 사용합니다.
| 판정 시점 | 사용하는 값 | 방지 대상 |
|---|---|---|
| 바이트를 읽기 전 | file.size | 명백한 제한 초과 입력을 조기 거부 |
| 바이트를 읽은 후 | bytes.size | 실제 메모리에 올라온 데이터가 제한을 넘지 않았는지 확인 |
두 번째 확인 때문에 파일을 두 번 읽는 것은 아닙니다. 바이트는 한 번만 읽고, 이후의 시그니처 확인과 크기 탐색, 전체 디코딩에서 같은 배열을 재사용합니다.
현재 예제의 기본 압축 크기 제한은 5 MiB입니다. 이 값은 모든 서비스에 적합한 권장값이 아닙니다. 서버 메모리, 동시 요청 수, 앞단 프록시 제한, 분석기 비용을 함께 계산해 서비스별로 정해야 합니다.
MIME 선언과 실제 파일 형식을 함께 본다
섹션 제목: “MIME 선언과 실제 파일 형식을 함께 본다”HTTP의 Content-Type은 클라이언트가 선언한 정보입니다. 확장자 역시 파일 이름의 일부일 뿐입니다. 입력
경계가 두 값만 믿으면 JPEG라고 선언한 PNG나, 지원하지 않는 데이터에 이미지 확장자만 붙인 요청을 분석기까지
보낼 수 있습니다.
예제는 바이트 시작 부분의 시그니처를 확인해 PNG, JPEG, WebP를 구분합니다. 지원하는 시그니처가 아니면
unsupported_image_format, 선언한 MIME 형식과 실제 시그니처가 다르면 media_type_mismatch로
거부합니다.
여기서 시그니처 확인은 전체 디코딩을 대신하지 않습니다. 파일 앞부분이 정상이어도 뒤쪽 데이터가 손상됐을 수 있습니다. 시그니처 확인은 저비용 형식 판정이고, 마지막 전체 디코딩은 이미지 전체를 실제로 읽을 수 있는지 확인하는 별도의 관문입니다.
압축 크기와 픽셀 예산은 서로 다른 비용을 막는다
섹션 제목: “압축 크기와 픽셀 예산은 서로 다른 비용을 막는다”압축 파일이 작다고 메모리와 CPU 비용도 작은 것은 아닙니다. 예를 들어 단색에 가까운 큰 이미지는 압축 바이트가 작아도 디코딩하면 매우 많은 픽셀을 만들 수 있습니다. 압축 크기만 제한하면 이런 입력이 전체 디코딩과 세 분석기에 도달합니다.
예제는 전체 디코딩 전에 가로·세로를 탐색하고 두 가지 예산을 적용합니다.
- 한 변의 길이는 기본 8,192픽셀을 넘을 수 없습니다.
- 가로와 세로를 곱한 전체 픽셀 수는 기본 16,777,216픽셀을 넘을 수 없습니다.
한 변 제한은 지나치게 길고 좁은 이미지를 막고, 전체 픽셀 제한은 디코딩된 이미지가 차지할 작업량을 제한합니다. 곱셈 과정에서도 큰 수를 안전하게 다뤄야 합니다. 이 예산을 통과한 뒤에만 전체 디코딩을 수행합니다.
이 제한값도 예제의 운영 안전장치입니다. 크기 조정이나 OCR 전처리 방식, 이미지 백엔드, 동시 분석 수가 달라지면 적절한 값도 달라집니다.
합격한 이미지는 한 번만 디코딩한다
섹션 제목: “합격한 이미지는 한 번만 디코딩한다”각 분석기가 원본 업로드를 직접 받으면 입력 처리 정책이 분석기별로 분산되기 쉽습니다.
// 피하고 싶은 구조ocr.analyze(decode(file.bytes))detection.analyze(decode(file.bytes))barcode.analyze(decode(file.bytes))이 구조는 단순히 디코딩을 세 번 실행하는 문제에 그치지 않습니다. 분석기마다 허용 형식과 크기 제한, 오류 변환 방식이 달라질 수 있습니다. 같은 요청을 두고 OCR은 실행됐지만 QR 판독은 입력 단계에서 거부하는 모순도 생깁니다.
예제에서는 ImageIntelligenceService가 자격 판정을 먼저 완료하고, 그 결과로 만든 하나의
ImmutableImage를 워크플로에 전달합니다.
val qualified = qualifier.qualify(file)
val results = workflow.analyze( image = qualified.image,)
QualifiedImage에는 실제 미디어 형식, 가로·세로, 디코딩된 불변 이미지가 들어 있습니다.
ImageIntelligenceWorkflow는 이 이미지 객체를 OCR, 객체 검출, 바코드·QR 공급자에 전달합니다. 각
공급자는 서로 다른 결과를 만들지만 멀티파트 입력과 원본 바이트를 다시 읽지 않습니다.
단일 디코딩을 “항상 복사가 전혀 없다”는 뜻으로 확대해서는 안 됩니다. 네이티브 OCR이나 특정 이미지 라이브러리는 자신의 입력 형식으로 바꾸는 과정에서 추가 변환이나 복사를 할 수 있습니다. 여기서 보장하는 불변식은 업로드 바이트의 공통 판정과 전체 이미지 디코딩을 분석기마다 반복하지 않는다는 것입니다.
실패는 안정적인 입력 사유로 바꾼다
섹션 제목: “실패는 안정적인 입력 사유로 바꾼다”입력 단계의 실패는 분석기의 Empty나 Failed와 구분됩니다. 분석 자체가 시작되지 않았으므로 API는
클라이언트가 수정할 수 있는 입력 오류를 안정적인 사유 코드로 반환합니다.
| 실패 상황 | 사유 코드 | HTTP 상태 |
|---|---|---|
| 비어 있는 파일 | empty_input | 400 |
| 지원하지 않는 선언 MIME | unsupported_media_type | 400 |
| 지원하지 않는 실제 파일 형식 | unsupported_image_format | 400 |
| 선언 MIME과 실제 형식 불일치 | media_type_mismatch | 400 |
| 크기 정보를 확인할 수 없거나 전체 디코딩 실패 | image_not_decodable | 400 |
| 업로드 바이트 읽기 실패 | image_read_failed | 400 |
| 압축 크기·한 변·전체 픽셀 제한 초과 | payload_too_large | 413 |
디코더가 던진 예외 문자열은 응답에 그대로 넣지 않습니다. 내부 파일 경로, 네이티브 라이브러리 정보,
진단용 데이터가 포함될 수 있기 때문입니다. 예제는 외부 응답을 image_not_decodable과 공개 가능한
설명으로 정규화합니다.
코루틴의 CancellationException은 다르게 다룹니다. 사용자가 요청을 취소했거나 상위 작업이 종료된 것은
손상된 이미지가 아닙니다. 파일을 읽거나 디코딩하는 도중 취소가 발생하면 image_read_failed 또는
image_not_decodable로 바꾸지 않고 다시 던져 요청 수명주기를 그대로 전파합니다.
테스트는 응답보다 실행 경계를 검증한다
섹션 제목: “테스트는 응답보다 실행 경계를 검증한다”400이나 413이 반환됐다는 사실만으로는 고비용 작업이 실제로 생략됐는지 알 수 없습니다. 그래서
ImageUploadQualifierTest는
주입한 디코더의 호출 횟수를 함께 확인합니다.
var decodeCalls = 0val qualifier = qualifierWithDecoder { bytes -> decodeCalls += 1 decodeImage(bytes)}
val qualified = qualifier.qualify(validPng)
assertThat(qualified.mediaType).isEqualTo("image/png")assertThat(decodeCalls).isEqualTo(1)거부 입력은 반대 경계를 검증합니다.
assertThrows<InvalidImageUploadException> { qualifier.qualify(oversizedOrMismatchedImage)}
assertThat(decodeCalls).isZero()| 테스트 상황 | 기대하는 디코딩 횟수 | 증명하는 경계 |
|---|---|---|
| 정상 PNG | 1 | 합격한 입력을 정확히 한 번 디코딩 |
| MIME과 시그니처 불일치 | 0 | 실제 형식 판정에서 조기 종료 |
| 압축 크기 초과 | 0 | 큰 바이트 입력을 디코딩 전에 거부 |
| 한 변 또는 전체 픽셀 예산 초과 | 0 | 큰 픽셀 입력을 전체 디코딩 전에 거부 |
여기에 취소가 그대로 전파되는지, 디코더의 내부 오류가 공개 응답에 포함되지 않는지도 별도로 검증합니다. 테스트의 목적은 정상 응답 예시를 하나 더 만드는 것이 아니라 “어느 단계까지 실행됐고, 어디부터 실행되지 않았는가”라는 경계를 증명하는 데 있습니다.
이 경계가 보장하지 않는 것
섹션 제목: “이 경계가 보장하지 않는 것”공통 입력 자격 판정은 분석 서비스의 출발 조건이지 업로드 보안 전체가 아닙니다.
| 이 예제가 판정하는 것 | 별도로 설계할 것 |
|---|---|
| 지원 형식, 실제 시그니처, 디코딩 가능 여부 | 악성 파일 검사와 콘텐츠 무해화 |
| 압축 바이트, 한 변, 전체 픽셀 예산 | 사용자·테넌트별 요청량과 저장 용량 제한 |
| 분석기들이 공유할 한 번의 디코딩 | 원본 저장·삭제·암호화 정책 |
| 안정적인 입력 오류 코드 | EXIF 개인정보 제거와 감사 이력 |
또한 입력 자격 판정을 통과했다고 OCR 정확도나 객체 검출 품질이 보장되지는 않습니다. 흐릿한 사진이나 너무
작은 QR 코드는 정상 이미지이지만 분석 결과가 Empty 또는 Failed일 수 있습니다. 이 상태는 다음 처리
경로와 정책 계층에서 다뤄야 합니다.
다른 이미지 처리 업무에도 같은 경계를 적용한다
섹션 제목: “다른 이미지 처리 업무에도 같은 경계를 적용한다”방문증 대신 배송 라벨이나 상품 라벨을 처리하더라도 입력 자격 판정의 책임은 크게 달라지지 않습니다.
- 서비스가 허용할 이미지 형식과 압축 크기, 픽셀 예산을 정합니다.
- 업무별 분석기보다 앞에 공통 자격 판정기를 둡니다.
- 판정을 통과한 바이트를 한 번 디코딩해 불변 입력 객체를 만듭니다.
- 여러 분석기는 같은 입력 객체를 읽고 자신의 결과만 반환합니다.
- 입력 오류와 분석 결과, 업무 정책 결정을 서로 다른 계약으로 유지합니다.
업무마다 제한값과 추가 보안 단계는 달라질 수 있습니다. 재사용할 대상은 숫자 자체가 아니라 저비용 검사에서 고비용 처리로 진행하는 순서, 단일 디코딩 불변식, 안정적인 실패 계약입니다.
시리즈에서 이어서 볼 내용
섹션 제목: “시리즈에서 이어서 볼 내용”- Part 1: 이미지 한 장에서 여러 정보를 추출하는 API
- Part 2: 이미지 분석 전에 입력부터 판정하라
- Part 3: OCR 처리 경로를 통합 응답에 연결하기
- Part 4: 이미지 검출 결과와 처리 정책을 분리하라
- Part 5: OCR과 다른 바코드·QR 추출 계약
- Part 6: 병렬 실행과 부분 실패를 응답 계약으로 만들기
- Part 7: 방문증 이미지 처리 API 통합 예제
Part 3에서는 자격 판정을 통과한 ImmutableImage를 OCR 공급자에 전달할 때 네이티브 런타임, 제한 시간,
빈 결과, 처리 실패를 통합 응답 계약에 어떻게 보존하는지 살펴봅니다.
구현 코드와 자료 살펴보기
섹션 제목: “구현 코드와 자료 살펴보기”- Spring Boot 이미지 인텔리전스 API: 공통 입력 판정부터 세 분석기와 방문증 정책까지 전체 실행 구조를 볼 수 있습니다.
ImageUploadQualifier.kt: 크기, 시그니처, 픽셀 예산과 단일 디코딩 순서를 구현합니다.ImageUploadQualifierTest.kt: 정상 입력 1회 디코딩과 거부 입력 0회 디코딩을 검증합니다.ImageIntelligenceService.kt: 자격 판정된 이미지 하나를 워크플로에 전달하는 서비스 경계를 보여 줍니다.- OCR 서비스를 실전에서 운영하기: 큰 이미지 입력 제한, 네이티브 OCR과 실패 응답 계약을 자세히 설명합니다.
- Kotlin API 입력 경계: 멀티파트와 메모리 입력 예산을 일반적인 API 경계에서 다룹니다.
- 순수 JVM에서 libvips로: 이미지 백엔드의 처리 비용과 운영 조건을 측정값으로 비교합니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.