← SwiftUI 14일 마스터 13 / 14 · 2부

Day 13 · 2부 · 작은 저장소·주입·테스트

수명에 따라 저장 도구를 고르고, 서비스를 환경으로 주입해 @Observable 모델을 Swift Testing으로 검증하며, Preview와 테스트에 인메모리 컨테이너를 쓴다.
진행률
0 / 0 완료
1부에서 SwiftData로 앱 데이터를 저장하고, @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).

📝 Apple 원문

"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 — 모든 걸 여기에 넣지 말라"고도 한다.

@State 프로세스 동안 @SceneStorage 씬 단위 · 시스템이 자동 복원 @AppStorage 앱 전체 · UserDefaults · 설정값 SwiftData 앱 데이터(모델) · 디스크 · 관계 · 검색 짧게 산다 오래 · 넓게 산다
저장 도구는 "얼마나 오래, 어디까지 공유되나"로 고른다. 위로 갈수록 가볍고 좁다.
✅ @AppStorage — 설정값. Binding이 나오므로 Toggle에 바로 연결
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)
                }
            }
        }
    }
}
✅ @SceneStorage — 창(씬)마다 따로 복원할 UI 상태
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+부터다. 둘 다 키가 곧 식별자이므로 서로 다른 데이터에 같은 키를 쓰지 않는다.

❌ 모델 전체를 JSON으로 UserDefaults에 밀어 넣는다
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) }
    }
}
✅ 모델은 SwiftData, UserDefaults에는 "마지막으로 본 것" 같은 작은 값만
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).

protocol TripSearching search(_:) async throws LiveTripSearch 실제 네트워크 · 앱 실행 시 MockTripSearch 고정 데이터 · Preview·테스트 채택 채택 루트 뷰 .environment(…) SearchScreen @Environment로 꺼냄 SearchModel(service:) @Observable · 로직 담당 앱 테스트는 뷰 없이 바로
의존성은 프로토콜 하나로 끊는다. 앱은 환경을 통해 진짜를, 테스트는 생성자로 가짜를 꽂는다.
✅ ① 프로토콜 + 환경 키
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 모델에, 뷰는 연결만
@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)
    }
}
✅ ③ Preview에서는 가짜를 환경에 꽂는다
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절에 요약)을 디스크 대신 메모리에만 두는 컨테이너에 넣고 검사한다.

✅ ⑤ SwiftData 로직은 인메모리 컨테이너로 테스트
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) == ["다음 부산"])
}
XCTestSwift 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로 매번 빈 저장소를 쓰고, 샘플이 필요하면 컨테이너를 만드는 코드를 한곳에 모은다.

1부의 모델 요약 — 이 페이지의 SwiftData 코드가 쓰는 정의
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)
}
👀 Preview 팁

샘플 컨테이너를 PreviewData 한곳에 두면 목록·상세·편집 화면 Preview가 같은 데이터를 공유한다. 샘플은 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("제주")) ?? [] }
    }
}
✅ 환경에서 꺼낸다 — 루트나 Preview가 무엇을 꽂든 뷰 코드는 그대로
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에 둔다.
쉽게 말하면 책상 위 포스트잇(씬)과 집 현관 메모판(앱 전체)의 차이다. 일기장 전체(모델)는 책장(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 뷰의 숫자는?
B도 1로 바뀐다. @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)
}

확인 방법: Live Preview에서 + 를 세 번 누르면 "메모 3"이 맨 위에 온다. 스와이프 삭제 후 목록이 즉시 줄어든다. 메모 텍스트를 "중요 회의"로 넣은 샘플이 있다면, 토글을 켰을 때 그 메모만 남는다.

정답 코드 보기
예시 정답 — 필터 여부에 따라 자식 뷰의 Query를 바꾼다
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]) }
                }
        }
    }
}

출처 · 참고자료