728x90
반응형
유저가 사진을 촬영하고 업로드할 때, 백엔드에서는 크게 2가지 무거운 작업이 일어납니다.
- Cloudinary 서버로 원본 이미지 전송 (수 초 소요)
- GPS 위경도를 한글 지명으로 변환하는 Reverse Geocoding API 호출 (수 초 소요)
이 두 가지 작업을 순차적으로 실행하면 유저는 화면이 멈춘 채로 10초 가까이 기다려야 합니다. 이를 해결하기 위해 자바의 CompletableFuture를 도입한 경험입니다.

1. 기존 방식의 문제점 (순차 실행)
I/O 바운드 작업들이 직렬로 연결되어 유저 경험(UX)을 심각하게 훼손하고 있었습니다.
// 기존의 느린 코드 (직렬 실행)
String imageUrl = cloudinaryClient.upload(request.file().getBytes()); // 3초 대기
String locationName = reverseGeocode(lat, lng); // 3초 대기
// 총 6초 대기 후 DB 저장...
2. CompletableFuture를 활용한 병렬 실행
두 작업은 서로 의존성이 없습니다. 사진이 업로드되는 동안 위치 정보를 알아내면 됩니다. 실제 PhotoService.createDraft 메서드의 병렬 처리 코드입니다.
// PhotoService.java — createDraft() 핵심 부분
// 프라이버시 보호를 위해 GPS 좌표를 소수점 2자리로 블러 처리
Double blurredLat = Math.round(request.latitude() * 100.0) / 100.0;
Double blurredLng = Math.round(request.longitude() * 100.0) / 100.0;
// ⚡ Cloudinary 업로드 + Reverse Geocoding 병렬 실행
CompletableFuture<String> uploadFuture =
CompletableFuture.supplyAsync(() -> {
try { return cloudinaryClient.upload(request.file().getBytes()); }
catch (Exception e) { throw new RuntimeException("이미지 업로드에 실패했습니다.", e); }
});
CompletableFuture<String> geoFuture =
CompletableFuture.supplyAsync(() -> reverseGeocode(blurredLat, blurredLng));
String imageUrl;
String locationName;
try {
// 병렬로 실행된 두 스레드의 결과를 최대 30초, 10초까지만 대기하고 합침
imageUrl = uploadFuture.get(30, TimeUnit.SECONDS);
locationName = geoFuture.get(10, TimeUnit.SECONDS);
} catch (Exception e) {
throw new RuntimeException("업로드 처리 중 오류가 발생했습니다.", e);
}
Reverse Geocoding 구현 상세
GPS 좌표를 한글 주소로 변환하는 로직도 직접 구현했습니다. OpenStreetMap의 무료 API(Nominatim)를 활용합니다.
// PhotoService.java — reverseGeocode() 발췌
private String reverseGeocode(Double lat, Double lng) {
if (lat == null || lng == null) return "알 수 없는 장소";
try {
String url = String.format(Locale.US,
"https://nominatim.openstreetmap.org/reverse"
+ "?format=json&lat=%f&lon=%f&zoom=18&addressdetails=1", lat, lng);
Map<String, Object> address = // API 응답에서 address 추출
// 시 > 구 > 동 순서로 한글 지명 조합
String city = (String) address.getOrDefault("city",
address.getOrDefault("state", null));
String district = (String) address.getOrDefault("borough",
address.getOrDefault("city_district", null));
String dong = (String) address.getOrDefault("neighbourhood",
address.getOrDefault("quarter",
address.getOrDefault("suburb", null)));
// "서울특별시 강남구 역삼동" 형태로 조합
StringBuilder sb = new StringBuilder();
if (city != null) sb.append(city);
if (district != null) sb.append(" ").append(district);
if (dong != null && !dong.equals(district)) sb.append(" ").append(dong);
return sb.length() > 0 ? sb.toString() : "알 수 없는 장소";
} catch (Exception e) {
log.warn("Reverse geocoding failed for lat:{}, lng:{}", lat, lng, e);
return "알 수 없는 장소";
}
}
3. 프론트엔드 측 추가 최적화: 이미지 리사이즈
백엔드의 병렬 처리 외에도, 프론트엔드에서 이미지를 업로드 전에 Canvas API로 리사이즈하여 네트워크 전송량을 줄였습니다.
// Gacha.jsx — 브라우저 Canvas를 이용한 이미지 리사이즈
const resizeImage = (file) => new Promise((resolve) => {
const img = new Image();
const reader = new FileReader();
reader.onload = (e) => {
img.onload = () => {
const MAX = 1200;
let { width, height } = img;
if (width > MAX || height > MAX) {
const ratio = Math.min(MAX / width, MAX / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.85);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
원본 12MB 사진 → Canvas 리사이즈 → JPEG 85% → 약 300KB로 압축 후 전송. 모바일 환경에서 업로드 속도가 체감적으로 크게 향상되었습니다.
4. 결과와 마무리
두 작업을 병렬 스레드로 밀어 넣음으로써, 전체 소요 시간은 두 작업 중 '가장 오래 걸리는 작업의 시간'으로 획기적으로 줄어들었습니다. 6초가 걸리던 작업이 3초 안팎으로 끝났습니다. 또한 uploadFuture.get(30, TimeUnit.SECONDS) 처럼 타임아웃을 꼼꼼하게 설정하여, 외부 API 서버(Cloudinary, Geocoding)가 죽더라도 백엔드의 스레드가 무한정 블로킹(Deadlock)되는 재앙을 막아냈습니다.
728x90
반응형