사용자 프로필을 구현할 때 가장 흔한 실수 중 하나는
프로필 이미지 업로드 로직을 Firestore 사용자 저장 로직과 섞어버리는 것이다.
이 글에서는 Firebase를 사용하는 iOS 앱에서
프로필 이미지 업로드를 왜 별도의 계층으로 분리했는지,
그리고 그 결과물인 ProfileImageUploading 프로토콜과
FirebaseProfileImageUploader 구현체를 어떻게 설계했는지를 정리한다.
1. 문제 정의: 프로필 데이터에는 두 종류가 있다
사용자 프로필을 구성하는 데이터는 크게 두 가지로 나뉜다.
1️⃣ 구조화된 데이터
- 사용자 이름
- 프로필 이미지 경로
- 기타 메타 정보
👉 Firestore에 저장
2️⃣ 바이너리 데이터
- 프로필 이미지 파일 (UIImage)
👉 Firebase Storage에 저장
이 둘을 같은 클래스에서 처리하기 시작하면 문제가 생긴다.
- Firestore 로직과 Storage 로직이 섞인다
- 클래스 책임이 과도해진다
- 테스트가 어려워진다
- 수정/삭제 시 로직이 꼬인다
그래서 이 프로젝트에서는 명확한 책임 분리를 선택했다.
2. 설계 목표
프로필 이미지 업로드 계층의 설계 목표는 다음과 같다.
- Firestore(User 데이터)와 Storage(Image 데이터) 책임 분리
- ViewModel은 “이미지 업로드 결과”만 사용
- Storage 구현체 교체 가능
- 수정/삭제 로직 확장 용이
이 목표를 만족하기 위해
프로토콜 + 구현체 구조를 사용했다.
3. ProfileImageUploading 프로토콜
먼저 “무엇을 할 수 있는지”를 정의한다.
protocol ProfileImageUploading {
func uploadProfileImage(
_ image: UIImage,
uid: String
) async throws -> String
func deleteProfileImage(
imagePath: String
) async throws
}
이 프로토콜의 의미
- 어디에 저장되는지는 숨긴다
- ViewModel은 결과(path)만 받는다
- Firestore 저장 여부는 전혀 관여하지 않는다
즉, 이 프로토콜은
“프로필 이미지를 저장하고, 그 결과를 돌려주는 역할”
만을 정의한다.
4. 왜 다운로드 URL이 아니라 path를 반환하는가?
Firebase Storage는 업로드 후 다운로드 URL을 제공한다.
하지만 이 프로젝트에서는 URL을 저장하지 않았다.
대신 다음과 같은 형태의 Storage path를 사용한다.
profile_images/{uid}.jpg
이유는 명확하다.
- 다운로드 URL에는 토큰이 포함된다
- 보안 규칙 변경 시 URL이 무효화될 수 있다
- 이미지 교체/삭제 시 관리가 복잡해진다
반면 path를 저장하면:
- 필요할 때 언제든 URL 재생성 가능
- 이미지 교체/삭제 로직 단순
- 보안 정책 변경에 유연
👉 실무에서도 path 저장 방식이 훨씬 안정적이다.
5. FirebaseProfileImageUploader 구현체
이제 실제 Firebase Storage를 사용하는 구현체를 살펴보자.
핵심 역할
- UIImage → JPEG 데이터 변환
- Firebase Storage 업로드
- 업로드된 이미지의 path 반환
- 기존 이미지 삭제
final class FirebaseProfileImageUploader: ProfileImageUploading
이 클래스는 Storage만 안다.
- Firestore ❌
- UserRepository ❌
- ViewModel ❌
6. 비동기 모델: async/await + throws
프로필 이미지 업로드와 삭제는 모두 1회성 작업이다.
- 스트림 ❌
- 실시간 감지 ❌
- 상태 관찰 ❌
그래서 completion handler나 Combine 대신
async/await + throws를 사용했다.
try await uploader.uploadProfileImage(image, uid: uid)
성공 / 실패 기준
- 성공 → 정상 종료
- 실패 → throw
UI에서의 Toast, Alert 처리는
반드시 ViewModel에서 담당한다.
Storage 계층이 UI를 알면 안 된다.
7. 이미지 수정은 “삭제 → 업로드”로 처리한다
프로필 이미지 수정 로직은 단순하게 설계했다.
기존 이미지 삭제
↓
새 이미지 업로드
↓
Firestore 사용자 정보 업데이트
이 방식의 장점:
- 캐시/URL 꼬임 없음
- 상태 전이가 명확
- 실패 지점이 분명
Firebase Storage 공식 문서에서도
파일 삭제 후 재업로드 방식을 권장한다.
8. ViewModel에서의 조합 방식
중요한 점은
ProfileImageUploader와 UserRepository를 ViewModel에서 조합한다는 것이다.
ProfileViewModel
├─ ProfileImageUploading
└─ UserRepository
- 이미지 업로드 → path 획득
- path를 포함한 PulseUser 생성
- Firestore 저장
각 계층은 자기 책임만 수행한다.
9. 이번 단계에서 꼭 기억해야 할 포인트
✔ Storage와 Firestore는 절대 섞지 않는다
✔ 이미지 업로드 결과는 path로 관리한다
✔ 삭제 성공 여부를 Bool로 반환하지 않는다
✔ UI 처리는 ViewModel에서만 한다
✔ “조합”은 ViewModel의 책임이다
10. 마무리
FirebaseProfileImageUploader는
단순히 이미지를 올리는 클래스가 아니다.
- 프로필 아키텍처의 책임 경계를 명확히 하고
- 수정/삭제/탈퇴까지 자연스럽게 확장 가능하며
- ViewModel이 비즈니스 흐름에만 집중할 수 있게 만드는
프로필 시스템의 중요한 구성 요소다.
✅ 전체 코드
import Foundation
import UIKit
// MARK: - ProfileImageUploading
/// 사용자 프로필 이미지 업로드 및 삭제 기능을 추상화한 프로토콜입니다.
///
/// 이 프로토콜은:
/// - 이미지 저장 방식(Firebase Storage 등)을 숨기고
/// - ViewModel이 이미지 업로드/삭제 결과만 사용할 수 있도록 설계되었습니다.
///
/// ## 책임 범위
/// - 프로필 이미지 업로드
/// - 업로드된 이미지의 Storage 경로 반환
/// - 기존 프로필 이미지 삭제
///
/// ## 설계 원칙
/// - Firestore(User 데이터)와 분리된 책임
/// - UI 로직(ViewController/ViewModel)과 분리
/// - 향후 다른 Storage 구현체로 교체 가능
///
/// ## Note
/// - 이 프로토콜은 이미지 업로드 결과(path)만 제공하며,
/// Firestore에 사용자 정보를 저장하는 책임은
/// `UserRepository`가 담당합니다.
protocol ProfileImageUploading {
// MARK: - uploadProfileImage
/// 프로필 이미지를 Firebase Storage에 업로드합니다.
///
/// - Parameters:
/// - image: 업로드할 UIImage
/// - uid: 사용자 uid (파일명으로 사용)
///
/// - Returns: Storage에 저장된 이미지의 path
/// - Throws:
/// - 이미지 변환 실패 또는 업로드 실패 에러
func uploadProfileImage(
_ image: UIImage,
uid: String
) async throws -> String
// MARK: - deleteProfileImage
/// 기존 프로필 이미지를 Firebase Storage에서 삭제합니다.
///
/// - Parameter imagePath: 삭제할 이미지의 Storage path
///
/// - Throws:
/// - Storage 삭제 실패 시 에러
func deleteProfileImage(
imagePath: String
) async throws
}
import Foundation
import UIKit
import FirebaseStorage
// MARK: - FirebaseProfileImageUploader
/// Firebase Storage를 사용하여 사용자 프로필 이미지를 업로드 및 삭제하는 구현체입니다.
///
/// 이 클래스의 책임은 다음과 같습니다:
/// - `UIImage`를 Firebase Storage에 업로드
/// - 업로드된 이미지의 **Storage 경로(path)** 를 반환
/// - 기존 프로필 이미지 삭제
///
/// ## 설계 의도
/// - Firestore(User 데이터)와 Storage(Image 데이터)의 책임을 분리하기 위함
/// - 이미지 업로드 로직이 `UserRepository`와 섞이지 않도록 분리
/// - ViewModel에서 이미지 업로드 결과(path)를 받아
/// `PulseUser.profileImagePath`로 Firestore에 저장하도록 설계
///
/// ## 중요한 특징
/// - 다운로드 URL을 직접 반환하지 않음
/// - 대신 **Storage path를 반환**하여:
/// - 보안 규칙 변경에 유연
/// - 이미지 교체/삭제 로직 단순화
///
/// ## 사용 흐름 예시
/// 1. ViewModel에서 이미지 선택
/// 2. `uploadProfileImage(_:uid:)` 호출
/// 3. 반환된 path를 포함하여 `PulseUser` 생성
/// 4. `FirestoreUserRepository.createUser()` 호출
///
/// ## Note
/// - 이미지 업로드/삭제 실패에 대한 UI 처리(Toast 등)는
/// 이 클래스가 아닌 **ViewModel의 책임**입니다.
final class FirebaseProfileImageUploader: ProfileImageUploading {
// MARK: - Properties
/// Firebase Storage 인스턴스
private let storage: Storage
/// 프로필 이미지가 저장되는 기본 경로
///
/// 예: `profile_images/{uid}.jpg`
private let basePath: String = "profile_images"
// MARK: - Initializer
/// FirebaseProfileImageUploader를 생성합니다.
///
/// - Parameter storage: 사용할 Firebase Storage 인스턴스
/// (테스트 및 의존성 주입을 위해 외부에서 주입 가능)
init(
storage: Storage = Storage.storage()
) {
self.storage = storage
}
// MARK: - ProfileImageUploading
/// 사용자 프로필 이미지를 Firebase Storage에 업로드합니다.
///
/// - Parameters:
/// - image: 업로드할 프로필 이미지
/// - uid: 사용자 uid (파일명으로 사용됨)
///
/// - Returns: Firebase Storage에 저장된 이미지의 path
/// (예: `profile_images/abc123.jpg`)
///
/// - Throws:
/// - `ProfileImageUploadError.failedToConvertImage`:
/// UIImage를 JPEG 데이터로 변환하지 못한 경우
/// - Firebase Storage 업로드 실패 에러
///
/// ## 동작 과정
/// 1. UIImage를 JPEG 데이터로 변환
/// 2. `profile_images/{uid}.jpg` 경로 생성
/// 3. Firebase Storage에 이미지 업로드
/// 4. 업로드 성공 시 Storage path 반환
func uploadProfileImage(_ image: UIImage, uid: String) async throws -> String {
// UIImage -> JPEG Data 변환
guard let imageDataa = image.jpegData(compressionQuality: 0.8) else {
throw ProfileImageUploadError.failedToConvertImage
}
// Storage 경로 생성
let imagePath = "\(basePath)/\(uid).jpg"
let ref = storage.reference(withPath: imagePath)
// 메타데이터 설정
let metadata = StorageMetadata()
metadata.contentType = "image/jpeg"
// 업로드
_ = try await ref.putDataAsync(imageDataa, metadata: metadata)
// Storage path 반환
return imagePath
}
// MARK: - deleteProfileImage
/// Firebase Storage에 저장된 기존 프로필 이미지를 삭제합니다.
///
/// - Parameter imagePath: 삭제할 이미지의 Storage path
///
/// - Throws:
/// - Firebase Storage 삭제 실패 에러
///
/// ## Note
/// - 주로 프로필 이미지 수정 시
/// (기존 이미지 삭제 → 새 이미지 업로드) 흐름에서 사용됩니다.
/// - 삭제 성공 여부에 따른 UI 처리(Toast 등)는
/// 호출하는 ViewModel에서 담당합니다.
func deleteProfileImage(imagePath: String) async throws {
let ref = storage.reference(withPath: imagePath)
// Firebase 공식 문서에서 권장하는 방식
try await ref.delete()
}
}
// MARK: - ProfileImageUploadError
/// Firebase Storage에 이미지를 저장 및 삭제 시 발생할 수 있는 에러를 정의합니다.
enum ProfileImageUploadError: Error {
case failedToConvertImage
}'PulseBoard > Profile' 카테고리의 다른 글
| 프로필 온보딩 로직 설계하기: ViewModel 책임 분리와 검증 로직의 경계 (0) | 2026.01.06 |
|---|---|
| FirestoreUserRepository 설계 — 사용자 프로필을 안전하게 관리하는 방법 (0) | 2026.01.04 |
| UserRepository 설계 — Firebase 프로필 데이터를 어떻게 다룰 것인가 (1) | 2026.01.03 |
| Firebase 기반 사용자 프로필 관리 전략 — 서버 단일 소스 + 세션 캐시 (0) | 2026.01.03 |
| PulseUser 모델 설계 — Firebase Auth 이후 사용자 프로필을 어떻게 정의할 것인가 (0) | 2026.01.03 |