본문 바로가기
PulseBoard/Profile

프로필 정보가 없으면 Home 위에 Sheet로 Profile 화면을 띄우는 구조

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

✅ 결론부터 말하면

HomeViewController에서 ProfileSetupViewModel을 직접 만들어서
“프로필 유무 판단”만 시키고
그 결과를 route로 받아서 Sheet를 띄우는 구조는 아주 좋다.

 

다만 중요한 조건 3가지를 지켜야 깔끔 👇

  1. HomeViewController는 “판단 요청”만 한다
  2. 프로필 존재 여부 판단은 ProfileSetupViewModel의 책임
  3. 화면 전환 의도는 오직 route(PassthroughSubject)로만 전달

 

🔄 실제 흐름 (로그인 이후)

[Login 성공]
   ↓
[RootCoordinator → HomeViewController]
   ↓
[HomeViewController viewDidAppear]
   ↓
[ProfileSetupViewModel.checkProfile()]
   ↓
[userRepository.fetchCurrentUser()]
   ↓
[userNotFound 에러]
   ↓
[route.send(.presentProfileSetup)]
   ↓
[HomeViewController가 Sheet 표시]

✨ ViewModel 설계

enum ProfileSetupRoute {
    case presentProfileSetup
    case dismiss
}

ProfileSetupViewModel

@MainActor
final class ProfileSetupViewModel {

    // MARK: - Output
    let route = PassthroughSubject<ProfileSetupRoute, Never>()

    // MARK: - Dependencies
    private let userRepository: UserRepository

    // MARK: - Init
    init(userRepository: UserRepository) {
        self.userRepository = userRepository
    }

    // MARK: - Profile Check
    func checkProfileIfNeeded() {
        Task {
            do {
                _ = try await userRepository.fetchCurrentUser()
                // ✅ 프로필 존재 → 아무것도 안 함
            } catch let error as UserRepositoryError {
                if error == .userNotFound {
                    route.send(.presentProfileSetup)
                }
            } catch {
                // 네트워크 / 디코딩 등
                LogManager.print(.error, "Profile check failed: \(error)")
            }
        }
    }
}

 

🖼 HomeViewController에서의 사용

final class HomeViewController: UIViewController {

    private let profileSetupViewModel: ProfileSetupViewModel
    private var cancellables = Set<AnyCancellable>()

    init(
        authViewModel: AuthViewModel,
        profileSetupViewModel: ProfileSetupViewModel
    ) {
        self.profileSetupViewModel = profileSetupViewModel
        super.init(nibName: nil, bundle: nil)
        bindProfileRoute()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        profileSetupViewModel.checkProfileIfNeeded()
    }

    private func bindProfileRoute() {
        profileSetupViewModel.route
            .receive(on: DispatchQueue.main)
            .sink { [weak self] route in
                switch route {
                case .presentProfileSetup:
                    self?.presentProfileSheet()
                case .dismiss:
                    self?.dismiss(animated: true)
                }
            }
            .store(in: &cancellables)
    }

    private func presentProfileSheet() {
        let vc = ProfileViewController()
        vc.modalPresentationStyle = .pageSheet
        if let sheet = vc.sheetPresentationController {
            sheet.detents = [.medium()]
        }
        present(vc, animated: true)
    }
}

 


위의 코드에서 고려해야하는 부분은 크게 2가지 있다. 

 

1️⃣ 문제 1:

HomeViewController에서 ProfileSetupViewModel 생성 문제

🔴 현재 문제 요약

ProfileSetupViewModel은 단순한 상태 확인용 ViewModel이 아님 👇

@MainActor
final class ProfileSetupViewModel {
    
    
    // MARK: - Dependencies
    
    /// 사용자 프로필 데이터 접근을 담당하는 Repository
    private let userRepository: UserRepository
    
    /// 프로필 이미지 업로드를 담당하는 Uploader
    private let imageUploader: ProfileImageUploading
    
    ...
    
    // MARK: - Initializer
    
    init(
        userRepository: UserRepository,
        imageUploader: ProfileImageUploading
    ) {
        self.userRepository = userRepository
        self.imageUploader = imageUploader
        
        bind()
    }
}
  • UserRepository
  • ProfileImageUploading

이라는 구체적인 의존성이 필요함

👉 그런데 HomeViewController에서 이걸 직접 생성하면?

❌ HomeVC가

  • Firestore 구현체를 알게 되고
  • Firebase Uploader 구현체를 알게 됨

UI 레이어가 Infra 레이어를 침범

 

✅ 결론 (중요)

ProfileSetupViewModel은 HomeViewController에서 만들면 안 된다.
👉 상위 계층(RootCoordinator)에서 생성해서 주입해야 한다.

 

이건 MVVM + Coordinator 구조의 정석

 

2️⃣ 문제 2: HomeViewController init → RootCoordinator 책임

🔴 지금 구조의 핵심 원칙

ViewController는 “받아쓰기”만 한다
무엇을 만들지는 Coordinator가 결정한다

 

그런데 지금은:

  • HomeViewController에서
    → ProfileSetupViewModel을 요구
  • 그럼 누가 만들어야 함?
    👉 RootCoordinator
final class HomeViewController: UIViewController {

    
    private var viewModel: AuthViewModel?
    
    // 추가
    private var profileSetupViewModel: ProfileSetupViewModel
    private var cancellables = Set<AnyCancellable>()
    
    ... 
    
    init(
        authViewModel: AuthViewModel,
        profileSetupViewModel: ProfileSetupViewModel
    ) {
        self.viewModel = authViewModel
        self.profileSetupViewModel = profileSetupViewModel
        super.init(nibName: nil, bundle: nil)
        bindProfileRoute()
    }

 

final class RootCoordinator {
    
    
    // MARK: - Properties
    
    /// 앱의 rootViewController를 교체하기 위한 UIWindow
    private let window: UIWindow
    
    /// Auth 상태를 관찰하기 위한 ViewModel
    private let authViewModel: AuthViewModel
    
    // ⬇️ Infra (추가)
    private let userRepository: UserRepository
    private let profileImageUploader: ProfileImageUploading
    

    // MARK: - Initialization ⬇️
    
    init(
        window: UIWindow,
        authViewModel: AuthViewModel = AuthViewModel(),
        userRepository: UserRepository = FirestoreUserRepository(), // 추가
        profileImageUploader: ProfileImageUploading = FirebaseProfileImageUploader() // 추가
    ) {
        self.window = window
        self.authViewModel = authViewModel
        self.userRepository = userRepository
        self.profileImageUploader = profileImageUploader
    }
    
    /// 메인(Home) 화면 생성
    ///
    /// Login 화면과 동일한 AuthViewModel을 공유함으로써
    /// Auth 상태를 Single Source of Truth로 유지합니다.
    @MainActor
    private func makeHome() -> UIViewController {

        // ⬇️
        let profileSetupViewModel = ProfileSetupViewModel(
            userRepository: userRepository,
            imageUploader: profileImageUploader
        )

        let vc = HomeViewController(
            authViewModel: authViewModel,
            profileSetupViewModel: profileSetupViewModel
        )

        return vc
    }
    
}

 

🔥 이 구조가 “좋은 이유”

✅ 단방향 의존성

RootCoordinator
   ↓
HomeViewController
   ↓
ProfileSetupViewModel
   ↓
UserRepository / ImageUploader

 


@MainActor 문제: 왜 에러가 났고, 어떻게 해결했나?

에러 메시지

Call to main actor-isolated initializer 'init(...)'
in a synchronous nonisolated context

 

 

이걸 쉬운 말로 바꾸면:

“메인 배우(MainActor) 전용 문을
아무 데서나 열려고 해서 막혔어요.”

MainActor를 초등학생 버전으로 설명하면

  • 앱에는 “UI를 담당하는 교실(= MainActor)”이 있다.
  • @MainActor가 붙은 클래스는 “UI 교실에서만 일해야 하는 친구”다.
  • 그런데 지금 코드는 “아무 교실”에서 그 친구를 불러서 일시키려고 해서 선생님(컴파일러)이 막은 거다.

DispatchQueue.main이 있는데 왜 안 되지?

여기서 많은 사람이 헷갈린다.

  • DispatchQueue.main.async는 “메인 스레드에서 실행해줘”라는 옛날 방식(GCD)
  • MainActor는 “UI 작업 규칙을 지키는 곳”이라는 최신 방식(Swift Concurrency)

중요 포인트:

Swift 컴파일러는 DispatchQueue.main을 봐도
“아, 여긴 MainActor구나”라고 100% 확신하지 못한다.

 

그래서 @MainActor init 호출이 막히는 경우가 생긴다.

private func switchToRoot(uid: String?) {
    let rootVC: UIViewController

    if uid == nil {
        rootVC = makeLogin()  // 로그인 화면
    } else {
        rootVC = makeHome()   // 메인 화면 
    }

    // root 교체는 항상 메인 스레드에서
    DispatchQueue.main.async {
        self.window.rootViewController = rootVC
        self.window.makeKeyAndVisible()
    }

}

@MainActor
private func makeHome() -> UIViewController {

    let profileSetupViewModel = ProfileSetupViewModel(
        userRepository: userRepository,
        imageUploader: profileImageUploader
    )

    let vc = HomeViewController(
        authViewModel: authViewModel,
        profileSetupViewModel: profileSetupViewModel
    )

    return vc
}

 

해결책: 컴파일러가 인정하는 방식으로 “MainActor로 이동”하기

DispatchQueue.main.async 대신 아래를 사용한다:

private func switchToRoot(uid: String?) {

    Task { @MainActor [weak self] in
        guard let self else { return }

        let rootVC: UIViewController
        if uid == nil {
            rootVC = self.makeLogin()
        } else {
            rootVC = self.makeHome()
        }

        self.window.rootViewController = rootVC
        self.window.makeKeyAndVisible()
    }
}

 

그리고 makeHome/makeLogin은 MainActor에서만 호출되도록 선언해준다.

@MainActor
private func makeHome() -> UIViewController { ... }

@MainActor
private func makeLogin() -> UIViewController { ... }

이렇게 하면:

  • UI 교체
  • @MainActor ViewModel 생성
  • 화면 표시

모두 정식으로 MainActor 규칙을 지키는 흐름이 된다.

 

HomeCoordinator를 예전에 제시했던 이유와, 지금 구조와의 차이

예전: HomeCoordinator가 하던 일

  • “Home 이후 흐름”을 별도 객체가 관리
  • 프로필 유무에 따라 Profile 화면으로 라우팅

즉, HomeCoordinator는 이런 역할이었다:

“홈 화면 이후의 화면 흐름 관리자”

 

지금: ProfileSetupViewModel이 직접 처리

  • fetchCurrentUser()로 프로필 유무 판단 (도메인 판단)
  • route(.presentProfileSetup)로 의도 전달

즉, 흐름은 “HomeCoordinator”가 아니라 “도메인 판단 + route 이벤트”로 단순해졌다.

 

차이의 본질

프로필 유무 판단은 ‘내비게이션’이라기보다 ‘도메인 규칙’에 가깝다.

  • “로그인된 사용자는 프로필이 있어야 한다”
  • 없으면 만들어야 한다

이건 화면 흐름보다 상태(도메인) 판단이다.
그래서 ViewModel로 내려오는 게 자연스럽다

 

그럼 Coordinator는 언제 쓰고, 언제 빼는 게 맞나?

Coordinator를 쓰기 좋은 경우

  • 여러 화면으로 이어지는 “독립적인 플로우”가 존재할 때
    예: 온보딩(여러 화면), 결제 흐름, 딥링크 진입 후 복귀 등
  • push/pop 기반 네비게이션 스택을 관리해야 할 때
  • 분기(조건)가 많아 화면 전환이 복잡해질 때

Coordinator가 과한 경우

  • 단일 Sheet 표시처럼 짧고 단순한 UI 흐름
  • “있으면 띄우고 없으면 안 띄우는” 정도의 상태 기반 표시
  • 화면이 하나로 끝나며, 완료 후 단순 dismiss인 경우

최종적으로 우리가 구현해야 하는 방식

현재 네가 만든 구조가 최종적으로 가장 균형이 좋다.

RootCoordinator

  • Auth 상태 기반 Root 분기 (Login/Home)
  • UI 루트 교체는 Task { @MainActor in }에서 수행
import Foundation
import UIKit


// MARK: - RootCoordinator

/// 앱의 최상위 진입점을 관리하는 Coordinator입니다.
///
/// RootCoordinator는 앱 실행 시점부터 종료까지
/// **"현재 사용자가 로그인 상태인가?"** 라는 단 하나의 기준으로
/// Root 화면(Login / Home)을 분기하는 역할만을 담당합니다.
///
/// 이 Coordinator는 **의도적으로 책임을 최소화**하여,
/// 앱 전체 흐름의 시작점(Composition Root) 역할에만 집중합니다.
///
/// ---
/// ### RootCoordinator의 책임
/// - 앱 시작 시 현재 인증(Auth) 상태를 기준으로 Root 화면 결정
/// - Auth 상태 변화(로그인 / 로그아웃 / 탈퇴)에 따른 Root 화면 교체
/// - Home 화면에 필요한 ViewModel / Repository / Infra 객체 생성 및 주입
///
/// ---
/// ### RootCoordinator가 하지 않는 일
/// ❌ 로그인 UI 구현
/// ❌ Firebase / Firestore 직접 접근
/// ❌ 사용자 프로필 존재 여부 판단
/// ❌ 비즈니스 로직 처리
///
/// 위 역할들은 각각 ViewModel, Repository, Infra 계층으로 위임됩니다.
///
/// ---
/// ### 설계 의도
/// - RootCoordinator는 @MainActor로 선언하지 않고,
///   **UI 교체가 필요한 시점에만 MainActor로 hop**합니다.
/// - 이를 통해 Coordinator 전체가 UI 스레드에 묶이는 것을 방지하고,
///   Swift Concurrency 규칙을 명확하게 준수합니다.
///
/// - Home 진입 이후의 세부 UI 흐름(예: 프로필 Sheet 표시)은
///   HomeViewController와 ProfileSetupViewModel의 책임으로 분리됩니다.
final class RootCoordinator {
    
    
    // MARK: - Properties
    
    /// 앱의 rootViewController를 교체하기 위한 UIWindow
    private let window: UIWindow
    
    /// 인증(Auth) 상태를 관찰하는 ViewModel
    ///
    /// RootCoordinator는 AuthViewModel을 통해
    /// "현재 로그인 상태인지"만을 판단하며,
    /// 인증 세부 구현에는 관여하지 않습니다.
    private let authViewModel: AuthViewModel
    
    /// 사용자 프로필 데이터 접근을 담당하는 Repository
    ///
    /// Home 화면에서 프로필 설정이 필요한 경우를 대비하여
    /// 상위 계층(Coordinator)에서 생성 후 주입합니다.
    private let userRepository: UserRepository
    
    /// 프로필 이미지 업로드를 담당하는 Uploader
    ///
    /// ViewController가 Firebase 구현체를 직접 알지 않도록,
    /// Infra 객체는 Coordinator에서 생성합니다.
    private let profileImageUploader: ProfileImageUploading
    

    // MARK: - Initialization
    
    /// RootCoordinator 초기화
    ///
    /// - Parameters:
    ///   - window: 앱의 메인 UIWindow
    ///   - authViewModel: 인증 상태를 관찰하는 ViewModel
    ///   - userRepository: 사용자 프로필 데이터 접근 Repository
    ///   - profileImageUploader: 프로필 이미지 업로드 Uploader
    ///
    /// 기본 구현체(FirestoreUserRepository, FirebaseProfileImageUploader)는
    /// 이곳에서 주입되며, 테스트 시에는 Mock 객체로 대체할 수 있습니다.
    init(
        window: UIWindow,
        authViewModel: AuthViewModel = AuthViewModel(),
        userRepository: UserRepository = FirestoreUserRepository(),
        profileImageUploader: ProfileImageUploading = FirebaseProfileImageUploader()
    ) {
        self.window = window
        self.authViewModel = authViewModel
        self.userRepository = userRepository
        self.profileImageUploader = profileImageUploader
    }
    
    
    // MARK: - Start
    
    /// Coordinator 시작 지점
    ///
    /// 1. 앱 시작 시 현재 Auth 상태를 기준으로 한 번 Root 분기
    /// 2. 이후 Auth 상태 변화에 따라 Root 화면을 지속적으로 갱신
    ///
    /// RootCoordinator는 오직 Auth 상태만 관찰하며,
    /// Home 이후의 세부 흐름은 관여하지 않습니다.
    func start() {
    
        // 앱 시작 시 현재 상태로 한 번 분기
        switchToRoot(uid: authViewModel.currentUserUID)
        
        // 로그인 / 로그아웃 / 탈퇴 등 Auth 상태 변화 감지
        authViewModel.onAuthStateChanged = { [weak self] uid in
            self?.switchToRoot(uid: uid)
        }
    }
    
    
    // MARK: - Root Switching
    
    /// 인증 상태(uid)에 따라 rootViewController를 교체합니다.
    ///
    /// - Parameter uid:
    ///   - nil  : 로그아웃 상태 → Login 화면
    ///   - value: 로그인 상태 → Home 화면
    ///
    /// UI 교체는 반드시 MainActor에서 수행되어야 하므로,
    /// Task + @MainActor를 통해 명시적으로 Actor hop을 수행합니다.
    ///
    /// DispatchQueue.main.async 대신 Swift Concurrency 방식을 사용하여
    /// @MainActor로 선언된 ViewModel 생성 및 UI 작업을 안전하게 보장합니다.
    private func switchToRoot(uid: String?) {

        Task { @MainActor [weak self] in
            guard let self else { return }

            let rootVC: UIViewController
            if uid == nil {
                rootVC = self.makeLogin()
            } else {
                rootVC = self.makeHome()
            }

            self.window.rootViewController = rootVC
            self.window.makeKeyAndVisible()
        }
    }

  
    // MARK: - Factory Methods
    
    /// 로그인(Login) 화면 생성
    ///
    /// AuthViewModel을 주입하여,
    /// 로그인 성공 시 Auth 상태 변경을 RootCoordinator가 감지할 수 있도록 합니다.
    ///
    /// 해당 메서드는 UI 생성 로직이므로 @MainActor에서만 호출됩니다.
    @MainActor
    private func makeLogin() -> UIViewController {
        let vc = LoginViewController()
        vc.bind(viewModel: authViewModel)
        return vc
    }
    
    /// 메인(Home) 화면 생성
    ///
    /// - AuthViewModel을 Login 화면과 공유하여
    ///   Auth 상태를 Single Source of Truth로 유지합니다.
    /// - ProfileSetupViewModel을 이 시점에 생성하여 HomeViewController에 주입함으로써,
    ///   Home 화면에서 "프로필 설정이 필요한지"를 판단할 수 있도록 합니다.
    ///
    /// Home 이후의 세부 UI 흐름(예: 프로필 Sheet 표시)은
    /// HomeViewController와 ProfileSetupViewModel의 책임입니다.
    @MainActor
    private func makeHome() -> UIViewController {

        let profileSetupViewModel = ProfileSetupViewModel(
            userRepository: userRepository,
            imageUploader: profileImageUploader
        )

        let vc = HomeViewController(
            authViewModel: authViewModel,
            profileSetupViewModel: profileSetupViewModel
        )

        return vc
    }
}

 

HomeViewController

  • Home 표시
  • ProfileSetupViewModel.route 구독
  • .presentProfileSetup 수신 시 Sheet 표시
// MARK: - HomeViewController

/// 로그인 이후 표시되는 Home 화면을 담당하는 ViewController입니다.
final class HomeViewController: UIViewController {

    
    // MARK: - Properties
    
    /// 인증(Auth) 관련 동작을 담당하는 ViewModel
    ///
    /// 로그아웃과 같은 인증 관련 액션만 처리하며,
    /// 프로필 관련 로직에는 관여하지 않습니다.
    private var viewModel: AuthViewModel?
    
    /// 프로필 설정 상태를 관리하는 ViewModel
    ///
    /// - 프로필 존재 여부 판단
    /// - 프로필 설정 필요 시 화면 전환 의도(route) 전달
    private var profileSetupViewModel: ProfileSetupViewModel
    
    /// Combine 구독 관리
    private var cancellables = Set<AnyCancellable>()
    

    // MARK: - Lifecycle
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemGreen
        setupLogoutButton()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        // Home 화면이 실제로 화면에 표시된 이후,
        // 프로필 설정이 필요한지 여부를 확인합니다.
        profileSetupViewModel.checkProfileIfNeeded()
    }
    
    
    // MARK: - Initialization
    
    /// HomeViewController 초기화
    ///
    /// - Parameters:
    ///   - authViewModel: 인증 상태 및 로그아웃 처리를 담당하는 ViewModel
    ///   - profileSetupViewModel: 프로필 설정 상태를 관리하는 ViewModel
    ///
    /// 필요한 모든 의존성을 초기화 시점에 주입받아,
    /// ViewController가 항상 **완전한 상태로 생성**되도록 합니다.
    init(
        authViewModel: AuthViewModel,
        profileSetupViewModel: ProfileSetupViewModel
    ) {
        self.viewModel = authViewModel
        self.profileSetupViewModel = profileSetupViewModel
        super.init(nibName: nil, bundle: nil)
        
        // ProfileSetupViewModel의 route를 구독하여
        // 프로필 설정 화면 표시 / 종료를 처리합니다.
        bindProfileRoute()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    
    // MARK: - UI Setup
    
    private func setupLogoutButton() {
        let button = UIButton(type: .system)
        button.setTitle("로그아웃", for: .normal)
        button.addTarget(
            self,
            action: #selector(logoutTapped),
            for: .touchUpInside
        )

        button.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(button)

        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }

    @objc private func logoutTapped() {
        viewModel?.logout()
    }
    
    
    // MARK: - Binding
    
    /// ProfileSetupViewModel의 route를 구독합니다.
    ///
    /// ViewModel은 실제 화면 전환을 수행하지 않고,
    /// 오직 "어떤 전환이 필요한지"에 대한 의도만 전달합니다.
    ///
    /// HomeViewController는 해당 의도를 받아
    /// - 프로필 설정 화면 Sheet 표시
    /// - 프로필 설정 화면 종료
    /// 와 같은 UI 처리를 담당합니다.
    private func bindProfileRoute() {
        profileSetupViewModel.route
            .receive(on: DispatchQueue.main)
            .sink { [weak self] route in
                switch route {
                case .presentProfileSetup:
                    self?.presentProfileSheet()
                case .dismiss:
                    self?.dismiss(animated: true)
                }
            }
            .store(in: &cancellables)
    }
    
    
    // MARK: - Presentation
    
    /// 프로필 설정 화면을 Sheet 형태로 표시합니다.
    ///
    /// Home 화면 위에 상태 보정 UI로 표시되며,
    /// 프로필 설정 완료 시 dismiss 됩니다.
    private func presentProfileSheet() {
        let vc = ProfileViewController()
        vc.modalPresentationStyle = .pageSheet
        if let sheet = vc.sheetPresentationController {
            sheet.detents = [.medium()]
        }
        present(vc, animated: true)
    }
    
}

 

ProfileSetupViewModel (@MainActor)

  • 프로필 존재 판단(fetchCurrentUser)
  • 프로필 생성/검증/업로드까지 확장 가능
  • “화면 전환 의도”는 route로만 전달
// MARK: - ProfileSetupViewModel

/// 프로필 온보딩(초기 설정)을 담당하는 ViewModel입니다.
///
/// 이 ViewModel은 **"로그인된 사용자가 유효한 프로필을 가지고 있는가?"** 라는
/// 도메인 규칙을 중심으로 동작합니다.
///
/// ---
/// ### 주요 책임
/// - 현재 로그인된 사용자의 **프로필 존재 여부 판단**
/// - 사용자가 입력한 유저 이름 상태 관리
/// - 유저 이름 형식 유효성 검사
/// - 유저 이름 중복 검사 (Repository 위임)
/// - 프로필 이미지 선택 상태 관리
/// - 프로필 생성 요청 처리
/// - 프로필 설정 결과에 따른 화면 전환 **의도(route)** 전달
///
/// ---
/// ### 프로필 존재 여부 판단
/// 앱 진입 후 Home 화면에서 `checkProfileIfNeeded()`가 호출되며,
/// 내부적으로 `userRepository.fetchCurrentUser()`를 통해
/// Firestore에 사용자 프로필 문서가 존재하는지 확인합니다.
///
/// - 프로필 문서가 존재할 경우:
///   - 아무 동작도 하지 않으며 Home 화면을 유지합니다.
/// - 프로필 문서가 존재하지 않을 경우:
///   - `UserRepositoryError.userNotFound` 에러를 감지하고
///   - `route.send(.presentProfileSetup)`을 통해
///     **프로필 설정 화면을 표시해야 한다는 의도**를 전달합니다.
///
/// ---
/// ### 화면 전환에 대한 설계 원칙
/// 이 ViewModel은 실제 화면 전환을 수행하지 않고,
/// 오직 **"어떤 화면 전환이 필요하다"는 의도만** `route`를 통해 전달합니다.
///
/// ViewController는 이 의도를 구독하여:
/// - 프로필 설정 화면 표시 (Sheet)
/// - 프로필 설정 완료 후 화면 종료
/// 와 같은 UI 처리를 담당합니다.
///
/// ---
/// ⚠️ 주의:
/// - 이 ViewModel은 **프로필 생성(온보딩) 전용**입니다.
/// - 프로필 수정 / 조회 / 삭제는 별도의 ViewModel에서 처리하는 것을 전제로 합니다.
///
/// - Note:
/// UI 상태(@Published 프로퍼티)와 직접 연결된 로직을 포함하므로
/// `@MainActor`에서 실행됩니다.
@MainActor
final class ProfileSetupViewModel {
    
    
    // MARK: - Dependencies
    
    /// 사용자 프로필 데이터 접근을 담당하는 Repository
    private let userRepository: UserRepository
    
    /// 프로필 이미지 업로드를 담당하는 Uploader
    private let imageUploader: ProfileImageUploading
    
    
    // MARK: - Input (ViewController -> ViewModel)
    
    /// 사용자가 입력 중인 유저 이름
    @Published var username: String = ""
    
    /// 사용자가 선택한 프로필 이미지 (선택 사항)
    @Published var selectedImage: UIImage?
    
    
    // MARK: - Output(ViewModel -> ViewController)
    
    /// 유저 이름 검증 실패 메세지
    @Published private(set) var usernameErrorMessage: String?
    
    /// 확인 버튼 활성화 여부
    @Published private(set) var isConfirmEnabled: Bool = false
    
    /// 프로필 submit 에러 메시지
    @Published var submitError: String?
    
    /// 화면 전환 의도 전달
    let route = PassthroughSubject<ProfileSetupRoute, Never>()
    
    
    // MARK: - Private State
    
    private var cancellables = Set<AnyCancellable>()
    
    /// 중복 검사 결과 캐싱
    private var isUsernameAvailable: Bool = false
    
    
    // MARK: - Initializer
    
    init(
        userRepository: UserRepository,
        imageUploader: ProfileImageUploading
    ) {
        self.userRepository = userRepository
        self.imageUploader = imageUploader
        
        bind()
    }
}


extension ProfileSetupViewModel {

    
    // MARK: - Profile Check

    /// 현재 로그인된 사용자의 프로필 존재 여부를 확인합니다.
    ///
    /// Home 화면 진입 시 호출되며,
    /// Firestore에 사용자 프로필 문서가 존재하는지를 판단합니다.
    ///
    /// 동작 방식:
    /// 1. `userRepository.fetchCurrentUser()` 호출
    /// 2. 프로필 문서가 존재하면 아무 동작도 하지 않음
    /// 3. `UserRepositoryError.userNotFound` 발생 시
    ///    - 프로필이 아직 생성되지 않은 상태로 판단
    ///    - `route.send(.presentProfileSetup)`을 통해
    ///      프로필 설정 화면 표시 의도를 전달
    ///
    /// 이 메서드는 **"화면을 열지"** 않고,
    /// **"열어야 한다는 신호"만 전달**합니다.
    func checkProfileIfNeeded() {
        Task {
            do {
                _ = try await userRepository.fetchCurrentUser()
                // ✅ 프로필 존재 → 아무것도 안 함
            } catch let error as UserRepositoryError {
                if error == .userNotFound {
                    route.send(.presentProfileSetup)
                }
            } catch {
                // 네트워크 / 디코딩 등
                LogManager.print(.error, "Profile check failed: \(error)")
            }
        }
    }
}

private extension ProfileSetupViewModel {
    
    
    // MARK: - bind
    
    /// username 입력에 대한 유효성 검사 및 중복 검사 바인딩을 설정합니다.
    ///
    /// - 형식 유효성 검사: 입력 즉시 수행
    /// - 중복 검사: 입력이 멈춘 후 debounce를 거쳐 수행
    func bind() {
        
        // 형식 유효성검사
        $username
            .removeDuplicates()
            .map { [weak self] in self?.validateUsernameFormat($0) }
            .sink { [weak self] errorMessage in
                self?.usernameErrorMessage = errorMessage?.localizedDescription
                self?.isConfirmEnabled = false
            }
            .store(in: &cancellables)
        
        // 중복 검사
        $username
            .removeDuplicates()
            .debounce(for: .milliseconds(400), scheduler: RunLoop.main)
            .sink { [weak self] username in
                Task {
                    await self?.checkUsernameAvailability(username)
                }
            }
            .store(in: &cancellables)
    }
}


private extension ProfileSetupViewModel {
    
    
    // MARK: - validateUsernameFormat
    
    /// 유저 이름의 형식 유효성을 검사합니다.
    ///
    /// - Parameter username: 검사할 유저 이름
    /// - Returns: 유효하지 않을 경우 'validateUsernameFormat'. 통과 시 'nil'
    func validateUsernameFormat(_ username: String) -> UsernameValidationError? {
        
        if username.isEmpty {
            return .empty
        }
        
        if username.count < UsernameRule.minLength {
            return .tooShort
        }
        
        if username.count > UsernameRule.maxLength {
            return .tooLong
        }
        
        let regex = "^[a-zA-Z0-9가-힣_]+$"
        let isValid = NSPredicate(
            format: "SELF MATCHES %@",
            regex
        ).evaluate(with: username)
        
        if isValid == false {
            return .invalidCharacters
        }
        
        return nil
    }
}


private extension ProfileSetupViewModel {
    
    
    // MARK: - checkUsernameAvailability
    
    /// 유저 이름 중복 여부를 확인합니다.
    ///
    ///
    /// - Note:
    /// 형식 유효성 검사를 통과한 경우에만 수행합니다.
    func checkUsernameAvailability(_ username: String) async {
        
        if validateUsernameFormat(username) != nil {
            return
        }
        
        do {
            let isAvailable = try await userRepository.isUsernameAvailable(username)
            
            self.isUsernameAvailable = isAvailable
            
            if isAvailable {
                self.usernameErrorMessage = nil
                self.isConfirmEnabled = true
            } else {
                self.usernameErrorMessage = UsernameValidationError.duplicated.localizedDescription
                self.isConfirmEnabled = false
            }
        } catch {
            self.submitError = NSLocalizedString(
                "username_check_error",
                comment: "유저 이름 확인 중 오류"
            )
            self.isConfirmEnabled = false
        }
    }
}


extension ProfileSetupViewModel {
    
    
    // MARK: - submitProfile
    
    /// 프로필 정보를 Firebase에 저장합니다.
    ///
    /// - Note:
    /// 성공 시 'route'를 통해 화면 종료 이벤트를 전달합니다.
    func submitProfile() async {
        
        do {
            guard let uid = Auth.auth().currentUser?.uid else{
                return
            }
            
            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(.dismiss)
        } catch {
            // 에러 메시지 또는 알럿 처리
        }
    }
}


// MARK: - ProfileSetupRoute

/// ProfileSetupViewModel에서 발생하는 화면 전환 의도 정의
///
/// 이 enum은 ViewModel이 **UI에 직접 관여하지 않기 위한 수단**입니다.
///
/// - presentProfileSetup:
///   - 사용자 프로필이 존재하지 않을 때
///   - 프로필 설정 화면을 표시해야 함을 의미
///
/// - dismiss:
///   - 프로필 설정이 성공적으로 완료되었을 때
///   - 현재 표시 중인 프로필 화면을 닫아야 함을 의미
enum ProfileSetupRoute {
    case presentProfileSetup
    case dismiss
}


// MARK: -  UsernameValidationError

/// 유저 이름 검증 과정에서 발생하는 에러 정의
enum UsernameValidationError {
    case empty
    case tooShort
    case tooLong
    case invalidCharacters
    case duplicated
    
    /// 다국어 처리가 적용된 에러 메시지
    var localizedDescription: String {
        switch self {
        case .empty:
            return NSLocalizedString("username_empty", comment: "유저 이름이 비어 있음")
        case .tooShort:
            return NSLocalizedString("username_too_short", comment: "유저 이름이 너무 짧음")
        case .tooLong:
            return NSLocalizedString("username_too_long", comment: "유저 이름이 너무 김")
        case .invalidCharacters:
            return NSLocalizedString("username_invalid_characters", comment: "허용되지 않은 문자")
        case .duplicated:
            return NSLocalizedString("username_duplicated", comment: "이미 사용 중인 유저 이름")
        }
    }
}


// MARK: - UsernameRule

/// username 길이 상수화
private enum UsernameRule {
    static let minLength = 2
    static let maxLength = 9
}

 

UserRepository / ImageUploader (Infra)

  • Firestore/Firebase 구체 구현은 상위에서 주입

“왜 예전엔 닫기만 하자고 했는데, 지금은 열기도 맡겼나?”

🔹 예전에 생각했던 것

“ProfileSetupViewModel은
닫기(dismiss)만 책임지는 게 낫다.”

 

이 말이 나왔던 이유는 이거야:

  • 화면을 여는 것은 보통 Coordinator의 역할
  • ViewModel은 결과만 알려주는 게 깔끔

👉 당시 전제
“프로필 화면이 하나의 ‘흐름(flow)’일 수 있다” 였어.

🔹 지금 구조가 달라진 이유 (핵심)

지금은 상황이 이렇게 정리됐지:

  • 프로필 화면은
    • 단일 Sheet
    • Home 위에 잠깐 표시
    • 완료되면 닫힘
  • 독립적인 네비게이션 스택 ❌
  • 여러 화면으로 이어지는 플로우 ❌

즉,

프로필 설정은 ‘화면 흐름’이 아니라
‘상태 보정(UI 보완)’에 가깝다

🔹 결정적인 포인트: “판단의 성격”

질문 성격
“이 화면을 띄워야 하나?” 도메인 판단
“어디로 push/pop 할까?” 내비게이션

지금 checkProfileIfNeeded()에서 하는 판단은:

“이 사용자는 프로필이라는 필수 조건을 만족하는가?”

 

👉 이건 도메인 규칙이다.

그래서:

  • Coordinator ❌
  • ViewModel ⭕️

이 된 거야.

🔹 route에 presentProfileSetup이 들어가도 괜찮은 이유

중요한 점은 이거야 👇

ViewModel이 화면을 ‘연다’고 결정한 게 아니다.
‘열어야 한다는 의도’를 낸 것뿐이다.

 


왜 checkProfileIfNeeded()는 viewDidAppear에서 호출할까?

❌ init()에서 하면 안 되는 이유

init(...) {
    ...
    profileSetupViewModel.checkProfileIfNeeded() // ❌
}

 

  • 이 시점에는 ViewController가
    • 화면에 나타날지 말지도 모름
    • present가 가능한 상태 ❌
  • 만약 여기서 바로 route.send(.presentProfileSetup)가 나오면?
    아직 화면도 안 떴는데 present 시도 → 경고/버그

❌ viewDidLoad()에서 하면 애매한 이유

override func viewDidLoad() {
    profileSetupViewModel.checkProfileIfNeeded()
}

 

  • viewDidLoad는
    • View가 메모리에 로드된 시점
    • 아직 화면에 보이기 전
  • 이 시점에 Sheet를 띄우면:
    • “view is not in the window hierarchy” 경고 가능
    • 화면 전환 타이밍이 어색해질 수 있음

✅ viewDidAppear()가 딱 맞는 이유

override func viewDidAppear(_ animated: Bool) {
    profileSetupViewModel.checkProfileIfNeeded()
}
 

이 시점은:

  • Home 화면이 실제로 화면에 표시된 이후
  • present(...) 호출이 안전
  • 사용자에게도 자연스러운 UX

👉 “이 화면 위에 뭔가를 띄우겠다”
라는 의도는 항상 viewDidAppear 이후가 정답이다.

 

 

왜 bind() 방식에서 init() 주입 방식으로 바꿨을까?

❌ 기존 bind() 방식의 문제점

let vc = HomeViewController()
vc.bind(viewModel: authVM, profileSetupViewModel: profileVM)

 

이 방식의 문제는:

  • bind()를 호출하지 않으면
    • ViewController가 불완전한 상태
  • 실수로 bind를 빼먹어도
    • 컴파일 에러 ❌
    • 런타임에서 조용히 깨짐 ⛔️

즉,

“반쪽짜리 ViewController가 생성될 수 있다”

 

✅ init 주입 방식의 장점

init(
    authViewModel: AuthViewModel,
    profileSetupViewModel: ProfileSetupViewModel
)

 

이 방식은:

  • ViewController 생성 시점에
    • 필요한 의존성이 강제
  • 빠뜨리면
    • 컴파일 에러
  • ViewController는 항상
    • “완전한 상태”로 존재

👉 이건 의존성 주입(DI)의 정석이다.

 

🔑 한 줄 요약

bind()는 “사후 설정”이고
init()은 “생성 계약(contract)”이다.

 

지금 HomeViewController는:

  • 생성 시점에
    • AuthViewModel 필요
    • ProfileSetupViewModel 필요
  • 이게 명확한 설계 계약으로 드러남

그래서 init() 방식이 맞다.

728x90
LIST