"Local DB"란 어플리케이션과 동일한 시스템에 있는 데이터베이스이다.
즉, 어플리케이션을 사용하는 핸드폰 혹은 테블릿 내에 위치한 저장소이다.
어플리케이션에서 매번 서버로부터 데이터를 가져오게 되면 엄청난 네트워킹 딜레이를 겪게 된다.
예를 들어, 카카오톡의 경우
친구목록을 항상 서버로부터 불러온다면,
해당 View에 접근할 때마다 친구목록을 fetch해오는 딜레이를 겪게 된다.
하지만, 이를 Local DB에서 fetch해오게 된다면 네트워킹 딜레이가 사라지게 된다.
이번 포스팅에선 Swift의 LocalDB 중
주로 간단한 형식의 데이터만 저장할 때 사용하는 "UserDefaults", "Keychain"에 대해서 알아보자.
UserDefaults
"UserDefaults"는
Single Tone형태로 제공되며,
Key-Value형태로 Local Stroage에 Persistent하게 데이터를 저장 혹은 가져오는 interface를 제공한다.
주로 다크모드인지 라이트모드인지와 같은 유저의 Default 동작을 저장하기 위해 사용한다.
Create & Update
"UserDefaults"는 앞서 말했듯 Key-Value 형태로 데이터가 저장된다.
이는 공식문서에서도 볼 수 있듯이, 데이터 타입은 String, Numeric, Bool 등을 저장할 수 있다.
이외에도 Array, Dictionary, Struct와 같은 객체도 Data 형태로 저장할 수 있다.
내부적으로 어떻게 저장될까?
UserDefaults는 데이터들을 내부적으로 plist 형태로 저장한다.
iOS의 경우는 plist 파일이 SandBox내의 Bundle Container에 저장되며,
MacOs의 경우는 다음과 같이 확인해 볼 수 있다.
UserDefaults를 통해 값을 저장하고 난 뒤에 breakPoint를 걸고 실행한 후,
po NSHomeDirectory()를 콘솔에 입력하게 되면, 다음과 같이 경로를 얻을 수 있다.
해당 경로로 이동하여 Library - Preferences로 이동하게 되면,
다음과 같이 UserDefaults에 setValue를 통해 저장한 데이터가 있는 것을 볼 수 있다.
하지만 실제 앱이 동작할 땐, 해당 경로를 통해서 가져오는 것이 아닌 이를 "Caching"하게 된다.
즉, "Caching"을 통해 런타임에 빠르게 가져올 수 있게 된다.
하지만 해당 이유로 용량이 큰 객체의 경우, 메모리 부족으로 이어질 수 있다.
따라서, 유저의 Default동작처럼 간단한 형식의 데이터만 저장하는데 주로 사용된다.
Thread-Safety
UserDefaults 클래스는 Thread-Safety하다.
값을 변경 혹은 저장하게 되면,
같은 Process내에서는 동기적으로 반영되고,
Persistence Stroage(Default Database)에는 비동기적으로 반영된다.
즉, 메모리에는 동기적으로 반영되고 디스크에는 비동기적으로 반영한다.
또한, 만약 여러 Thread에서 하나의 데이터에 write작업을 하게 되면,
먼저 들어온 요청을 처리하고 lock을 걸어버린다.
Read
다음과 같이 데이터를 가져올 수 있으며,
dictionaryRepresentation()는 Default Database에 저장된 모든 key-value 쌍을 가져올 수 있다.
만약 잘못된 키를 입력하게 되면,
리턴 타입이 Optional인 경우는 nil을 리턴하고,
리턴 타입이 Optional이 아니라면 0을 리턴한다.
Delete
해당 메서드를 통해 삭제할 수도 있고, setValue에 nil값을 저장해 삭제하는 방법도 있다.
func removeObject(forKey defaultName: String)
UserDefaults.standard.setValue(nil, forKey: "key")
Keychain
UserDefaults의 경우는 info파일로 key-value 쌍의 텍스트 형태로 저장한다.
하지만 이는, 유저의 API Token과 같은 민간함 정보를 저장하기에는 적합하지 않다.
이러한 민간함 정보들을 저장하기 위해 Apple에서는 Keychain을 제공한다.
"Keychain"이란 암호화된 데이터 베이스를 의미하며,
"Keychain Services API"는 keychain에 저장하는 메카니즘을 제공한다.
KeyChain은 사용자가 직접 제거하지 않는 이상, 앱을 삭제해도 남아있다.
또한 유저가 Device를 잠그게 되면, Keychain도 잠기게 되며
유저가 Device는 unlock하면 Keychain도 같이 unlock된다.
Keychain items
Keychain은 "Keychain Item"형태로 저장한다.
"Keychain Item"에는 저장하려는 "Data"외에도 "Attributes"가 같이 저장된다.
"Attributes"는 "Data"를 접근 및 공유되는 방식을 관리하고 검색할 수 있도록 제공하며,
"Data"는 암호화되어 저장된다.
즉 Data와 Data의 속성들이 Keychain Item이다.
이렇게 생성된 Keychain Item은 Keychain에 저장된다.
데이터를 가져올 때는 "Data"는 해독하여 가져온다.
CFDictionary
// Create
func SecItemAdd(
_ attributes: CFDictionary,
_ result: UnsafeMutablePointer<CFTypeRef?>?
) -> OSStatus
// Read
func SecItemCopyMatching(
_ query: CFDictionary,
_ result: UnsafeMutablePointer<CFTypeRef?>?
) -> OSStatus
// Update
func SecItemUpdate(
_ query: CFDictionary,
_ attributesToUpdate: CFDictionary
) -> OSStatus
// Delete
func SecItemDelete(_ query: CFDictionary) -> OSStatus
Keychain은 CRUD가 가능한데, 모두 CFDictionary타입의 쿼리문을 작성해야 한다.
쿼리문에는 공통적으로 Keychain Item 타입을 정의하는 kSecClass가 포함된다.
Keychain Item타입을 정의하는 kSecClass에는 다음과 같다.
kSecClassGenericPassword: 일반적인 password item을 나타내는 값kSecClassInternetPassword: 인터넷 password item을 나타내는 값kSecClassCertificate: 인증서 item을 나타내는 값kSecClassKey: 암호화된 key item를 나타내는 값kSecClassIdentify: ID item을 나타내는 값
더 자세한 항목은 애플 공식 문서에 나와있다.
CRUD별로 어떤 쿼리가 사용되는지 이제부터 자세히 알아보자.
Create
Keychain의 생성은 다음과 같은 메서드를 통해 가능하다.
func SecItemAdd(
_ attributes: CFDictionary,
_ result: UnsafeMutablePointer<CFTypeRef?>?
) -> OSStatus
Keychain생성을 위해선 추가할 Keychain Item을 CFDictionary형태인 쿼리문으로 전달해야 한다.
Keychain생성을 위한 쿼리문은 다음과 같이 구성된다.
kSecClass: Keychain Item의 타입을 정의한다.kSecValuData: Keychain에 저장될 Data를 정의한다.Data타입으로 정의되어야 한다.- (Optional)Attributes: "Data"를 접근 및 공유되는 방식을 관리하고 검색할 수 있도록 제공한다.
- (Optional)Return Types: 완료되면
SecItemAdd의result파라미터를 통해 리턴한다. Create에선 필요하지 않기에 아래 예시에선 사용하지 않는다.
kSecClass별로 사용될 수 있는 Attributes가 다른데, 자세한 내용은 애플 공식문서에 있다.
Token을 저장하기 위한 예시이기에, kSecClassKey로 아이템 타입을 정의해 주자.
kSecClassKey에 사용할 수 있는 Attribute는 애플 공식 문서에 자세히 정의되어 있다.
func addTokenOnKeyChain(_ token: String) {
guard let tokenData = token.data(using: .utf8) else { return }
let addQuery: [CFString: Any] = [
kSecClass: kSecClassKey,
kSecAttrLabel: "accessToken", // 아이템의 Label을 나타내는 Attribute
kSecAttrApplicationLabel: "Keychain-Demo", // 아이템의 어플 Label을 나타내는 Attribute
kSecValueData: tokenData // 저장될 데이터
]
...
}
저장은 SecItemAdd 메서드에 해당 쿼리문을 파라미터로 전달하면 된다.
let status = SecItemAdd(addQuery as CFDictionary, nil)
이후 SecItemAdd는 상태 코드를 리턴하게 된다.
if status == errSecSuccess {
print("저장 완료")
} else if status == errSecDuplicateItem {
updateTokenKeyChain()
} else {
print("저장 실패")
}
이미 존재하는 Keychain일 경우,
errSecDuplicateItem 상태 코드를 리턴하며 저장에 실패하게 되는데,
해당 경우 SecItemUpdate 메서드를 이용해야 한다.
Read
Keychain에서 데이터를 읽어오기 위해선 다음 메서드를 사용해야 한다.
func SecItemCopyMatching(
_ query: CFDictionary,
_ result: UnsafeMutablePointer<CFTypeRef?>?
) -> OSStatus
Read를 위한 쿼리문은 다음과 같이 구성된다.
kSecClass: 찾을 item의 타입을 정의한다.- Attrubutes: item의 Attributes를 정의하여 검색 범위를 좁힌다.
- Search Parameters: item의 갯수를 지정하는 등의 검색 조건을 지정한다. 더 자세한 내용은 애플 공식문서에 나와있다.
- Return Types:
result파라미터에 Data 혹은 Attributes를 전달할지 결정한다.
우선 쿼리 문부터 작성하면 다음과 같다.
let searchQuery: [CFString: Any] = [
kSecClass: kSecClassKey,
kSecAttrLabel: label, // keychain이 가지고 있는 Attribute
kSecReturnAttributes: true, // Attributes를 리턴할지에 대한 Result Type 쿼리
kSecReturnData: true // Data를 리턴할지에 대한 Result Type 쿼리
]
SecItemAdd와 다르게 return Type쿼리를 명시해 주었기 때문에,
result 파라미터도 같이 전달해주어야 한다.
var keychainItem: CFTypeRef?
let status = SecItemCopyMatching(
searchQuery as CFDictionary,
&keychainItem
)
이후, 검색에 성공한다면 검색의 결과는 keychainItem에 담기게 된다.
guard
status == errSecSuccess,
let item = keychainItem,
let labelAttr = item[kSecAttrLabel] as? String,
let token = item[kSecValueData] as? Data
else {
print("검색 실패, 상태 코드: \(status)")
return
}
print("labelAttr: \(labelAttr)")
print("token: \(String(data: token, encoding: .utf8) ?? "")")
만약 Data만 리턴하게 된다면,
let searchQuery: [CFString: Any] = [
kSecClass: kSecClassKey,
kSecAttrLabel: label, // keychain이 가지고 있는 Attribute
kSecReturnData: true // Data를 리턴할지에 대한 Result Type 쿼리
]
아래와 같이 result가 Dictionary형태로 리턴되지 않고, 단순히 item만 담게 된다.
guard
status == errSecSuccess,
let item = keychainItem,
let token = item as? Data
else {
print("검색 실패, 상태 코드: \(status)")
return
}
print("token: \(String(data: token, encoding: .utf8) ?? "")")
Update
func SecItemUpdate(
_ query: CFDictionary,
_ attributesToUpdate: CFDictionary
) -> OSStatus
item을 update하기 위해선,
update할 item을 찾는 쿼리문과
업데이트할 Attribute를 쿼리문을 작성해야 한다.
let searchQuery: [CFString: Any] = [
kSecClass: kSecClassKey,
kSecAttrLabel: label
]
let updateQuery: [CFString: Any] = [
kSecValueData: data
]
이 두 쿼리문을 메서드의 파라미터를 통해 전달한다.
let status = SecItemUpdate(
searchQuery as CFDictionary,
updateQuery as CFDictionary
)
Delete
func SecItemDelete(_ query: CFDictionary) -> OSStatus
Delete 역시 삭제할 Keychain item을 Search 쿼리문을 작성한다.
let searchQuery: [CFString: Any] = [
kSecClass: kSecClassKey,
kSecAttrLabel: label
]
let status = SecItemDelete(searchQuery as CFDictionary)
Performance Considerations
Keychain CRUD메서드들은 호출한 Thread에서 동기적으로 진행되기 때문에,
Main Thread에서 해당 메서드를 호출하게 된다면, UI Update가 멈추게 된다.
따라서 해당 메서드들을 호출해 줄 때는 background Dispatch Queue나 async function을 이용하여 호출하는 것이 좋다.
func addTokenOnKeyChain(
_ token: String,
_ completion: @escaping (OSStatus) -> Void
) {
guard let tokenData = token.data(using: .utf8) else { return }
let addQuery: [CFString: Any] = [
kSecClass: kSecClassKey,
kSecAttrLabel: "accessToken",
kSecAttrApplicationLabel: "Keychain-Demo",
kSecValueData: tokenData
]
DispatchQueue.global().async {
let status = SecItemAdd(addQuery as CFDictionary, nil)
completion(status)
}
}
Reference
Apple Documentation
- UserDefaults
- Keychain Services
- kSecClass
- Storing Keys in the Keychain
- Item Attribute Keys and Values
- Search Attribute Keys and Values
https://crystalminds.medium.com/where-are-the-standard-userdefaults-stored-d02bf74854ff
'iOS > iOS' 카테고리의 다른 글
[iOS] LocalDB(3) - Core Data CRUD (0) | 2023.10.25 |
---|---|
[iOS] Local DB(2) - Core Data Concept (0) | 2023.10.09 |
[iOS] Infinite Carousel (0) | 2023.07.23 |
[iOS] Floating Custom TabBarController (0) | 2023.07.22 |
[iOS] Modularization(2) - Loose Coopling (0) | 2023.05.18 |