Swift에서 Json 앱 데이터 저장 플로우 정리

나우리·2024년 11월 15일

Swift

목록 보기
13/13
post-thumbnail

JSON 사용 계기

swiftData만 쓰다가 규모가 작은 앱을 만들면서 이 정도라면 별도의 데이터 프레임워크를 추가하기보다는 Json으로 경량화 하는 게 더 나을지도 모르겠다는 생각이 들었다.

또한 처음부터 bundle data를 기본적으로 제공해야 했었기 때문에 기본 데이터를 바로 제공할 수 있고 확인이 비교적 편하다는 판단이 든 JSON을 사용해보기로 했다.

내가 사용한 저장방식

Json 데이터를 받아올 구조 생성

JSON 데이터를 저장하거나, 외부에서 받아오기 위해서는 그에 맞는 구조를 만들어주어야 한다. 어떤 값 타입을 어떤 키값을 기준으로 가져올지 지정해주어야 한다.

struct MusicTrack: Codable {
    var title: String
    var artist: String
    var url: URL
}

swift4부터 활용할 수 있게 된 Codable을 사용하면 자동적으로 이 구조체가 EncodableDecodable을 준수한다는 것을 밝힐 수 있게 되었다.

JSON 파일의 Encode와 Decode

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

profile
왕초보 개발일지

0개의 댓글