소셜 로그인(Google / Apple / Kakao / Naver)을 Firebase Authentication으로 구현한 뒤,
다음으로 마주한 문제는 “사용자 프로필 데이터를 어떻게 관리할 것인가”였다.
이 글에서는 Firebase Firestore를 사용하는 환경에서
사용자 프로필을 다루기 위한 UserRepository 프로토콜을 왜, 어떻게 설계했는지를 정리한다.
특히,
- Repository를 왜 도입했는지
- async/await를 선택한 이유
- completion handler 방식과의 차이
- 로그아웃 / 회원 탈퇴 시 캐시 처리 설계
를 중심으로 설명한다.
1. 왜 UserRepository가 필요한가?
가장 단순한 방식은 ViewModel에서 Firestore를 직접 호출하는 것이다.
Firestore.firestore()
.collection("users")
.document(uid)
.getDocument()
하지만 이 방식은 몇 가지 문제를 만든다.
- Firestore 의존성이 ViewModel까지 침투한다
- 캐시 전략을 ViewModel마다 따로 관리해야 한다
- 테스트가 어렵다
- 나중에 데이터 소스가 바뀌면 수정 범위가 커진다
그래서 “프로필 데이터에 접근하는 책임을 한 곳으로 모으는 계층”이 필요해졌다.
그 역할을 하는 것이 UserRepository다.
UserRepository는
“프로필 데이터를 어디서 가져오는지”를 숨기고,
“무엇을 할 수 있는지”만 드러낸다.
2. UserRepository의 책임 범위
UserRepository는 인증(Auth)을 다루지 않는다.
오직 “인증이 완료된 이후의 사용자 프로필”만 책임진다.
책임은 명확하다.
- 현재 사용자 프로필 조회
- 프로필 최초 생성
- 프로필 수정
- 프로필 삭제
- 세션 캐시 관리
로그인 방식, 토큰, Auth 상태 감지는 절대 포함되지 않는다.
3. UserRepository 프로토콜 정의
최종적으로 정의한 프로토콜은 다음과 같다.
import Foundation
/// 사용자 프로필(PulseUser)에 대한 데이터 접근을 추상화한 Repository 프로토콜입니다.
///
/// 이 프로토콜은 프로필 데이터의 출처(Firestore, 캐시 등)를 숨기고,
/// ViewModel이 오직 "무엇을 한다"에만 집중할 수 있도록 설계되었습니다.
///
/// - Note:
/// Auth(로그인/로그아웃)는 이 Repository의 책임이 아닙니다.
/// 이 Repository는 **인증이 완료된 이후의 사용자 프로필**만을 다룹니다.
protocol UserRepository {
/// 현재 로그인한 사용자의 프로필을 조회합니다.
///
/// - Returns: `PulseUser`
///
/// - Behavior:
/// - 메모리 캐시가 존재하면 즉시 반환합니다.
/// - 캐시가 없으면 원격 데이터 소스(Firestore)에서 로드합니다.
///
/// - Throws:
/// - 프로필이 존재하지 않는 경우
/// - 네트워크 또는 디코딩 오류
func fetchCurrentUser() async throws -> PulseUser
/// 사용자 프로필을 최초로 저장합니다.
///
/// - Parameter user: 저장할 `PulseUser`
///
/// - Note:
/// - 주로 최초 프로필 온보딩 완료 시 호출됩니다.
/// - 저장 성공 시 내부 캐시도 함께 갱신됩니다.
func createUser(_ user: PulseUser) async throws
/// 사용자 프로필을 수정합니다.
///
/// - Parameter user: 수정된 `PulseUser`
///
/// - Note:
/// - Firestore 업데이트 후 내부 캐시를 갱신합니다.
func updateUser(_ user: PulseUser) async throws
/// 사용자 프로필을 삭제합니다.
///
/// - Parameter uid: 삭제할 사용자 uid
///
/// - Note:
/// - 회원 탈퇴 시 사용됩니다.
/// - Auth 계정 삭제는 별도의 책임(AuthService)에 있습니다.
func deleteUser(uid: String) async throws
/// 로그아웃 시 호출
/// - 캐시만 제거
func clearCacheOnLogout()
/// 회원 탈퇴 완료 후 호출
/// - 캐시 제거 + 내부 상태 초기화
func clearCacheOnWithdrawal()
}
이 인터페이스만 보면,
ViewModel은 “프로필을 가져온다 / 저장한다 / 수정한다”는 개념만 알면 된다.
Firestore인지, 캐시인지, 네트워크인지 전혀 신경 쓰지 않는다.
4. 왜 async/await + throws를 선택했는가?
“completion handler + Result 방식이 더 안전하지 않나?”
결론부터 말하면, 프로필 통신이 1회성이기 때문에, 이 케이스에서는 async/await가 더 적합하다.
🔹 Completion Handler 방식
func fetchUser(completion: @escaping (Result<PulseUser, Error>) -> Void)
이 방식은 “흐름이 여러 갈래로 갈 수 있는 경우”에 강함.
- 실시간 스트림
- 상태 변화 관찰
- 반복 호출
- cancel / retry / chaining
👉 대표 사례
- Firestore addSnapshotListener
- Notification 기반 이벤트
- Rx / Combine 스트림
🔹 async / await 방식
func fetchUser() async throws -> PulseUser
이 방식은 “한 번 요청 → 한 번 결과”에 최적화됨.
- 요청 → 응답
- 성공 or 실패가 명확
- 비즈니스 로직을 순차적으로 표현 가능
👉 대표 사례
- REST API 호출
- 파일 업로드
- Firestore getDocument()
지금 “프로필 통신”의 성격을 보자
프로필 데이터의 특성
- 로그인 직후 한 번 로드
- 수정 시 한 번 저장
- 실시간 동기화 ❌
- 스트림 ❌
- 관찰 ❌
👉 이건 명백한 request–response 패턴
그래서 async/await가 더 자연스럽다.
async/await의 진짜 중요한 장점 (이게 핵심)
🔥 1. 비즈니스 로직이 “읽히는 코드”가 된다
let user = try await userRepository.fetchCurrentUser()
try await userRepository.updateUser(user)
vs
userRepository.fetchCurrentUser { result in
switch result {
case .success(let user):
userRepository.updateUser(user) { ... }
case .failure:
...
}
}
👉 로직이 위에서 아래로 읽힌다
👉 설계 의도가 코드에서 바로 보인다.
🔥 2. Error 전파가 자연스럽다
func saveProfile() async throws {
let user = try await fetch()
try await uploadImage()
try await saveUser(user)
}
- 중간 실패 → 바로 throw
- 중첩 switch / if 없음
- rollback 로직도 명확
👉 “실패 가능성이 있는 1회성 작업”에 최적
🔥 3. Repository와 ViewModel 책임 분리가 깔끔해진다
- Repository: throws
- ViewModel: do / catch
do {
try await userRepository.createUser(user)
} catch {
// UI 에러 처리
}
👉 completion 기반보다 역할 경계가 훨씬 명확
5. UserStore를 만들지 않은 이유
Rx / Combine 기반 앱에서는 종종 UserStore 같은 상태 저장 계층이 필요하다.
- 실시간 사용자 정보 변경
- 여러 화면에서 즉시 반응
- 상태 스트림이 핵심인 앱
하지만 이 프로젝트에서는:
- 프로필 변경 빈도 낮음
- 실시간 동기화 필요 없음
- 단순한 CRUD + 캐시만 필요
그래서 Repository 내부에 얕은 메모리 캐시만 두는 구조를 선택했다.
✅ 정리
이 UserRepository 설계의 핵심은 다음과 같다.
- 프로필 데이터는 Firestore가 단일 진실 소스
- 앱은 세션 동안만 캐시를 유지
- Repository가 데이터 접근과 캐시 전략을 책임
- 1회성 통신에는 async/await가 가장 적합
- 로그아웃과 탈퇴는 의미적으로 분리
'PulseBoard > Profile' 카테고리의 다른 글
| FirebaseProfileImageUploader 설계 — 프로필 이미지를 왜 분리해야 하는가 (0) | 2026.01.05 |
|---|---|
| FirestoreUserRepository 설계 — 사용자 프로필을 안전하게 관리하는 방법 (0) | 2026.01.04 |
| Firebase 기반 사용자 프로필 관리 전략 — 서버 단일 소스 + 세션 캐시 (0) | 2026.01.03 |
| PulseUser 모델 설계 — Firebase Auth 이후 사용자 프로필을 어떻게 정의할 것인가 (0) | 2026.01.03 |
| 소셜 로그인 이후 프로필 작성 온보딩 설계 (Firebase Auth + Firestore + Storage) (1) | 2026.01.01 |