프로필 온보딩 ViewModel 설계 점검 기록
소셜 로그인을 구현한 뒤, 다음으로 마주한 문제는
“사용자 프로필을 언제, 어떻게 작성하게 할 것인가”였다.
이 글은 Firebase 기반 iOS 앱에서
프로필 온보딩을 담당하는 ViewModel을 설계하며 고민했던 로직과,
그 로직을 역할과 책임 관점에서 점검하며 정리한 기록이다.
1. 처음에 생각했던 프로필 온보딩 로직
처음 설계한 흐름은 다음과 같았다.
- 사용자가 소셜 로그인을 완료한다.
- HomeViewController로 이동한다.
- 이 시점에 Firestore에 사용자 프로필이 존재하는지 확인한다.
- 프로필이 없다면,
- UISheetPresentationController (detent: .medium) 방식으로
ProfileViewController를 표시한다.
- UISheetPresentationController (detent: .medium) 방식으로
- 사용자는 다음 정보를 입력한다.
- 프로필 이미지 (선택)
- 유저 이름 (필수)
- 유저 이름은 입력 즉시 유효성 검사를 수행한다.
- 통과 시 확인 버튼 활성화
- 실패 시 텍스트필드 하단에 실패 이유 표시
- 확인 버튼을 누르면 Firebase에 프로필 정보를 저장한다.
- 프로필이 정상적으로 저장되면 Sheet를 닫고 Home 화면으로 복귀한다.
- 이미 프로필이 존재하는 사용자라면 ProfileViewController는 표시하지 않는다.
UX 관점에서는 자연스럽고,
기능적으로도 문제가 없어 보이는 구조였다.
전체 사용자 흐름
소셜 로그인 성공
↓
HomeViewController 진입
↓
Profile 존재 여부 확인
↓
없음 → ProfileViewController (Sheet, medium)
있음 → 아무것도 띄우지 않음
ProfileViewController 내부
- 이미지 선택 (선택)
- 유저이름 입력 (필수)
- 실시간 유효성 검사
- 실패 사유를 텍스트필드 하단에 표시
- 통과 시 확인 버튼 활성화
- 확인 버튼 → Firebase 저장
👉 이 흐름, UX / 아키텍처 / Firebase 전부 잘 맞는다.
2. ProfileViewModel에 담으려 했던 책임들
이 흐름을 구현하기 위해 ProfileViewModel에는 다음 역할이 필요하다고 판단했다.
✅ 1. 프로필 존재 여부 판단
- FirestoreUserRepository를 통해
- “이미 프로필이 있는 사용자”인지 판단
👉 이 결과로 ProfileViewController를 띄울지 말지 결정
✅ 2. 사용자가 입력 중인 프로필 상태 관리
- 입력 중인 username
- 선택된 profile image (optional)
- 아직 Firebase에 저장되지 않은 임시 상태
👉 이건 “작성 중 상태”이지, DB 상태가 아님
✅ 3. 유저이름 유효성 검사
- 길이
- 허용 문자
- 중복 여부 (Firestore)
- 실패 사유 문자열 생성
👉 유효성 로직은 ViewModel의 핵심 책임
✅ 4. 버튼 활성화 상태 계산
- 유효성 검사 결과에 따라
- “확인 버튼 활성화 / 비활성화” 결정
✅ 5. 프로필 생성 / 수정 / 삭제
- ImageUploader + UserRepository 조합
- 실제 Firebase 반영
✅ 6. 화면 전환 이벤트 방출
- Profile 완료 → Sheet 닫기
- Profile 삭제 → 다시 온보딩 진입
👉 ViewModel은 “의도”만 방출, 실제 화면 전환은 VC가 담당
3. Combine 기반 설계가 딱 맞는 이유
여기서 중요한 판단 포인트 👇
❓ “실시간 통신처럼 보이는데, async/await 말고 Combine이 맞나?”
정답은:
- 입력 상태 관리 / 버튼 활성화 / 에러 메시지
→ Combine이 최적 - Firebase CRUD
→ async/await
👉 즉, ViewModel 내부에서 둘을 섞는 구조가 정답이다.
ProfileViewModel 설계 (프로퍼티 & 스트림)
4. ProfileViewModel 설계 (프로퍼티 & 스트림)
📌 ProfileViewModel 프로퍼티 설계
import Combine
import UIKit
final class ProfileViewModel {
// MARK: - Dependencies
private let userRepository: UserRepository
private let imageUploader: ProfileImageUploading
// MARK: - Input (VC → VM)
/// 사용자가 입력 중인 유저 이름
@Published var username: String = ""
/// 사용자가 선택한 프로필 이미지 (선택 사항)
@Published var selectedImage: UIImage?
// MARK: - Output (VM → VC)
/// 유저 이름 유효성 검사 결과
@Published private(set) var usernameValidationMessage: String?
/// 확인 버튼 활성화 여부
@Published private(set) var isConfirmEnabled: Bool = false
/// 프로필 완료/삭제 등의 흐름 이벤트
let route = PassthroughSubject<Route, Never>()
// MARK: - Internal
private var cancellables = Set<AnyCancellable>()
}
📌 Route 정의 (화면 이동 의도)
enum Route {
case dismissProfileSheet
case showProfile
}
유저이름 유효성 검사 설계 (핵심)
유효성 검사는 “즉시 실패 이유를 알 수 있어야” 한다.
private func validateUsername(_ name: String) -> String? {
if name.isEmpty {
return "유저 이름을 입력해주세요"
}
if name.count < 2 {
return "유저 이름은 2자 이상이어야 합니다"
}
if name.count > 12 {
return "유저 이름은 12자 이하로 입력해주세요"
}
let regex = "^[a-zA-Z0-9가-힣_]+$"
if NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: name) == false {
return "특수문자는 사용할 수 없습니다"
}
return nil
}
👉 중복 검사만 Firestore async로 따로 분리
Combine으로 버튼 활성화 계산
private func bind() {
$username
.map { [weak self] name -> String? in
self?.validateUsername(name)
}
.sink { [weak self] message in
self?.usernameValidationMessage = message
self?.isConfirmEnabled = (message == nil)
}
.store(in: &cancellables)
}
- 텍스트필드 변경
- 자동 유효성 검사
- 버튼 활성화
- 실패 사유 업데이트
👉 VC는 그냥 바인딩만 하면 된다
프로필 저장 로직
func submitProfile() async {
do {
let uid = try requireUID()
var imagePath: String? = nil
if let image = selectedImage {
imagePath = try await imageUploader
.uploadProfileImage(image, uid: uid)
}
let user = PulseUser(
uid: uid,
username: username,
profileImagePath: imagePath
)
try await userRepository.createUser(user)
route.send(.dismissProfileSheet)
} catch {
// 에러 토스트 처리
}
}
“프로필 존재 여부” 확인은 어디서?
이건 ProfileViewModel 초기화 시점 또는
HomeViewModel / Coordinator에서 판단해도 된다.
func checkProfileExists() async {
do {
_ = try await userRepository.fetchCurrentUser()
// 이미 존재 → 아무것도 안 함
} catch UserRepositoryError.userNotFound {
route.send(.showProfile)
}
}
⚠️ 역할과 책임 관점에서 꼭 짚고 갈 개선 포인트
⚠️ 1. “유효성 검사”와 “중복 검사”는 분리해야 한다
- 유저 이름 입력
- 유효성 검사
- 중복 검사
한 덩어리로 느껴질 수 있음
이걸 이렇게 나눈다 👇
1️⃣ 동기 유효성 검사 (즉시)
- 길이
- 허용 문자
- 빈 값 여부
👉 텍스트 입력 즉시
2️⃣ 비동기 중복 검사 (지연)
- Firestore 조회
- debounce 필요
👉 입력이 멈췄을 때만
[입력 중] → 로컬 유효성
[0.3~0.5초 멈춤] → 중복 검사
📌 이걸 안 나누면 생기는 문제
- Firestore 요청 과다
- 입력 중 UI 깜빡임
- 배터리/성능 문제
⚠️ 2. ViewModel이 Firestore “쿼리 구조”를 알면 안 된다
중복 검사 관련해서 흔히 생기는 실수:
// ❌ ViewModel에서 직접 Firestore 쿼리
db.collection("users")
.whereField("username", isEqualTo: username)
👉 중복 검사도 UserRepository 책임.
protocol UserRepository {
func isUsernameAvailable(_ username: String) async throws -> Bool
}
ViewModel은:
if isAvailable == false {
showError
}
⚠️ 3. ProfileViewModel이 “온보딩 전용인지” 명확히 하자
지금 ProfileViewModel은:
- 생성
- 읽기
- 수정
- 삭제
모두를 담당하고 있음.
역할과 책임 입장에서는 질문이 하나 생긴다 👇
“이 ViewModel은 온보딩 전용인가, 편집용인가?”
ViewModel은 “행동”이 아니라 “상태”를 표현하기 때문
🔹 온보딩 ViewModel의 상태
- 반드시 생성해야 함
- 취소 불가 (또는 제한적)
- userNotFound 상태에서만 진입
- 완료 시 앱의 정상 상태로 전이
[미완성 상태] → [정상 사용자 상태]
🔹 편집 ViewModel의 상태
- 이미 유효한 사용자
- 수정/삭제 선택 가능
- 취소 가능
- 실패해도 앱 상태는 유지됨
[정상 상태] → [정상 상태]
선택지 2가지
✅ A안 (추천)
- ProfileOnboardingViewModel
- ProfileEditViewModel
👉 책임이 명확
👉 화면 로직 단순
👉 실무에서 더 선호됨
⚠️ B안
- 하나의 ProfileViewModel
- mode enum으로 분기
enum Mode {
case onboarding
case edit
}
👉 지금 단계에서는 가능
if mode == .onboarding {
// 생성
} else {
// 수정
}
이게 늘어나면:
- 분기 폭발
- 테스트 케이스 증가
- “이 상태에서 이 버튼이 보이는 게 맞나?” 혼란
👉 하지만 코드가 빨리 복잡해짐
⚠️ 4. Route 이벤트는 “의도”만 담고 있는가?
enum Route {
case dismissProfileSheet
case showProfile
}
❌ 만약 이런 게 들어오면 위험:
case presentProfileVC
case pushHomeVC
👉 ViewModel은 UIKit을 모르면 모를수록 좋다.
⚠️ 5. 에러 메시지 문자열을 ViewModel에 하드코딩할지?
지금 설계에서는:
"유저 이름은 2자 이상이어야 합니다"
이 자체는 ❌ 아님.
대안
enum UsernameValidationError {
case empty
case tooShort
case tooLong
case invalidCharacter
case duplicated
}
ViewModel → enum
VC → enum → 문자열 변환
👉 다국어, 디자인 변경에 강함
'PulseBoard > Profile' 카테고리의 다른 글
| FirebaseProfileImageUploader 설계 — 프로필 이미지를 왜 분리해야 하는가 (0) | 2026.01.05 |
|---|---|
| FirestoreUserRepository 설계 — 사용자 프로필을 안전하게 관리하는 방법 (0) | 2026.01.04 |
| UserRepository 설계 — Firebase 프로필 데이터를 어떻게 다룰 것인가 (1) | 2026.01.03 |
| Firebase 기반 사용자 프로필 관리 전략 — 서버 단일 소스 + 세션 캐시 (0) | 2026.01.03 |
| PulseUser 모델 설계 — Firebase Auth 이후 사용자 프로필을 어떻게 정의할 것인가 (0) | 2026.01.03 |