최근 집을 구해야 할 일이 있어 부동산 관련된 개인 프로젝트를 해보고 있는데, (100% 바이브 코딩으로) 아이폰으로 촬영한 사진을 매물 상세 화면에서 올리면 *.jpeg 업로드에 실패했습니다라는 메시지가 표시되는 문제가 있었다.
특히 사진 앱에서 바로 선택한 파일은 확장자가 .jpeg이라도 실제 포맷이 HEIC인 경우가 있어, 브라우저가 보내는 파일명이나 Content-Type만 확인해서는 문제를 해결할 수 없었다.
Halley 이 글은 그 프로젝트에서 문제를 재현하고, 서버에서 HEIC를 JPEG로 변환·리사이즈해 저장하도록 바꾼 과정을 정리한 것이다.
증상

- iPhone에서 촬영한 사진 업로드 실패
- 서버 애플리케이션 로그가 남지 않는 경우가 있음
- 일반 JPEG·PNG는 업로드되지만 특정 iPhone 사진만 실패
- 큰 사진은 Nginx 또는 Spring multipart 제한에 먼저 걸릴 수 있음
즉, 실패 지점은 하나가 아니었다.
iPhone 사진 선택
├─ Nginx 요청 크기 제한
├─ Spring multipart 크기 제한
├─ HEIC 디코딩(ImageIO/libheif)
└─ 리사이즈·파일 저장·DB 저장
원인
1. 확장자와 실제 이미지 포맷이 다를 수 있다
브라우저의 file.name과 file.type은 신뢰할 수 있는 이미지 포맷 판별 기준이 아니다. HEIC 파일을 JPEG처럼 이름 붙여 보내거나, 반대로 MIME 타입이 비어 있는 경우도 있다.
따라서 서버에서 파일 앞부분의 ISO Base Media File Format ftyp 박스를 읽고 HEIC/HEIF 브랜드를 판별했다.
2. Java 기본 ImageIO는 HEIC를 읽지 못한다
기존 JPEG·PNG 처리에는 Thumbnailator와 Java ImageIO를 사용하고 있었지만, HEIC 디코더가 없으면 Thumbnailator가 이미지를 열기 전에 실패한다. 애플리케이션 컨트롤러까지 요청이 도달하지 못하는 multipart 제한 문제와 달리, 이 경우에는 서버 로그에 디코딩 실패가 남는다.
3. 업로드 앞단에도 크기 제한이 있다
파일이 Spring에 도착하기 전에 Nginx의 client_max_body_size에 걸릴 수 있다. Spring의 파일당 제한과 요청 전체 제한도 별도로 존재하므로 두 계층을 함께 맞춰야 한다.
해결 전략
원본 HEIC를 웹에서 직접 제공하지 않고, 업로드 시점에 다음 파이프라인으로 통일했다.
HEIC 감지
→ libheif/ImageIO로 디코딩
→ 최대 1920px JPEG 생성
→ 320px JPEG 썸네일 생성
→ JPEG 두 개와 메타데이터만 저장
→ 원본 HEIC는 저장하지 않음
기존 JPEG·PNG도 동일하게 JPEG 원본과 썸네일로 변환하되, EXIF 방향 보정은 유지했다.
서버 구현
의존성과 JVM 옵션
build.gradle에 NightMonkeys HEIF 플러그인을 추가했다.
runtimeOnly 'com.github.gotson.nightmonkeys:imageio-heif:1.1.0'
tasks.withType(Test).configureEach {
jvmArgs '--enable-native-access=ALL-UNNAMED'
}
실행 환경에는 Java 25의 네이티브 접근 허용 옵션도 필요하다. 배포 실행 방식에 따라 JAVA_TOOL_OPTIONS 또는 systemd 서비스의 Environment에 같은 옵션을 넣는다.
HEIC 판별과 디코딩
HeicImageDecoder는 확장자가 아니라 ftyp 브랜드를 확인한 뒤 ImageIO 플러그인으로 BufferedImage를 만든다. 디코더 미설치, 손상 파일, 읽을 수 없는 포맷은 서로 다른 애플리케이션 예외로 구분해 응답한다.
저장 서비스
PropertyImageService는 디코딩된 이미지를 Thumbnailator로 리사이즈한다.
private void writeHeicJpeg(BufferedImage image, int size, Path target) throws IOException {
Thumbnails.of(image)
.size(size, size)
.keepAspectRatio(true)
.outputFormat("jpg")
.toFile(target.toFile());
}
저장 순서는 다음과 같다.
- 업로드 권한·파일·이미지 종류 검증
- 저장 디렉터리 생성
- HEIC면 한 번 디코딩한 뒤 원본/썸네일 JPEG 생성
- DB에 JPEG 원본 경로 저장
- 평면도인 경우 기존 평면도 교체
변환 또는 DB 저장이 실패하면 이번 요청에서 만든 JPEG 파일을 삭제한다. 평면도 교체도 새 파일과 DB 저장이 성공한 뒤에 실행하므로, 실패한 교체가 기존 평면도를 먼저 지우지 않는다.
업로드 크기 제한 설정
Spring에는 파일당 20MB, 요청 전체 25MB를 기본값으로 설정했다.
spring:
servlet:
multipart:
max-file-size: ${IMAGE_MAX_FILE_SIZE:20MB}
max-request-size: ${IMAGE_MAX_REQUEST_SIZE:25MB}
Nginx도 요청 전체를 허용해야 한다.
server {
client_max_body_size 25m;
# proxy_pass ...
}
설정 변경 후에는 다음 순서로 검증하고 reload한다.
sudo nginx -t
sudo systemctl reload nginx
Amazon Linux 2023 배포
Amazon Linux 2023에서는 다음 패키지를 설치한다.
sudo dnf install -y libheif libheif-devel libheif-tools libde265
그 다음 애플리케이션을 새 JAR로 배포하고, 실행 프로세스가 라이브러리를 찾는지 확인한다.
Homebrew 경로가 있는 로컬 환경과 달리 서버에서는 시스템 라이브러리 경로를 사용하므로, 필요하다면 JAVA_LIBRARY_PATH를 서비스 환경에 지정한다.
export JAVA_LIBRARY_PATH=/usr/lib64
export JAVA_TOOL_OPTIONS="--enable-native-access=ALL-UNNAMED"
java -jar halley.jar
systemd를 사용한다면 셸에서 export한 값이 서비스에 자동으로 전달되지 않으므로 서비스 파일의 Environment=에 넣고 daemon-reload 후 재시작해야 한다.
실패 응답과 관찰성
업로드 한도 초과는 413과 PROPERTY_IMAGE_TOO_LARGE로 응답하고, 디코더 오류·손상 이미지·저장 실패는 별도 메시지로 구분한다. 로그에는 매물 ID, 파일 크기, Content-Type, HEIC 판별 결과와 원인을 남긴다. 단, raw_paste_text나 이미지 원문을 외부 서비스로 전송하지 않는다.
브라우저에서도 서버 JSON 메시지를 읽어 파일명과 함께 표시한다.
IMG_1234.HEIC: HEIC 디코더가 서버에 설치되어 있지 않습니다
Nginx가 HTML 형식의 413을 반환하는 경우에도 클라이언트는 상태 코드로 용량 초과를 안내한다.
테스트
다음 회귀 테스트를 추가했다.
- 실제 HEIC 바이트를 이용한 디코더 테스트
- HEIC 업로드 후 JPEG 원본·썸네일만 생성되는지 검증
- 변환 실패 시 생성 파일 정리 및 기존 평면도 보존 검증
- multipart 용량 초과 응답 검증
- 브라우저 업로드 실패 메시지 검증
화면 테스트는 실제 app.js를 jsdom에서 실행했으며, 전체 Node 화면 테스트 57개가 통과했다.
결과

마무리
핵심은 “파일 확장자를 JPEG로 바꾸어 받기”가 아니라, 서버가 실제 포맷을 판별하고 디코딩 가능한 이미지로 정규화하는 것이다. HEIC는 업로드 경계에서 JPEG로 변환하고, 웹 전시 형식을 JPEG 하나로 통일하면 브라우저 호환성·리사이즈·저장 정책을 한 곳에서 관리할 수 있다.