✅ 결론부터 말하면
HomeViewController에서 ProfileSetupViewModel을 직접 만들어서
“프로필 유무 판단”만 시키고
그 결과를 route로 받아서 Sheet를 띄우는 구조는 아주 좋다.
다만 중요한 조건 3가지를 지켜야 깔끔 👇
- HomeViewController는 “판단 요청”만 한다
- 프로필 존재 여부 판단은 ProfileSetupViewModel의 책임
- 화면 전환 의도는 오직 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() 방식이 맞다.
'PulseBoard > Profile' 카테고리의 다른 글
| 프로필 온보딩 로직 설계하기: ViewModel 책임 분리와 검증 로직의 경계 (1) | 2026.01.06 |
|---|---|
| FirebaseProfileImageUploader 설계 — 프로필 이미지를 왜 분리해야 하는가 (0) | 2026.01.05 |
| FirestoreUserRepository 설계 — 사용자 프로필을 안전하게 관리하는 방법 (0) | 2026.01.04 |
| UserRepository 설계 — Firebase 프로필 데이터를 어떻게 다룰 것인가 (1) | 2026.01.03 |
| Firebase 기반 사용자 프로필 관리 전략 — 서버 단일 소스 + 세션 캐시 (0) | 2026.01.03 |