포스트

Dex (12)

SwiftData 사용하기

Migration 하기

사실 이전에도 CoreData → SwiftData 마이그레이션 작업을 진행한 적이 있다.
이전글 참고.

SwiftData 모델을 만들기 위해 Editor 메뉴에서 Create SwiftData Model을 실행하면, 다음과 같이 자동 생성된 코드가 나타난다:

예시:

1
2
3
4
@Model public class Pokemon {
  #Unique<Pokemon>([\.id])
  // 생략
}

하지만 이 상태에서는 CoreData의 기존 모델과 이름이 겹쳐서 충돌이 발생하므로, 이전에 사용하던 CoreData 모델을 삭제해야 한다.

SwiftData 모델 구성 및 리팩토링

Pokemon 모델은 SwiftData 기반의 클래스로 재정의하였다. 기존에 분산되어 있던 FetchedPokemon, PokemonExt.swift의 모든 속성과 연산 프로퍼티, 디코딩 로직을 하나의 파일에 통합했다.

특히 다음과 같은 변경이 이루어졌다:

  • Int16Int: SwiftData에서는 Int 사용이 가능하므로 더 Swifty한 방식으로 리팩토링함
  • @Attribute(.unique): 이전의 #Unique<Pokemon>([\.id]) 선언을 더 명시적이고 선언적인 방식으로 전환
  • 옵셔널 제거: types, name, URL 등 불필요한 옵셔널 제거로 모델의 명확성 향상
  • 분산된 파일 병합: Extension이나 FetchedPokemon에서 나눠서 관리하던 기능들을 하나로 합침
1
2
3
4
5
6
7
@Model
class Pokemon: Decodable {
  @Attribute(.unique) var id: Int
  var name: String
  var types: [String]
  // 생략
}

디코딩 구현

FetchedPokemon에서 사용하던 init(from decoder:) 및 내부 enum 타입들은 그대로 옮겨왔다.

디코딩 구현에 대한 상세한 내용은 Dex (2) 글에서 이미 다뤘기 때문에 이곳에서는 생략한다.

SwiftData 모델 내부에서 바로 Decodable을 채택할 수 있게 되면서 외부 fetch 모델 없이도 네트워크 디코딩과 로컬 저장을 함께 처리할 수 있게 되었고, 이는 큰 구조적 이점이다.

이러한 리팩토링을 통해 이제 Pokemon 관련 코드는 단일 SwiftData 모델 하나로 모두 통합되었으며, CoreData 시절보다 훨씬 간결하고 유지보수하기 쉬운 구조가 되었다.

SwiftData의 ModelContainer 도입

CoreData에서 SwiftData로 전환하면서 NSPersistentContainer 대신 ModelContainer를 사용하게 되었다.
이전에는 PersistenceController라는 싱글톤을 통해 .container.viewContext를 주입했지만,
SwiftData에서는 훨씬 간결하게 구현할 수 있다.


App 파일 수정

DexApp의 App 구조는 다음과 같이 변경하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Before (CoreData 방식)
struct DexApp: App {
    let persistenceController = PersistenceController.shared

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, persistenceController.container.viewContext)
        }
    }
}

// After (SwiftData 방식

import SwiftData

@main
struct DexApp: App {
    var sharedModelContainer: ModelContainer = {
        let schema = Schema([
            Pokemon.self,
        ])

        let modelConfiguration = ModelConfiguration(
            schema: schema,
            isStoredInMemoryOnly: false
        )

        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(sharedModelContainer)
        }
    }
}

Preview 데이터 처리

samplepokemon.json 파일을 이용하여 데이터를 디코딩하고, 인메모리 컨테이너에 넣어준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import SwiftData
import Foundation

@MainActor
struct PersistenceController {
    static let shared = PersistenceController()

    static var previewPokemon: Pokemon {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase

        let pokemonData = try! Data(contentsOf: Bundle.main.url(forResource: "samplepokemon", withExtension: "json")!)
        let pokemon = try! decoder.decode(Pokemon.self, from: pokemonData)
        return pokemon
    }

    static let preview: ModelContainer = {
        let container = try! ModelContainer(
            for: Pokemon.self,
            configurations: ModelConfiguration(isStoredInMemoryOnly: true)
        )
        container.mainContext.insert(previewPokemon)
        return container
    }()
}
  • isStoredInMemoryOnly: true 옵션은 미리보기 데이터가 실제 DB에 영향을 주지 않도록 한다.
  • insert만 해주면 SwiftData가 자동 저장해준다. 별도의 save 호출은 불필요하다.
  • previewPokemon은 JSON을 바탕으로 디코딩된 더미 객체이다.

samplepokemon.json을 사용하는 이유

SwiftData로 변환한 Pokemon 모델은 init(from decoder:)을 사용한 Decodable 초기화만 제공한다.
따라서 미리보기(preview)에서 객체를 직접 생성하는 방식(예: Pokemon(...))으로는 초기화가 불가능하다.
SwiftData 모델은 모든 속성이 초기화되어야 하기 때문에, JSON 파일을 디코딩하여 객체를 생성하는 방식이 필요하다.

이러한 이유로 preview 환경에서는 samplepokemon.json을 사용하여 데이터를 로딩하고,
이를 인메모리 컨테이너에 삽입하는 방식으로 미리보기를 구성하게 된다.


주의할 점

SwiftData 모델을 생성할 때 Widget 등 다른 타겟에 포함되지 않는 경우가 있다. 반드시 타겟을 확인하도록 하자.

ContentView 수정

여기도 이제 손봐야한다.

1. Context 선언

1
2
3
4
5
6
// Before
@Environment(\.managedObjectContext) private var viewContext

// After
@Environment(\.modelContext) private var modelContext


2. 데이터 요청 방식

SwiftData로 마이그레이션하면서 가장 뚜렷하게 바뀐 점 중 하나는 데이터 요청 방식이다. CoreData에서는 두 개의 @FetchRequest를 두어 하나는 전체 목록(allPokedex), 다른 하나는 필터링된 결과(pokedex)를 관리했다.

1
2
3
4
5
6
7
8
9
10
// Before
@FetchRequest<Pokemon>(sortDescriptors: []) private var allPokedex

@FetchRequest<Pokemon>(
    sortDescriptors: [SortDescriptor(\.id)],
    animation: .default
) private var pokedex

// After
@Query(sort: \Pokemon.id, animation: .default) private var pokedex: [Pokemon]

allPokedex는 항상 전체 데이터를 갖고 있었고, pokedex는 사용자 상호작용(예: 즐겨찾기, 검색 등)에 따라 predicate를 바꿔가며 사용했다. 하지만 이 방식은 SwiftUI의 ContentUnavailableView와 같은 동작과 맞지 않아, 불필요한 이중 구조를 갖게 되는 단점이 있었다.

SwiftData의 @Query는 동적으로 predicate를 변경해도 내부적으로 모든 데이터를 유지하고 있다. 따라서 기존에 필요했던 allPokedex 같은 전체 데이터용 프로퍼티가 불필요해졌고, 단일 @Query로 전체 및 필터링 데이터를 모두 대응할 수 있게 되었다.

  • 결론
    • SwiftData는 predicate가 적용되더라도 전체 결과에 대한 접근이 쉬움
    • Query의 count 값은 predicate 적용 전 전체 개수를 기준으로 동작
    • 검색 및 즐겨찾기 필터처럼 뷰의 상태에 따라 결과가 바뀌는 상황에서도 pokedex 하나로 처리 가능
    • SwiftUI의 조건 뷰(예: ContentUnavailableView)에서 혼동 없이 사용할 수 있음

이 변화로 인해 코드가 간결해지고 상태 관리가 쉬워졌으며, 잘못된 비어있는 상태 판단을 피할 수 있게 되었다.


3. 뷰 렌더링 조건

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Before
if allPokedex.isEmpty {
    // ...
} else {
    NavigationStack {
        // ...
    }
}

// After
if pokedex.isEmpty {
    // ...
} else {
    NavigationStack {
        // ...
    }
}

2번의 사유로 pokedex가 all의 역할도 대체 가능


4. 즐겨찾기 토글 버튼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Before
Button(pokemon.favorite ? "Remove from Favorites" : "Add to Favorites", systemImage: "star") {
    pokemon.favorite.toggle()
    do {
        try viewContext.save()
    } catch {
        print(error)
    }
}

// After
Button(pokemon.favorite ? "Remove from Favorites" : "Add to Favorites", systemImage: "star") {
    pokemon.favorite.toggle()
    do {
        try modelContext.save()
    } catch {
        print(error)
    }
}

1번의 사유에 의한 context 변경


1
2
3
4
5
6
7
8
9
// Before
if allPokedex.count < 151 {
    // ...
}

// After
if pokedex.count < 151 {
    // ...
}

3번과 동일한 이유


6. getPokemon 함수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Before
private func getPokemon(from id: Int) {
    Task {
        for i in id..<152 {
            do {
                let fetchedPokemon = try await fetcher.fetchPokemon(i)
                // ...
                try viewContext.save()
            } catch {
                print(error)
            }
        }
        storeSprites()
    }
}

// After
private func getPokemon(from id: Int) {
    Task {
        for i in id..<152 {
            do {
                let fetchedPokemon = try await fetcher.fetchPokemon(i)
                modelContext.insert(fetchedPokemon)
            } catch {
                print(error)
            }
        }
        storeSprites()
    }
}

4번과 동일한 이유

단 SwiftUI에서는 insert를 사용


7. storeSprites 함수

1
2
3
4
5
6
7
8
9
// Before
pokemon.sprite = try await URLSession.shared.data(from: pokemon.spriteURL!).0  
pokemon.shiny = try await URLSession.shared.data(from: pokemon.shinyURL!).0  
try viewContext.save()

// After
pokemon.sprite = try await URLSession.shared.data(from: pokemon.spriteURL).0  
pokemon.shiny = try await URLSession.shared.data(from: pokemon.shinyURL).0  
try modelContext.save()

4번과 동일한 이유


8. Preview 설정

1
2
3
4
5
// Before
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)

// After
ContentView().modelContainer(PersistenceController.preview)

Container 변경에 따라 코드 수정


0. MainActor 설정

1
2
3
4
5
6
7
8
9
10
// Before
struct FetchService { 
    // 생략
}    

// After
@MainActor
struct FetchService { 
    // 생략
}    

Image

이 에러때문에 @MainActor wrapper를 붙인다.

이전글에 관련글을 작성한적이 있다. 읽어볼 것


이외에도 onchange Modifier도 지우고 이제는 optional type도 없기에 !도 전부 지워준다.

DetailView 수정

ContentView와 동일하게 바꾼다.

1. Pokemon 주입 방식 변경

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Before (CoreData 기반)
@EnvironmentObject private var pokemon: Pokemon

#Preview {
    NavigationStack {
        PokemonDetailView()
            .environmentObject(PersistenceController.previewPokemon)
    }
}

// After (SwiftData 기반)
var pokemon: Pokemon

#Preview {
    NavigationStack {
        PokemonDetailView(pokemon: PersistenceController.previewPokemon)
    }
}

이전에는 Pokemon 인스턴스를 @EnvironmentObject로 주입받고, Preview에서도 .environmentObject()로 전달하였지만, 지금은 직접 파라미터로 전달받는 방식으로 변경되었다.


2. Context 참조 방식 변경

1
2
3
4
5
// Before
@Environment(\.managedObjectContext) private var viewContext

// After
@Environment(\.modelContext) private var modelContext

CoreData에서 SwiftData로의 전환에 따라 managedObjectContextmodelContext로 바뀜

그래서 viewContext를 사용했던곳을 modelContext로도 바꿔주었다 (코드생략)


Widget에도 에러가 발생하지만 앱이 일단 제대로 작동하는지 확인하기 위해 위젯은 모두 주석을 잡는다.

이번엔 기존 CoreData 기반 앱을 SwiftData로 전환하면서 필요한 전반적인 구조 변경과 관련 이유들에 대해서 정리를 해보았다.

확실히 리팩토링하는건 에러가 많이 발생해서 꼼꼼하게 하는게 가장 중요해보인다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.