파일 업로드 설계: “파일 바이트를 누가 받는가”에서 시작하기
파일 업로드를 설계할 때 가장 먼저 정해야 할 질문은 하나다.
파일 바이트를 앱 서버가 받을 것인가, 스토리지가 직접 받을 것인가?
이 선택에 따라 성능, 비용, 장애 포인트, UX, 운영 복잡도가 크게 달라진다.
왜 이 문제가 중요한가
겉보기에는 “이미지 하나 업로드”지만, 실제로는 아래가 동시에 걸려 있다.
- 인증/인가와 권한 통제
- 파일 검증과 정책 강제
- 업로드 실패 복구와 사용자 피드백
- 대용량/동시 업로드에서의 확장성
- 캐시 일관성과 데이터 확정 타이밍
즉, 업로드는 단순 I/O가 아니라 도메인 상태 변경 트랜잭션에 가깝다.
1) 앱 서버 경유 업로드 방식
브라우저가 파일을 앱 서버로 전송하고, 서버가 검증/가공 후 스토리지에 다시 업로드하는 구조다.
흐름은 보통 다음과 같다.
- 브라우저 → 앱 서버 (
multipart/form-data) - 서버에서 인증/인가, 크기/형식 검사, 변환(리사이즈/썸네일), 악성 파일 검사
- 서버 → 스토리지 업로드
- 서버가 DB에 최종 URL 반영
장점은 “파일 내용을 서버에서 일관되게 통제하기 쉽다”는 점이다.
반면 단점은 명확하다.
- 파일 바이트가 서버를 통과해 네트워크/메모리/CPU 부담 증가
- 대용량/동시 업로드에서 앱 서버 병목 발생
- 서버리스 환경에서 바디 크기, 타임아웃, 메모리 제한 리스크 증가
2) 스토리지 직접 업로드 방식
파일 바이트는 스토리지로 바로 보내고, 앱 서버는 권한과 상태만 관리한다.
핵심 흐름은 3단계다.
init-upload: 브라우저가 업로드 초기화 요청presigned PUT: 브라우저가 스토리지에 직접 업로드complete-upload: 서버가 검증 후 도메인 상태(DB) 확정
여기서 presigned URL은 영구 권한이 아니라,
특정 bucket/key + 특정 동작(PUT) + 짧은 만료 시간에만 유효한 임시 위임 권한이다.
핵심 설계 포인트: “업로드 성공”과 “상태 확정” 분리
스토리지에 객체가 존재한다고 해서 도메인 상태가 확정되는 것은 아니다.
그래서 complete-upload가 반드시 필요하다.
- 객체 실제 존재 여부 확인 (
HeadObject) - 허용된 key 범위/정책 재검증
- 검증 통과 시에만 DB 갱신
이 분리를 해두면 보안과 데이터 정합성이 유지된다.
예시 구현 흐름
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 }),
});
const uploadUrl = await getSignedUrl(
s3Client,
new PutObjectCommand({
Bucket: s3Config.bucketName,
Key: objectKey,
ContentType: "image/jpeg",
}),
{ expiresIn: 60 },
);
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 단계
즉, 비용과 성능을 개선하면서도 보안과 정합성의 통제 지점을 유지한다.
시퀀스 다이어그램
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