Firebase Storage 파일 업로드 완벽 마스터하기 - 실전 코드로 배우는 업로드 전략
Firebase Storage 파일 업로드 완벽 마스터하기
실전 코드로 배우는 업로드 전략
Angular + Ionic 프로젝트에서 Firebase Storage로 이미지, PDF, 엑셀 파일을 업로드하는 방법을 실제 프로덕션 코드와 함께 설명한다. 단순 업로드부터 에러 처리, 파일명 관리, 다운로드 URL 생성까지 모든 것을 다룬다.
목차
1. Firebase Storage 초기 설정
Firebase Storage를 사용하기 전에 프로젝트에 Storage 모듈을 초기화해야 한다.
1.1 AppModule 설정
app.module.ts에서 Storage를 초기화한다:
import { NgModule } from '@angular/core';
import { provideStorage, getStorage } from '@angular/fire/storage';
@NgModule({
imports: [
// ... other imports
provideStorage(() => getStorage()),
],
})
export class AppModule {}
1.2 Firebase Console에서 Storage 활성화
- 1Firebase Console 접속
- 2좌측 메뉴에서 "Storage" 선택
- 3"시작하기" 클릭
- 4보안 규칙 모드 선택 (테스트 모드 또는 프로덕션 모드)
- 5위치 선택 (가장 가까운 리전 선택)
2. 기본 파일 업로드 구현
가장 간단한 파일 업로드부터 시작한다.
2.1 기본 업로드 코드
컴포넌트에서 직접 업로드하는 방법:
import { Component } from '@angular/core';
import { Storage, ref, uploadBytes, getDownloadURL } from '@angular/fire/storage';
@Component({
selector: 'app-upload',
template: `
<input type="file" (change)="onFileSelected($event)" accept="image/*">
<button (click)="upload()">업로드</button>
`,
})
export class UploadComponent {
selectedFile: File | null = null;
constructor(private storage: Storage) {}
onFileSelected(event: any) {
this.selectedFile = event.target.files[0];
}
async upload() {
if (!this.selectedFile) return;
// 1. Storage 참조 생성
const storageRef = ref(this.storage, `images/${Date.now()}_${this.selectedFile.name}`);
// 2. 파일 업로드
const snapshot = await uploadBytes(storageRef, this.selectedFile);
// 3. 다운로드 URL 가져오기
const downloadURL = await getDownloadURL(snapshot.ref);
console.log('업로드 완료:', downloadURL);
}
}
2.2 업로드 과정 설명
- Storage 참조 생성:
ref()로 업로드할 경로를 지정한다. - 파일 업로드:
uploadBytes()로 실제 파일을 업로드한다. - URL 가져오기:
getDownloadURL()로 공개 접근 가능한 URL을 가져온다.
ref()의 경로는 bucket/경로/파일명 형식이다. 경로에 폴더 구조를 만들 수 있다.3. 실전 업로드 서비스 구현
실제 프로덕션 환경에서 사용하는 서비스 클래스를 구현한다.
3.1 ImageService 구현
재사용 가능한 업로드 서비스를 만든다:
import { Injectable } from '@angular/core';
import { Storage, ref, uploadBytes, getDownloadURL } from '@angular/fire/storage';
@Injectable({
providedIn: 'root',
})
export class ImageService {
constructor(private storage: Storage) {}
/**
* 파일 업로드 (기본)
* @param parentPath 저장할 경로 (예: 'images', 'documents')
* @param fileNameSwitch true면 원본 파일명도 반환
* @returns 다운로드 URL 또는 {url, fileName} 객체
*/
fileUpload(
parentPath: string,
fileNameSwitch?: boolean
): Promise<string | any> {
return new Promise((resolve) => {
// 파일 선택 input 생성
let inputElement = document.createElement('input');
inputElement.type = 'file';
inputElement.accept = '.png, .jpg, .pdf, .jpeg';
inputElement.onchange = (event: any) => {
if (!event.target.files || event.target.files.length === 0) {
resolve(null);
return;
}
const file = event.target.files[0];
// 파일 확장자 추출
let extensionSlashIndex = event.target.value.lastIndexOf('.');
const extension = event.target.value.substring(extensionSlashIndex + 1);
// 고유한 파일명 생성
const dbfileName = this.generateFilename() + `.${extension}`;
// Storage 참조 생성
const storageRef = ref(this.storage, `${parentPath}/${dbfileName}`);
if (!fileNameSwitch) {
// URL만 반환
uploadBytes(storageRef, file).then((snapshot) => {
resolve(
`https://storage.googleapis.com/${snapshot.metadata.bucket}/${snapshot.metadata.fullPath}`
);
});
} else {
// URL과 원본 파일명 반환
const fileNameIndex = event.target.value.lastIndexOf('\\');
let fileName = event.target.value.substring(fileNameIndex + 1);
uploadBytes(storageRef, file).then((snapshot) => {
resolve({
url: `https://storage.googleapis.com/${snapshot.metadata.bucket}/${snapshot.metadata.fullPath}`,
fileName: fileName,
});
});
}
inputElement = null;
};
// 파일 선택 다이얼로그 열기
inputElement.click();
});
}
/**
* 고유한 파일명 생성
*/
private generateFilename(): string {
return `${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
}
}
3.2 사용 예제
컴포넌트에서 서비스를 사용하는 방법:
import { Component } from '@angular/core';
import { ImageService } from './core/services/image.service';
@Component({
selector: 'app-profile',
template: `
<button (click)="uploadImage()">이미지 업로드</button>
<img [src]="imageUrl" *ngIf="imageUrl">
`,
})
export class ProfileComponent {
imageUrl: string = '';
constructor(private imageService: ImageService) {}
async uploadImage() {
const url = await this.imageService.fileUpload('profile-images');
if (url) {
this.imageUrl = url as string;
console.log('업로드된 이미지 URL:', url);
}
}
}
4. 다양한 업로드 시나리오
실제 프로젝트에서 자주 사용하는 업로드 패턴들을 살펴본다.
4.1 PDF 파일 업로드
문서 파일을 업로드하는 경우:
// pdf 파일을 업로드하기
selectFile(type: string) {
const inputElement = document.createElement('input');
inputElement.type = 'file';
inputElement.accept = '.pdf, .xls, .xlsx, .jpg, .png, .jpeg';
inputElement.click();
inputElement.onchange = async (event: any) => {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0];
const lastSlashIndex = event.target.value.lastIndexOf('\\');
const name = event.target.value.substring(lastSlashIndex + 1);
// Storage 참조 생성
const storageRef = ref(
this.storage,
`${type}/${this.commonService.generateFilename()}`
);
this.loadingService.load();
uploadBytes(storageRef, file).then((snapshot) => {
// 잘못된 파일 타입 체크
if (snapshot.metadata.contentType === 'application/octet-stream') {
this.loadingService.hide();
console.error('지원하지 않는 파일 형식입니다.');
return;
}
this.loadingService.hide();
// 업로드된 파일 정보 저장
this.applications[type].push({
type: 'file',
name,
src: `https://storage.googleapis.com/${snapshot.metadata.bucket}/${snapshot.metadata.fullPath}`,
});
});
}
};
}
4.2 Base64 이미지 업로드
카메라나 갤러리에서 가져온 Base64 이미지를 업로드하는 경우:
base64SaveStorage(base64: string, parentPath: string): Promise<string> {
return new Promise((resolve) => {
// Base64를 Blob으로 변환
const byteCharacters = atob(base64.split(',')[1]);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'image/jpeg' });
// 파일명 생성
const fileName = this.generateFilename() + '.jpg';
const storageRef = ref(this.storage, `${parentPath}/${fileName}`);
uploadBytes(storageRef, blob).then((snapshot) => {
const url = snapshot.metadata.fullPath;
resolve(url);
}).catch((error) => {
console.error('업로드 실패:', error);
resolve('');
});
});
}
4.3 다중 파일 업로드
여러 파일을 한 번에 업로드하는 경우:
async uploadMultipleFiles(files: File[], parentPath: string): Promise<string[]> {
const uploadPromises = files.map((file) => {
const fileName = `${Date.now()}_${file.name}`;
const storageRef = ref(this.storage, `${parentPath}/${fileName}`);
return uploadBytes(storageRef, file).then((snapshot) => {
return `https://storage.googleapis.com/${snapshot.metadata.bucket}/${snapshot.metadata.fullPath}`;
});
});
return Promise.all(uploadPromises);
}
4.4 업로드 진행률 표시
대용량 파일 업로드 시 진행률을 표시하는 경우:
import { uploadBytesResumable } from '@angular/fire/storage';
async uploadWithProgress(file: File, parentPath: string) {
const fileName = `${Date.now()}_${file.name}`;
const storageRef = ref(this.storage, `${parentPath}/${fileName}`);
const uploadTask = uploadBytesResumable(storageRef, file);
uploadTask.on('state_changed',
(snapshot) => {
// 진행률 계산
const progress = (snapshot.bytesTransferred / snapshot.totalBytes) * 100;
console.log(`Upload is ${progress}% done`);
switch (snapshot.state) {
case 'paused':
console.log('Upload is paused');
break;
case 'running':
console.log('Upload is running');
break;
}
},
(error) => {
console.error('Upload error:', error);
},
() => {
// 업로드 완료
getDownloadURL(uploadTask.snapshot.ref).then((downloadURL) => {
console.log('File available at', downloadURL);
});
}
);
}
5. 에러 처리와 최적화
실제 프로덕션 환경에서 필요한 에러 처리와 최적화 기법을 설명한다.
5.1 에러 처리
업로드 실패 시 적절한 에러 처리를 추가한다:
async uploadFile(file: File, parentPath: string): Promise<string | null> {
try {
// 파일 크기 체크 (예: 10MB 제한)
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
throw new Error('파일 크기가 10MB를 초과합니다.');
}
// 파일 타입 체크
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];
if (!allowedTypes.includes(file.type)) {
throw new Error('지원하지 않는 파일 형식입니다.');
}
const fileName = `${Date.now()}_${file.name}`;
const storageRef = ref(this.storage, `${parentPath}/${fileName}`);
const snapshot = await uploadBytes(storageRef, file);
const downloadURL = await getDownloadURL(snapshot.ref);
return downloadURL;
} catch (error: any) {
console.error('업로드 실패:', error);
// 에러 타입에 따른 처리
if (error.code === 'storage/unauthorized') {
console.error('권한이 없습니다.');
} else if (error.code === 'storage/canceled') {
console.error('업로드가 취소되었습니다.');
} else if (error.code === 'storage/unknown') {
console.error('알 수 없는 오류가 발생했습니다.');
}
return null;
}
}
5.2 파일명 최적화
파일명에 특수문자가 포함되어 있을 수 있으므로 정리한다:
private sanitizeFileName(fileName: string): string {
// 특수문자 제거 및 공백을 언더스코어로 변경
return fileName
.replace(/[^a-zA-Z0-9._-]/g, '_')
.replace(/\s+/g, '_')
.toLowerCase();
}
private generateFilename(originalName?: string): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 15);
if (originalName) {
const sanitized = this.sanitizeFileName(originalName);
return `${timestamp}_${random}_${sanitized}`;
}
return `${timestamp}_${random}`;
}
5.3 이미지 리사이징 (선택사항)
대용량 이미지를 업로드하기 전에 리사이징하면 저장 공간과 대역폭을 절약할 수 있다:
private resizeImage(file: File, maxWidth: number, maxHeight: number): Promise<Blob> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e: any) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
// 비율 유지하면서 리사이징
if (width > height) {
if (width > maxWidth) {
height *= maxWidth / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width *= maxHeight / height;
height = maxHeight;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
resolve(blob!);
}, 'image/jpeg', 0.8);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
6. 보안 규칙 설정
프로덕션 환경에서는 반드시 Storage 보안 규칙을 설정해야 한다.
6.1 기본 보안 규칙
Firebase Console에서 Storage 보안 규칙을 설정한다:
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
// 인증된 사용자만 업로드 가능
match /{allPaths=**} {
allow read: if request.auth != null;
allow write: if request.auth != null
&& request.resource.size < 10 * 1024 * 1024 // 10MB 제한
&& request.resource.contentType.matches('image/.*|application/pdf');
}
// 공개 읽기, 인증된 사용자만 쓰기
match /public/{allPaths=**} {
allow read: if true;
allow write: if request.auth != null;
}
// 사용자별 폴더 (자신의 파일만 접근)
match /users/{userId}/{allPaths=**} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
}
}
6.2 파일 타입 제한
특정 파일 타입만 업로드 가능하도록 제한:
match /images/{allPaths=**} {
allow write: if request.auth != null
&& request.resource.contentType.matches('image/.*')
&& request.resource.size < 5 * 1024 * 1024; // 5MB 제한
}
6.3 파일명 검증
파일명 패턴을 검증하는 규칙:
match /documents/{fileName} {
allow write: if request.auth != null
&& fileName.matches('^[a-zA-Z0-9_-]+\\.[a-zA-Z0-9]+$'); // 특수문자 제한
}
마무리
Firebase Storage를 사용한 파일 업로드는 간단해 보이지만, 실제 프로덕션 환경에서는 에러 처리, 보안, 최적화 등 많은 고려사항이 있다.
이 가이드에서 설명한 패턴들을 참고해서 프로젝트에 맞게 커스터마이징하자. 특히 보안 규칙은 반드시 설정하고, 파일 크기와 타입을 검증하는 것이 중요하다.
ref()로 Storage 참조 생성,uploadBytes()로 업로드- 고유한 파일명 생성으로 중복 방지
- 에러 처리와 파일 검증 필수
- 보안 규칙으로 접근 제어
- 대용량 파일은 진행률 표시 고려