swiftData만 쓰다가 규모가 작은 앱을 만들면서 이 정도라면 별도의 데이터 프레임워크를 추가하기보다는 Json으로 경량화 하는 게 더 나을지도 모르겠다는 생각이 들었다.
또한 처음부터 bundle data를 기본적으로 제공해야 했었기 때문에 기본 데이터를 바로 제공할 수 있고 확인이 비교적 편하다는 판단이 든 JSON을 사용해보기로 했다.
JSON 데이터를 저장하거나, 외부에서 받아오기 위해서는 그에 맞는 구조를 만들어주어야 한다. 어떤 값 타입을 어떤 키값을 기준으로 가져올지 지정해주어야 한다.
struct MusicTrack: Codable {
var title: String
var artist: String
var url: URL
}
swift4부터 활용할 수 있게 된 Codable을 사용하면 자동적으로 이 구조체가 Encodable과 Decodable을 준수한다는 것을 밝힐 수 있게 되었다.
Encode는 데이터값을 컴퓨터가 이해하는 방식인 바이너리로 변경하거나 암호화를 적용하는 등 특정한 형식으로 변경하는 것이다.
Decode는 반대로 Encode된 데이터들을 다시 원래의 상태로 바꾸는 것이다.
JSON 데이터를 내부에서 이런 식으로 작성해봤다.
//swift에서 여러줄 입력을 위해 """ 이런 식으로 작성했다.
let jsonString = """
{
"title": "Bohemian Rhapsody",
"artist": "Queen",
"url": "https://example.com/music/bohemian_rhapsody.mp3"
}
"""
이 구조는 위에서 작성한 musicTrack과 키값이 일치하다.
각각 "title", "artist", "url"값을 가진다.
이 값을 데이터 형식으로 인코딩을 해보자
//jsonString값을 지정된 인코딩(utf8)로 인코딩한 데이터를 반환함
if let jsonData = jsonString.data(using: .utf8) {
let decoder = JSONDecoder()
do {
let musicTrack = try decoder.decode(MusicTrack.self, from: jsonData)
print("Decoded Music Track: \(musicTrack)")
} catch {
print("Failed to decode JSON: \(error)")
}
}
먼저 jsonData라는 값에 string을 .utf8 인코딩 방식으로 인코딩한 데이터를 저장했다.
그리고 JSONDecoder()의 인스턴스를 생성하고,
musicTrack이라는 상수값에 jsonData를 decode해서 저장했다.
func decode는 인자로 저장될 타입을 받고, from 뒤에 그 타입으로 decode될 값을 받는다.
print로 정상출력이 되면 값은 이렇게 나온다
Decoded Music Track: MusicTrack(title: "Bohemian Rhapsody", artist: "Queen", url: https://example.com/music/bohemian_rhapsody.mp3)
url은 임시값이니 무시주시길
이렇게 MusicTrack의 구조를 준수하는 데이터 인스턴스가 생성되었다.
이 값을 인코딩해보자
let encoder = JSONEncoder()
//예쁘게 줄바꿈과 Indent를 적용해서 출력하도록 설정함
encoder.outputFormatting = .prettyPrinted
do {
let encodedData = try encoder.encode(decodeMusicTrack)
if let jsonString = String(data: encodedData, encoding: .utf8) {
print("Encoded JSON String:\n\(jsonString)")
}
} catch {
print("Failed to encode JSON: \(error)")
}
decoding된 값을 다시 JSON 형태로 변경 후 string으로 바꿔 출력했다.
Encoded JSON String:
{
"artist" : "Queen",
"url" : "https:\/\/example.com\/music\/bohemian_rhapsody.mp3",
"title" : "Bohemian Rhapsody"
}
출력값은 이렇다.
이 때 출력되는 키와 값의 순서는 매번 출력시마다 다르다.
키와 값이 중요하지 출력될 때 순서는 중요하지 않는 걸 보아, 딕셔너리 형태로 저장되는 듯하다.
지금 구조는 그러나 실제로 앱을 제작할 때는 상당히 달라져야 한다.
서버에서 값을 가져올 경우 특정 값이 존재하지 않거나 키로 설정된 값이 다를 수 있다.
이를 위해 구조에 설정을 더할 수 있다.
먼저 init을 추가하여 받아올 때 값을 설정할 수 있다.
//Codable로 정의된 struct에 init을 추가하면 자동 완성되는 init
struct MusicTrack: Codable {
var title: String
var artist: String
var url: URL
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.title = try container.decode(String.self, forKey: .title)
self.artist = try container.decode(String.self, forKey: .artist)
self.url = try container.decode(URL.self, forKey: .url)
}
}
Decodable이 가능하도록 설정이 되어있기 때문에 decoder를 통해 init처리되는 것이 기본적으로 정의되어 있다.
여기서 현재 구조에는 없는 값들을 임의로 추가하여 값을 더 설정하도록 설정해보자.
struct MusicTrack: Codable {
var title: String
var artist: String
var url: URL
var playTime: Float
예를 들어 노래의 길이 등을 추가 저장하고자 playTime이라는
그 후 decode를 하려고 하면
keyNotFound(CodingKeys(stringValue: "playTime", intValue: nil)
이런 에러가 난다.
이 문제를 해결하기 위해서는 여러 방법이 있다.
하나, playTime을 옵셔널로 설정하는 것이다.
그러면 값이 없을 경우에는 값이 자동적으로 받아들여지지 않으며 에러 없이 동작한다.
둘, init을 통해서 초기값을 설정하는 방식이 있다.
내 케이스에는 값이 있을 수도, 없을 수도 있지만 없다면 초기값을 설정하도록 init을 설정했었다. 이렇게 하면 사용시 옵셔널 체이닝을 하지 않아도 되었고, 데이터를 안전하게 활용할 수 있었다.
struct MusicTrack: Codable {
var title: String
var artist: String
var url: URL
var playTime: Float
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.title = try container.decode(String.self, forKey: .title)
self.artist = try container.decode(String.self, forKey: .artist)
self.url = try container.decode(URL.self, forKey: .url)
self.playTime = try container.decodeIfPresent(Float.self, forKey: .playTime) ?? 0
}
}
decodeIfPresent를 쓰면 키값이 없거나 값이 없을 경우 nil을, 값이 있다면 값을 반환해주기 때문에 안전하게 디코드할 수 있다.
여기에 옵셔널 체이닝으로 기본값을 0으로 설정해주었다.
키값이 다른 경우에는 어떻게 처리해야 할까?
예를 들어 데이터 셋에 title값의 키가 songName으로 지정되어 있다고 해보자.
내 경우에는 그 값을 title 구조로 받아오고 싶다.
struct MusicTrack: Codable {
var title: String
var artist: String
var url: URL
var playTime: Float
enum CodingKeys: String, CodingKey {
case title = "songName"
case artist
case url
case playTime
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.title = try container.decode(String.self, forKey: .title)
self.artist = try container.decode(String.self, forKey: .artist)
self.url = try container.decode(URL.self, forKey: .url)
self.playTime = try container.decodeIfPresent(Float.self, forKey: .playTime) ?? 0
}
}
codingKey값을 지정하는 CodingKeys enum을 선언하고 이를 codingKey로 활용할 수 있도록 CodingKey 프로토콜을 준수하도록 설정했다.
그 후 enum에 string값으로 전송받은 데이터의 원래 키 값 songName을 정의했다.
let container = try decoder.container(keyedBy: codingKey.self)
이후 init에서 decoder에서 데이터를 구조에 맞게 각각 반환하는 값의 기준을 codingKey로 바꿔두었다.
앱에서는 보통 이렇게 미리 정의한 값들의 배열을 받아 저장하고 활용했다.
이를 위해서 바꿔야 할 부분은 별로 많지 않다.
일단 받아오는 json 파일의 구조는 이런 식으로 바뀐다.
let jsonString = """
[
{
"songName": "Bohemian Rhapsody",
"artist": "Queen",
"url": "https://example.com/music/bohemian_rhapsody.mp3"
},
{
"songName": "네모네모 스폰지밥",
"artist": "스폰지송",
"url": "https://example.com/music/spongeBoB.mp3"
},
]
"""
배열 대괄호 안에 중괄호({})로 각각의 값을 받는다.
또한 배열로 저장하기 위해 decode 설정에 받는 타입도 모두 [MusicTrack].self 로 바꿔준다.
musicTrack의 배열을 받도록 설정해도 codable은 정상 작동이 된다.
그리고 저장경로를 설정하기 위해 fileManager를 활용한다.
fileManager에서 앱 샌드박스 내 위치 경로를 정하고, 그 경로에 저장하도록 설정한다.
//fileManager 경로정하기
let filePath = try FileManager.default.url(for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true)
.appending(component: "musicTrack.json")
//fileManager 경로에 파일 작성하기
let data = try JSONEncoder().encode(decodeMusicTrack)
let outfile = filePath
try data.write(to: outfile, options: [.atomic, .completeFileProtection])
//경로와 내용 확인하기
print("파일경로:\(filePath.path(percentEncoded: false))\n데이터내용:\(data)")
//fileManager 특정 경로의 파일 불러오기
if let newData = try? Data(contentsOf: filePath) {
let myMusicTrack = try JSONDecoder().decode([MusicTrack].self, from: newData)
print(myMusicTrack)
}
파일매니저에서 url값을 가져와서 json타입의 파일로 경로 끝 컴포넌트를 선언한 뒤, encode한 데이터를 경로에 write해주면 된다.
데이터를 다시 불러올 땐 경로에 있는 데이터를 가져와서 decode를 실행하면 된다.
데이터 관리 프레임워크를 도입하는 경우에는 대부분 저장 타이밍을 내부에서 미리 설정해준다.
그러나 json과 fileManager를 활용하여 직접 작성하는 경우에는 저장 타이밍을 설정해줘야 한다.
HackingWithSwift: When does swiftData autosave
이 게시글을 참조했을 때 swiftData는 크게 세 경우 데이터를 자동으로 저장처리한다고 한다.
하나, 앱의 현재 runLoop가 종료되었을 때
둘, 앱이 백그라운드로 돌아갈 때
셋, 앱이 포그라운드로 돌아올 때
runLoop는 특정 이벤트가 발생할 때 스레드를 관리하는 loop이다.
스레드별로 하나씩 있는데, 동기와 비동기 이벤트들을 받아두었다가 실행하는 순간 이벤트를 관리한다.
단순히 생각했을 때 매 이벤트가 스레드에서 처리될 때마다 저장처리한다는 것 같다.
swiftData는 매번 저장처리를 빠르게 처리할 수 있도록 최적화가 되어있기 때문에
런루프가 유지되는 거의 1/60초 또는 1/120초 안에 저장할 수 있다.
그러나 직접 만든 json 파일을 저장처리하는 건 비교적 데이터 규모가 작은 편이었음에도 동작에 지장을 주진 않을지 확신이 없었다.
그래서 고민하다가 저장처리를 두 경우만 처리하도록 간소화했다.
하나, 치명적인 변화가 나타났을 때만 저장한다.
특정 파일이 추가되거나 삭제되는 등 전반적 데이터 구조에 변화가 생길 경우 저장처리했다.
둘째, 앱이 inactive될 때 저장했다.
ios14+ 부터 scenePhase라는 환경변수를 제공하여 앱의 변화를 추적할 수 있다.
앱은 포그라운드에서 벗어나는 순간 바로 Inactive처리가 되고, 그 이후 background 또는 아예 종료처리된다.
@main
struct MyApp: App {
@StateObject var model: DataModel
@Environment(\.scenePhase) private var scenePhase
var body: some View {
WindowGroup {
ContentView()
.onChange(of: scenePhase) { phase, newPhase in
if newPhase == .inactive {
Task {
do {
//save func 위치
} catch {
//error 출력
}
}
}
}
}
}
}
가벼운 앱의 경우는 이 정도로도 괜찮았다.
만약 변화가 정말 자주 일어난다면 Timer를 통해 앱이 켜져있는 동안 주기적 저장을 추가할 수도 있을 것 같다.
SwiftData를 활용할 때와 달리 하나하나 어떻게, 어떤 파일경로에 저장될 것인지 고민할 점이 많았다.
또 어떤 타이밍에 저장할 지 고민이 더 필요했다.
또한 데이터가 변경되거나 추가될 때 고민할 점도 많았다.
예를 들면 나는 앱을 앱스토어에 올리고, 업데이트하면서 데이터 구조가 추가될 경우가 있었다.
별 거 아닌 Bool값 추가임에도 추가시 데이터가 고장나거나 문제가 생기지 않을지 생각해야 했다.
옵셔널로 처리하면 대부분 간결하게 추가되었지만 조금 더 규모가 커졌을 경우 migration func을 따로 만들어야 할 듯하다.
SwiftData를 사용 시 migration을 지원하고, 심지어 자동으로 cloud로 데이터 연동처리까지 할 수 있는 걸 생각하면
json만 써서 만드는 건 특별한 케이스가 아닌 경우에는 크게 추천하진 않는 것 같다.
최적화도 정말 잘되어있어서 생각보다 동작이 무겁지도 않았을 것 같다.
다만 학습이 목적이라면 json만 사용했을 때 배우는 점이 많아 추천한다.
앱에 저장을 구현하는 걸 제외한 한번에 파악 가능한 소스코드는 아래 링크에서 확인 가능하다.
playground 등에서 바로 실행하면서 확인할 수 있다.
StudySwift git repo/SaveAppDataJson.swift
Codable document
HackingWithSwift: When does swiftData autosave