본문 바로가기
PulseBoard/Profile

FirebaseProfileImageUploader 설계 — 프로필 이미지를 왜 분리해야 하는가

by 밤새는 탐험가89 2026. 1. 5.
728x90
SMALL

사용자 프로필을 구현할 때 가장 흔한 실수 중 하나는
프로필 이미지 업로드 로직을 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
}
728x90
LIST