콘텐츠로 이동

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

업로드된 방문증 이미지가 여러 검사 관문을 통과한 뒤 한 번 디코딩되어 OCR, 객체 검출, QR 분석 작업대로 전달되는 어두운 3D 작업대
분석기가 여러 개여도 입력 판정과 디코딩까지 여러 번 할 필요는 없습니다. 공통 경계에서 분석 가능한 이미지인지 먼저 판정하고, 합격한 이미지 하나를 여러 처리 경로가 공유합니다.

방문증 이미지 한 장을 OCR, 객체 검출, QR 판독기가 각각 다시 읽는다면 분석기도 세 개지만 입력 경계도 세 개가 됩니다. OCR은 JPEG라고 받아들이는데 QR 판독기는 손상된 파일이라고 거부하거나, 세 경로가 같은 바이트를 각각 디코딩해 CPU와 메모리를 반복해서 사용할 수 있습니다.

검증 코드를 공통 함수로 옮기는 것만으로는 충분하지 않습니다. 이 입력으로 분석을 시작해도 되는지 판정하는 단계분석기가 어떤 정보를 찾았는지 판단하는 단계를 분리해야 합니다. 입력 판정이 성공하면 해당 단계에서 만든 이미지 객체를 모든 분석기가 공유합니다.

이 글은 bluetape4k-image의 Spring Boot 이미지 인텔리전스 API를 바탕으로 공통 입력 자격 판정과 단일 디코딩 경계를 살펴봅니다. 바이러스 검사나 콘텐츠 무해화까지 포괄하는 종합 업로드 보안 계층을 만들려는 것이 아니라, 분석을 시작하기 위한 최소 조건을 일관되게 판정하고 고비용 작업의 중복을 방지하는 구조에 초점을 맞춥니다.

입력 자격과 분석 결과는 다른 질문이다

섹션 제목: “입력 자격과 분석 결과는 다른 질문이다”

“유효한 이미지인가?”라는 질문은 범위가 너무 넓습니다. 방문증에 이름이 선명하게 보이지 않거나 QR 코드가 없다고 해서 파일 자체가 잘못된 것은 아닙니다. 반대로 .jpg라는 파일 이름이 있고 Content-Type: image/jpeg로 전송됐다고 해서 실제 JPEG 이미지라고 단정할 수도 없습니다.

공통 입력 판정과 개별 분석은 다음처럼 서로 다른 질문에 답합니다.

입력 자격 판정개별 분석 결과
지원하는 이미지 형식인가?읽을 수 있는 글자가 있는가?
압축된 바이트 크기가 제한 안에 있는가?얼굴이나 민감 영역이 있는가?
이미지 크기와 전체 픽셀 수가 예산 안에 있는가?QR 코드와 값이 있는가?
전체 이미지를 정상적으로 디코딩할 수 있는가?결과가 비었는가, 분석기가 실패했는가?

입력 자격 판정에 합격했다는 말은 “OCR과 객체 검출, QR 판독을 실행해도 되는 이미지”라는 뜻입니다. 글자, 얼굴, QR 코드가 반드시 존재하거나 분석 품질이 충분하다는 보장은 아닙니다. 이 구분을 지켜야 “QR이 없는 정상 이미지”를 입력 오류로 잘못 처리하지 않습니다.

세 분석기 앞에 하나의 관문을 둔다

섹션 제목: “세 분석기 앞에 하나의 관문을 둔다”

ImageUploadQualifier는 멀티파트 파일을 받으면 저비용 검사부터 수행합니다. 앞 단계가 실패하면 이후의 고비용 검사와 세 분석기는 실행하지 않습니다.

멀티파트 이미지가 빈 입력, 선언된 형식, 보고된 크기, 실제 바이트, 파일 시그니처, 이미지 크기와 픽셀 예산, 전체 디코딩 검사를 수직으로 통과해 QualifiedImage가 되는 흐름
위에서 아래로 갈수록 더 많은 바이트를 읽고 연산 비용도 커집니다. 거부할 수 있는 입력은 전체 디코딩 전에 처리합니다.

실제 구현의 핵심 순서를 의사코드로 줄이면 다음과 같습니다.

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),
)
}

이 코드는 설명을 위해 함수 이름과 세부 처리를 단순화한 의사코드입니다. 중요한 것은 특정 함수명이 아니라 검사 순서와 실행 횟수입니다.

  1. 빈 입력인지 확인합니다.
  2. 클라이언트가 선언한 MIME 형식이 PNG, JPEG, WebP 중 하나인지 확인합니다.
  3. 멀티파트 어댑터가 보고한 압축 크기가 제한 안에 있는지 확인합니다.
  4. 업로드 바이트를 한 번 읽습니다.
  5. 실제로 읽은 바이트 크기도 같은 제한으로 확인합니다.
  6. 파일 시그니처로 실제 이미지 형식을 확인하고 선언한 MIME 형식과 비교합니다.
  7. 이미지의 가로·세로를 확인한 뒤 한 변의 길이와 전체 픽셀 수를 검증합니다.
  8. 마지막으로 전체 이미지를 한 번 디코딩해 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,
)
OCR, 객체 검출, QR 분석기가 업로드를 각각 읽고 디코딩하는 구조와 공통 입력 판정에서 한 번 디코딩한 QualifiedImage를 세 분석기가 공유하는 구조를 비교하는 다이어그램
단일 디코딩은 성능 최적화인 동시에 계약을 단일화하는 설계입니다. 세 분석기는 동일한 판정을 통과한 단일 이미지 객체에서 작업을 시작합니다.

QualifiedImage에는 실제 미디어 형식, 가로·세로, 디코딩된 불변 이미지가 들어 있습니다. ImageIntelligenceWorkflow는 이 이미지 객체를 OCR, 객체 검출, 바코드·QR 공급자에 전달합니다. 각 공급자는 서로 다른 결과를 만들지만 멀티파트 입력과 원본 바이트를 다시 읽지 않습니다.

단일 디코딩을 “항상 복사가 전혀 없다”는 뜻으로 확대해서는 안 됩니다. 네이티브 OCR이나 특정 이미지 라이브러리는 자신의 입력 형식으로 바꾸는 과정에서 추가 변환이나 복사를 할 수 있습니다. 여기서 보장하는 불변식은 업로드 바이트의 공통 판정과 전체 이미지 디코딩을 분석기마다 반복하지 않는다는 것입니다.

실패는 안정적인 입력 사유로 바꾼다

섹션 제목: “실패는 안정적인 입력 사유로 바꾼다”

입력 단계의 실패는 분석기의 EmptyFailed와 구분됩니다. 분석 자체가 시작되지 않았으므로 API는 클라이언트가 수정할 수 있는 입력 오류를 안정적인 사유 코드로 반환합니다.

실패 상황사유 코드HTTP 상태
비어 있는 파일empty_input400
지원하지 않는 선언 MIMEunsupported_media_type400
지원하지 않는 실제 파일 형식unsupported_image_format400
선언 MIME과 실제 형식 불일치media_type_mismatch400
크기 정보를 확인할 수 없거나 전체 디코딩 실패image_not_decodable400
업로드 바이트 읽기 실패image_read_failed400
압축 크기·한 변·전체 픽셀 제한 초과payload_too_large413

디코더가 던진 예외 문자열은 응답에 그대로 넣지 않습니다. 내부 파일 경로, 네이티브 라이브러리 정보, 진단용 데이터가 포함될 수 있기 때문입니다. 예제는 외부 응답을 image_not_decodable과 공개 가능한 설명으로 정규화합니다.

코루틴의 CancellationException은 다르게 다룹니다. 사용자가 요청을 취소했거나 상위 작업이 종료된 것은 손상된 이미지가 아닙니다. 파일을 읽거나 디코딩하는 도중 취소가 발생하면 image_read_failed 또는 image_not_decodable로 바꾸지 않고 다시 던져 요청 수명주기를 그대로 전파합니다.

테스트는 응답보다 실행 경계를 검증한다

섹션 제목: “테스트는 응답보다 실행 경계를 검증한다”

400이나 413이 반환됐다는 사실만으로는 고비용 작업이 실제로 생략됐는지 알 수 없습니다. 그래서 ImageUploadQualifierTest는 주입한 디코더의 호출 횟수를 함께 확인합니다.

var decodeCalls = 0
val 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()
테스트 상황기대하는 디코딩 횟수증명하는 경계
정상 PNG1합격한 입력을 정확히 한 번 디코딩
MIME과 시그니처 불일치0실제 형식 판정에서 조기 종료
압축 크기 초과0큰 바이트 입력을 디코딩 전에 거부
한 변 또는 전체 픽셀 예산 초과0큰 픽셀 입력을 전체 디코딩 전에 거부

여기에 취소가 그대로 전파되는지, 디코더의 내부 오류가 공개 응답에 포함되지 않는지도 별도로 검증합니다. 테스트의 목적은 정상 응답 예시를 하나 더 만드는 것이 아니라 “어느 단계까지 실행됐고, 어디부터 실행되지 않았는가”라는 경계를 증명하는 데 있습니다.

공통 입력 자격 판정은 분석 서비스의 출발 조건이지 업로드 보안 전체가 아닙니다.

이 예제가 판정하는 것별도로 설계할 것
지원 형식, 실제 시그니처, 디코딩 가능 여부악성 파일 검사와 콘텐츠 무해화
압축 바이트, 한 변, 전체 픽셀 예산사용자·테넌트별 요청량과 저장 용량 제한
분석기들이 공유할 한 번의 디코딩원본 저장·삭제·암호화 정책
안정적인 입력 오류 코드EXIF 개인정보 제거와 감사 이력

또한 입력 자격 판정을 통과했다고 OCR 정확도나 객체 검출 품질이 보장되지는 않습니다. 흐릿한 사진이나 너무 작은 QR 코드는 정상 이미지이지만 분석 결과가 Empty 또는 Failed일 수 있습니다. 이 상태는 다음 처리 경로와 정책 계층에서 다뤄야 합니다.

다른 이미지 처리 업무에도 같은 경계를 적용한다

섹션 제목: “다른 이미지 처리 업무에도 같은 경계를 적용한다”

방문증 대신 배송 라벨이나 상품 라벨을 처리하더라도 입력 자격 판정의 책임은 크게 달라지지 않습니다.

  1. 서비스가 허용할 이미지 형식과 압축 크기, 픽셀 예산을 정합니다.
  2. 업무별 분석기보다 앞에 공통 자격 판정기를 둡니다.
  3. 판정을 통과한 바이트를 한 번 디코딩해 불변 입력 객체를 만듭니다.
  4. 여러 분석기는 같은 입력 객체를 읽고 자신의 결과만 반환합니다.
  5. 입력 오류와 분석 결과, 업무 정책 결정을 서로 다른 계약으로 유지합니다.

업무마다 제한값과 추가 보안 단계는 달라질 수 있습니다. 재사용할 대상은 숫자 자체가 아니라 저비용 검사에서 고비용 처리로 진행하는 순서, 단일 디코딩 불변식, 안정적인 실패 계약입니다.

Part 3에서는 자격 판정을 통과한 ImmutableImage를 OCR 공급자에 전달할 때 네이티브 런타임, 제한 시간, 빈 결과, 처리 실패를 통합 응답 계약에 어떻게 보존하는지 살펴봅니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.