Firebase 상품 이미지 업로드와 DB 저장이 따로 실패하면 어떻게 처리하나요?
이미지 업로드와 상품 DB 저장은 별개의 작업이므로 둘 중 하나만 성공했을 때의 처리를 정해야 합니다. 파일 크기·형식과 사용자 경로를 확인하고 업로드 결과의 storagePath를 보관하세요.
적용 조건·범위: 브라우저 file.type과 contentType은 파일 내용을 실제로 해독한 결과가 아닙니다. 둘 다 허용 MIME·용량 계약에 도움을 주지만 악성 파일의 실내용 검사를 대체하지 못합니다. 실제 이미지 포맷 검증이 필요하면 서버에서 디코딩하세요. 5 × 1024 × 1024바이트는 5 MiB입니다. SVG는 이 예제의 허용 목록에 포함하지 않습니다.
본문 근거: firebase.google.com/docs/storage/web/upload-files · firebase.google.com/docs/storage/security/rules-conditions · Storage 플랜 요구와 오류 코드
학습 목표와 사전 지식
목표: 상품 이미지 업로드의 경로·파일 검사·DB 반영·실패 복구 책임을 분리합니다.
사전 지식: 브라우저 File, Promise, Firebase Auth와 Storage 초기화
이미지 업로드 성공과 상품 DB 저장 성공은 별개의 작업입니다. 객체 경로를 업로드 결과에서 저장하고, 부분 실패를 처리해야 재시도 때 고아 파일이나 중복 이미지가 쌓이지 않습니다.
호출 입력을 먼저 준비합니다. storage 인스턴스는 Firebase 초기화 구조: Next.js firebase.ts 설계 기준의 초기화 모듈, 로그인 완료 user는 Firebase Authentication 사용 기준: 로그인 유지와 비밀번호 재설정의 인증 관찰 결과입니다. productId는 독자가 소유한 개발용 상품 ID, File은 브라우저에서 선택한 JPEG/PNG/WebP 파일입니다. 상품 DB까지 연결하지 않아도 아래 uploadProductImage의 반환 경로와 업로드 실패를 독립적으로 확인할 수 있습니다.
학습 예제의 경로 계약
아래 모듈은 판매자 본인 경로 users/{uid}/products/{productId}/{fileId}에 이미지 한 개를 올립니다. 기존 카테고리 기반 URL 정규식에서 상품 ID를 역추출하는 대신 storagePath를 DB 필드로 별도 보관합니다. 상품 편집 권한 자체는 별도 Firestore Rules 또는 서버에서 확인해야 합니다. 경로에 본인 UID를 썼다는 것만으로 특정 상품의 소유권이 증명되지는 않습니다.
src/product-images.js · 전체 업로드 모듈; 초기화된 storage·인증 user·File을 전달
import { deleteObject, getDownloadURL, ref, uploadBytes } from 'firebase/storage';
export async function uploadProductImage(storage, user, productId, file) {
if (!user) throw new Error('로그인이 필요합니다.');
if (!/^[a-zA-Z0-9_-]+$/.test(productId)) throw new Error('상품 ID 오류');
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
throw new Error('JPEG, PNG, WebP 파일을 선택하세요.');
}
if (file.size === 0 || file.size > 5 * 1024 * 1024) {
throw new Error('0바이트보다 크고 5 MiB 이하인 파일이 필요합니다.');
}
const path = `users/${user.uid}/products/${productId}/${crypto.randomUUID()}`;
const snapshot = await uploadBytes(ref(storage, path), file, { contentType: file.type });
try {
return { storagePath: snapshot.ref.fullPath, url: await getDownloadURL(snapshot.ref) };
} catch (error) {
const cleanup = await Promise.allSettled([deleteObject(snapshot.ref)]);
if (cleanup[0].status === 'rejected') {
throw new AggregateError([error, cleanup[0].reason], 'URL 조회와 객체 정리에 실패했습니다.');
}
throw error;
}
}
storage.rules · 전체 최소 규칙; 다른 기존 경로는 병합 시 보존
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /users/{userId}/products/{productId}/{fileId} {
function owner() {
return request.auth != null && request.auth.uid == userId;
}
function image() {
return request.resource.size > 0
&& request.resource.size <= 5 * 1024 * 1024
&& request.resource.contentType.matches('image/(jpeg|png|webp)');
}
allow get: if owner();
allow create, update: if owner() && image();
allow delete: if owner();
}
}
}

프론트 검사와 Rules의 역할
브라우저 file.type과 contentType은 파일 내용을 실제로 해독한 결과가 아닙니다. 둘 다 허용 MIME·용량 계약에 도움을 주지만 악성 파일의 실내용 검사를 대체하지 못합니다. 실제 이미지 포맷 검증이 필요하면 서버에서 디코딩하세요. 5 × 1024 × 1024바이트는 5 MiB입니다. SVG는 이 예제의 허용 목록에 포함하지 않습니다.
상품 데이터까지 저장할 때
업로드 반환값의 storagePath와 url을 상품 DB에 반영합니다. DB 저장이 실패하면 해당 신규 객체 삭제를 시도하고 정리 실패도 재시도 대상으로 기록합니다. 기존 이미지 교체는 새 업로드 → DB에 새 경로 반영 → 이전 객체 삭제 순으로 진행합니다. 여러 파일의 Promise.all 실패는 이미 성공한 업로드를 자동으로 취소·롤백하지 않습니다. 동시 수를 제한하고 개별 결과를 수집하세요.

미리보기와 다운로드 링크의 수명
업로드 전에는 URL.createObjectURL(file)로 로컬 미리보기를 만들고 교체·컴포넌트 종료 때 URL.revokeObjectURL로 해제합니다. 이는 Firebase URL이 아닙니다. getDownloadURL의 token URL은 가진 사람이 접근 가능한 공유 링크로 취급합니다. 엄격한 비공개 파일은 token URL을 공개하지 않고 인증 SDK 다운로드나 서버 전달 정책을 설계하세요.
실제 프로젝트에서 확인할 것
로그인한 A의 정상 JPEG, 0바이트, 5 MiB 초과, 허용하지 않는 MIME을 구분합니다. B로 A 경로에 업로드·삭제를 시도하면 거부되어야 합니다. 업로드 후 DB 저장 실패를 의도적으로 만들어 신규 파일 정리 여부를 확인합니다. Storage 플랜·bucket은 Console 실제 상태를 확인해야 하며 이 모듈만으로 로그인 UI나 상품 관리 앱이 완성되지는 않습니다.
업로드와 상품 저장을 연결할 때
업로드 함수를 호출한 뒤 Firestore의 기존 products/{productId} 문서에 storagePath를 저장하는 예제입니다. 상품 문서가 이미 존재하고, 그 문서의 쓰기 규칙이 인증 사용자의 상품 소유권을 검사한다는 전제가 필요합니다. 다운로드 token URL은 공개 상품 문서에 기본 저장하지 않습니다.
src/save-product-image.js · product-images.js와 같은 폴더
import { doc, updateDoc } from 'firebase/firestore';
import { deleteObject, ref } from 'firebase/storage';
import { uploadProductImage } from './product-images.js';
export async function saveProductImage(db, storage, user, productId, file) {
const image = await uploadProductImage(storage, user, productId, file);
try {
await updateDoc(doc(db, 'products', productId), {
storagePath: image.storagePath,
});
return { storagePath: image.storagePath };
} catch (error) {
// 권한 거부·문서 없음처럼 쓰기가 확실히 거부된 경우에만 신규 파일 정리
if (error.code === 'permission-denied' || error.code === 'not-found') {
try {
await deleteObject(ref(storage, image.storagePath));
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], '상품 저장과 신규 파일 정리에 실패했습니다.');
}
}
throw error;
}
}
| 상황 | 기대 상태와 다음 행동 |
|---|---|
| 파일 검사 또는 업로드 실패 | 상품 문서를 수정하지 않고 오류를 보여줍니다. |
| 업로드 후 URL 조회 실패 | 기존 uploadProductImage가 신규 객체 삭제를 시도하고 두 오류를 묶어 알립니다. |
| DB가 permission-denied 또는 not-found로 거부 | 새 객체 삭제를 시도하고 원래 실패를 알립니다. 삭제도 실패하면 경로를 기록해 정리합니다. |
| DB 응답이 불명확한 네트워크 오류 | 무조건 파일을 지우지 않습니다. productId 문서를 재조회해 storagePath가 새 경로인지 확인한 뒤 정리 여부를 결정합니다. |
| DB 저장 성공 | 문서의 storagePath를 사용합니다. 기존 이미지 교체라면 이전 객체 삭제는 그 다음 단계입니다. |
스스로 확인하기: DB가 저장됐는데 응답만 끊긴 경우 새 파일을 곧바로 삭제하면 어떤 상태가 될까요? 상품 문서가 삭제된 객체를 가리키므로 경로를 다시 확인해야 합니다. 실제 네트워크·권한 실패 복구와 상품 소유권 규칙은 이 편집 환경에서 실행하지 않았습니다.
직접 확인할 결과
업로드 모듈 문법 검사
MIME·0/상한 용량·권한 표 검토
실제 업로드·정리·브라우저 File 미실행
사용 전 플랜 확인
2026-09-13 공식 문서 기준 Cloud Storage for Firebase는 기본 버킷 접근을 포함해 Blaze 종량제 플랜이 필요합니다. 무료 사용량과 결제 플랜은 다른 개념입니다. 개발 프로젝트의 결제 연결·예산 알림·실제 사용량을 확인하세요.
검증 범위와 기준일
공식 문서 확인일: 2026-09-13. 웹 SDK 예제는 모듈형 API 기준입니다. 이 글의 코드·구조 정적 검토와 실제 클라우드 인증·저장·배포 검증을 구분합니다. 본인 개발 Firebase 프로젝트의 실제 인증·권한·배포 요청은 이 편집 환경에서 실행하지 않았습니다.
이어서 학습하기
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 20개 · 필수 14개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.