클라우드 Mac에서 iOS JPEG 내보내기 검증: 용량 한도와 실패 조건

클라우드 Mac에서 iOS JPEG 내보내기 검증: 용량 한도와 실패 조건

클라우드 Mac에서 iOS JPEG 내보내기 검증: 용량 한도와 실패 조건

프로필 사진을 업로드할 때 로컬 미리보기는 정상인데 서버에서 파일을 거부하는 경우가 있습니다. 원인은 확장자가 아니라 JPEG 인코딩 결과의 바이트 수가 API 한도를 넘었기 때문인 경우가 많습니다. 품질 매개변수를 특정 값으로 고정하면 일부 이미지는 해결되지만, 모든 이미지가 한도를 만족한다고 보장할 수는 없습니다. 클라우드 Mac에서 내보내기 로직을 독립 실행형 검증 스크립트로 먼저 만든 뒤, 동일한 동작 계약을 iOS 테스트 타깃으로 옮길 수 있습니다.

품질을 비율로 보지 말고 바이트 한도부터 정의하기

API가 바이트 단위로 용량을 제한한다면 클라이언트도 상한을 정수로 저장해야 합니다. 그래야 십진 단위와 이진 단위를 혼용하지 않습니다. 품질 값은 인코더에 전달하는 입력값이지, 출력 파일 크기의 백분율이 아닙니다. 픽셀 크기가 같아도 단색 배경과 촘촘한 질감이 있는 이미지는 압축 결과가 크게 달라질 수 있습니다.

이 글에서는 입력 이미지의 방향 정규화, 투명 배경 처리, 색 공간 선택이 이미 끝났다고 가정하며, 내보내기 단계에서는 픽셀 크기를 바꾸지 않습니다. 미리 정한 유한한 품질 단계를 높은 순서부터 탐색하고, 바이트 한도를 만족하는 첫 번째 결과를 반환합니다. 이 결과는 어디까지나 ‘후보 단계 중 우선순위가 가장 높은 합격 결과’이며, 연속적인 품질 범위에서 최적값을 찾았다는 뜻은 아닙니다.

파일 크기는 반드시 검사해야 하는 출력 조건이고, 화질은 별도의 검수 조건입니다. 용량 한도를 지켰다고 해서 글자 가장자리, 피부색, 그라데이션까지 요구 수준을 충족한 것은 아닙니다.

원격 macOS 터미널에서 다음 내용을 jpeg-budget.swift로 저장합니다. 사용 가능한 Swift 툴체인이 설정되어 있어야 합니다. 이 스크립트는 시스템 프레임워크만 사용하며, 개인 사진을 읽거나 시스템 설정을 변경하지 않습니다.

import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers

enum ExportError: Error {
    case invalidBudget
    case encoding
    case budgetUnreachable
    case decoding
}

func encodeJPEG(_ image: CGImage, quality: Double) throws -> Data {
    let buffer = NSMutableData()
    guard let destination = CGImageDestinationCreateWithData(
        buffer as CFMutableData,
        UTType.jpeg.identifier as CFString,
        1,
        nil
    ) else {
        throw ExportError.encoding
    }
    let properties = [
        kCGImageDestinationLossyCompressionQuality: quality
    ] as CFDictionary
    CGImageDestinationAddImage(destination, image, properties)
    guard CGImageDestinationFinalize(destination) else {
        throw ExportError.encoding
    }
    return buffer as Data
}

func exportJPEG(
    _ image: CGImage,
    maxBytes: Int
) throws -> (data: Data, quality: Double) {
    guard maxBytes > 0 else {
        throw ExportError.invalidBudget
    }
    let levels = [0.9, 0.8, 0.7, 0.6, 0.5]
    for quality in levels {
        let data = try encodeJPEG(image, quality: quality)
        if data.count <= maxBytes {
            return (data, quality)
        }
    }
    throw ExportError.budgetUnreachable
}

func verify(_ data: Data, width: Int, height: Int) throws {
    guard let source = CGImageSourceCreateWithData(
        data as CFData, nil
    ),
    CGImageSourceGetType(source) == UTType.jpeg.identifier as CFString,
    let decoded = CGImageSourceCreateImageAtIndex(source, 0, nil),
    decoded.width == width,
    decoded.height == height else {
        throw ExportError.decoding
    }
}

let space = CGColorSpace(name: CGColorSpace.sRGB)!
let context = CGContext(
    data: nil,
    width: 320,
    height: 240,
    bitsPerComponent: 8,
    bytesPerRow: 0,
    space: space,
    bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
context.setFillColor(
    CGColor(red: 0.2, green: 0.4, blue: 0.6, alpha: 1)
)
context.fill(CGRect(x: 0, y: 0, width: 320, height: 240))
let image = context.makeImage()!

let result = try exportJPEG(image, maxBytes: 1_000_000)
precondition(result.data.count <= 1_000_000)
try verify(result.data, width: 320, height: 240)

do {
    _ = try exportJPEG(image, maxBytes: 1)
    preconditionFailure("Expected budget rejection")
} catch ExportError.budgetUnreachable {
}

xcrun swift jpeg-budget.swift를 실행합니다. 성공하면 아무것도 출력하지 않고, 실패하면 0이 아닌 종료 상태로 끝납니다. 여기서 단색 이미지는 처리 흐름을 검증하기 위한 것이며, 실제 압축 성능을 대표하는 샘플이 아닙니다. 코드의 강제 언래핑은 고정된 테스트 픽스처에만 사용합니다. 외부 이미지를 받는 앱 코드에서는 명확한 오류를 반환해야 합니다.

성공한 파일뿐 아니라 실패 경로도 검사하기

스크립트는 잘못된 바이트 한도, 인코딩 실패, 한도 충족 불가를 구분합니다. 이 세 가지를 모두 빈 파일로 처리해서는 안 됩니다. 잘못된 한도는 호출 오류이고, 인코딩 실패는 입력이나 인코더를 조사해야 하는 문제이며, 한도 충족 불가는 제품 정책에 따라 처리해야 하는 상황입니다.

테스트를 보강할 때는 최소한 한도가 0인 경우와 음수인 경우, 출력 바이트 수와 정확히 같은 경우, 모든 품질 단계가 한도를 초과하는 경우를 포함해야 합니다. 출력 크기와 한도가 같으면 반드시 통과해야 합니다. ‘최고 품질 결과보다 한 바이트 작으면 무조건 실패한다’고 단정해서는 안 됩니다. 이후의 품질 단계에서 조건을 만족할 수 있기 때문입니다.

탐색 분기를 별도로 검증하기

실제 인코딩 작업을 주입 가능한 함수로 분리하고, 길이가 미리 정해진 반환값으로 탐색 흐름을 검증합니다. 첫 단계가 통과하면 한 번만 호출해야 하고, 중간 단계가 통과하면 추가 인코딩을 중단해야 하며, 모든 단계가 한도를 초과하면 시도 횟수가 후보 수와 같아야 합니다. 이렇게 하면 특정 이미지에서 우연히 특정 파일 크기가 나오기를 기대하지 않고도 조기 종료가 제대로 작동하는지 확인할 수 있습니다.

검증되지 않은 이진 탐색을 사용하지 않는 이유는 파일 크기의 엄격한 단조성을 숨은 전제로 삼지 않기 위해서입니다. 유한 탐색은 비용의 상한이 명확하지만, 큰 이미지를 반복 인코딩하게 됩니다. 따라서 입력 픽셀 수에도 상한을 두고, 한꺼번에 너무 많은 이미지를 처리하지 않도록 해야 합니다.

문제를 드러낼 수 있는 이미지 픽스처 구성하기

테스트 이미지에는 단색, 가는 획의 글자, 그라데이션, 세밀한 질감이 모두 포함되어야 합니다. 코드로 생성해도 되고, 팀에서 보관을 승인한 고정 샘플을 사용해도 됩니다. 각 샘플의 크기와 내용 요약을 기록하고, 매번 무작위로 생성하면서 시드를 남기지 않는 방식은 피해야 합니다.

Data.count 검사 외에도 결과를 다시 읽어 형식과 픽셀 크기를 확인해야 합니다. 손상된 파일은 용량이 작아도 유효한 결과가 아닙니다. JPEG는 손실 압축이므로 디코딩한 픽셀이 원본과 하나씩 정확히 같다고 단언할 수 없습니다. 프로젝트에서 시각적 오차를 자동 평가해야 한다면 계산에 사용할 색 공간과 허용 오차를 고정하고, 수동 검토용 샘플도 남겨야 합니다.

같은 이미지라도 시스템 버전이 다르면 동일한 해시가 나올 필요는 없습니다. 형식이 올바른지, 픽셀 크기가 유지되는지, 바이트 한도를 지키는지, 탐색 횟수에 상한이 있는지를 검증하는 편이 더 안정적입니다. 실제로 선택된 품질 단계도 기록해 두면 원인 조사에 도움이 됩니다.

iOS 내보내기 흐름에 통합하고 지속적으로 검증하기

Mac 스크립트가 검증을 통과하면 함수를 앱에서 재사용할 수 있는 모듈로 옮기고, 대상 iOS 실행 환경에서도 동일한 픽스처를 실행합니다. 시스템 프레임워크의 인코딩 결과는 달라질 수 있으므로, macOS에서 성공했다는 사실만으로 iOS에서도 검증이 끝났다고 볼 수는 없습니다.

모든 품질 단계가 실패하면 현재 조건으로는 내보낼 수 없다는 점을 UI에서 명확히 알려야 합니다. 이미지 축소를 허용한다면 별도의 크기 단계와 최소 변 길이를 정하고, 사용자에게도 분명히 안내해야 합니다. 품질 탐색 도중에 몰래 이미지 크기를 바꾸어서는 안 됩니다. 업로드 전에 파일을 요청에 담는 추가 처리가 있다면 이미지 파일 자체의 한도와 전체 요청 본문의 한도를 각각 확인해야 합니다.

최종적으로는 입력 픽스처 식별자, 시스템 버전, 후보 품질 단계, 선택된 품질, 출력 바이트 수만 남기면 됩니다. 개인 원본 이미지를 기록할 필요는 없습니다. 바이트 한도, 화질 요구 사항, 실패 동작을 각각 명확히 다뤄야 내보내기 문제가 계속 낮아지는 품질 숫자 뒤에 가려지지 않습니다.

자주 묻는 질문

JPEG 품질을 0.5로 설정하면 용량도 절반이 되나요?

아닙니다. 품질 값은 인코딩 방식을 조절할 뿐 압축 비율을 뜻하지 않습니다. 인코딩이 끝난 뒤 Data.count로 한도 충족 여부를 확인해야 합니다.

모든 품질 단계에서 용량 한도를 초과하면 어떻게 하나요?

정해진 실패를 반환하고 탐색을 종료합니다. 픽셀 크기 축소가 허용된다면 최소 크기를 정한 별도 처리 단계로 넘겨야 합니다.

Mac에서 통과한 테스트로 iOS 검증을 대신할 수 있나요?

대신할 수 없습니다. Mac에서는 탐색 로직을 빠르게 확인하고, 대상 iOS 실행 환경에서도 같은 계약을 검증해야 합니다. 시스템 간 JPEG 바이트 일치까지 요구하지는 않습니다.

직접 작업해 보며 원격 Mac을 검증하세요.

Mac mini M4 전체를 단독으로 임대해 macOS 그래픽 인터페이스와 명령줄을 사용해 보세요. 연결과 작업 흐름을 먼저 검증하기에 적합합니다. 장시간 오프라인 작업이나 전문 하드웨어 연결이 필요하다면 직접 구매하는 방법도 비교해 보세요.

전용 Mac mini 임대

하루 먼저 임대하고, 구매 여부를 결정하세요.

연결감과 실제 작업을 함께 확인한 뒤, 자신에게 맞는 사용 기간을 선택하세요.