Neoself의 기술 블로그

Keychain과 UserDefaults를 활용한 사용자 정보 관리 본문

개발지식 정리/Swift

Keychain과 UserDefaults를 활용한 사용자 정보 관리

Neoself 2024. 10. 7. 12:37

iOS 앱개발에서 사용자의 정보를 안전하게 관리하기 위해 Apple이 제공하는 매커니즘은 2가지가 있습니다.

바로 UserDefaults와 Keychain입니다. 먼저 이 두 매커니즘의 차이를 비교해보도록 하겠습니다.

UserDefaults Keychain
키-값 쌍으로 데이터 저장 데이터베이스가 암호화됨
앱이 삭제되면 데이터도 삭제됨 앱이 삭제되어도 데이터 유지
보안수준 상대적으로 약함 보안수준 높음
간단한 사용자 설정 및 비민감 정보 저장에 적합 비밀번호, 인증토큰 저장에 적합
사용용도: 언어, 최근 검색어, 앱 실행 횟수 ... 사용용도: 인증 토큰, 암호화 키 ...

 

제가 제작한 앱인 TyTE에서는 아래와 같이 KeychainManager 클래스를 따로 만들어 Keychain 관련 작업을 정리 및 추상화하였습니다.

먼저 전체 코드입니다.

import Foundation
import Security // Keychain 서비스 접근에 필요

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

    static func save(token: String, service: String, account: String) 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: account,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
        ]
        
        let status = SecItemAdd(query as CFDictionary, nil)
        
        if status == errSecDuplicateItem {
            let updateQuery: [String: Any] = [
                kSecClass as String: kSecClassGenericPassword,
                kSecAttrService as String: service,
                kSecAttrAccount as String: account
            ]
            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)
        }
    }

    static func retrieve(service: String, account: String) throws -> String {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            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
    }

    static func delete(service: String, account: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]

        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unknown(status)
        }
    }
}

 


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

가장 먼저 Keychain 작업 중 발생할 수 있는 오류상황들을 enum 형태로 정리하였습니다.

 

let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: service,
    kSecAttrAccount as String: account,
    kSecValueData as String: data,
    kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]

이후 Keychian 항목을 추가하기 위한 Query Dictionary를 생성하였습니다. 여기서 좀 생소한 단어들이 많이 나오는데요...

Keychain Services API의 상수 접두사인 kSec으로 시작되는 쿼리들에 대해 속성을 정의하는 구문입니다. 

여기서 kSecClassGenericPassword는 사용될 키체인 서비스의 클래스(kSecClass)가 일반 암호항목임을 나타내며,

kSecAttrService(서비스 이름), kSecAttrAccount(계정이름)를 통해 추후 전달받는 인자를 키체인 서비스의 식별자를 지정하고

kSecValueData를 통해 실제 전달받는 데이터, 즉 토큰을 지정합니다.

마지막으로 kSecAttrAccessible은 항목의 접근성을 설정하는데요. "kSecAttrAccessibleWhenUnlockedThisDeviceOnly"를 통해 디바이스가 잠금 해제된 상태에서만 접근가능하도록 설정해줍니다.

 

이후, SecItemAdd 함수를 사용해 Keychain에 새 항목을 추가해준 후, 반환받는 상태값을 대응로직에 활용하기 위해 status 변수에 할당시킵니다. 

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: account
    ]
    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)
}

위 구문은 새 항목 추가 시도 이후, 발생하는 에러를 처리하는 조건문인데요. Keychain에 이미 해당 항목이 존재할 경우 Keychain 업데이트를 위한 쿼리 및 속성을 다시 정의, SecItemUpdate 함수를 사용해 기존 Keychain 아이템을 갱신해주고, 업데이트에 실패하거나, 기존 새 항목 추가가 원활히 되지 않았을 시, unknown 에러를 throw 합니다.

 

이어지는 retrieve, delete 메서드 또한 save와 유사한 패턴으로 사용된 쿼리 및 속성을 정의한 후, 데이터를 받아 검색 및 삭제 과정을 수행하는 것을 확인할 수 있을 것입니다.

 

제 기존 프로젝트인 TyTE의 경우, 위에 설명한 KeychainManager를 UserDefaults와 조합해 토큰 핸들링 메서드들을 구성하였는데요. 먼저 전체 코드를 공유드리겠습니다.

class APIManager {
    func saveToken(_ token: String, for email: String) {
        do {
            try KeychainManager.save(token: token,
                                     service: AuthConstants.tokenService,
                                     account: email)
            UserDefaults.standard.set(email, forKey: "lastLoggedInEmail")
        } catch KeychainManager.KeychainError.unknown(let status) {
            print("Failed to save token. Unknown error with status: \(status)")
        } catch KeychainManager.KeychainError.encodingError {
            print("Failed to save token. Encoding error.")
        } catch {
            print("Failed to save token: \(error.localizedDescription)")
        }
    }
    
    private func getToken() -> String? {
        guard let email = UserDefaults.standard.string(forKey: "lastLoggedInEmail") else {
            return nil
        }
        
        do {
            return try KeychainManager.retrieve(service: AuthConstants.tokenService,
                                                account: email)
        } catch {
            print("Failed to retrieve token: \(error.localizedDescription)")
            return nil
        }
    }
        
    func clearToken() {
        guard let email = UserDefaults.standard.string(forKey: "lastLoggedInEmail") else {
            return
        }
        
        do {
            try KeychainManager.delete(service: AuthConstants.tokenService,
                                       account: email)
            UserDefaults.standard.removeObject(forKey: "lastLoggedInEmail")
        } catch {
            print("Failed to clear token: \(error.localizedDescription)")
        }
    }
}

토큰을 저장하는 메서드의 경우, KeychainManager.save()를 호출해 앞서 설명한 Keychain 아이템 생성 로직을 실행하고, 해당 로직을 실행한 사용자를 식별하기 위해 이메일 주소를 UserDefaults를 활용해 lastLoggedInEmail라는 키에 저장합니다. 

이렇게 UserDefaults로 저장된 이메일 주소는 추후 getToken 메서드에서 Keychain 서비스 접근 유무를 결정하는 데에 사용됩니다. 만일 lastLoggedInEmail 값이 비어있다면, 해당 기기에서 Keychain 서비스 접근, 즉 회원가입이나 로그인을 한 적이 없다는 것이기에 nil을 조기 반환하여 불필요한 Keychain 서비스 접근을 막습니다.

이메일이 있는 것이 확인될 경우, KeychainManager.retreive()를 이용해 토큰을 검색 및 가져오게 됩니다. 이때 저는 전역적으로 서비스명에 접근하기 위해 AuthConstants 클래스 내부 tokenService 변수를 생성 및 접근하였는데, 코드 상에 서비스명이 노출되는 것은 상관이 없습니다.

마지막으로 토큰을 비우는 메서드도 마찬가지로 lastLoggedInEmail을 검색해 비울 토큰의 존재여부를 확인한 후, 이메일값이 존재할 경우, KeychainMagager.delete 메서드를 통해 토큰을 Keychain에서 삭제하는 로직을 구현하였습니다.

 

감사합니다.