2026.05.28 · 9분 읽기
앱에서 여러 화면을 탭으로 전환할 때 쓰는 컨테이너 뷰. 각 Tab은 탭 바에 표시될 이름과 아이콘, 그리고 해당 탭을 눌렀을 때 보여줄 뷰로 구성된다. 탭 개수만큼 하단 탭 바가 자동으로 만들어진다.
TabView {
Tab("Friends", systemImage: "person.and.person") {
FriendList()
}
Tab("Movies", systemImage: "film.stack") {
MovieList()
}
}systemImage는 Apple이 기본 제공하는 SF Symbols 아이콘 이름이다. 수천 개의 아이콘이 있으며 SF Symbols 앱에서 이름을 확인할 수 있다.
탭이 많아지면 사용성이 떨어지기 때문에 보통 2~5개 사이로 유지하는 것이 좋다.
SwiftData에서 저장 가능한 데이터 클래스를 선언할 때 쓰는 매크로. 클래스 앞에 @Model을 붙이면 SwiftData가 자동으로 해당 클래스의 저장/조회/삭제를 처리해준다.
@Model
class Friend {
var name: String
init(name: String) {
self.name = name
}
}struct가 아닌 class를 써야 하는 이유는 SwiftData가 데이터의 변경을 추적하기 위해 참조 타입(class)을 필요로 하기 때문이다. struct는 값 타입이라 변경 추적이 불가능하다.
@Model을 붙이는 순간 내부적으로 여러 프로토콜이 자동으로 추가되는데, 그 중 Identifiable도 포함되어 있어서 ForEach에서 별도로 id를 지정하지 않아도 된다.
static을 붙이면 해당 프로퍼티는 인스턴스가 아닌 타입 자체에 소속된다. 즉 객체를 생성하지 않아도 타입이름.프로퍼티로 바로 접근할 수 있다.
// static 없을 때 → 반드시 인스턴스 생성 후 접근
let friend = Friend(name: "Elena")
friend.sampleData // 인스턴스를 통해서만 접근 가능
// static 있을 때 → 인스턴스 없이 바로 접근
Friend.sampleData // 타입 이름으로 바로 접근 가능모델 클래스 안에 샘플 데이터를 static으로 선언해두면 어디서든 Friend.sampleData로 접근할 수 있어서 데이터를 한 곳에서 관리할 수 있다.
@Model
class Friend {
var name: String
init(name: String) { self.name = name }
static let sampleData = [
Friend(name: "Elena"),
Friend(name: "Graham"),
Friend(name: "Mayuri"),
Friend(name: "Rich"),
Friend(name: "Rody"),
]
}SwiftData 저장소를 만들기 위한 3단계 구성 요소. 각각의 역할이 명확하게 분리되어 있다.
앱에서 저장할 모델 타입들을 SwiftData에 등록하는 단계. 어떤 클래스를 저장소에서 관리할지 알려주는 목록이라고 보면 된다.
let schema = Schema([Friend.self, Movie.self])Friend.self처럼 .self를 붙이는 건 인스턴스가 아닌 타입 자체를 넘기는 것이다. "Elena"라는 실제 친구 데이터가 아니라 "Friend라는 클래스가 어떻게 생겼는지 설계도"를 넘기는 거라고 이해하면 된다.
저장소의 동작 방식을 설정하는 단계.
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)isStoredInMemoryOnly: true로 설정하면 데이터가 기기에 저장되지 않고 메모리에만 존재한다. 앱을 재실행하거나 프리뷰를 새로고침하면 데이터가 초기화된다. 프리뷰에서는 항상 깨끗한 상태로 시작해야 하기 때문에 이 옵션을 쓴다. 반대로 실제 앱에서는 false(기본값)로 설정해 기기에 영구 저장한다.
Schema와 ModelConfiguration을 바탕으로 만들어지는 실제 저장소. @Query와 @Environment(\.modelContext)가 동작하려면 반드시 이 컨테이너가 연결되어 있어야 한다.
let container = try ModelContainer(for: schema, configurations: [config])try가 필요한 이유는 저장소 생성이 실패할 수 있기 때문이다. 예를 들어 모델 구조가 기존 저장 데이터와 호환되지 않을 때 실패한다.
Swift에서 실패 가능한 코드를 안전하게 다루는 오류 처리 구조.
"이 함수는 실패할 수 있다"는 표시. throws가 붙은 함수를 호출할 때 반드시 앞에 붙여야 한다. 붙이지 않으면 컴파일 에러가 난다.
// throws가 붙은 함수
func riskyFunction() throws { ... }
// try 없이 호출 → 컴파일 에러
riskyFunction()
// try 붙여서 호출 → OK
try riskyFunction()try 코드의 성공/실패를 처리하는 구조. do 블록 안의 코드가 성공하면 계속 진행하고, 실패하면 catch 블록이 실행된다. catch 블록에는 error라는 변수가 자동으로 만들어져서 어떤 오류인지 확인할 수 있다.
do {
modelContainer = try ModelContainer(for: schema, configurations: [config])
insertSampleData()
try context.save()
} catch {
// error 변수에 오류 정보가 자동으로 들어옴
print(error)
}복구가 불가능한 치명적인 오류일 때 앱을 즉시 종료시킨다. 개발 중에 절대 일어나면 안 되는 상황을 명시적으로 표시할 때 주로 사용한다. 저장소 생성 실패처럼 앱이 아예 동작할 수 없는 상황에서만 써야 하고, 일반적인 오류(네트워크 실패, 입력값 오류 등)에는 절대 쓰면 안 된다.
do {
modelContainer = try ModelContainer(for: schema, configurations: [config])
} catch {
fatalError("저장소 생성 실패: \(error)")
}앱 전체에서 특정 객체의 인스턴스를 딱 하나만 만들고 공유하는 디자인 패턴. static let shared로 클래스 내부에서 단 하나의 인스턴스를 생성하고, private init()으로 외부에서 직접 생성하는 것을 막는다.
class SampleData {
static let shared = SampleData() // 클래스 자체에 속하는 단 하나의 인스턴스
private init() {
// 초기화 로직
}
}
// 외부에서 사용할 때
SampleData.shared.modelContainer // ✅ 항상 같은 인스턴스
SampleData() // ❌ 컴파일 에러 (private)같은 저장소를 모든 프리뷰에서 공유하기 때문에 뷰마다 따로 샘플 데이터를 만들 필요가 없어진다. UIApplication.shared, UserDefaults.standard처럼 Apple 프레임워크에서도 이 패턴이 자주 쓰인다.
Swift의 동시성(Concurrency) 시스템에서 특정 코드를 메인 스레드에서만 실행하도록 보장하는 어노테이션.
앱에서는 여러 작업이 동시에 실행될 수 있는데, 같은 데이터를 여러 스레드에서 동시에 읽고 쓰면 충돌이 생긴다. Swift는 이를 막기 위해 Actor라는 개념을 제공하고, 메인 Actor는 UI 업데이트를 담당하는 메인 스레드에서만 코드가 실행되도록 보장한다.
modelContainer.mainContext는 메인 Actor에서만 접근할 수 있도록 설계되어 있어서, @MainActor가 없으면 에러가 난다.
// @MainActor 없으면
class SampleData {
var context: ModelContext {
modelContainer.mainContext // ❌ 에러: 메인 Actor에서만 접근 가능
}
}
// @MainActor 붙이면
@MainActor
class SampleData {
var context: ModelContext {
modelContainer.mainContext // ✅ 클래스 전체가 메인 스레드에서 실행 보장
}
}SwiftUI 코드도 기본적으로 메인 스레드에서 실행되기 때문에 SampleData를 SwiftUI에서 사용할 때 자연스럽게 조건이 맞아떨어진다.
SwiftData 저장소에서 데이터를 읽어오는 프로퍼티 래퍼. 단순히 한 번 읽어오는 게 아니라 저장소의 데이터가 변경될 때마다 자동으로 뷰를 업데이트해준다. @State처럼 값이 바뀌면 뷰가 다시 그려지는 방식이다.
// 이름 순으로 정렬해서 Friend 배열 가져오기
@Query(sort: \Friend.name) private var friends: [Friend]
// 정렬 없이 가져오기
@Query private var friends: [Friend]sort 파라미터에 키패스(\Friend.name)를 넘기면 해당 프로퍼티 기준으로 자동 정렬된다. 정렬 외에도 필터 조건(#Predicate)을 추가해서 원하는 데이터만 가져올 수도 있다.
@Query는 반드시 ModelContainer가 연결된 환경에서만 동작한다. 연결되지 않은 상태에서 쓰면 런타임 크래시가 난다.
저장소에 데이터를 삽입하거나 삭제할 때 필요한 ModelContext를 환경에서 가져오는 방식. @Query가 읽기 전용이라면 modelContext는 쓰기/삭제 전담이다.
@Environment(\.modelContext) private var context
// 데이터 삽입
context.insert(Friend(name: "Elena"))
// 데이터 삭제
context.delete(friend)
// 저장소에 반영 (insert/delete는 즉시 반영되지 않음, save 필요)
try context.save()@Environment는 부모 뷰에서 자식 뷰로 값이 자동으로 전달되는 SwiftUI의 환경 시스템을 이용한다. App.swift에서 .modelContainer를 한 번만 붙이면 그 안의 모든 자식 뷰에서 자동으로 같은 컨텍스트를 받아올 수 있다.
프리뷰는 App.swift나 ContentView를 거치지 않고 해당 뷰만 독립적으로 실행하기 때문에, 각 뷰 파일마다 .modelContainer를 직접 붙여줘야 한다.
// 프리뷰: 메모리 저장, 샘플 데이터
#Preview {
FriendList()
.modelContainer(SampleData.shared.modelContainer)
}
// 실제 앱: App.swift에 한 번만, 영구 저장
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(for: [Friend.self, Movie.self])
}
}
}| 프리뷰 | 실제 앱 | |
|---|---|---|
| 저장 방식 | 메모리 (임시) | 기기에 영구 저장 |
| 새로고침 시 | 데이터 초기화 | 데이터 유지 |
| 데이터 출처 | SampleData 샘플 | 사용자가 입력한 데이터 |
| 연결 위치 | 각 뷰 파일마다 직접 | App.swift에 한 번만 |
목록과 상세 화면을 연결하는 내비게이션 컨테이너. iPhone에서는 목록에서 항목을 탭하면 상세 화면으로 슬라이드 전환되고, iPad/Mac에서는 목록과 상세 화면이 좌우로 나란히 표시된다. 코드 한 벌로 두 가지 레이아웃을 자동으로 처리해주는 것이 핵심 장점이다.
NavigationSplitView {
// sidebar: 목록 화면
List { ... }
.navigationTitle("Friends") // 루트 타이틀 → 크게 왼쪽 표시
} detail: {
// 아무것도 선택 안 했을 때 표시 (iPad/Mac 전용)
Text("Select a friend")
.navigationTitle("Friends")
}detail 클로저는 iPad/Mac처럼 두 화면을 동시에 보여줄 수 있는 기기에서 아무것도 선택되지 않았을 때 오른쪽 영역에 표시된다. iPhone에서는 무시된다.
목록의 각 행을 탭했을 때 상세 화면으로 이동하게 해주는 뷰. 첫 번째 인자로 목록에 표시할 텍스트를 받고, 클로저 안에 이동할 상세 화면 뷰를 넣는다. NavigationSplitView 또는 NavigationStack 안에서만 동작한다.
NavigationLink(friend.name) {
Text(friend.name)
.navigationTitle(friend.name)
.navigationBarTitleDisplayMode(.inline)
}.navigationBarTitleDisplayMode로 타이틀 표시 방식을 결정한다.
| 모드 | 크기 | 위치 | 사용 상황 |
|---|---|---|---|
.large (기본값) | 크게 | 왼쪽 아래 | 루트(목록) 화면 |
.inline | 작게 | 가운데 위 | 상세 화면 |
목록에서 항목을 탭하면 내비게이션 바의 타이틀이 위로 올라가면서 뒤로 가기 버튼으로 변하는 애니메이션이 자동으로 적용된다.
① Friend/Movie 클래스에 @Model 선언
→ SwiftData가 저장 가능한 타입으로 인식
② 각 모델에 static let sampleData 선언
→ 프리뷰용 샘플 데이터를 한 곳에서 관리
③ SampleData.shared 초기화 (싱글톤)
→ Schema → ModelConfiguration → ModelContainer 생성
→ insertSampleData()로 sampleData 배열을 context.insert()
→ context.save()로 저장소에 반영
④ 각 뷰 파일의 #Preview에
.modelContainer(SampleData.shared.modelContainer) 연결
→ @Query가 이 저장소에서 데이터를 읽어와 배열 자동으로 채움
⑤ List + ForEach로 목록 표시
→ @Query 배열이 바뀔 때마다 뷰 자동 업데이트
⑥ NavigationLink 탭 → NavigationSplitView 상세 화면으로 이동
→ .navigationBarTitleDisplayMode(.inline)으로 타이틀 축소React, Next.js, React Native로 만들고 기록합니다.
giscus로 동작하며 GitHub Discussions에 저장됩니다.