본문 바로가기
PulseBoard

UIKit + Firebase Apple 로그인, MVVM & Coordinator로 설계하기

by 밤새는 탐험가89 2025. 12. 22.
728x90
SMALL

📁 Auth 모듈 구조 & 역할 정리

Auth
 ├─ SocialLoginProvider.swift
 ├─ AuthError.swift
 ├─ AuthProviding.swift
 ├─ AppleAuthHandler.swift
 ├─ AuthService.swift
 └─ AuthViewModel.swift

 

왜 Auth 구조를 따로 설계했는가?

  • SNS 로그인은 외부 의존성이 강하다
  • ViewController에 넣으면 코드가 비대해진다
  • Firebase / Apple SDK에 UI가 직접 의존하게 된다

👉 그래서 Auth를 하나의 모듈로 분리

 

1️⃣ SocialLoginProvider.swift

✅ 역할 (한 줄)

지원하는 SNS 로그인 타입을 정의하는 추상화 레이어

 

// MARK: - Social Login Provider
/// 앱에서 지원하는 SNS 로그인 타입을 정의합니다.
///
/// View / ViewModel 레벨에서는
/// "Apple인지, Google인지"만 구분하면 되도록 하기 위한 추상화입니다.
///
/// 실제 로그인 구현 여부와는 무관하며,
/// 아직 구현되지 않은 Provider도 enum 레벨에서는 미리 정의해둘 수 있습니다.
enum SocialLoginProvider {
    case apple
    case google
    case kakao
    case naver
}

 

왜 필요한가?

  • 지금은 Apple만 쓰지만
  • Auth 구조를 “Apple 전용”으로 고정시키지 않기 위해

📌 포인트

  • ViewModel / View는 “Apple인지 Google인지” 몰라도 됨
  • 나중에 확장 시 switch 분기 최소화

 

2️⃣ AuthError.swift

✅ 역할

Auth 도메인에서 발생하는 에러를 명확한 의미 단위로 정의

 

// MARK: - Auth Error
/// Auth 도메인에서 발생할 수 있는 에러를 의미 단위로 정의합니다.
///
/// Firebase / Apple SDK 에러를 그대로 노출하지 않고,
/// "우리 서비스 관점에서 해석 가능한 에러"로 감싸기 위한 목적입니다.
enum AuthError: Error {

    /// 현재 로그인된 사용자가 없는 상태
    /// (예: 로그아웃 상태에서 탈퇴를 시도한 경우)
    case userNotFound

    /// 아직 구현되지 않은 SNS 로그인 Provider를 선택한 경우
    case unsupportedProvider

    /// 로그인에 필요한 인증 정보를 생성할 수 없는 상태
    /// (예: Apple identityToken 없음, nonce 유실 등)
    case invalidCredential
}

 

왜 필요한가?

  • Firebase 에러 그대로 쓰면:
    • 의미가 불분명
    • UI 처리 어려움
  • “우리 서비스 관점의 에러”로 감싸기 위함

📌 포인트

  • ViewModel / View는 FirebaseError를 몰라도 됨
  • 에러 메시지 매핑 쉬워짐

 

3️⃣ AuthProviding.swift 

✅ 역할

Auth 기능의 “인터페이스(계약)” 정의

 

// MARK: - AuthProviding
/// Auth 모듈이 제공해야 하는 기능을 정의한 인터페이스입니다.
///
/// ViewModel은 이 Protocol만 의존하며,
/// Firebase / Apple / Google 등의 실제 구현 디테일은 알 필요가 없습니다.
protocol AuthProviding: AnyObject {

    // MARK: - Auth State

    /// 현재 로그인된 사용자의 UID
    /// (로그아웃 상태라면 nil)
    var currentUserUID: String? { get }

    /// Firebase Auth의 상태 변화를 관찰합니다.
    ///
    /// Auth는 "한 번 로그인하고 끝"이 아니라
    /// 로그인 / 로그아웃 / 탈퇴에 따라 상태가 변하는 구조이므로
    /// 상태 기반으로 UI를 전환하기 위해 사용합니다.
    func observeAuthState(_ handler: @escaping (String?) -> Void)

    // MARK: - Login

    /// 지정된 SNS Provider로 로그인을 시도합니다.
    ///
    /// - Parameters:
    ///   - provider: 사용자가 선택한 SNS 로그인 방식
    ///   - presentationContext: Apple 로그인 UI를 표시할 기준 View
    ///   - completion: 로그인 시도의 결과
    func login(
        with provider: SocialLoginProvider,
        from presentationContext: ASAuthorizationControllerPresentationContextProviding,
        completion: @escaping (Result<Void, Error>) -> Void
    )

    // MARK: - Logout / Delete

    /// 현재 로그인된 사용자를 로그아웃합니다.
    func logout() throws

    /// 현재 로그인된 사용자 계정을 삭제(탈퇴)합니다.
    func deleteAccount() async throws
}

 

왜 필요한가?

  • ViewModel이 AuthService의 구현을 몰라도 되게
  • 테스트 / Mock / 확장성 확보

📌 포인트

  • MVVM에서 가장 중요한 파일
  • “Auth가 무엇을 할 수 있는지”만 선언

 

4️⃣  AppleAuthHandler.swift — “Apple 로그인 전담 담당자” 

AppleAuthHandler는
Apple 로그인에만 필요한 복잡한 책임을 한 곳에 모아둔 전담 객체다.
이를 통해 AuthService는“어떤 로그인 방식을 선택할지”만 판단하고,
실제 Apple 인증 흐름은 신경 쓰지 않아도 된다.

 

import Foundation
import AuthenticationServices
import FirebaseAuth


// MARK: - AppleAuthHandler

/// Apple 로그인에 필요한 모든 로직을 전담하는 핸들러입니다.
///
/// nonce 생성, SHA256 해싱, ASAuthorizationControllerDelegate 처리 등
/// Apple 로그인 특유의 복잡도를 AuthService로부터 분리하기 위해 존재합니다.
final class AppleAuthHandler: NSObject {
    
    // MARK: - Properties
    
    /// Apple 로그인 요청과 응답을 연결하기 위한 nonce
    private var currentNonce: String?
    
    /// 로그인 시도의 결과를 AuthService로 전달하기 위한 completion
    private var completion: ((Result<Void, Error>) -> Void)?
    
    
    // MARK: - Login

    /// Apple 로그인 요청을 시작합니다.
    func startLogin(
        presentationContext: ASAuthorizationControllerPresentationContextProviding,
        completion: @escaping (Result<Void, Error>) -> Void
    ) {
        self.completion = completion
        
        let nonce = CryptoUtils.randomNonceString()   // nonce 만들기 - "이번 요청이 우리 앱이 만든 요청"이라는 증거
        currentNonce = nonce
        
        let request = ASAuthorizationAppleIDProvider().createRequest()
        request.requestedScopes = [.fullName, .email]
        request.nonce = CryptoUtils.sha256(nonce)     // Apple 요청에 nonce 해시 넣기
        
        let controller = ASAuthorizationController(
            authorizationRequests: [request]
        )
        
        // Delegate에서 결과 받음
        controller.delegate = self
        controller.presentationContextProvider = presentationContext
        controller.performRequests()
    }
}


// MARK: - ASAuthorizationControllerDelegate

extension AppleAuthHandler: ASAuthorizationControllerDelegate {

    func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithAuthorization authorization: ASAuthorization
    ) {
        do {
            guard
                let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
                let nonce = currentNonce,
                let tokenData = credential.identityToken,
                let idTokenString = String(data: tokenData, encoding: .utf8)
            else {
                throw AuthError.invalidCredential
            }

            // Apple 인증 결과를 Firebase에서 인식 가능한 Credential로 변환
            let firebaseCredential = OAuthProvider.appleCredential(
                withIDToken: idTokenString,
                rawNonce: nonce,
                fullName: credential.fullName
            )

            // Firebase에 Signin 요청
            Auth.auth().signIn(with: firebaseCredential) { _, error in
                if let error {
                    self.completion?(.failure(error))
                } else {
                    self.completion?(.success(()))
                }
            }

        } catch {
            completion?(.failure(error))
        }
    }

    func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithError error: Error
    ) {
        completion?(.failure(error))
    }
}

 

 

5️⃣. AuthService.swift — “Auth 모듈의 통합 창구” 

이 Auth 구조는 SNS 로그인이라는 외부 의존성이 강한 기능을 하나의 모듈로 격리하고,
View / ViewModel이 Firebase나 Apple SDK에 직접 의존하지 않도록 설계되었다.

특히 Apple 로그인은 AppleAuthHandler로 분리하여 AuthService의 책임을 최소화하고,
향후 Google / Kakao / Naver 로그인 확장에도 유연하게 대응할 수 있다.

import Foundation
import AuthenticationServices
import FirebaseAuth


// MARK: - AuthService

/// AuthProviding을 구현하는 실제 Auth 비즈니스 로직 담당 클래스입니다.
///
/// 로그인 방식 선택, Firebase Auth 연동,
/// Auth 상태 감시를 통합적으로 관리합니다.
final class AuthService: AuthProviding {
    
    
    // MARK: - Properties
    
    /// Apple 로그인 전담 핸들러
    private let appleHandler = AppleAuthHandler()
    
    /// Firebase Auth 상태 리스너 핸들
    private var authStateHandle: AuthStateDidChangeListenerHandle?
    
    
    // MARK: - Auth State
    
    var currentUserUID: String? {
        Auth.auth().currentUser?.uid
    }
    
    
    func observeAuthState(_ handler: @escaping (String?) -> Void) {
        // Firebase Auth 상태 감시
        authStateHandle = Auth.auth().addStateDidChangeListener { _, user in
            handler(user?.uid)
        }
    }
    
    
    // MARK: - Login
    
    func login(
        with provider: SocialLoginProvider,
        from presentationContext: ASAuthorizationControllerPresentationContextProviding,
        completion: @escaping (Result<Void, Error>) -> Void
    ) {
        // 어떤 로그인 방식을 쓸지 분기
        // Google, Kakao, Naver 추가할 때, handler 만들어 case 추가
        switch provider {
            
        case .apple:
            appleHandler.startLogin(
                presentationContext: presentationContext,
                completion: completion
            )
            
        case .google,
                .kakao,
                .naver:
            completion(.failure(AuthError.unsupportedProvider))
        }
    }
    
    
    // MARK: - Logout
    
    func logout() throws {
        try Auth.auth().signOut()
    }
    
    
    // MARK: - Delete Account
    
    func deleteAccount() async throws {
        guard let user = Auth.auth().currentUser else {
            throw AuthError.userNotFound
        }
        try await user.delete()
    }
    
    deinit {
        if let handle = authStateHandle {
            Auth.auth().removeStateDidChangeListener(handle)
        }
    }
    
}

 

 

6️⃣ AuthViewModel.swift 

AuthViewModel은 View(UIKit)와 AuthService 사이의 중재자 역할
을 하며, 인증 결과를 “상태 변화”와 “에러 이벤트”로 분리해 외부에 전달한다.
import Foundation
import AuthenticationServices

// MARK: - AuthViewModel
/// Auth 화면과 AuthService 사이를 연결하는 ViewModel입니다.
///
/// - ViewController로부터 사용자 액션(로그인/로그아웃/탈퇴)을 전달받고
/// - AuthService의 결과를 UI가 처리하기 쉬운 형태로 변환하여 외부로 전달합니다.
///
/// 이 ViewModel은 비즈니스 로직을 직접 수행하지 않으며,
/// Auth 상태 변화와 에러 이벤트를 중계하는 역할에 집중합니다.
final class AuthViewModel {

    // MARK: - Dependencies

    /// Auth 기능을 제공하는 서비스 객체
    private let authService: AuthProviding

    // MARK: - Outputs

    /// 로그인 / 로그아웃 / 탈퇴 등으로 인해
    /// 인증 상태가 변경될 때 호출됩니다.
    ///
    /// - Parameter uid: 로그인 상태라면 사용자 UID, 로그아웃 상태라면 nil
    var onAuthStateChanged: ((String?) -> Void)?

    /// Auth 과정 중 발생한 에러를 외부로 전달합니다.
    var onError: ((Error) -> Void)?

    // MARK: - State

    /// 현재 로그인된 사용자의 UID
    ///
    /// RootCoordinator 등 상위 레이어에서
    /// 안전하게 현재 인증 상태를 조회하기 위해 사용됩니다.
    var currentUserUID: String? {
        authService.currentUserUID
    }

    // MARK: - Initialization

    init(authService: AuthProviding = AuthService()) {
        self.authService = authService
        observeAuthState()
    }

    // MARK: - Private

    /// Firebase Auth 상태 변화를 관찰하고
    /// 상태가 바뀔 때마다 외부로 전달합니다.
    private func observeAuthState() {
        authService.observeAuthState { [weak self] uid in
            self?.onAuthStateChanged?(uid)
        }
    }

    // MARK: - Actions

    /// 지정된 SNS Provider로 로그인을 시도합니다.
    func login(
        provider: SocialLoginProvider,
        from presentationContext: ASAuthorizationControllerPresentationContextProviding
    ) {
        authService.login(
            with: provider,
            from: presentationContext
        ) { [weak self] result in
            if case let .failure(error) = result {
                self?.onError?(error)
            }
        }
    }

    /// 현재 로그인된 사용자를 로그아웃합니다.
    func logout() {
        do {
            try authService.logout()
        } catch {
            onError?(error)
        }
    }

    /// 현재 로그인된 사용자 계정을 삭제(탈퇴)합니다.
    func deleteAccount() async {
        do {
            try await authService.deleteAccount()
        } catch {
            onError?(error)
        }
    }
}

 

 

🤔 왜 @Published가 없을까? (중요 포인트) 

이 ViewModel에서 의도적으로:

  • @Published ❌
  • Combine ❌

을 사용하지 않은 이유는 👇

Auth는 “지속적으로 바뀌는 화면 상태”가 아니라
“이벤트와 상태 전환” 중심의 기능이기 때문

  • 로그인 성공 → 화면 전환
  • 로그아웃 → 루트 전환
  • 탈퇴 → 초기화

👉 그래서 Closure 기반 Output이 가장 적합함

 

7️⃣ RootCoordinator.swift

RootCoordinator는 Auth 상태(uid 존재 여부)만을 기준으로
앱의 최상위 화면(Login / Home)을 결정하는 전담 객체다.

import Foundation
import UIKit

// MARK: - RootCoordinator
/// 앱의 최상위 진입점을 관리하는 Coordinator입니다.
///
/// RootCoordinator는 인증(Auth) 상태만을 기준으로
/// Login 화면과 Home 화면을 분기하는 역할을 담당합니다.
///
/// ❌ 로그인 구현
/// ❌ Firebase 직접 접근
/// ❌ 비즈니스 로직
///
/// ⭕️ 오직 "현재 사용자가 로그인 상태인가?"만 판단합니다.
final class RootCoordinator {

    // MARK: - Properties

    /// 앱의 rootViewController를 교체하기 위한 UIWindow
    private let window: UIWindow

    /// Auth 상태를 관찰하기 위한 ViewModel
    private let authViewModel: AuthViewModel

    // MARK: - Initialization

    init(
        window: UIWindow,
        authViewModel: AuthViewModel = AuthViewModel()
    ) {
        self.window = window
        self.authViewModel = authViewModel
    }

    // MARK: - Start

    /// Coordinator 시작 지점
    ///
    /// 1. 앱 시작 시 현재 Auth 상태로 한 번 분기
    /// 2. 이후 Auth 상태 변화에 따라 root 화면을 갱신
    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 화면
    private func switchToRoot(uid: String?) {
        let rootVC: UIViewController

        if uid == nil {
            rootVC = makeLogin()
        } else {
            rootVC = makeHome()
        }

        // rootViewController 변경은 반드시 메인 스레드에서 수행
        DispatchQueue.main.async {
            self.window.rootViewController = rootVC
            self.window.makeKeyAndVisible()
        }
    }

    // MARK: - Factory Methods

    /// 로그인 화면 생성
    ///
    /// AuthViewModel을 주입하여
    /// 로그인 성공 시 Auth 상태 변경을 RootCoordinator가 감지할 수 있도록 합니다.
    private func makeLogin() -> UIViewController {
        let vc = LoginViewController()
        vc.bind(viewModel: authViewModel)
        return vc
    }

    /// 메인(Home) 화면 생성
    ///
    /// Login 화면과 동일한 AuthViewModel을 공유함으로써
    /// Auth 상태를 Single Source of Truth로 유지합니다.
    private func makeHome() -> UIViewController {
        let vc = HomeViewController()
        vc.bind(viewModel: authViewModel)
        return vc
    }
}

 

 

 8️⃣ SceneDelegate.swift

import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    /// 앱의 루트 흐름을 관리하는 Coordinator
    private var rootCoordinator: RootCoordinator?

    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let windowScene = scene as? UIWindowScene else { return }

        let window = UIWindow(windowScene: windowScene)
        self.window = window

        // RootCoordinator 생성 및 시작
        let coordinator = RootCoordinator(window: window)
        self.rootCoordinator = coordinator
        coordinator.start()
    }
}

 

✅ 전체 Auth 흐름에서 ViewModel의 위치

[ViewController]
   ↓ (사용자 액션)
[AuthViewModel]
   ↓
[AuthService]
   ↓
[AppleAuthHandler]
   ↓
[Firebase Auth]
   ↓
[AuthStateDidChangeListener]
   ↓
[AuthViewModel]
   ↓
[UI / Coordinator]
728x90
LIST