본문 바로가기
PulseBoard/Profile

FirestoreUserRepository 설계 — 사용자 프로필을 안전하게 관리하는 방법

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

소셜 로그인(Google / Apple / Kakao / Naver)을 Firebase Authentication으로 구현한 뒤,
다음으로 해결해야 할 문제는 “사용자 프로필을 어디서, 어떻게 관리할 것인가”였다.

 

이 글에서는 Firebase Firestore를 사용하는 iOS 앱에서
FirestoreUserRepository를 왜 도입했고, 어떤 기준으로 설계했는지를 정리한다.

 

특히 이번 단계에서 반드시 기억해야 할 핵심 포인트들을 함께 다룬다.

 

1. 왜 FirestoreUserRepository가 필요한가?

가장 단순한 구현은 ViewModel에서 Firestore를 직접 호출하는 것이다.

Firestore.firestore()
    .collection("users")
    .document(uid)
    .getDocument()

 

하지만 이 방식에는 명확한 문제가 있다.

  • Firestore 의존성이 ViewModel까지 침투한다
  • 캐시 전략을 ViewModel마다 직접 관리해야 한다
  • 테스트가 어렵고, 수정 범위가 커진다
  • “프로필 데이터”라는 도메인의 경계가 흐려진다

그래서 “프로필 데이터 접근 책임을 한 곳으로 모으는 계층”이 필요해졌다.

 

그 역할을 하는 것이 UserRepository이고,
Firestore를 사용하는 구현체가 바로 FirestoreUserRepository다.

 

2. FirestoreUserRepository의 책임

FirestoreUserRepository의 책임은 명확하다.

  • Firestore users/{uid} 문서와 PulseUser 모델 간 매핑
  • 사용자 프로필 CRUD (조회 / 생성 / 수정 / 삭제)
  • 앱 실행 중에만 유지되는 세션 메모리 캐시 관리

반대로, 이 클래스가 절대 책임지지 않는 것도 분명하다.

  • 로그인 / 로그아웃
  • 소셜 로그인 방식
  • UI 전환 판단

Auth는 AuthService,
프로필 데이터는 UserRepository.

이 경계를 지키는 것이 핵심이다.

 

3. Firestore는 Single Source of Truth다

이번 단계에서 가장 중요한 원칙 중 하나다.

  • Firestore에 저장된 데이터가 항상 기준
  • 메모리 캐시는 보조 수단
  • 앱이 종료되면 캐시가 사라지는 것이 정상

즉, 구조는 다음과 같다.

Firestore (진짜 데이터)
   ↓
Session Memory Cache (앱 실행 중)

 

이 원칙을 지키지 않으면:

  • 앱 재실행 시 데이터 불일치
  • 캐시와 서버 상태 충돌
  • 디버깅 난이도 급상승

 

4. 왜 “세션 메모리 캐시”를 넣었나?

프로필 데이터의 특성을 보면:

  • 로그인 후 한 번 로드
  • 앱 사용 중 자주 참조
  • 자주 변경되지는 않음
  • 실시간 스트림 필요 없음

이 경우 매번 Firestore를 호출하는 것은 불필요한 비용이다.

그래서 Repository 내부에 아주 얕은 메모리 캐시를 둔다.

private var cachedUser: PulseUser?

 

캐시 전략

  • fetchCurrentUser()
    • 캐시 존재 → 즉시 반환
    • 캐시 없음 → Firestore 조회 후 캐시 저장
  • createUser / updateUser
    • Firestore 반영 후 캐시 갱신
  • 로그아웃 / 탈퇴
    • 캐시 제거

ViewModel은 캐시 존재 여부를 전혀 알 필요가 없다.
이 책임은 전부 Repository가 가진다.

 

5. async/await를 사용한 이유

Firestore 단건 요청은 전형적인 request–response 패턴이다.

  • 실시간 관찰 ❌
  • 스트림 ❌
  • 상태 변화 구독 ❌

이런 작업에 completion handler나 Combine을 쓰면 오히려 코드가 복잡해진다.

let user = try await userRepository.fetchCurrentUser()

 

async/await + throws를 사용하면:

  • 로직이 위에서 아래로 읽힌다
  • 에러 전파가 자연스럽다
  • 비즈니스 흐름이 코드에 그대로 드러난다

이건 단순한 문법 선택이 아니라,
데이터 성격에 맞는 비동기 모델 선택이다.

 

6. userNotFound는 에러가 아니라 “상태”다

이번 단계에서 가장 놓치기 쉬운 포인트다.

enum UserRepositoryError: Error {
    case notAuthenticated
    case userNotFound
}

 

userNotFound는 실패일까?

아니다.

  • 로그인은 성공
  • Auth.uid는 존재
  • Firestore에 프로필 문서만 없음

이건 에러가 아니라 “아직 프로필을 작성하지 않은 상태”다.

이 설계 덕분에 상위 계층에서는 이렇게 자연스럽게 분기할 수 있다.

do {
    let user = try await userRepository.fetchCurrentUser()
    showHome()
} catch UserRepositoryError.userNotFound {
    showProfileOnboarding()
}

👉 이 흐름이 프로필 온보딩 UX의 핵심이다.

 

7. Repository에서 UI 결정을 하면 안 된다

FirestoreUserRepository는:

  • 데이터를 가져온다
  • 상태를 알려준다 (throws)

하지만 절대 이런 판단을 하지 않는다.

  • “온보딩 화면을 띄워라”
  • “홈으로 가라”

이건 ViewModel의 책임이다.

Repository가 UI를 알기 시작하면
아키텍처는 빠르게 무너진다.

 

8. 로그아웃과 회원 탈퇴는 다르다

이번 구현에서는 캐시 제거 로직을 명시적으로 나눴다.

func clearCacheOnLogout()
func clearCacheOnWithdrawal()

 

지금 구현은 동일할 수 있다.
하지만 의미는 전혀 다르다.

  • 로그아웃
    • 서버 데이터 유지
    • 세션 종료
  • 회원 탈퇴
    • Firestore 데이터 삭제
    • Storage 이미지 삭제
    • 캐시 제거

 

9. 이번 단계에서 꼭 기억해야 할 포인트 정리

✔ Firestore는 Single Source of Truth
✔ 캐시는 세션 단위 메모리 캐시만 사용
✔ userNotFound는 에러가 아니라 온보딩 트리거
✔ Repository는 UI 결정을 하지 않는다
✔ Storage는 반드시 별도 컴포넌트로 분리해야 한다

 

10. 마무리

FirestoreUserRepository는 단순히 Firestore를 감싼 클래스가 아니다.

  • 도메인 경계를 명확히 하고
  • 데이터 흐름을 예측 가능하게 만들고
  • 이후 온보딩, 로그아웃, 탈퇴 로직까지 자연스럽게 연결되는

프로필 아키텍처의 중심축이다.

 

 

전체코드

import Foundation
import FirebaseFirestore
import FirebaseAuth


// MARK: - FirestoreUserRepository

/// Firestore를 기반으로 사용자 프로필(PulseUser)을 관리하는 Repository 구현체입니다.
///
/// 이 클래스의 책임은 다음과 같습니다:
/// - Firestore `users/{uid}` 문서와 `PulseUser` 도메인 모델 간의 매핑
/// - 사용자 프로필의 조회 / 생성 / 수정 / 삭제(CRUD)
/// - 앱 실행 중에만 유지되는 **세션 단위 메모리 캐시 관리**
///
/// ## 설계 원칙
/// - Firestore는 **Single Source of Truth**
/// - 메모리 캐시는 성능 최적화를 위한 보조 수단
/// - Auth(로그인/로그아웃) 로직은 이 클래스의 책임이 아님
///
/// ## 캐시 전략
/// - 앱 실행 중에만 유지되는 `cachedUser`를 사용
/// - 앱 종료 시 캐시는 자동으로 해제됨
/// - 로그아웃 / 회원 탈퇴 시 명시적으로 캐시 제거
///
/// ## 비동기 모델
/// - Firestore 단건 요청은 request–response 형태이므로
///   `async/await + throws` 기반으로 구현
///
/// ## Note
/// - 현재 구현에서는 Firebase Auth의 `currentUser.uid`를 직접 참조합니다.
/// - 추후 아키텍처 확장 시, uid 주입 방식으로 리팩토링 가능합니다.
final class FirestoreUserRepository: UserRepository {
    
    
    // MARK: - Properties

    /// 앱 실행 중에만 유지되는 사용자 프로필 메모리 캐시
    ///
    /// - Firestore 접근을 최소화하기 위한 용도
    /// - 세션 종료(앱 종료) 시 자동 해제됨
    private var cachedUser: PulseUser?
    
    /// Firestore 인스턴스
    private let db: Firestore
    
    /// 사용자 프로필이 저장되는 컬렉션 경로
    private let usersCollection: String = "users"
    
    
    // MARK: - Initializer

    /// FirestoreUserRepository를 생성합니다.
    ///
    /// - Parameter db: 사용할 Firestore 인스턴스
    ///   (테스트 및 의존성 주입을 위해 외부에서 주입 가능)
    init(
        db: Firestore = Firestore.firestore()
    ) {
        self.db = db
    }
    
    
    // MARK: - UserRepository

    /// 현재 로그인된 사용자의 프로필을 조회합니다.
    ///
    /// 동작 순서:
    /// 1. 메모리 캐시가 존재하면 즉시 반환
    /// 2. Firebase Auth에서 현재 사용자 uid 확인
    /// 3. Firestore `users/{uid}` 문서 조회
    /// 4. 조회 성공 시 캐시 갱신 후 반환
    ///
    /// - Returns: 현재 사용자의 `PulseUser`
    ///
    /// - Throws:
    ///   - `UserRepositoryError.notAuthenticated`:
    ///     로그인된 사용자가 없는 경우
    ///   - `UserRepositoryError.userNotFound`:
    ///     Firestore에 프로필 문서가 존재하지 않는 경우
    ///   - Firestore SDK 에러
    func fetchCurrentUser() async throws -> PulseUser {
        
        // 세션 캐시 우선 반환
        if let cachedUser {
            return cachedUser
        }
        
        // 인증 상태 확인
        guard let uid = Auth.auth().currentUser?.uid else {
            throw UserRepositoryError.notAuthenticated
        }
        
        // Firestore 사용자 문서 조회
        let snapshot = try await db
            .collection(usersCollection)
            .document(uid)
            .getDocument()
        
        guard snapshot.exists else {
            throw UserRepositoryError.userNotFound
        }
        
        // 디코딩 및 캐시 갱신
        let user = try snapshot.data(as: PulseUser.self)
        self.cachedUser = user
        
        return user
    }
    
    /// 사용자 프로필을 최초로 생성합니다.
    ///
    /// 주로 소셜 로그인 직후, 프로필 온보딩 완료 시 호출됩니다.
    ///
    /// - Parameter user: 생성할 사용자 프로필 정보
    ///
    /// - Note:
    ///   생성 성공 후 내부 메모리 캐시를 갱신합니다.
    func createUser(_ user: PulseUser) async throws {
        try db.collection(usersCollection)
            .document(user.id)
            .setData(from: user)
        
        self.cachedUser = user
    }
    
    /// 기존 사용자 프로필을 수정합니다.
    ///
    /// - Parameter user: 수정된 사용자 프로필 정보
    ///
    /// - Note:
    ///   - `merge: true` 옵션을 사용하여
    ///     기존 문서를 안전하게 부분 업데이트합니다.
    ///   - 수정 성공 후 내부 메모리 캐시를 갱신합니다.
    func updateUser(_ user: PulseUser) async throws {
        try db.collection(usersCollection)
            .document(user.id)
            .setData(from: user, merge: true)
        
        self.cachedUser = user
    }
    
    /// 사용자 프로필을 삭제합니다.
    ///
    /// 회원 탈퇴 플로우에서 호출되며,
    /// Firebase Auth 계정 삭제와는 별도의 책임을 가집니다.
    ///
    /// - Parameter uid: 삭제할 사용자 uid
    ///
    /// - Note:
    ///   삭제 성공 시 내부 메모리 캐시를 제거합니다.
    func deleteUser(uid: String) async throws {
        try await db
            .collection(usersCollection)
            .document(uid)
            .delete()
        
        self.cachedUser = nil
    }
    
    
    /// 로그아웃 시 호출되어 세션 메모리 캐시를 제거합니다.
    ///
    /// - Note:
    ///   서버 데이터는 유지되며,
    ///   앱 내 사용자 상태만 초기화됩니다.
    func clearCacheOnLogout() {
        cachedUser = nil
    }
    
    /// 회원 탈퇴 완료 후 호출되어 세션 메모리 캐시를 제거합니다.
    ///
    /// - Note:
    ///   서버 데이터 삭제 이후 호출되는 것을 전제로 합니다.
    func clearCacheOnWithdrawal() {
        cachedUser = nil
    }
    
}


// MARK: - Error

/// UserRepository 동작 중 발생할 수 있는 에러 정의
enum UserRepositoryError: Error {
    
    /// 로그인된 사용자가 없는 경우
    case notAuthenticated
    
    /// Firestore에 사용자 프로필 문서가 존재하지 않는 경우
    ///
    /// - Note:
    ///   이 에러는 프로필 온보딩 필요 여부를 판단하는 데 사용됩니다.
    case userNotFound
}
728x90
LIST