앱에 저장해야할 정보는 어떻게 저장하면 좋을까
UserDefaults를 사용하면 앱의 첫 진입여부나 간단한 설정정보를 저장할 수 있었음.
하지만 민감정보(아이디, 패스워드, 카드 정보 등)을 UserDefaults에 저장하는 것은 좋은 방법이 아닐 수 있음.
UserDefaults vs Keychain
UserDefaults
샌드박스 내부에 저장되는 key-value 형태의 데이터 저장 인터페이스임. 앱을 삭제할 경우 데이터가 삭제됨.
앱 시작시점에 UserDefaults.plist 파일이 메모리에 로드되기 때문에 많은 데이터를 저장하는 것은 성능저하를 일으킬 수 있음.
그렇기 때문에 간단한 설정정보만 저장하는 것이 권고됨.
Keychain
디바이스 안에 암호화된 데이터 저장공간으로 앱 삭제 후 재설치해도 데이터가 남아있음. (샌드박스 외부 저장)
디바이스 lock할 경우 키체인도 잠기고, unlock하면 키체인도 풀림 (물리적 탈취는 키체인도 털릴 수 있다는...)
같은 개발자의 여러 앱은 키체인 정보를 공유할 수 있음 (Keychain Sharing)
저장단위는 키체인 아이템
Keychain Item
키체인의 저장단위가 되는 키체인 아이템은 "Data"와 "Attribute" 두가지 항목으로 구성되어 있음.
데이터는 암호화되어 저장되고 속성들은 항목의 접근성 제어 및 검색 시에 사용됨.
Using the Keychain to manage user secrets
왼쪽은 키체인 아이템 갱신 플로우, 중앙이 일반적인 인증 플로우, 오른쪽이 키체인 아이템 추가 플로우를 나타냄.
공식 홈페이지에서는 일반적인 경우에 사용자에게 계속 비밀번호를 묻지 않고 키체인에서 검색을 통해 찾아 인증하라고 함.
하지만 초기 상태(오른쪽 플로우)일 경우 유저에게 비밀번호를 요청하여 인증 후 키체인에 저장해야 함.
또한 인증정보의 변경이 일어났을 경우(왼쪽 플로우) 비밀번호를 새로 요청하여 인증 후 키체인에 업데이트 함.
Adding a password to the keychain
민감정보를 담을 구조체와 키체인 에러를 설정
struct Credentials {
static let server = "www.example.com"
var username: String
var password: String
}
enum KeychainError: Error {
case noPassword
case unexpectedPasswordData
case unhandledError(status: OSStatus)
}
키체인 추가
// 키체인 추가 함수
private func addKeychain() throws {
let account = credentials.username
let password = credentials.password.data(using: String.Encoding.utf8)!
// 저장에 사용되는 쿼리
let query: [String: Any] = [
kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: account,
kSecAttrServer as String: Credentials.server,
kSecValueData as String: password
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
}
쿼리를 설정하고 해당 쿼리로 "SecItemAdd"함수를 실행시켜 키체인에 저장함.
쿼리를 살펴보면
1. 어떤종류의 값을 저장할것인지(kSecClassInternetPassword: 인터넷 패스워드) 키체인 아이템 종류를 설정
2. 서버정보나 계정과 패스워드를 설정
SecItemAdd 함수는 status를 반환하는데 이 반환값이 실제로 사용되지 않더라도, 저장에 성공했는지 검증해야 함.
(중복저장일 경우 오류 발생가능)
키체인 검색
// 키체인 검색 함수
private func searchKeychain() throws -> Credentials {
let query: [String: Any] = [
kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: Credentials.server,
kSecMatchLimit as String: kSecMatchLimitOne, // 결과값 하나로 제한
kSecReturnAttributes as String: true, // 속성 요청(사용자 Account)
kSecReturnData as String: true // 데이터 요청암호 자체의 보관)
]
// 검색 시작
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status != errSecItemNotFound else { throw KeychainError.noPassword }
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
// 결과 추출 (Dictionary 형태로 제공)
guard let existingItem = item as? [String : Any],
let passwordData = existingItem[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: String.Encoding.utf8),
let account = existingItem[kSecAttrAccount as String] as? String else {
throw KeychainError.unexpectedPasswordData
}
let credentials = Credentials(username: account, password: password)
return credentials
}
SecItemCopyMatching으로 검색조건에 맞는 결과를 추출함.
결과는 Dictionary 형태로 제공되며 Data와 Attribute를 추출해낼 수 있음.
키체인 갱신
// 키체인 업데이트 함수
private func updateKeychain(newPassword: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: Credentials.server
]
let account = credentials.username
let password = newPassword.data(using: String.Encoding.utf8)!
let attributes: [String: Any] = [
kSecAttrAccount as String: account,
kSecValueData as String: password
]
// 업데이트 실행. 이름은 동일하고 비밀번호만 변경되어도 괜찮음
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard status != errSecItemNotFound else { throw KeychainError.noPassword }
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }
}
SecItemUpdate함수에 query를 통해 검색조건에 맞는 결과값을 찾아 attribute 인자를 사용해 갱신.
업데이트는 계정은 동일하고 비밀번호만 갱신하여도 됨.
키체인 삭제
// 키체인 삭제 함수
// 로그아웃할 경우 필요없는 항목의 삭제
private func deleteKeychain() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: Credentials.server
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainError.unhandledError(status: status) }
}
SecItemDelete함수에 query 인자를 통해 데이터를 삭제.
로그아웃 시 필요없는 항목을 삭제해줄 수 있음.
참고자료
'iOS > iOS 지식' 카테고리의 다른 글
[iOS] Fastlane + Github Action으로 CI/CD 구축하기(1) - Fastlane을 통한 TestFlight 업로드 (2) | 2025.03.13 |
---|---|
Swift Concurrency(3) - Behind the scenes (0) | 2025.02.25 |
[iOS] StackView의 Distribution과 Alignment (0) | 2024.11.28 |
Swift Concurrency(2) - Continutation (0) | 2024.11.11 |
Swift Concurrency(1) - Async와 Await (2) | 2024.11.10 |