Neoself의 기술 블로그

Keychain+UserDefaults+Combine을 활용해 iOS 로그인 플로우 고도화하기 본문

개발지식 정리/Swift

Keychain+UserDefaults+Combine을 활용해 iOS 로그인 플로우 고도화하기

Neoself 2024. 11. 7. 22:23

본 게시글은 Keychain, UserDefaults를 활용하여 로그인 여부를 영구적으로 관리하고 Combine을 통해 뷰를 업데이트하는 로그인 플로우 고도화 과정을 공유하고자 작성된 글입니다. 로그인 플로우 파악에 더 나아가, 자동로그인 및 자동 로그아웃 기능을 고려하시는 개발자 분들께 도움이 되시리라 생각합니다.

1. 기존 로그인 플로우의 한계

우선 제가 여태껏 구현해왔던 로그인 및 로그아웃 플로우를 간단하게 소개드리겠습니다.

AppState라는 싱글톤 패턴 기반의 클래스를 환경객체로 앱에 적용한 후, 로그인 여부 불린값 isLoggedIn을 통해 보이는 뷰를 관리하였습니다. 

class AppState: ObservableObject {
    static let shared = AppState()
    @Published var isLoggedIn: Bool = false
    @Published var isGuestMode: Bool = false
}

struct ContentView: View {
    @EnvironmentObject var appState: AppState
    
    var body: some View {
        if appState.isLoggedIn || appState.isGuestMode { // 게스트모드일 때에도 부분적으로 MainTabView 접근할 수 있도록 조건 추가
            MainTabView()
        } else {
            OnboardingView()
                .ignoresSafeArea()
        }
    }
}

 

@Published 속성 래퍼와 @ObservedObject 래퍼를 바탕으로 isLoggedIn 값이 변경되면 뷰가 자동으로 업데이트되게 하는 것도 필요했지만, 아래 코드와 같이 인증 관련 로직을 수행하는 뷰모델 혹은 네트워크 요청 핸들러 클래스 내부에서 상태값을 직접 접근하거나 변경해야 했기 때문에, 환경객체와 싱글톤 패턴을 동시에 사용하였습니다.

class ExampleViewModel: ObservableObject {
    let appState = AppState.shared // AppState 직접 접근

    func logout() {
        // ...    	
        appState.isLoggedIn = false
        clearAllUserData()
    }

    private func handleSuccessfulLogin(loginResponse: LoginResponse) {
        // ...
        appState.isLoggedIn = true // 
    }
    ...
}

class NetworkService: NetworkServiceProtocol {
    func request<T: Decodable>(
        _ endpoint: APIEndpoint,
        method: HTTPMethod = .get,
        parameters: Parameters? = nil
    ) -> AnyPublisher<T, APIError> {
        return Future { promise in
            var headers: HTTPHeaders = [:]
            // TODO: AppStorage 관련 변수값 or UserDefaults 값으로 분기처리하기
            if AppState.shared.isGuestMode {
                print("requesting API in guest Mode: returning...")
                return
            }

하지만, 위와 같은 플로우는 단순한 상태관리만을 통해 로그인여부가 트래킹되고 있기 때문에, 앱을 종료하였다가 다시 진입하면, AppState 클래스 내부 초기값인 false로 초기화되는 문제, 즉 상태 지속성 문제가 있었습니다.

때문에, 앱 실행 이후 뷰모델 단에서 서버와의 통신을 통해 로그인유지 여부 검증 과정이 완료되기 전까지는 온보딩 화면을 필수적으로 거쳐야 하는 문제가 있었습니다. 이는 유저들에게 Flickering 현상을 초래할 수 있기 때문에, 사용자 경험이 저해될 수도 있었습니다.

 

2. 새로운 인증 절차 플로우

2. 1 UserDefaults와 Keychain을 사용한 상태 지속성 문제 해결

우선 위에 언급한 상태 지속성 문제를 해결하고자 UserDefaults와 Keychain을 사용했습니다.

 

UserDefaults와 Keychain은 뭔가요?

UserDefaults는 앱 시작 시, 사용자의 기본 데이터베이스를 Key-value 쌍으로 저장하는 인터페이스입니다.

그리고, Keychain은 비밀번호, 인증서와 같은 민감한 데이터를 저장할 수 있는 암호화된 데이터 베이스입니다.

 

둘다, iOS 시스템에서 제공하는 기본 저장소이며, 앱을 종료하고 다시 실행해도 데이터가 유지된다는 공통점을 갖고 있지만, 암호화되지 않은 plist파일로 저장되는 UserDefaults와 달리 Keychain은 데이터가 암호화되어 높은 보안성을 제공한다는 차이점이 있으며, User Defaults와 달리 앱을 삭제하여도 데이터가 유지된다는 차이점이 있습니다.

 

이러한 각 수단의 특성에 맞춰 로그인 과정에서 발급되는 AccessToken과 RefreshToken은 Keychain을 통해, 그리고 isLoggedIn 상태값은 UserDefaults 인터페이스를 활용해 관리하였습니다.

 

실제 코드

// UserDefaultsManager.swift

import Foundation
import Combine

// UserDefaults에 사용되는 키값에 대한 열거형 정의
enum UserDefaultsKeys {
    static let isLoggedIn = "isLoggedIn"
    static let username = "username"
    static let appleUserEmails = "appleUserEmails"
}

final class UserDefaultsManager {
    static let shared = UserDefaultsManager() // 싱글톤 적용
    private let defaults: UserDefaults
    
    private init(defaults: UserDefaults = .standard) {
        self.defaults = defaults // UserDefaults 인터페이스의 싱글톤 접근
    }
    
    // isLoggedIn 상태값 private(set)으로 정의
    private(set) var isLoggedIn: Bool {
        get { defaults.bool(forKey: UserDefaultsKeys.isLoggedIn) }
        set { defaults.set(newValue, forKey: UserDefaultsKeys.isLoggedIn) }
    }
    
    // 자체 로그인 및 로그아웃 메서드 내부에 생성
    func login() {
        isLoggedIn = true
    }
    
    func logout() {
        KeychainManager.shared.clearToken()
        isLoggedIn = false
        username = nil
    }
}

우선 UserDefaults 인터페이스로 관리되는 상태값을 한 클래스에 집약시켜 관리하는 UserDefaultsManager 클래스를 정의한 후, 싱글톤 패턴을 적용해주었습니다. 여기서, isLoggedIn 변수는 앱 플로우에 큰 영향을 주는 변수값이기 때문에, 싱글톤으로 전역에서 접근은 가능하게 하되 별도의 write 동작은 클래스 외부에서 할 수 없도록 막아두어 의도되지 않은 동작을 예방하고자 했습니다.

import Foundation
import Security

enum KeychainError: Error {
    case unknown(OSStatus)
    case notFound
    case encodingError
}

enum KeychainKeys {
    static let serviceName = "com.example.app"
    
    static let accessToken = "accessToken"
    static let refreshToken = "refreshToken"
}

protocol KeychainManagerProtocol {
    func saveTokens(_ accessToken: String, _ refreshToken: String)
    func getAccessToken() -> String?
    func getRefreshToken() -> String?
    func clearToken()
}

class KeychainManager:KeychainManagerProtocol {
    static let shared = KeychainManager()
    
    private init() {}
    
    func getAccessToken() -> String? {
        do {
            return try retrieve(forKey: KeychainKeys.accessToken)
        } catch{
            print("getAccessToken Error in KeychainManager")
            return nil
        }
    }
    
    func getRefreshToken() -> String? {
        do {
            return try retrieve(forKey: KeychainKeys.refreshToken)
        } catch{
            print("getRefreshToken Error in KeychainManager")
            return nil
        }
    }
    
    func saveTokens(_ accessToken: String, _ refreshToken: String) {
        do{
            try save(token: accessToken, forKey: KeychainKeys.accessToken)
            try save(token: refreshToken, forKey: KeychainKeys.refreshToken)
        } catch{
            print("Save token Error in KeychainManager")
        }
    }
    
    func clearToken() {
        do{
            try delete(forKey: KeychainKeys.accessToken)
            try delete(forKey: KeychainKeys.refreshToken)
        } catch{
            print("clear token Error in KeychainManager")
        }
    }
}

// MARK: 내부용 핵심 함수
private extension KeychainManager {
    func save(token: String, forKey key: String, service: String = KeychainKeys.serviceName) throws {
        guard let data = token.data(using: .utf8) else {
            throw KeychainError.encodingError
        }
        
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
        ]
        
        let status = SecItemAdd(query as CFDictionary, nil)
        
        if status == errSecDuplicateItem {
            // Item already exists, let's update it
            let updateQuery: [String: Any] = [
                kSecClass as String: kSecClassGenericPassword,
                kSecAttrService as String: service,
                kSecAttrAccount as String: key
            ]
            let updateAttributes: [String: Any] = [kSecValueData as String: data]
            let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary)
            
            guard updateStatus == errSecSuccess else {
                throw KeychainError.unknown(updateStatus)
            }
        } else if status != errSecSuccess {
            throw KeychainError.unknown(status)
        }
    }
    
    func retrieve(forKey key: String, service: String = KeychainKeys.serviceName) throws -> String {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecMatchLimit as String: kSecMatchLimitOne,
            kSecReturnData as String: true
        ]
        
        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        
        guard status != errSecItemNotFound else {
            throw KeychainError.notFound
        }
        guard status == errSecSuccess else {
            throw KeychainError.unknown(status)
        }
        
        guard let data = result as? Data, let token = String(data: data, encoding: .utf8) else {
            throw KeychainError.unknown(status)
        }
        
        return token
    }
    
    func delete(forKey key: String, service: String = KeychainKeys.serviceName) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key
        ]
        
        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unknown(status)
        }
    }
}

다음으로 Keychain을 통해 관리하는 상태값들과 함께 상태값 저장(C), 상태값 조회(R), 상태값 삭제(D) 로직들을 집약시켜놓은 KeychainManager 클래스를 생성해주었습니다. UserDefaults와 마찬가지로, 클래스 외부에서 직접 Keychain 값을 변경하는 것을 막기 위해, 핵심 로직인 save, retrieve와 delete는 클래스 내부에 정의된 함수를 통해서만 호출할 수 있도록 private extension 내부에 정의하였습니다.

 

2.2 영구저장되는 상태값들을 활용하는 인증 플로우

영구 저장되는 상태값을 다루는 핵심 2개 클래스를 생성하였으니, 이제 이 상태값들을 활용하는 로직들을 설명드리겠습니다.

 

로그인 완료처리

가장 먼저 handleSuccessfulLogin 함수입니다. 서버에서 로그인 및 회원가입 처리가 완료되면서 사용자 인증토큰을 반환할때, 이를 클라이언트에서 받아 최종적으로 처리하는 함수입니다. KeychainManager를 통해 서버에서 반환받은 토큰값들을 저장하고, UserDefaultsManager에서 정의한 login 내부함수를 호출해 isLoggedIn 변수값을 true로 변경하는 로직입니다.

class CreateAccountViewModel: ObservableObject {
    // private let appState: AppState = AppState.shared // 제거
    ...
	func handleSuccessfulLogin(accessToken: String,refreshToken:String) {
        // appState.isLoggedIn = true // 제거
        KeychainManager.shared.saveTokens(accessToken, refreshToken)
        UserDefaultsManager.shared.login()
    }
    ...
}

 

네트워크 요청로직

class NetworkService: NetworkServiceProtocol {
    func request<T: Decodable>(
        _ endpoint: APIEndpoint,
        method: HTTPMethod = .get,
        parameters: Parameters? = nil
    ) -> AnyPublisher<APIResult<T>, APIError> {
        return Future { promise in
        // handleSuccessfulLogin 함수에서 저장했던 accessToken을 retrieve(읽기)
            guard let token = KeychainManager.shared.getAccessToken() else {
                self.handleUnauthorized() // 토큰값이 없을 경우, 자동 로그아웃
                return
            }
            // 접근한 토큰값을 헤더에 주입하여 api 호출
            let headers: HTTPHeaders = ["Authorization": "Bearer \(token)"]
            
            AF.request(
                APIConstants.baseUrl + endpoint.path,
                method: method,
                parameters: parameters,
                encoding: JSONEncoding.default,
                headers: headers
            )
            .validate()
            .responseData { response in
                if let statusCode = response.response?.statusCode {
                    switch statusCode {
                    case 401: // 토큰유효기간이 만료되어도, 자동 로그아웃
                        self.handleUnauthorized()
                        return
                    ...
                    }
                }
                ...
            }
        }
        .eraseToAnyPublisher()
    }
    // UserDefaultsManager에서 정의한 logout함수 호출
    private func handleUnauthorized() {
        UserDefaultsManager.shared.logout()
    }   
}

// UserDefaultsManager.swift
final class UserDefaultsManager {
    static let shared = UserDefaultsManager()
    ...
 	func logout() {
        KeychainManager.shared.clearToken()
        isLoggedIn = false
        username = nil
    }
}

이렇게 KeychainManager을 통해 저장한 AccessToken값은 네트워크 요청 핸들러에서 인증 헤더 작성을 위해 접근됩니다. 여기서 토큰값이 접근이 되지 않거나, 서버로부터 401 상태코드를 받게 되면 로그아웃 함수를 호출하게 로직을 구성하였는데, 이는 api에 대한 비정상적인 접근과 토큰의 유효기간이 만료되었을 상황에서의 자동 로그아웃을 구현한 것입니다. 정상적으로 로그인과 회원가입을 할 경우, 앞서 말씀드린 handleSuccessfulLogin 함수를 필연적으로 거쳐가기 때문에 AccessToken이 Keychain에 저장된 상태에서 api를 호출하게 되겠죠.

2. 3 그래서 뷰 업데이트는 어떻게 해결한거죠.

여기서 개발 짬이 좀 있으신 분이면, 뷰 업데이트 해결 내용은 언제쯤 나올까 의문이 드실겁니다.

저는 개발 짬이 낮아서, UserDefaultsManager를 다 만들고 나서야 뷰 업데이트 문제가 아직 해결되지 않았다는 것을 깨달았습니다.

UserDefaults 인터페이스를 활용하여 isLoggedIn 상태값을 영구 유지할 수 있게 되었지만, @Published 속성 레퍼와 같은 SwiftUI로의 상태변경 전파 매커니즘이 부재하면 UserDefaults로 아무리 값을 변경하여도 온보딩뷰와 실제 메인화면을 바꿔줄 수 없겠죠.

struct ContentView: View {
    private let userDefaults = UserDefaultsManager.shared
    
    var body: some View {
        if userDefaults.isLoggedIn { // 변경되어도, SwiftUI 뷰 업데이트가 진행되지 않음
            MainTabView()
        } else {
            OnboardingView()
                .ignoresSafeArea()
        }
    }
}

따라서, SwiftUI에게 상태변경을 전파해줄 수 있는 AppState 환경객체 사용을 유지하되, Combine 프레임워크의 Publisher-Subscriber 패턴을 통해 AppState 내부 @Published 래퍼가 적용된 isLoggedIn 변수가 UserDefaultsManager 내부에서 관리하는 isLoggedIn 변수값에 맞춰 계속 추적하고 동기화 될수 있게 구성해주었습니다.

final class UserDefaultsManager {
    static let shared = UserDefaultsManager()
    private let defaults: UserDefaults
    
    // 다른 클래스에서 로그인 상태 변화를 구독하여 실시간 대응 가능하게끔 Publisher 생성
    var isLoggedInPublisher: CurrentValueSubject<Bool, Never>
    
    private init(defaults: UserDefaults = .standard) {
        self.defaults = defaults
        //Publisher 초기값 설정
        isLoggedInPublisher = CurrentValueSubject<Bool, Never>(
            defaults.bool(forKey: "isLoggedIn")
        )
    }
    
    private(set) var isLoggedIn: Bool {
        get { defaults.bool(forKey: UserDefaultsKeys.isLoggedIn) }
        set {
            defaults.set(newValue, forKey: UserDefaultsKeys.isLoggedIn)
            isLoggedInPublisher.send(newValue) 
            // isLoggedIn 값이 변경될 때, Publisher를 통해 상태 변경을 구독자(AppState)에게 알림
        }
    }
}
class AppState: ObservableObject {
    // UserDefaultsManager 내부 isLoggedIn 상태값을 초기값으로 설정
    @Published var isLoggedIn = UserDefaultsManager.shared.isLoggedIn 
    @Published var currentToast: ToastType?
    @Published var isGuestMode: Bool = false
    
    // 구독 저장소, 뷰 모델해제될때 안에 저장된 cancellable들도 해제됨
    private var cancellables = Set<AnyCancellable>() 
    
    init() {
        UserDefaultsManager.shared.isLoggedInPublisher
            .receive(on: DispatchQueue.main) // 메인 스레드에서 반응
            // UserDefaultsManager 내부 isLoggedIn 변수값 변경을 구독하고 있기 때문에 즉시 반응
            .sink { [weak self] newValue in  
                self?.isLoggedIn = newValue
            }
            .store(in: &cancellables) 
            // 구독 저장함에 따라, ViewModel이 해제되면 같이 해제됨.
            // 저장하지 않으면, ViewModel이 해제되어도 계속 활성화되면서 메모리 누수 발생
    }
}

 

최종적으로, AppState를 환경객체로 가져와 ContentView에서 뷰 분기점으로 사용하게 되면, 로그인 여부에 따라 뷰 업데이트가 성공적으로 진행되는 것을 확인할 수 있습니다.

struct ContentView: View {
    @EnvironmentObject var appState: AppState
    
    var body: some View {
        if appState.isLoggedIn || appState.isGuestMode {
            MainTabView()
        } else {
            OnboardingView()
                .ignoresSafeArea()
        }
    }
}

 

감사합니다.