주요 포인트 한눈에 보기
이 글은 완성된 포트폴리오 코드를 기준으로 Next.js + TypeScript + Firebase를 사용할 때
어떤 방식으로 제작되었는지 프로젝트를 분석합니다.
그 중 이번 챕터에서는 Firebase Storage 구조를 분석합니다.
- 포트폴리오: STYNA
- 1편: Firebase 설계와 구조 분석
- 2편: Firebase Auth 설정 분석
- 3편: Firebase Storage 설정 분석
- 4편: Firebase Auth Context 구조 분석
- 5편: Firebase Functions 구조 분석
- 6편: Firebase 규칙 작성법
- Firebase Storage 전체 유틸 구조
- 상품 이미지 업로드 전체 흐름
- 이미지 삭제 처리 구조
- 상품 전체 이미지 삭제 처리
- 카테고리 경로 변환 유틸
- Storage 경로 파싱 유틸
- 파일 크기 포맷 유틸
- 파일 검증 유틸 묶음
- 이미지 미리보기 URL 유틸
Firebase Storage 전체 유틸 구조
아래는 이 문서에서 설명하는 모든 함수들을 한 번에 볼 수 있도록 정리한 요약용 전체 코드 구조입니다.
실제 구현 코드를 그대로 나열하지 않고, 각 함수가 어떤 역할을 하는지 한눈에 파악할 수 있도록
시그니처 + 책임 설명 형태로 단순화했습니다.
// src/shared/libs/firebase/storage.ts
// 이미지 업로드: 다중 파일 업로드 후 다운로드 URL 배열 반환
export const uploadProductImages = async (
files: File[], // 업로드할 이미지 파일 목록
category: string, // 상품 카테고리 (경로 분기용)
productId: string, // 상품 고유 ID
onProgress?: Function // 업로드 진행률 콜백
): Promise => {
// Firebase Storage에 이미지를 업로드하고
// 모든 업로드가 성공하면 다운로드 URL 배열을 반환
};
// 이미지 단건 삭제: 다운로드 URL 기준으로 Storage 객체 삭제
export const deleteProductImage = async (
imageUrl: string // Firebase Storage 다운로드 URL
): Promise => {
// URL을 분석하여 실제 Storage 경로를 추출한 뒤 삭제 처리
};
// 상품 전체 이미지 삭제 (클라이언트에서는 직접 구현하지 않음)
export const deleteAllProductImages = async (
category: string,
productId: string
): Promise => {
// 상품에 속한 이미지 전체 삭제 (서버 로직 위임 대상)
};
// 카테고리 한글 → 영문 경로 변환
const getCategoryPath = (category: string): string => {
// 한글 카테고리를 Storage 경로용 영문 문자열로 변환
};
// 영문 경로 → 한글 카테고리 변환
export const getCategoryFromPath = (path: string): string => {
// Storage 경로에서 카테고리 표시용 문자열 추출
};
// Storage URL에서 카테고리와 상품 ID 파싱
export const parseStoragePath = (
imageUrl: string
): { category: string; productId: string } | null => {
// 다운로드 URL을 분석하여 메타 정보 추출
};
// 파일 크기 포맷 유틸 (Byte → MB 등)
export const formatFileSize = (bytes: number): string => {
// 사람이 읽기 쉬운 문자열로 변환
};
// 이미지 파일 여부 검사
export const isImageFile = (file: File): boolean => {
// MIME 타입 기준 이미지 여부 판단
};
// 파일 크기 제한 검사
export const isFileSizeExceeded = (
file: File,
maxSizeMB: number
): boolean => {
// 최대 파일 크기 초과 여부 판단
};
// 업로드 전 이미지 파일 검증
export const validateImageFiles = (
files: File[]
): { valid: File[]; errors: string[] } => {
// 업로드 가능한 파일과 에러 목록 분리
};
// 이미지 미리보기 URL 생성
export const createPreviewUrl = (file: File): string => {
// URL.createObjectURL 래핑
};
// 미리보기 URL 해제
export const revokePreviewUrl = (url: string): void => {
// 메모리 누수 방지를 위한 URL 해제
};
이 파일은 Firebase Storage와 직접 통신하는 모든 이미지 관련 로직을 하나로 모아둔 유틸 파일입니다.
실제 서비스에서는 이미지 업로드, 삭제, 검증 로직이 여러 컴포넌트에서 반복적으로 사용되기 쉽습니다.
이 로직을 각 컴포넌트에 분산시키면 유지보수 난이도가 급격히 상승하기 때문에,
하나의 책임 단위로 묶어 관리하는 구조를 선택했습니다.
이 유틸의 핵심 설계 의도는 다음과 같습니다.
첫째, 컴포넌트는 UI와 사용자 상호작용에만 집중합니다.
둘째, Storage 관련 예외 상황과 에러 처리는 한 곳에서 일관되게 관리합니다.
셋째, 이미지 경로 규칙을 코드 레벨에서 강제하여 데이터 구조가 흐트러지지 않도록 합니다.
import { storage } from './firebase';
import { ref, uploadBytesResumable, getDownloadURL, deleteObject } from 'firebase/storage';
Firebase Storage SDK 중에서도 resumable 업로드 방식을 사용하는 이유는
네트워크 환경이 불안정한 상황에서도 업로드 상태를 추적하고,
진행률을 사용자에게 안정적으로 제공하기 위함입니다.
resumable 업로드 방식: 대용량 파일 전송 중 네트워크 끊김 등 오류 발생 시 처음부터 다시 시작하지 않고 업로드가 중단된 지점부터 이어서 전송하는 기술
상품 이미지 업로드 흐름 한눈에 이해하기
export const uploadProductImages = async (
files: File[],
category: string,
productId: string,
onProgress?: (progress: number, fileName: string) => void
): Promise => {
try {
console.log('📤 Firebase Storage 업로드 시작:', {
files: files.length,
category,
productId
});
if (!storage) {
throw new Error('Firebase Storage가 초기화되지 않았습니다.');
}
const uploadPromises = files.map((file, index) => {
return new Promise((resolve, reject) => {
const categoryPath = getCategoryPath(category);
const timestamp = Date.now();
const fileExtension = file.name.split('.').pop()?.toLowerCase() || 'jpg';
const fileName = `${timestamp}_${index}.${fileExtension}`;
const filePath = `images/${categoryPath}/${productId}/${fileName}`;
const storageRef = ref(storage, filePath);
const uploadTask = uploadBytesResumable(storageRef, file);
uploadTask.on(
'state_changed',
(snapshot) => {
const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
onProgress?.(progress, file.name);
},
(error) => {
switch (error.code) {
case 'storage/unauthorized':
reject(new Error('업로드 권한이 없습니다.'));
break;
case 'storage/canceled':
reject(new Error('업로드가 취소되었습니다.'));
break;
case 'storage/quota-exceeded':
reject(new Error('저장 공간이 부족합니다.'));
break;
default:
reject(new Error(error.message));
}
},
async () => {
try {
const downloadURL = await getDownloadURL(uploadTask.snapshot.ref);
resolve(downloadURL);
} catch {
reject(new Error('업로드 완료 후 URL 생성에 실패했습니다.'));
}
}
);
});
});
const urls = await Promise.all(uploadPromises);
return urls;
} catch (error) {
throw error;
}
};
위 코드는 여러 이미지 파일을 동시에 업로드하되,
모든 업로드가 성공했을 때만 다운로드 URL 배열을 반환하도록 설계된 구조입니다.
이제 이 전체 코드를 기준으로,
실제 실행 흐름에 따라 어떤 코드가 어떤 역할을 하는지 단계별로 나누어 설명합니다.
1. 함수 시그니처와 책임 범위
export const uploadProductImages = async (
files: File[],
category: string,
productId: string,
onProgress?: (progress: number, fileName: string) => void
): Promise => {
이 함수는 여러 이미지 파일을 받아 Firebase Storage에 업로드한 뒤,
모든 업로드가 성공했을 경우에만 다운로드 URL 배열을 반환합니다.
업로드 도중 하나라도 실패하면 전체 작업은 실패로 간주됩니다.
2. Storage 초기화 여부 확인
if (!storage) {
throw new Error('Firebase Storage가 초기화되지 않았습니다.');
}
Storage 인스턴스가 초기화되지 않은 상태에서 업로드를 시도하면
이후 모든 로직이 의미를 잃게 됩니다.
따라서 함수 초입에서 즉시 실행을 중단하여
원인을 빠르게 드러내도록 처리했습니다.
3. 파일별 업로드 작업을 Promise로 분리
const uploadPromises = files.map((file, index) => {
return new Promise((resolve, reject) => {
각 파일은 서로 독립적인 업로드 작업이므로
개별 Promise로 감싸 병렬 실행이 가능하도록 구성했습니다.
이 Promise들은 이후 Promise.all을 통해 하나의 결과로 묶입니다.
4. Storage 경로와 파일명 생성
const categoryPath = getCategoryPath(category);
const timestamp = Date.now();
const fileName = `${timestamp}_${index}.${fileExtension}`;
const filePath = `images/${categoryPath}/${productId}/${fileName}`;
파일 경로는 사람이 보기 위한 값이 아니라
시스템 안정성을 위한 식별자입니다.
timestamp와 index를 조합해
동시에 업로드되는 상황에서도 파일명이 충돌하지 않도록 설계했습니다.
5. resumable 업로드와 진행률 처리
uploadTask.on(
'state_changed',
(snapshot) => {
const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
onProgress?.(progress, file.name);
},
uploadBytesResumable API는 업로드 진행 상황을 실시간으로 제공합니다.
이를 통해 UI에서는 업로드 중임을 명확히 표현할 수 있으며,
사용자는 현재 상태를 직관적으로 인지할 수 있습니다.
6. 에러 처리와 업로드 완료 처리
(error) => {
reject(new Error(error.message));
},
async () => {
const downloadURL = await getDownloadURL(uploadTask.snapshot.ref);
resolve(downloadURL);
}
업로드 중 발생한 에러는 즉시 reject 처리되어
전체 업로드 흐름이 실패하도록 설계했습니다.
반대로 업로드가 정상 완료되면
다운로드 URL을 resolve하여 상위 로직으로 전달합니다.
7. 모든 업로드 결과를 하나로 묶기
const urls = await Promise.all(uploadPromises);
return urls;
Promise.all을 사용함으로써
모든 이미지 업로드가 성공했을 때만
URL 배열을 반환하도록 보장합니다.
이 방식은 상품 데이터와 이미지 상태가
어긋나는 상황을 사전에 차단하는 역할을 합니다.
이미지 삭제 처리 구조 (deleteProductImage)
이 섹션에서는 Firebase Storage에 업로드된 이미지를
다운로드 URL 기준으로 안전하게 삭제하는 전체 흐름를
업로드 로직과 동일한 방식으로 단계별 분해하여 설명합니다.
삭제 로직 역시 단순히 deleteObject 한 줄로 끝나는 것이 아니라,
여러 방어 로직이 함께 구성되어 있습니다.
export const deleteProductImage = async (imageUrl: string): Promise => {
try {
if (!storage) {
throw new Error('Firebase Storage가 초기화되지 않았습니다.');
}
const url = new URL(imageUrl);
const pathname = url.pathname;
const match = pathname.match(/\/v0\/b\/[^/]+\/o\/(.+)/);
if (!match) {
throw new Error('유효하지 않은 Firebase Storage URL입니다');
}
const encodedPath = match[1];
const cleanEncodedPath = encodedPath.split('?')[0];
const filePath = decodeURIComponent(cleanEncodedPath);
const imageRef = ref(storage, filePath);
await deleteObject(imageRef);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('storage/object-not-found')) {
return;
}
if (error.message.includes('storage/unauthorized')) {
throw new Error('삭제 권한이 없습니다. 관리자에게 문의하세요.');
}
}
throw new Error(`이미지 삭제 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
}
};
위 코드는 이미지 삭제를 위한 전체 코드입니다.
이제 업로드 섹션과 동일하게,
이 코드를 실제 실행 순서에 따라 단계별로 나누어 설명합니다.
1. Storage 초기화 여부 확인
if (!storage) {
throw new Error('Firebase Storage가 초기화되지 않았습니다.');
}
삭제 로직 역시 Storage 인스턴스가 준비되지 않은 상태에서는
정상적으로 동작할 수 없습니다.
업로드와 동일하게 함수 초입에서 즉시 방어 로직을 둡니다.
2. 다운로드 URL 객체화 및 경로 접근
const url = new URL(imageUrl);
const pathname = url.pathname;
Firebase Storage 다운로드 URL은 단순 문자열이 아니라
표준 URL 구조를 따르므로,
URL 생성자를 통해 안전하게 pathname에 접근합니다.
3. Storage URL 패턴 검증
const match = pathname.match(/\/v0\/b\/[^/]+\/o\/(.+)/);
if (!match) {
throw new Error('유효하지 않은 Firebase Storage URL입니다');
}
모든 URL이 Firebase Storage URL이라고 가정하면 위험합니다.
패턴 검증을 통해 예상한 형태의 URL만 처리하도록 제한합니다.
4. 실제 Storage 파일 경로 복원
const encodedPath = match[1];
const cleanEncodedPath = encodedPath.split('?')[0];
const filePath = decodeURIComponent(cleanEncodedPath);
다운로드 URL에는 URL 인코딩과 쿼리 파라미터가 함께 포함됩니다.
이를 제거하고 디코딩해야만
Firebase Storage가 인식할 수 있는 실제 파일 경로가 됩니다.
5. Storage 객체 참조 생성 및 삭제
const imageRef = ref(storage, filePath);
await deleteObject(imageRef);
복원된 파일 경로를 기준으로 Storage reference를 생성한 뒤,
deleteObject API를 호출하여 실제 파일을 삭제합니다.
6. 삭제 예외를 정상 흐름으로 처리
if (error.message.includes('storage/object-not-found')) {
return;
}
이미 삭제된 파일에 대한 요청은
실패가 아닌 정상 흐름으로 처리합니다.
이는 UI 중복 이벤트나 상태 지연으로 인한
불필요한 에러 노출을 방지하기 위한 설계입니다.
7. 권한 오류와 기타 예외 처리
if (error.message.includes('storage/unauthorized')) {
throw new Error('삭제 권한이 없습니다. 관리자에게 문의하세요.');
}
권한 오류는 사용자 조치로 해결할 수 없는 영역이므로
명확한 메시지와 함께 상위 로직으로 전달합니다.
이를 통해 UI에서는 상황에 맞는 안내를 제공할 수 있습니다.
상품 전체 이미지 삭제 처리 구조 (deleteAllProductImages)
이 함수는 상품 삭제 시 호출되는 진입용 유틸 함수입니다.
실제로 모든 이미지를 즉시 삭제하는 역할이 아니라,
“이 상품에 속한 이미지들의 삭제 범위는 어디까지인가”를
명확히 정의하는 역할을 담당합니다.
export const deleteAllProductImages = async (
category: string,
productId: string
): Promise => {
try {
const categoryPath = getCategoryPath(category);
const folderPath = `images/${categoryPath}/${productId}`;
console.log('상품 폴더 삭제 요청:', folderPath);
// 실제 삭제는 서버 로직 또는 개별 이미지 삭제로 처리
} catch (error) {
console.error('상품 이미지 폴더 삭제 실패:', error);
throw error;
}
};
코드를 보면 알 수 있듯이,
이 함수 내부에서는 deleteObject와 같은
실제 삭제 API를 호출하지 않습니다.
이는 의도적인 설계이며,
클라이언트가 상품 단위 대량 삭제를 직접 수행하지 않도록
구조적으로 차단한 형태입니다.
1. 상품 단위 Storage 경로 생성
const categoryPath = getCategoryPath(category);
const folderPath = `images/${categoryPath}/${productId}`;
상품 이미지는 항상
images/{category}/{productId} 구조로 저장됩니다.
이 규칙을 기반으로,
현재 상품에 속한 이미지들의 공통 경로를 생성합니다.
이 값은 실제 삭제 대상이 아니라,
이후 삭제 전략을 판단하기 위한 기준 정보로 사용됩니다.
2. 이 함수에서 실제 삭제를 수행하지 않는 이유
// Firebase Storage Client SDK 한계
// 1. 폴더 단위 삭제 API 미제공
// 2. 경로 하위 파일 목록 조회 불가
// 3. 클라이언트 대량 삭제는 보안 위험
Firebase Storage 클라이언트 SDK는
특정 폴더 안에 어떤 파일이 있는지 조회하거나,
폴더 단위로 일괄 삭제하는 기능을 제공하지 않습니다.
또한 클라이언트에 대량 삭제 권한을 부여하는 것은
실수나 악의적인 조작으로 인한
데이터 손실 위험을 크게 증가시킵니다.
3. 실제 서비스에서의 삭제 흐름
// 방법 1: 클라이언트 기준
// DB에 저장된 imageUrls 순회
// deleteProductImage(imageUrl) 반복 호출
// 방법 2: 서버 기준
// Cloud Functions에서 folderPath 기준 일괄 삭제
일반적인 서비스에서는
상품 데이터에 저장된 이미지 URL 배열을 기준으로
deleteProductImage를 반복 호출하는 방식이 사용됩니다.
관리자 전용 기능이나 정리 작업처럼
강한 권한이 필요한 경우에는
Cloud Functions에서 폴더 경로 기준 삭제를 수행하는 것이 안전합니다.
카테고리 경로 변환 유틸 (Path ↔ Domain)
이 섹션에서는 Firebase Storage에서 사용하는 카테고리 경로 규칙을 양방향으로 변환하는 유틸을 함께 다룹니다.
Storage 경로는 시스템 안정성을 위해 영문 문자열로 고정되며,
화면(UI)이나 도메인 로직에서는 사람이 이해하기 쉬운 한글 카테고리가 사용됩니다.
이 두 세계를 연결하는 역할을 담당하는 것이 바로 아래 두 함수입니다.
1. 한글 카테고리 → Storage 경로 (getCategoryPath)
const getCategoryPath = (category: string): string => {
const categoryMap: { [key: string]: string } = {
'상의': 'tops',
'하의': 'bottoms',
'신발': 'shoes',
'액세서리': 'accessories',
'가방': 'bags',
'기타': 'others'
};
return categoryMap[category] || 'others';
};
이 함수는 상품 등록이나 이미지 업로드 시 사용됩니다.
사용자가 선택한 한글 카테고리를
Firebase Storage 경로에 안전하게 사용할 수 있는 영문 문자열로 변환합니다.
정의되지 않은 값이 들어오더라도
항상 others로 fallback 되도록 설계하여,
Storage 경로 규칙이 깨지지 않도록 방어합니다.
// 사용 예시
// uploadProductImages 내부
const categoryPath = getCategoryPath(category);
const filePath = `images/${categoryPath}/${productId}/${fileName}`;
이처럼 업로드·삭제 로직 전반에서
동일한 경로 규칙을 강제하는 기준점 역할을 합니다.
2. Storage 경로 → 한글 카테고리 (getCategoryFromPath)
export const getCategoryFromPath = (path: string): string => {
const pathMap: { [key: string]: string } = {
'tops': '상의',
'bottoms': '하의',
'shoes': '신발',
'accessories': '액세서리',
'bags': '가방',
'others': '기타'
};
return pathMap[path] || '기타';
};
이 함수는 Storage URL을 분석한 결과를
다시 도메인 값이나 UI 표시용 카테고리로 복원할 때 사용됩니다.
예를 들어 이미지 URL에서 추출한 경로 정보를
관리자 화면에 표시할 때 그대로 영문을 노출하지 않기 위해 사용합니다.
// 사용 예시
// Storage URL 파싱 이후
const { category } = parseStoragePath(imageUrl);
const displayCategory = getCategoryFromPath(category);
매핑되지 않은 값이 들어와도
항상 기타로 처리되기 때문에,
UI 단계에서 예외 분기 없이 안정적인 표시가 가능합니다.
3. 두 유틸을 함께 사용하는 이유
한글 카테고리 (UI)
↓ getCategoryPath
영문 Storage 경로 (시스템)
↓ getCategoryFromPath
한글 카테고리 (UI)
이 두 함수는 단독으로 의미를 가지기보다,
쌍(pair)으로 존재할 때 경로 규칙이 완결됩니다.
입력 → 저장 → 조회 → 표시의 전체 흐름에서
카테고리 정보가 변형되거나 어긋나지 않도록 보장하는 장치입니다.
Storage 경로 파싱 유틸 (parseStoragePath)
이 유틸 함수는 Firebase Storage 다운로드 URL을 분석하여,
해당 이미지가 어떤 카테고리와 상품 ID에 속해 있는지를 추출합니다.
이미지 삭제, 관리자 화면 표시, 데이터 복원과 같은 상황에서
URL 하나만 가지고도 메타 정보를 복원하기 위해 사용됩니다.
export const parseStoragePath = (
imageUrl: string
): { category: string; productId: string } | null => {
try {
const url = new URL(imageUrl);
const pathname = url.pathname;
const match = pathname.match(/\/v0\/b\/[^/]+\/o\/images%2F([^%]+)%2F([^%]+)%2F/);
if (!match) {
return null;
}
const categoryPath = match[1];
const productId = match[2];
return {
category: getCategoryFromPath(categoryPath),
productId
};
} catch (error) {
console.error('Storage 경로 파싱 실패:', error);
return null;
}
};
이 함수는 URL이 항상 올바른 형태라고 가정하지 않고,
파싱에 실패할 경우 null을 반환하도록 설계되었습니다.
이를 통해 상위 로직에서는
“파싱 성공 / 실패”를 명확하게 분기 처리할 수 있습니다.
1. 다운로드 URL을 객체로 변환하는 이유
const url = new URL(imageUrl);
const pathname = url.pathname;
Firebase Storage 다운로드 URL은
단순 문자열이 아닌 표준 URL 구조를 따릅니다.
URL 객체를 사용하면
쿼리 스트링이나 도메인과 분리된
pathname에 안전하게 접근할 수 있습니다.
2. Storage URL 패턴 기반 경로 추출
const match = pathname.match(
/\/v0\/b\/[^/]+\/o\/images%2F([^%]+)%2F([^%]+)%2F/
);
Firebase Storage 다운로드 URL은
/v0/b/{bucket}/o/ 뒤에
URL 인코딩된 실제 파일 경로가 포함됩니다.
이 정규식을 통해
images/{category}/{productId} 구조를 전제로
카테고리와 상품 ID를 안전하게 분리합니다.
3. 경로 값 복원 및 도메인 변환
return {
category: getCategoryFromPath(categoryPath),
productId
};
추출된 카테고리 경로는
Storage 내부 식별자인 영문 문자열이므로,
getCategoryFromPath를 통해
다시 한글 카테고리명으로 변환합니다.
이를 통해 UI나 도메인 로직에서는
Storage 구조를 직접 알 필요가 없도록 분리합니다.
4. 파싱 실패를 예외로 처리하지 않는 이유
if (!match) {
return null;
}
모든 이미지 URL이
항상 현재 서비스의 Storage 규칙을 따를 것이라고
가정하는 것은 위험합니다.
따라서 파싱 실패는 예외가 아닌
정상적인 흐름 중 하나로 취급하여,
상위 로직에서 유연하게 대응할 수 있도록 설계했습니다.
파일 크기 포맷 유틸 (formatFileSize)
이 유틸 함수는 파일 크기(Byte 단위)를
사람이 읽기 쉬운 문자열 형태로 변환하는 역할을 합니다.
이미지 업로드 전 검증 단계나,
업로드 실패 시 사용자에게 파일 크기를 안내할 때 주로 사용됩니다.
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
이 함수는 단순히 숫자를 나누는 방식이 아니라,
로그 계산을 통해 현재 파일 크기에 적합한 단위를 자동으로 선택합니다.
이를 통해 1024 Bytes는 1 KB로,
1048576 Bytes는 1 MB와 같은 형태로 자연스럽게 표현됩니다.
1. 0 Bytes를 별도로 처리하는 이유
if (bytes === 0) return '0 Bytes';
로그 계산은 0에 대해 정의되지 않기 때문에,
파일 크기가 0인 경우를 별도로 처리하지 않으면
계산 과정에서 오류가 발생할 수 있습니다.
따라서 가장 앞단에서 즉시 반환하도록 구성했습니다.
2. 단위 기준을 1024로 사용하는 이유
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
파일 크기는 이진 단위 기준으로 계산되므로,
1000이 아닌 1024를 기준으로 단위를 나눕니다.
이를 통해 운영체제와 브라우저에서 인식하는
파일 크기 기준과 동일한 결과를 제공합니다.
3. 업로드 검증 로직과의 연결 지점
// 사용 예시
if (isFileSizeExceeded(file, 5)) {
alert(`파일 크기 초과 (${formatFileSize(file.size)})`);
}
이 유틸은 파일 검증 로직과 함께 사용될 때 가장 큰 효과를 발휘합니다.
단순히 “용량 초과”라는 메시지를 보여주는 대신,
실제 파일 크기를 함께 표시함으로써
사용자에게 보다 명확한 피드백을 제공할 수 있습니다.
파일 검증 유틸 묶음 (validateImageFiles)
이 섹션에서는 이미지 업로드 전에 수행되는 파일 검증 전용 유틸 함수들을 하나의 흐름으로 묶어 설명합니다.
이 유틸들은 실제 업로드 로직보다 앞단에서 실행되며,
잘못된 파일이 Firebase Storage로 전달되는 것을 사전에 차단하는 역할을 합니다.
export const isImageFile = (file: File): boolean => {
return file.type.startsWith('image/');
};
export const isFileSizeExceeded = (
file: File,
maxSizeMB: number = 5
): boolean => {
const maxSizeBytes = maxSizeMB * 1024 * 1024;
return file.size > maxSizeBytes;
};
export const validateImageFiles = (
files: File[]
): { valid: File[]; errors: string[] } => {
const valid: File[] = [];
const errors: string[] = [];
files.forEach((file) => {
if (!isImageFile(file)) {
errors.push(`${file.name}은(는) 이미지 파일이 아닙니다.`);
return;
}
if (isFileSizeExceeded(file)) {
errors.push(`${file.name}의 크기가 5MB를 초과합니다. (${formatFileSize(file.size)})`);
return;
}
valid.push(file);
});
return { valid, errors };
};
1. 이미지 MIME 타입 검증 (isImageFile)
file.type.startsWith('image/');
이 코드는 파일 확장자가 아닌 MIME 타입을 기준으로
이미지 파일 여부를 판단합니다.
단순 확장자 검사보다 신뢰도가 높으며,
브라우저가 인식한 실제 파일 타입을 기준으로 검증합니다.
2. 파일 크기 제한 검사 (isFileSizeExceeded)
const maxSizeBytes = maxSizeMB * 1024 * 1024;
return file.size > maxSizeBytes;
이 함수는 MB 단위로 받은 제한 값을
실제 파일 크기 비교가 가능한 Byte 단위로 변환한 뒤,
업로드 허용 여부를 판단합니다.
기본값을 5MB로 두어,
호출부에서 별도 설정 없이도 안전한 제한이 적용되도록 설계했습니다.
3. 업로드 전 파일 검증 파이프라인 (validateImageFiles)
1. 선택된 파일 목록 순회
2. 이미지 파일 여부 검사
3. 파일 크기 제한 검사
4. 통과한 파일은 valid 배열로 분리
5. 실패 사유는 errors 배열에 누적
이 함수는 단순히 true / false를 반환하지 않고,
업로드 가능한 파일 목록과
사용자에게 보여줄 에러 메시지 목록을 함께 반환합니다.
이를 통해 UI에서는 실패한 파일을 명확히 안내하면서도,
성공한 파일만 업로드 흐름으로 자연스럽게 연결할 수 있습니다.
// 사용 예시
const { valid, errors } = validateImageFiles(files);
if (errors.length > 0) {
alert(errors.join('
'));
}
uploadProductImages(valid, category, productId);
이 구조를 사용하면
업로드 함수 내부에서 파일 검증을 반복할 필요가 없어지고,
업로드 로직은 오직 “정상 파일 업로드”라는 본래 책임에만 집중할 수 있습니다.
이미지 미리보기 URL 유틸 (createPreviewUrl / revokePreviewUrl)
이 섹션에서는 이미지 업로드 UX를 보조하기 위한 미리보기 URL 관리 유틸을 설명합니다.
실제 업로드 이전 단계에서 사용자가 선택한 이미지를 화면에 즉시 보여주기 위해,
브라우저에서 제공하는 URL.createObjectURL API를 안전하게 래핑한 구조입니다.
export const createPreviewUrl = (file: File): string => {
return URL.createObjectURL(file);
};
export const revokePreviewUrl = (url: string): void => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
};
이 두 함수는 항상 쌍(pair)으로 사용됩니다.
하나는 미리보기를 생성하고,
다른 하나는 더 이상 필요 없는 시점에 메모리를 해제하는 역할을 담당합니다.
1. 이미지 미리보기 URL 생성 (createPreviewUrl)
URL.createObjectURL(file);
이 코드는 파일 객체(File)를 기반으로
브라우저 내부에서만 유효한 임시 URL(blob:)을 생성합니다.
이 URL은 네트워크 요청 없이 즉시 이미지 미리보기를 렌더링할 수 있기 때문에,
업로드 전 UX를 크게 개선하는 데 사용됩니다.
// 사용 예시
const previewUrl = createPreviewUrl(file);
;
이 방식은 Firebase Storage 업로드와는 무관하며,
오직 클라이언트 로컬 환경에서만 동작합니다.
2. 미리보기 URL 해제 (revokePreviewUrl)
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
URL.createObjectURL로 생성된 URL은
명시적으로 해제하지 않으면 메모리에 계속 남아 있습니다.
따라서 이미지 미리보기가 더 이상 필요 없는 시점에
반드시 revokeObjectURL을 호출해 주어야 합니다.
// 사용 예시
useEffect(() => {
return () => {
revokePreviewUrl(previewUrl);
};
}, [previewUrl]);
특히 이미지 미리보기가 많은 관리자 화면이나,
파일 선택을 반복하는 UI에서는
이 해제 로직이 누락되면 메모리 누수가 누적될 수 있습니다.
3. 업로드 흐름에서의 위치
파일 선택
→ createPreviewUrl (미리보기 표시)
→ validateImageFiles (검증)
→ uploadProductImages (업로드)
→ revokePreviewUrl (미리보기 해제)
이처럼 미리보기 유틸은
업로드 로직의 일부가 아니라,
업로드 이전 UX 단계를 담당하는 보조 도구입니다.
업로드 성공 여부와 관계없이
UI 생명주기에 맞춰 명확히 관리되는 것이 중요합니다.
FAQ
Q. 이미지 업로드를 컴포넌트에서 바로 처리하지 않고 유틸로 분리한 이유는 무엇인가요?
이미지 업로드는 네트워크 요청, 진행률 관리, 에러 분기, 경로 규칙 등 고려할 요소가 매우 많습니다.
이를 컴포넌트 내부에 직접 작성하면 UI 코드가 비대해지고 재사용이 어려워집니다.
따라서 Storage 관련 로직은 하나의 유틸 파일로 분리하고,
컴포넌트는 오직 사용자 인터랙션과 화면 상태 관리에만 집중하도록 설계했습니다.
Q. 여러 이미지를 Promise.all로 병렬 업로드해도 문제가 없나요?
각 이미지 업로드는 서로 독립적인 작업이므로 병렬 처리에 적합합니다.
Promise.all을 사용하면 모든 업로드가 성공했을 때만 결과를 반환할 수 있어,
상품 데이터와 이미지 상태가 어긋나는 상황을 사전에 방지할 수 있습니다.
다만 대용량 파일이 많을 경우에는 동시 업로드 개수 제한을 두는 전략을 추가로 고려할 수 있습니다.
Q. 이미지 삭제 시 이미 존재하지 않는 파일을 에러로 처리하지 않는 이유는 무엇인가요?
UI 중복 클릭이나 상태 지연으로 인해 이미 삭제된 파일에 대해 다시 삭제 요청이 들어오는 경우는
실제 서비스에서 충분히 발생할 수 있습니다.
이를 실패로 처리하면 사용자 경험이 불필요하게 나빠지기 때문에,
존재하지 않는 파일은 정상 흐름으로 간주하도록 설계했습니다.
Q. 상품 전체 이미지 삭제를 클라이언트에서 바로 처리하지 않는 이유는 무엇인가요?
Firebase Storage 클라이언트 SDK는 폴더 단위 삭제와 파일 목록 조회를 지원하지 않습니다.
또한 클라이언트에 대량 삭제 권한을 부여하는 것은 보안 사고로 이어질 수 있습니다.
따라서 상품 단위 삭제는 개별 이미지 URL 기반으로 처리하거나,
Cloud Functions와 같은 서버 권한 로직으로 위임하는 것이 안전한 선택입니다.
Q. 카테고리 경로를 문자열로 고정해서 사용하는 이유는 무엇인가요?
Firebase Storage 경로는 시스템 내부 식별자로 사용되기 때문에,
한글이나 자유 입력 문자열을 그대로 사용하면 URL 인코딩 문제나 환경별 해석 차이가 발생할 수 있습니다.
카테고리를 코드 레벨에서 허용된 영문 값으로 제한함으로써,
업로드·삭제·파싱 로직 전반에서 일관성과 예측 가능성을 확보할 수 있습니다.
Q. 이미지 미리보기 URL을 반드시 해제해야 하나요?
URL.createObjectURL로 생성된 미리보기 URL은 명시적으로 해제하지 않으면
브라우저 메모리에 계속 남아 있습니다.
특히 관리자 페이지처럼 파일 선택이 반복되는 화면에서는
메모리 누수가 누적될 수 있으므로,
미리보기가 더 이상 필요 없는 시점에 revokeObjectURL을 호출하는 것이 중요합니다.