Why Direct-to-Storage Uploads with Presigned URLs

파일 업로드 설계: “파일 바이트를 누가 받는가”에서 시작하기

파일 업로드를 설계할 때 가장 먼저 정해야 할 질문은 하나다.
파일 바이트를 앱 서버가 받을 것인가, 스토리지가 직접 받을 것인가?

이 선택에 따라 성능, 비용, 장애 포인트, UX, 운영 복잡도가 크게 달라진다.

왜 이 문제가 중요한가

겉보기에는 “이미지 하나 업로드”지만, 실제로는 아래가 동시에 걸려 있다.

  • 인증/인가와 권한 통제
  • 파일 검증과 정책 강제
  • 업로드 실패 복구와 사용자 피드백
  • 대용량/동시 업로드에서의 확장성
  • 캐시 일관성과 데이터 확정 타이밍

즉, 업로드는 단순 I/O가 아니라 도메인 상태 변경 트랜잭션에 가깝다.

1) 앱 서버 경유 업로드 방식

브라우저가 파일을 앱 서버로 전송하고, 서버가 검증/가공 후 스토리지에 다시 업로드하는 구조다.

흐름은 보통 다음과 같다.

  1. 브라우저 → 앱 서버 (multipart/form-data)
  2. 서버에서 인증/인가, 크기/형식 검사, 변환(리사이즈/썸네일), 악성 파일 검사
  3. 서버 → 스토리지 업로드
  4. 서버가 DB에 최종 URL 반영

장점은 “파일 내용을 서버에서 일관되게 통제하기 쉽다”는 점이다.
반면 단점은 명확하다.

  • 파일 바이트가 서버를 통과해 네트워크/메모리/CPU 부담 증가
  • 대용량/동시 업로드에서 앱 서버 병목 발생
  • 서버리스 환경에서 바디 크기, 타임아웃, 메모리 제한 리스크 증가

2) 스토리지 직접 업로드 방식

파일 바이트는 스토리지로 바로 보내고, 앱 서버는 권한과 상태만 관리한다.

핵심 흐름은 3단계다.

  1. init-upload: 브라우저가 업로드 초기화 요청
  2. presigned PUT: 브라우저가 스토리지에 직접 업로드
  3. complete-upload: 서버가 검증 후 도메인 상태(DB) 확정

여기서 presigned URL은 영구 권한이 아니라,
특정 bucket/key + 특정 동작(PUT) + 짧은 만료 시간에만 유효한 임시 위임 권한이다.

핵심 설계 포인트: “업로드 성공”과 “상태 확정” 분리

스토리지에 객체가 존재한다고 해서 도메인 상태가 확정되는 것은 아니다.
그래서 complete-upload가 반드시 필요하다.

  • 객체 실제 존재 여부 확인 (HeadObject)
  • 허용된 key 범위/정책 재검증
  • 검증 통과 시에만 DB 갱신

이 분리를 해두면 보안과 데이터 정합성이 유지된다.

예시 구현 흐름

ts
const init = await fetch("/api/page/image/init-upload", {
  method: "POST",
  body: JSON.stringify({ handle }),
});

await fetch(initPayload.uploadUrl, {
  method: "PUT",
  headers: initPayload.uploadHeaders,
  body: outputFile,
});

const complete = await fetch("/api/page/image/complete-upload", {
  method: "POST",
  body: JSON.stringify({ handle }),
});
ts
const uploadUrl = await getSignedUrl(
  s3Client,
  new PutObjectCommand({
    Bucket: s3Config.bucketName,
    Key: objectKey,
    ContentType: "image/jpeg",
  }),
  { expiresIn: 60 },
);
ts
await s3Client.send(new HeadObjectCommand({ Bucket: s3Config.bucketName, Key: objectKey }));

const imageUrl = buildPageImagePublicUrl({
  publicObjectBaseUrl,
  bucketName,
  objectKey,
  version: Date.now().toString(),
});

await updateOwnedPageImage({ storedHandle, userId, image: imageUrl });

version: Date.now().toString()은 캐시 버스팅 용도다.
이미지 교체 후에도 CDN/브라우저가 이전 캐시를 보여주는 문제를 줄여준다.

왜 presigned URL 방식을 선택하는가

직접 업로드의 본질은 서버 책임 재배치다.

  • 서버는 파일 중계자가 아니다.
  • 서버는 권한 제어자이자 상태 확정자다.

결과적으로 얻는 이점은 다음과 같다.

  • 서버 네트워크/메모리/CPU 부담 감소
  • 동시 업로드 확장성 개선
  • 서버리스 환경에서 타임아웃/대역폭 병목 완화
  • 대용량 파일일수록 비용 효율 증가(이중 전송 제거)

단점과 운영 보완

직접 업로드는 구조가 좋아지는 대신 운영 난이도가 올라간다.

  • 3단계 플로우라 실패 케이스가 많아짐
  • 업로드 중단 시 orphan object 발생 가능
  • MIME spoofing, 악성 파일 검사는 후처리 파이프라인 필요

보완 전략은 명확하다.

  • 특정 prefix lifecycle rule로 orphan 자동 삭제
  • 주기적 정리 배치 운영
  • 업로드 후 트리거 기반 검사(포맷 확인/바이러스 스캔)

이 설계는 “파일 전송”과 “도메인 결정”을 분리해 확장성과 통제를 동시에 얻는 방식이다.

  • 파일 바이트 전송: 스토리지에 위임
  • 업로드 권한/범위 통제: 서버
  • 최종 채택(DB 반영): 서버의 complete 단계

즉, 비용과 성능을 개선하면서도 보안과 정합성의 통제 지점을 유지한다.

시퀀스 다이어그램

mermaid
sequenceDiagram
    participant U as 사용자
    participant UI as SceneImageInput
    participant H as useSceneImage
    participant C as scene-image-client
    participant A1 as init-image-upload API
    participant A2 as complete-image-upload API
    participant A3 as delete-image API
    participant S3 as Supabase S3
    participant DB as Database

    U->>UI: 이미지 선택
    UI->>UI: 파일 검증 (5MB, png/jpeg/webp)

    alt 검증 실패
        UI->>U: 에러 토스트 표시
    else 검증 성공
        UI->>UI: ObjectURL 미리보기 표시
        UI->>H: handleUpload(file)
        H->>C: optimizeSceneImage(file)
        C-->>H: optimized, preview, metadata
        H->>H: 압축 메타데이터 로그

        H->>A1: POST (userId, sceneId, handle, originalFileName, originalFileType, originalFileSize)
        A1->>DB: 기존 이미지 조회
        A1->>S3: 이전 키 삭제 (필요 시)
        A1-->>H: uploadUrl, uploadHeaders, key

        alt init 실패
            H->>UI: 미리보기 롤백
            UI->>U: 에러 토스트 표시
        else init 성공
            H->>S3: PUT optimized file (presigned URL)
            alt 업로드 실패
                H->>UI: 미리보기 롤백
                UI->>U: 에러 토스트 표시
            else 업로드 성공
                H->>A2: POST (userId, sceneId, handle, key)
                A2->>DB: scenes.original_image_url 업데이트
                A2-->>H: scene

                alt complete 실패
                    H->>UI: 미리보기 롤백
                    UI->>U: 에러 토스트 표시
                else complete 성공
                    H-->>UI: 업로드 성공 콜백
                    UI->>UI: 서버 URL로 교체
                    UI->>UI: imageVersion 갱신
                end
            end
        end
    end

    U->>UI: 삭제 버튼 클릭
    UI->>H: handleDelete(originalImageUrl)
    H->>UI: UI에서 즉시 제거
    H->>A3: POST (userId, sceneId, handle, original_image_url)

    alt 삭제 실패
        H->>UI: 이전 이미지 복원
        UI->>U: 에러 토스트 표시
    else 삭제 성공
        H-->>UI: 삭제 성공 콜백
    end