iOS에서 데이터를 저장하는 다양한 방법들

Seoyoung Lee·2023년 5월 16일
2

졸업프로젝트

목록 보기
2/2
post-thumbnail

어느덧 졸업프로젝트가 막바지를 향하고 있다 🤯 스타트 때 열심히 주제를 고민하던 때가 엊그제 같은데..

다시 한 번 우리 팀의 프로젝트를 간단하게 소개하자면, 사용자가 영어로 말한 것을 STT로 변환하고 이를 기반으로 사용자의 영어 말하기를 분석해주는 앱이다.

프로젝트 개발 단계에서 가장 많이 신경쓰고 고민했던 부분은 앱 내에서 데이터를 저장하고 관리하는 것이었다.

열심히 UI들을 만들고 보니, 사용자 정보도 저장해야 하고, 이미지나 음성 파일들도 저장해야 하고… 생각보다도 데이터를 저장해야 하는 경우가 정말 다양했다.

실제로 iOS에서 데이터를 저장하는 방법은 굉장히 다양하다. 우리 프로젝트에서도 여러 방법을 사용해서 데이터를 저장했었다. 그래서 이 글에서 iOS에서 데이터를 저장하는 방법들을 우리 프로젝트에서 적용한 방식과 함께 소개하고자 한다. 👀

1. 사용자 정보를 저장할 때 - UserDefaults

우리 앱에서는 홈 화면뿐만 아니라 스피킹 분석 화면 등 사용자의 닉네임을 필요로 하는 화면이 여기저기에 있다.

닉네임, 이메일, 자기소개 같은 사용자의 데이터를 저장하기 위해서 UserDefaults 를 사용하였다.

UserDefaults가 뭐지?

UserDefaults는 사용자의 기본 데이터베이스에 대한 인터페이스이다. 키-값 쌍으로 저장되며, 앱을 실행하면 영구적으로 데이터가 저장된다.

주로 간단한 사용자의 정보나 설정 등을 저장할 때 사용된다.

UserDefaults 클래스의 메소드를 사용해서 BoolFloatDoubleIntStringURL 같은 기본 자료형 데이터를 쉽게 저장할 수 있다.

데이터 저장하기

UserDefaults를 사용하기 위해서는 먼저 UserDefaults의 인스턴스를 생성해주어야 한다.

let defaults = UserDefaults.standard

그 다음 set 메소드를 사용하여 데이터와 데이터에 대한 고유한 키값을 함께 저장해주면 된다.

defaults.set(25, forKey: "Age")
defaults.set(true, forKey: "UseTouchID")
defaults.set(CGFloat.pi, forKey: "Pi")

데이터 가져오기

그럼 UserDefaults에 저장된 데이터는 다시 어떻게 가져올 수 있을까? 데이터를 가져오는 것도 역시 아주 간단하다. 단, 데이터를 읽을 때는 저장된 데이터의 타입과 맞는 메소드를 호출해야 한다.

대표적인 메소드들은 다음과 같다.

  • integer(forKey:)
    • key 값이 존재하면 데이터를 반환하고, 그렇지 않으면 0을 반환한다.
  • bool(forKey:)
    • key 값이 존재하면 데이터를 반환하고, 그렇지 않으면 false를 반환한다.
  • float(forKey:)
    • key 값이 존재하면 데이터를 반환하고, 그렇지 않으면 0.0을 반환한다.
  • double(forKey:)
    • key 값이 존재하면 데이터를 반환하고, 그렇지 않으면 0.0을 반환한다.
  • object(forKey:)
    • Any? 를 반환하여 원하는 데이터 타입으로 형변환하여 사용할 수 있다.

SpeaKing에서 사용 방법

위 메소드들만 사용해도 문제는 없지만 약간의 불편한 점이 있다.

  1. UserDefaults를 사용할 때마다 UserDefaults.standard 에 접근해야 한다.
  2. 데이터의 키값을 기억하고 있어야 하고, 일일이 타이핑 해야 한다. 오타가 날 확률도 있다. 🤯

그래서 우리 프로젝트에서는 더 편리하게 UserDefaults에 접근할 수 있도록 UserDefaultsManager 라는 커스텀 클래스를 만들어서 사용하였다.

// UserDefaultsManager.swift

class UserDefaultsManager {
    enum UserDefaultsKeys: String, CaseIterable {
        case email
        case nickname
        case intro
    }
    
    static func setData<T>(value: T, key: UserDefaultsKeys) {
        let defaults = UserDefaults.standard
        defaults.set(value, forKey: key.rawValue)
    }
    
    static func getData<T>(type: T.Type, forKey: UserDefaultsKeys) -> T? {
        let defaults = UserDefaults.standard
        let value = defaults.object(forKey: forKey.rawValue) as? T
        return value
    }
    
    static func removeData(key: UserDefaultsKeys) {
        let defaults = UserDefaults.standard
        defaults.removeObject(forKey: key.rawValue)
    }
}
  • 클래스 내에서 UserDefaultsKeys 라는 열거형을 정의하여 필요한 키값을 일일이 타이핑하거나 기억할 필요 없이 편리하게 사용할 수 있도록 하였다.
  • 데이터를 생성하고, 읽고, 삭제하는 메소드를 구현하여 부가적인 설정 없이 바로바로 데이터를 관리할 수 있도록 하였다.

UserDefaultsManager 클래스를 사용한 실제 예시는 다음과 같다.

AF.request(url, method: .get, headers: headers)
            .validate()
            .responseDecodable(of: ProfileModel.self) { response in
                switch response.result {
                case .success(let response):
                    UserDefaultsManager.setData(value: response.result.email, key: .email)
                    UserDefaultsManager.setData(value: response.result.nickname, key: .nickname)
                    UserDefaultsManager.setData(value: response.result.intro, key: .intro)
                    completion()
                case .failure(let error):
                    debugPrint(error)
                }
            }

⬆️ 서버에서 사용자 정보를 받아온 후 UserDefaults에 저장

let nickname = UserDefaultsManager.getData(type: String.self, forKey: .nickname) ?? "사용자"

⬆️ 홈 화면에서 사용자의 닉네임을 표시하기 위해 UserDefaults에 저장된 사용자 닉네임 데이터를 가져옴

2. 민감한 정보를 저장할 때 - Keychain

SpeaKing 서버의 API들은 호출할 때 사용자의 JWT 토큰을 request header에 넣어주도록 구현되어 있다. 따라서 앱에서 SpeaKing 서버 API를 호출하기 위해 사용자의 JWT 토큰을 어딘가에 저장해두어야 한다.

🤔 토큰도 그냥 UserDefaults에 저장하면 되지 않을까?

→ Noooo!!!!!!!!

UserDefaults의 데이터들은 property list 문서 (.plist)에 저장된다. 사용자가 특정 툴 등을 사용하면 UserDefaults에 쉽게 접근해서 이 데이터를 확인하고 수정할 수 있다.

따라서 UserDefaults에 사용자의 비밀번호나 토큰 등 민감한 데이터들을 저장하는 것은 보안상 좋은 선택이 아니다.

Keychain?

Keychain은 비밀번호나 암호키 등 작은 개인정보 등을 안전하게 저장할 수 있는 곳이다. keychain service의 API를 사용해서 키체인 항목을 추가, 삭제, 수정할 수 있다.

데이터는 다음과 같은 과정으로 암호화되어 Keychain에 저장된다.

Keychain에 저장할 데이터를 키체인 아이템으로 감싼다. 그리고 아이템의 접근을 제어하고, 아이템을 검색할 수 있도록 공개적으로 볼 수 있는 속성 값들을 함께 정의한다.

Keychain service는 Keychain에서 데이터를 암호화하고 저장한다. 이때 Keychain은 디스크 내에 암호화되어 저장된 데이터베이스이다. 이후 아이템을 찾고 데이터를 복호화할 때에도 keychain service가 사용된다.

Keychain에 데이터 저장하기

이제 Keychain에 실제로 비밀번호를 저장하는 과정을 보겠다.

1. 쿼리 생성하기

var query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
                            kSecAttrAccount as String: account,
                            kSecAttrServer as String: server,
                            kSecValueData as String: password]

Keychain service를 사용할 때는 항상 먼저 쿼리를 생성해야 한다. 이때 쿼리는 현재 보고 있는 데이터에 대해 설명해주는 역할을 한다.

  • kSecClass: keychain service에게 데이터의 종류를 알려주는 역할. 여기서 저장할 키체인 아이템은 인터넷 비밀번호이다.
  • kSecAttrAccount: 사용자 이름
  • kSecAttrServer: 이 계정 정보를 사용하는 서버의 도메인명
  • kSecValueData: 사용자 비밀번호

2. 키체인 아이템 추가하기

위 쿼리를 사용해서 SecItemAdd(_:_:) 메소드를 호출하면 Keychain에 항목이 추가된다.

let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) }

이 메소드를 호출하면 keychain에 저장이 잘 되었는지 알려주는 결과 코드가 리턴된다. 이를 사용해서 정상적으로 저장되었는지 여부를 확인할 수 있다.

데이터 수정/삭제하기

키체인 항목을 수정하려면 먼저 키체인에 저장된 항목을 찾아야 한다. 따라서 데이터를 찾기 위한 쿼리를 생성한다.

let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
                            kSecAttrServer as String: server]

또한 새로 업데이트할 데이터의 내용에 대한 attribute도 함께 작성한다.

let account = credentials.username
let password = credentials.password.data(using: String.Encoding.utf8)!
let attributes: [String: Any] = [kSecAttrAccount as String: account,
                                 kSecValueData as String: password]

이 둘을 가지고 SecItemUpdate(_:_:) 메소드를 호출하면 키체인 아이템을 업데이트 할 수 있다.

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) }

아이템을 추가할 때와 마찬가지로 메소드의 리턴 값을 사용해서 업데이트 성공 여부를 확인한다.

아이템을 삭제할 때는 검색 쿼리를 가지고 SecItemDelete(_:) 메소드를 호출하면 된다.

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

SpeaKing에서 사용 방법

Keychain도 마찬가지로 더 쉽게 데이터를 관리하기 위해 KeychainManager 클래스를 만들어서 사용하였다.

// KeychainManager.swift

class KeychainManager {
    enum KeychainError: Error {
        case duplicateEntry
        case noToken
        case unknown(OSStatus)
    }
    
    static func save(userId: String, token: Data) throws {
        let query: [String: AnyObject] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: userId as AnyObject,
            kSecValueData as String: token as AnyObject,
        ]
        
        let status = SecItemAdd(
            query as CFDictionary,
            nil
        )
        
        guard status != errSecDuplicateItem else {
            throw KeychainError.duplicateEntry
        }
        
        guard status == errSecSuccess else {
            throw KeychainError.unknown(status)
        }
    }
    
    static func get() -> (userId: String, token: String)? {
        let query: [String: AnyObject] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecReturnAttributes as String: kCFBooleanTrue,
            kSecReturnData as String: kCFBooleanTrue,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        
        var result: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &result)
        
        guard let data = result as? [String: AnyObject],
              let tokenData = data[kSecValueData as String] as? Data,
              let token = String(data: tokenData, encoding: String.Encoding.utf8),
              let userId = data[kSecAttrAccount as String] as? String
        else {
            return nil
        }
        
        return (userId, token)
    }
    
    static func delete() throws {
        let query: [String: AnyObject] = [
            kSecClass as String: kSecClassGenericPassword,
        ]
        
        let status = SecItemDelete(query as CFDictionary)
        
        guard status != errSecItemNotFound else { throw KeychainError.noToken }
        guard status == errSecSuccess else { throw KeychainError.unknown(status) }

    }
}

우리 앱에서는 서버에서 지정한 사용자 ID와 JWT 토큰을 함께 저장해주었다.

AF.request(url, method: .post, parameters: userInfo, encoder: JSONParameterEncoder.default)
            .validate()
            .responseDecodable(of: LoginResponseModel.self) { response in
                switch response.result {
                case .success(let response):
                    do {
                        try KeychainManager.save(
                            userId: "\(response.result.userId)",
                            token: response.result.token.data(using: .utf8) ?? Data())
                    }
                    catch {
                        print(error)
                    }
                    completion(response)
                case .failure(let error):
                    print(debugPrint(error))
                }
            }

⬆️ 로그인 API 호출 후 반환받은 사용자 ID와 JWT 토큰을 저장하는 예시

// SceneDelegate.swift

if let token = KeychainManager.get()?.token {
    AuthService().authenticateToken(token) { isSuccess in
        if isSuccess {
            print(token)
            navigationController = UINavigationController(rootViewController: HomeViewController(profileService: ProfileService()))
        } else {
            do {
                try KeychainManager.delete()
            } catch {
                debugPrint(error)
            }
            navigationController = UINavigationController(rootViewController: LoginViewController(loginService: AuthService()))
        }
        window.rootViewController = navigationController
        window.makeKeyAndVisible()
        self.window = window
    }
} else {
    navigationController = UINavigationController(rootViewController: LoginViewController(loginService: AuthService()))
    window.rootViewController = navigationController
    window.makeKeyAndVisible()
    self.window = window
}

⬆️ SceneDelegate에서 JWT 토큰 여부에 따른 첫 화면을 설정하는 예시. 유효한 토큰이 Keychain 내에 저장되어 있으면 홈 화면을 띄우고, 그렇지 않으면 로그인 화면을 띄운다.

3. 파일을 저장할 때는 FileManager

우리 앱에서는 간단한 데이터를 넘어서 음성 녹음 파일도 저장해야 한다.
iOS 앱은 각자 디바이스 내에 자신들만의 공간을 가지고 있다. 이 공간은 FileManager 클래스를 사용해서 접근할 수 있다.

FileManager를 사용해서 새 파일 저장하기

1. 파일을 저장할 경로에 접근한다.

let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)

위 경로는 사용자의 문서 경로를 의미한다.

2. 해당 경로에 파일을 추가하기 위해 새로운 경로를 추가한다.

let audioFile = path.appendingPathComponent("recording.wav")

appendingPathComponent 메소드를 사용하여 경로를 새롭게 추가한다.

음성 녹음의 경우는 AVAudioRecorderstop() 메소드를 호출하여 선택한 경로에 음성 파일이 저장할 수 있다.

4. 음성 파일의 메타데이터 저장하기 - Realm

음성 파일들을 재생하려면 음성 파일들의 경로를 알고 있어야 한다. 이런 경로들은 어떻게 저장할 수 있을까?

UserDefaults를 사용해서도 경로를 저장할 수는 있다. 하지만 우리 앱에서는 UserDefaults에서는 간단한 사용자 정보만을 저장하고, 음성 파일의 메타데이터는 다른 데이터베이스를 사용하여 저장하기로 하였다.

iOS에서 사용 가능한 로컬 데이터베이스는 Core Data, SQLite, Realm 등 굉장히 다양한데, 우리 프로젝트에서는 Realm을 사용하였다.

Realm이 뭐지?

Realm은 모바일 환경에 최적화된 데이터베이스이다. Realm을 사용하면 iOS 디바이스 내에 데이터를 영구적으로 저장할 수 있다. 추가로 설치해야 한다는 부담이 있지만, Core Data, SQLite보다 작업 속도가 빠르고 사용법이 간단하기 때문에 이를 사용하기로 하였다.

Realm 사용해보기

참고로 Realm을 사용하려면 먼저 Realm을 설치해야 한다. Realm 공식 문서에서 설치 방법을 확인할 수 있다.

1. 데이터 모델 정의하기

Realm을 설치한 다음에는 데이터베이스에 저장하기 위한 모델을 정의한다.

import Foundation
import RealmSwift

class Audio: Object {
    @Persisted var id: String
    @Persisted var url: String = ""
    
    override class func primaryKey() -> String? {
        return "id"
    }
    
    convenience init(id: String, url: String) {
        self.init()
        self.id = id
        self.url = url
    }
}

Realm 데이터베이스에 데이터를 저장할 때는 각 데이터마다 고유한 primary key도 함께 저장되어야 한다. 여기서는 id 를 primary key로 사용하기 위해 primaryKey() 메소드를 사용하였다.

2. 데이터 추가하기

let audioData = Audio(id: audioId, url: audioRecorder.url.absoluteString)
        
try! realm.write {
	realm.add(audioData)
}

⬆️ 음성 파일의 고유 ID와 파일 경로를 저장하는 예시

추가할 데이터에 대한 객체를 만들고 realm의 add() 메소드를 사용하여 데이터를 추가한다. 이때 파일을 쓰는 작업은 realm 의 권한으로 접근해야 한다. 따라서 add() 메소드 호출 전에 realm.write{ } 을 호출한다.

3. 데이터 읽기

realm.objects(Audio.self)

⬆️ Realm에 저장된 모든 Audio 타입의 데이터를 불러오는 예시

비교해보자!

이렇게 iOS에서의 다양한 데이터 저장 방법을 알아보았다.

마지막으로, 다시 한 번 각 방법들을 비교해보자.

  • UserDefault vs Keychain
    • 앱을 제거해도 Keychain의 정보는 유지된다. 반면 UserDefaults의 값들을 지워진다.
  • Realm vs Core Data
    • Realm은 설치가 쉽고 무제한으로 사용할 수 있다.
    • Realm이 실행 속도가 더 빠르다. 하지만 서드 파티이기 때문에 Core Data보다 앱의 크기가 커진다.

이번 졸업 프로젝트 덕분에 그동안 이름만 들어봤던 다양한 데이터 저장 방법들을 마음껏 활용해볼 수 있어서 좋았다. 또한, 데이터 저장 방식을 고민하고 선택하는 과정도 재미있었다. 프로젝트도 마지막까지 잘 마무리 하고 싶다. 파이팅! 💪


✏️ 참고 자료

https://developer.apple.com/documentation/foundation/userdefaults

https://developer.apple.com/documentation/security/keychain_services/keychain_items

https://developer.apple.com/documentation/security/certificate_key_and_trust_services/keys/storing_keys_in_the_keychain

https://developer.apple.com/documentation/security/keychain_services/keychain_items/updating_and_deleting_keychain_items

https://developer.apple.com/documentation/foundation/filemanager

https://realm.io/realm-swift/

profile
나의 내일은 파래 🐳

0개의 댓글