@Query로 읽고, modelContext로 고쳤다. 하지만 "선택한 탭"이나 "정렬 방식" 같은 작은 값까지 DB에 넣을 필요는 없고, 서버를 부르는 서비스는 싱글턴이면 Preview·테스트에서 바꿀 수 없다. 2부는 앱 구조의 나머지 절반이다. @AppStorage/@SceneStorage로 작은 UI 상태를 남기고, 환경으로 주입해 테스트 가능한 모델을 만들고, Preview용 인메모리 컨테이너로 마무리한다.2부 목표 · 22분
- "이 값은 @State / @SceneStorage / @AppStorage / SwiftData 중 어디?"를 수명 기준으로 바로 고른다
- 서비스 싱글턴 호출을 프로토콜 + Environment 주입으로 바꾸고, Preview에서 가짜 서비스를 꽂는다
@Observable모델을 Swift Testing@Test/#expect로 뷰 없이 검증한다- Preview와 테스트에 인메모리
ModelContainer를 써서 실제 저장소를 건드리지 않는다
1. @AppStorage · @SceneStorage — 작은 UI 상태는 따로 저장한다
@State는 프로세스가 살아 있는 동안만 산다. 앱이 죽거나 기기가 재시작하면 돌아오지 않는다. 그래서 SwiftUI는 수명이 더 긴 저장소 두 개를 준다(WWDC20 · Data Essentials in SwiftUI).
"Note, these are not your model. Rather, they are lightweight stores that you use in conjunction with your model." — WWDC20 · Data Essentials in SwiftUI
→ @SceneStorage와 @AppStorage는 모델이 아니다. 모델 곁에 두는 가벼운 저장소다. 같은 세션에서 "persistence isn't free — 모든 걸 여기에 넣지 말라"고도 한다.
enum ListSortOption: String, CaseIterable {
case date, name
var title: String { self == .date ? "날짜순" : "이름순" }
}
struct SettingsView: View {
@AppStorage("showsCompleted") private var showsCompleted = true
@AppStorage("listSort") private var sort: ListSortOption = .date // String raw value enum 가능
var body: some View {
Form {
Toggle("완료한 활동 보기", isOn: $showsCompleted)
Picker("정렬", selection: $sort) {
ForEach(ListSortOption.allCases, id: \.self) { option in
Text(option.title).tag(option)
}
}
}
}
}struct RootView: View {
@SceneStorage("selectedTab") private var selectedTab = 0 // 창마다 독립
var body: some View {
TabView(selection: $selectedTab) {
Text("여행").tabItem { Label("여행", systemImage: "airplane") }.tag(0)
SettingsView().tabItem { Label("설정", systemImage: "gear") }.tag(1)
}
}
}| 저장할 것 | 도구 | 이유 |
|---|---|---|
| 버튼 눌림, 시트 표시 여부 | @State | 앱이 죽으면 사라져도 된다 |
| 선택한 탭, 선택한 항목 ID | @SceneStorage | 창마다 다르고, 재시작 뒤 복원하면 좋다 |
| 정렬 방식, 알림 on/off | @AppStorage | 앱 전체에서 같은 값, 작은 데이터 |
| 여행·메모·활동 목록 | SwiftData | 양이 많고 관계·검색이 필요 |
@AppStorage가 받는 타입은 문서 기준 Bool·Int·Double·String·URL·Data, 그리고 Int/String raw value를 가진 RawRepresentable이다. Date는 iOS 18+부터다. 둘 다 키가 곧 식별자이므로 서로 다른 데이터에 같은 키를 쓰지 않는다.
struct TripDraft: Codable { var name: String; var notes: String }
struct BadStorageView: View {
@AppStorage("allTrips") private var allTripsData = Data() // 목록 전체가 한 덩어리
var trips: [TripDraft] {
(try? JSONDecoder().decode([TripDraft].self, from: allTripsData)) ?? []
}
var body: some View {
List(trips, id: \.name) { Text($0.name) }
}
}struct GoodStorageView: View {
@Query(sort: \Trip.startDate) private var trips: [Trip]
@AppStorage("lastOpenedTripName") private var lastOpenedTripName = ""
var body: some View {
List(trips) { trip in
Button(trip.name) { lastOpenedTripName = trip.name }
}
.navigationTitle(lastOpenedTripName.isEmpty ? "여행" : lastOpenedTripName)
}
}2. 환경으로 주입하고, 모델을 테스트한다
UIKit에서는 APIClient.shared 같은 싱글턴을 곳곳에서 불렀다. 그러면 Preview와 테스트에서 가짜로 바꿀 방법이 없다. SwiftUI에서는 프로토콜로 추상화 → 환경에 넣기 → 뷰에서 꺼내 모델에 전달 순서로 바꾼다. 환경은 부모가 한 번 넣으면 아래 뷰 전체가 읽는 통로다(Day 5에서 본 @Environment).
import SwiftUI
struct TripSummary: Identifiable, Hashable, Sendable {
let id: UUID
let name: String
}
protocol TripSearching: Sendable {
func search(_ text: String) async throws -> [TripSummary]
}
struct LiveTripSearch: TripSearching {
func search(_ text: String) async throws -> [TripSummary] {
try await Task.sleep(for: .milliseconds(300)) // 실제로는 URLSession 호출
return []
}
}
private struct TripSearchingKey: EnvironmentKey {
static let defaultValue: any TripSearching = LiveTripSearch()
}
extension EnvironmentValues {
var tripSearching: any TripSearching {
get { self[TripSearchingKey.self] }
set { self[TripSearchingKey.self] = newValue }
}
// Xcode 16 이상이면 위 두 덩어리를 한 줄로 (Day 5):
// @Entry var tripSearching: any TripSearching = LiveTripSearch()
}@Observable
final class SearchModel {
var query = ""
private(set) var results: [TripSummary] = []
private(set) var errorMessage: String?
private let service: any TripSearching
init(service: any TripSearching) {
self.service = service
}
@MainActor
func search() async {
guard !query.isEmpty else { results = []; return }
do {
results = try await service.search(query)
errorMessage = nil
} catch {
results = []
errorMessage = "검색에 실패했다"
}
}
}
struct SearchScreen: View {
@Environment(\.tripSearching) private var service
@State private var model: SearchModel? // 옵셔널로 두고 (Day 3 1부 · 함정 ②)
var body: some View {
Group {
if let model { SearchContent(model: model) }
else { ProgressView() }
}
.task { if model == nil { model = SearchModel(service: service) } } // 딱 한 번 만든다
}
}
struct SearchContent: View {
@Bindable var model: SearchModel // 소유하지 않는다 — 바인딩만 꺼낸다
var body: some View {
List(model.results) { Text($0.name) }
.overlay { if let message = model.errorMessage { Text(message) } }
.searchable(text: $model.query)
.task(id: model.query) { await model.search() } // 검색어가 바뀌면 이전 작업 취소 (Day 12)
}
}struct MockTripSearch: TripSearching {
var stub: [TripSummary]
func search(_ text: String) async throws -> [TripSummary] {
stub.filter { $0.name.contains(text) }
}
}
#Preview {
SearchScreen()
.environment(\.tripSearching, MockTripSearch(stub: [
TripSummary(id: UUID(), name: "제주 올레길"),
TripSummary(id: UUID(), name: "부산 해변")
]))
}Swift Testing으로 모델 검증
Swift Testing은 Xcode 16에서 도입된 테스트 라이브러리다. 함수에 @Test를 붙이면 테스트가 되고, #expect가 대부분의 XCTAssert…를 대신한다. 테스트는 기본으로 병렬 실행된다(WWDC26 · Migrate to Swift Testing).
import Testing
@testable import Trips
struct FailingSearch: TripSearching {
struct Boom: Error {}
func search(_ text: String) async throws -> [TripSummary] { throw Boom() }
}
struct SearchModelTests {
let stub = [TripSummary(id: UUID(), name: "제주 올레길"),
TripSummary(id: UUID(), name: "부산 해변")]
@Test func 검색어와_맞는_여행만_남긴다() async {
let model = SearchModel(service: MockTripSearch(stub: stub))
model.query = "제주"
await model.search()
#expect(model.results.map(\.name) == ["제주 올레길"])
}
@Test func 빈_검색어면_결과를_비운다() async {
let model = SearchModel(service: MockTripSearch(stub: stub))
await model.search()
#expect(model.results.isEmpty)
}
@Test func 실패하면_에러_메시지를_보인다() async {
let model = SearchModel(service: FailingSearch())
model.query = "제주"
await model.search()
#expect(model.errorMessage != nil)
}
}SwiftData 로직도 같은 방식이다. 1부의 Trip 모델(3절에 요약)을 디스크 대신 메모리에만 두는 컨테이너에 넣고 검사한다.
import Testing
import SwiftData
import Foundation
@testable import Trips
@Test @MainActor func 다가올_여행만_가져온다() throws {
let container = try ModelContainer(
for: Trip.self, Activity.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true) // 디스크에 안 남는다
)
let context = container.mainContext
context.insert(Trip(name: "지난 제주", destination: "제주", startDate: .now.addingTimeInterval(-86_400)))
context.insert(Trip(name: "다음 부산", destination: "부산", startDate: .now.addingTimeInterval(86_400)))
let now = Date.now
let upcoming = try context.fetch(FetchDescriptor<Trip>(predicate: #Predicate { $0.startDate > now }))
#expect(upcoming.map(\.name) == ["다음 부산"])
}| XCTest | Swift Testing |
|---|---|
class …: XCTestCase + func testX() | 아무 함수에 @Test (struct로 묶기 선택) |
XCTAssertEqual(a, b), XCTAssertTrue(x) | #expect(a == b), #expect(x) |
XCTUnwrap / continueAfterFailure = false | #require — 실패하면 그 자리에서 멈춘다 |
XCTFail("…") | Issue.record("…") |
| UI 자동화 · 성능 측정 | 지원 안 함 → XCTest를 계속 쓴다 |
Apple 권장은 "기존 XCTest는 그대로 두고, 새 테스트부터 Swift Testing으로"다. 한 타깃에 두 프레임워크가 공존할 수 있다. 다만 @Test 함수를 XCTestCase 클래스 안에 넣을 수는 없다(WWDC26 · Migrate to Swift Testing).
3. Preview용 인메모리 컨테이너 + 흔한 실수
Preview마다 실제 디스크 저장소를 쓰면 이전 실행의 데이터가 섞인다. inMemory: true로 매번 빈 저장소를 쓰고, 샘플이 필요하면 컨테이너를 만드는 코드를 한곳에 모은다.
import SwiftUI
import SwiftData
@Model
final class Trip {
var name: String
var destination: String
var startDate: Date
@Relationship(deleteRule: .cascade, inverse: \Activity.trip)
var activities: [Activity] = []
init(name: String, destination: String, startDate: Date) {
self.name = name
self.destination = destination
self.startDate = startDate
}
}
@Model
final class Activity {
var name: String
var trip: Trip?
init(name: String) { self.name = name }
}
struct TripListView: View { // 1부 2절 목록의 축약판
@Query(sort: \Trip.startDate, order: .reverse) private var trips: [Trip]
var body: some View {
List(trips) { Text($0.name) }
}
}#Preview("빈 목록") {
TripListView()
.modelContainer(for: [Trip.self, Activity.self], inMemory: true)
}
@MainActor
enum PreviewData {
static let container: ModelContainer = {
let container = try! ModelContainer(
for: Trip.self, Activity.self,
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
)
let jeju = Trip(name: "제주", destination: "제주", startDate: .now)
jeju.activities = [Activity(name: "올레 7코스"), Activity(name: "성산 일출")]
container.mainContext.insert(jeju)
container.mainContext.insert(
Trip(name: "부산", destination: "부산", startDate: .now.addingTimeInterval(86_400 * 30))
)
return container
}()
}
#Preview("샘플 2개") {
TripListView()
.modelContainer(PreviewData.container)
}샘플 컨테이너를 PreviewData 한곳에 두면 목록·상세·편집 화면 Preview가 같은 데이터를 공유한다. 샘플은 Preview 전용이라 앱 저장소에는 절대 들어가지 않는다.
흔한 실수 — 서비스 싱글턴
final class TripAPI: Sendable {
static let shared = TripAPI()
func search(_ text: String) async throws -> [TripSummary] { [] }
}
struct HardwiredSearchView: View {
@State private var results: [TripSummary] = []
var body: some View {
List(results) { Text($0.name) }
.task { results = (try? await TripAPI.shared.search("제주")) ?? [] }
}
}struct InjectedSearchView: View {
@Environment(\.tripSearching) private var service
@State private var results: [TripSummary] = []
var body: some View {
List(results) { Text($0.name) }
.task { results = (try? await service.search("제주")) ?? [] }
}
}✅ 복습 · 5분
"마지막으로 선택한 탭"과 "알림 on/off"는 각각 어디에 저장하나?
@SceneStorage. 알림 설정은 앱 전체에서 하나인 작은 값이니 @AppStorage. 둘 다 모델이 아니므로 여행 목록 같은 데이터는 SwiftData에 둔다.SearchScreen은 왜 @State private var model: SearchModel?로 두고 .task에서 모델을 만드나? init에서 만들면 안 되나?
service가 필요한데, @Environment 값은 init 시점에 아직 읽을 수 없고 body·.task에서 읽힌다. 또 뷰의 init은 부모가 다시 그릴 때마다 여러 번 불릴 수 있어서(Day 1), init에서 모델을 만들면 쓰지도 않을 인스턴스가 계속 생긴다. (환경 의존이 없고 싼 행 모델이라면 init에서 준비해도 된다 — Day 9 2부의 lazy 스택 수칙. 여기서 옵셔널 + .task를 쓰는 이유는 환경 값이 필요하기 때문이다.) 그래서 옵셔널 @State에 두고 .task에서 nil일 때 딱 한 번 만든 뒤, 자식은 @Bindable로 바인딩만 꺼낸다.🔮 코드 예측: 두 뷰가 모두 @AppStorage("count") var count = 0을 갖고, A 뷰에서 count += 1을 누르면 B 뷰의 숫자는?
@AppStorage는 같은 UserDefaults 키를 읽고, 문서 설명대로 그 값이 바뀌면 뷰를 무효화(다시 그리기)한다. 그래서 같은 키를 서로 다른 뜻으로 쓰면 버그가 된다.🔁 1부 복습: 부모에게 받은 날짜 이후의 여행만 보여 주려면 @Query를 어디서 어떻게 만드나?
init(after date: Date)에서 _trips = Query(filter: #Predicate<Trip> { $0.startDate > date }, sort: \.startDate)처럼 조립한다. 조건을 predicate로 저장소에 맡기면 필요한 행만 읽는다. body에서 trips.filter로 거르면 전체를 읽고 body마다 다시 훑는다.🛠 미니 과제 (선택 · 10분)
SwiftData 메모장을 Preview 하나로 만든다. 새 파일에 아래 시작 코드를 붙인다.
import SwiftUI
import SwiftData
@Model
final class Memo {
var text: String
var createdAt: Date
init(text: String, createdAt: Date = .now) {
self.text = text
self.createdAt = createdAt
}
}
struct MemoListView: View {
var body: some View {
Text("TODO")
}
}
#Preview {
MemoListView()
.modelContainer(for: Memo.self, inMemory: true)
}- 요구 1:
@Query로 메모를 최신순으로 보여 준다. - 요구 2: 툴바 + 버튼으로 "메모 N"을 insert하고, 스와이프로 delete한다.
- 요구 3:
@AppStorage로 "'중요'가 들어간 메모만 보기" 토글을 만들고, 켜면#Predicate가 든 Query로 바꾼다.
확인 방법: Live Preview에서 + 를 세 번 누르면 "메모 3"이 맨 위에 온다. 스와이프 삭제 후 목록이 즉시 줄어든다. 메모 텍스트를 "중요 회의"로 넣은 샘플이 있다면, 토글을 켰을 때 그 메모만 남는다.
정답 코드 보기
struct MemoListView: View {
@Environment(\.modelContext) private var context
@AppStorage("importantOnly") private var importantOnly = false
var body: some View {
NavigationStack {
MemoList(importantOnly: importantOnly)
.toolbar {
Toggle("중요만", isOn: $importantOnly)
Button("추가", systemImage: "plus") {
let count = (try? context.fetchCount(FetchDescriptor<Memo>())) ?? 0
context.insert(Memo(text: "메모 \(count + 1)"))
}
}
}
}
}
struct MemoList: View {
@Environment(\.modelContext) private var context
@Query private var memos: [Memo]
init(importantOnly: Bool) {
if importantOnly {
_memos = Query(filter: #Predicate<Memo> { $0.text.localizedStandardContains("중요") },
sort: \.createdAt, order: .reverse)
} else {
_memos = Query(sort: \.createdAt, order: .reverse)
}
}
var body: some View {
List {
ForEach(memos) { memo in Text(memo.text) }
.onDelete { offsets in
for index in offsets { context.delete(memos[index]) }
}
}
}
}