본문 바로가기
PulseBoard/Profile

프로필 온보딩 로직 설계하기: ViewModel 책임 분리와 검증 로직의 경계

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

프로필 온보딩 ViewModel 설계 점검 기록

소셜 로그인을 구현한 뒤, 다음으로 마주한 문제는
“사용자 프로필을 언제, 어떻게 작성하게 할 것인가”였다.

 

이 글은 Firebase 기반 iOS 앱에서
프로필 온보딩을 담당하는 ViewModel을 설계하며 고민했던 로직과,
그 로직을 역할과 책임 관점에서 점검하며 정리한 기록
이다.

 

1. 처음에 생각했던 프로필 온보딩 로직

처음 설계한 흐름은 다음과 같았다.

  1. 사용자가 소셜 로그인을 완료한다.
  2. HomeViewController로 이동한다.
  3. 이 시점에 Firestore에 사용자 프로필이 존재하는지 확인한다.
  4. 프로필이 없다면,
    • UISheetPresentationController (detent: .medium) 방식으로
      ProfileViewController를 표시한다.
  5. 사용자는 다음 정보를 입력한다.
    • 프로필 이미지 (선택)
    • 유저 이름 (필수)
  6. 유저 이름은 입력 즉시 유효성 검사를 수행한다.
    • 통과 시 확인 버튼 활성화
    • 실패 시 텍스트필드 하단에 실패 이유 표시
  7. 확인 버튼을 누르면 Firebase에 프로필 정보를 저장한다.
  8. 프로필이 정상적으로 저장되면 Sheet를 닫고 Home 화면으로 복귀한다.
  9. 이미 프로필이 존재하는 사용자라면 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 → 문자열 변환

 

👉 다국어, 디자인 변경에 강함

 

728x90
LIST