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
'PulseBoard' 카테고리의 다른 글
| Firebase Google 로그인 튜토리얼을 “실서비스 아키텍처”로 리팩토링하기 (iOS) (0) | 2025.12.23 |
|---|---|
| 🤔 Firebase Apple 로그인 튜토리얼 코드 → 우리가 만든 구조 (1) | 2025.12.22 |
| 🤔 Firebase - Apple Login 하기 (Firebase Authentication, Apple Developer 설정) (0) | 2025.12.22 |
| Firebase Email/Password 인증을 사용하지 않은 이유— SNS 로그인만 채택한 서비스 설계 판단 기록 (0) | 2025.12.19 |
| Xcode + Sourcetree + GitHub 초기 연동 시 push 에러 해결기 (1) | 2025.12.17 |