사진 업로드 — 압축·AI 분석·청크 병렬 업로드 파이프라인 구현기

DAM·2026년 4월 30일

[Project]

목록 보기
6/8
post-thumbnail

숙소 등록 플로우를 개발하다가 직접 테스트해보니 사진 업로드가 너무 느렸다. DSLR로 찍은 파일 몇 장을 그대로 올렸더니 용량이 커서 업로드가 한참 걸렸고, 여러 장을 올릴 때는 하나가 끝나야 다음 업로드가 시작되어 장수가 늘수록 기다리는 시간도 선형으로 늘어났다.

그 사이에 진행 상황도 안 보이니 "되고 있는 건가?" 싶어서 버튼을 여러 번 더 누르기도 했다. 어차피 만들어야 하는 기능이라면 처음부터 제대로 구조를 잡자는 생각으로 압축 → AI 분류 → 병렬 업로드 파이프라인을 설계했다.


문제 인식

기존 방식은 단순했다.

파일 선택 → FormData → POST /api/upload → 완료

세 가지가 문제였다.

  • 원본 파일 그대로 전송: DSLR로 찍은 숙소 사진은 한 장에 5~15MB에 달한다. 이걸 압축 없이 그대로 올리면 업로드 시간이 길어지는 건 물론이고, 서버 스토리지와 네트워크 비용도 낭비다.
  • 진행 상황 없음: 업로드 중에 아무 피드백이 없으면 잘 되고 있는 건지 알 수가 없다. 기다리다 못해 버튼을 한 번 더 누르면 같은 파일이 중복으로 올라가고, 그걸 나중에 수동으로 정리해야 하는 상황이 생긴다.
  • 순차 처리: 파일을 하나씩 순서대로 처리하면 장수가 늘수록 대기 시간이 그대로 늘어난다. 10장이면 10배, 15장이면 15배다. 숙소 등록 시 사진을 한꺼번에 올리는 경우가 많은데, 매번 30초 넘게 기다리는 건 현실적으로 쓸 수 없는 흐름이었다.

파이프라인 구조

파일 선택 → 유효성 검사 → 청크 분할 (3개씩)
    ↓ Promise.all (청크 내 병렬)
  [Canvas 압축 (업로드용 1600px / Vision용 1200px) → Vision API → SEO 파일명]
    ↓
  S3 Presigned URL 발급 → XHR PUT (progress 추적)
    ↓
  메인 지정 → 순서 저장 → 삭제 처리

1. Canvas 기반 다단계 이미지 압축

서버 정책(2MB 이하, JPEG/PNG/WebP)을 맞추기 위해 Canvas로 직접 구현했다. 단순히 1600px 고정으로 리사이즈하면 고해상도 원본은 여전히 2MB를 넘는다. 목표 용량을 달성할 때까지 단계적으로 압축 강도를 올리는 방식을 썼다.

const passes = [
  { size: 1600, quality: 0.85 },
  { size: 1280, quality: 0.78 },
  { size: 1024, quality: 0.70 },
  { size: 800,  quality: 0.60 }, // 최후 수단
];

for (const { size, quality } of passes) {
  const compressed = await compressImage(file, size, quality);
  if (compressed.size <= TARGET_MAX_BYTES) return compressed;
}

같은 파일을 업로드용(1600px)과 Vision 분석용(1200px base64)으로 각각 압축해야 하는데, 두 작업을 Promise.all로 병렬 실행해 대기 시간을 줄였다.

const [uploadFile, visionBase64] = await Promise.all([
  compressImageForUpload(file),
  compressImageForVision(file),
]);

2. Vision API로 이미지 자동 분류 + SEO 파일명 생성

숙소 사진은 공간별로 분류되어야 하는데(침실, 욕실, 주방 등), 호스트가 일일이 지정하기엔 번거롭다. Vision API에 압축된 이미지를 넘기면 공간 카테고리를 자동으로 반환한다.

const visionResult = await callVisionApi(visionBase64, visionTimeout);
// { category: 'BEDROOM', confidence: 0.92 }

Vision API가 실패하거나 타임아웃(기본 10초)이 나도 업로드는 계속 진행된다. 실패는 warnings 배열에 누적하고 fallback으로 UNCERTAIN을 사용한다.

Vision 결과는 SEO 파일명 생성에도 활용한다. IMG_2048.JPG 같은 파일명은 검색엔진에 기여하지 못한다.

// gyedong-hanok-bedroom-03.jpg
generateSeoFilename({
  accommodationSlug: 'gyedong-hanok',
  category: visionResult.category,
  sequence: 3,
  extension: 'jpg',
});

Vision이 실패하면 gyedong-hanok-photo-03.jpg로 fallback한다.


3. S3 Presigned URL + XHR 진행률 추적

파일을 서버를 거쳐 올리면 서버 메모리와 대역폭을 소모한다. Presigned URL을 쓰면 클라이언트가 S3에 직접 PUT한다.

클라이언트 → POST /api/s3-upload (파일명, 크기, 타입)
서버        → Presigned URL 생성 후 반환
클라이언트 → PUT presignedUrl (직접 전송)

fetch()는 업로드 진행률을 추적할 수 없어 XMLHttpRequest를 사용했다.

xhr.upload.addEventListener('progress', (event) => {
  if (event.lengthComputable) {
    const progress = Math.round((event.loaded / event.total) * 100);
    onProgress?.(progress);
  }
});

4. 청크 병렬 처리

파일 1장에 약 2초, 10장 순차 처리면 20초다. 파일 배열을 concurrency=3 단위로 쪼개고 청크 내부에서 Promise.all로 병렬 실행한다.

const chunks: File[][] = [];
for (let i = 0; i < files.length; i += concurrency) {
  chunks.push(files.slice(i, i + concurrency));
}

for (const chunk of chunks) {
  const results = await Promise.all(chunk.map(processFile));
}

concurrency를 3으로 설정한 이유는 Vision API 동시 요청 한도와 브라우저 메모리(파일당 3~5MB × 3 ≈ 10~15MB) 사이의 균형점이기 때문이다.


5. 업로드 오케스트레이션 (imageChangeSync)

업로드 이후에도 메인 이미지 지정, 순서 저장, 삭제 처리가 남아있다. 이 흐름을 processImages가 담당한다.

// 1) 신규 파일 업로드
for (const item of newItems) {
  const imageType = await api.analyze?.(item.file);
  const uploaded = await api.upload(item.file, imageType);
}

// 2) 메인 이미지 지정
await api.setMain(mainId);

// 3) 순서 저장 (변경 없으면 스킵)
if (!isSameOrder(currentOrderIds, finalOrderIds)) {
  await api.reorder(finalOrderIds);
}

// 4) 삭제 처리
await api.deleteOne(id); // 또는 api.deleteAll()

return { warnings }; // 부분 실패는 throw 대신 누적

두 가지 원칙을 지켰다. 부분 실패 허용 — 이미지는 숙소 저장의 필수값이 아니라서, 업로드 실패가 전체 저장 실패로 전파되면 안 된다. 의존성 주입ImageApi 인터페이스를 주입받는 구조라 룸, 조직 등 다른 엔터티에도 동일한 유틸을 재사용할 수 있다.


성능 추정

파일 1장 처리 시간을 분해하면, 전체 약 2,240ms 중 86%가 네트워크 I/O다(Vision 1,200ms + S3 600ms + Presigned URL 140ms). Promise.all로 병렬화가 가장 효과적인 구간이다.

파일 수순차 처리청크 병렬 (×3)단축률
3장6.7초2.8초57.7%
6장13.4초5.7초57.7%
15장33.6초14.2초57.7%

위 수치는 각 단계별 평균 소요 시간을 기반으로 한 이론적 추정치다. 실제 수치는 Vision API 응답 속도와 네트워크 환경에 따라 달라진다.

단축률이 파일 수와 무관하게 일정한 이유는, ceil(n / 3) 청크 수에 비례해 시간이 줄어들기 때문이다.


마치며

지금 구조에서 아직 해결 못 한 게 있다면, 업로드 실패 시 재시도 로직이다. 현재는 실패를 warnings에 누적하고 끝이라서, 네트워크가 불안정한 환경에서는 일부 파일이 조용히 빠질 수 있다.

이 부분을 어떻게 개선할지 찾아보다가 지수 백오프(exponential backoff) 라는 패턴을 알게 됐다. 실패했을 때 즉시 재시도하면 서버가 과부하 상태일 경우 오히려 503이 반복되는데, 재시도 간격을 1초 → 2초 → 4초처럼 점점 늘려가면 서버가 회복할 시간을 줄 수 있다는 개념이다. 아직 적용하지는 못했지만, 다음 개선 포인트로 정해뒀다.

Vision API 타임아웃도 마찬가지다. 현재 10초로 설정했는데, 이 값은 실측 없이 일단 넉넉하게 잡은 숫자다. 타임아웃을 어떻게 정해야 하는지 찾아보니, 실제 응답 시간 분포를 측정해서 대부분의 요청이 완료되는 시점을 기준으로 잡는 게 맞다고 한다. 운영 데이터가 쌓이면 그때 다시 조정해볼 생각이다.


profile
🌐 DOM 위에서 살아남기

0개의 댓글