| name | swiftui-patterns |
| description | SwiftUI 아키텍처 패턴, @Observable을 사용한 상태 관리, 뷰 구성, 네비게이션, 성능 최적화 및 현대적인 iOS/macOS UI 모범 사례입니다. |
SwiftUI 패턴 (SwiftUI Patterns)
Apple 플랫폼에서 선언적이고 성능이 뛰어난 사용자 인터페이스를 구축하기 위한 현대적인 SwiftUI 패턴입니다. Observation 프레임워크, 뷰 구성, 타입 안전 네비게이션 및 성능 최적화를 다룹니다.
활성화 시점
- SwiftUI 뷰를 구축하고 상태를 관리할 때 (
@State, @Observable, @Binding)
NavigationStack을 사용하여 네비게이션 흐름을 설계할 때
- 뷰 모델과 데이터 흐름을 구조화할 때
- 리스트 및 복잡한 레이아웃의 렌더링 성능을 최적화할 때
- SwiftUI에서 환경 값(Environment values) 및 의존성 주입을 사용할 때
상태 관리
프로퍼티 래퍼 선택
상황에 맞는 가장 단순한 래퍼를 선택하세요:
| 래퍼 | 사용 사례 |
|---|
@State | 뷰 로컬 값 타입 (토글, 폼 필드, 시트 표시 여부 등) |
@Binding | 부모 뷰의 @State에 대한 양방향 참조 |
@Observable 클래스 + @State | 여러 프로퍼티를 가진 소유된 모델 |
@Observable 클래스 (래퍼 없음) | 부모로부터 전달받은 읽기 전용 참조 |
@Bindable | @Observable 프로퍼티에 대한 양방향 바인딩 |
@Environment | .environment()를 통해 주입된 공유 의존성 |
@Observable 뷰 모델
ObservableObject 대신 @Observable을 사용하세요. 프로퍼티 수준의 변경을 추적하므로, 변경된 프로퍼티를 읽는 뷰만 다시 렌더링됩니다.
@Observable
final class ItemListViewModel {
private(set) var items: [Item] = []
private(set) var isLoading = false
var searchText = ""
private let repository: any ItemRepository
init(repository: any ItemRepository = DefaultItemRepository()) {
self.repository = repository
}
func load() async {
isLoading = true
defer { isLoading = false }
items = (try? await repository.fetchAll()) ?? []
}
}
뷰 모델을 사용하는 뷰
struct ItemListView: View {
@State private var viewModel: ItemListViewModel
init(viewModel: ItemListViewModel = ItemListViewModel()) {
_viewModel = State(initialValue: viewModel)
}
var body: some View {
List(viewModel.items) { item in
ItemRow(item = item)
}
.searchable(text: $viewModel.searchText)
.overlay { if viewModel.isLoading { ProgressView() } }
.task { await viewModel.load() }
}
}
환경 객체 주입 (Environment Injection)
@EnvironmentObject를 @Environment로 교체하세요:
ContentView()
.environment(authManager)
struct ProfileView: View {
@Environment(AuthManager.self) private var auth
var body: some View {
Text(auth.currentUser?.name ?? "Guest")
}
}
뷰 구성
하위 뷰 추출을 통한 리렌더링 제한
뷰를 작고 집중된 구조체로 분리하세요. 상태가 변경될 때 해당 상태를 읽는 하위 뷰만 다시 렌더링됩니다.
struct OrderView: View {
@State private var viewModel = OrderViewModel()
var body: some View {
VStack {
OrderHeader(title: viewModel.title)
OrderItemList(items: viewModel.items)
OrderTotal(total: viewModel.total)
}
}
}
재사용 가능한 스타일링을 위한 ViewModifier
struct CardModifier: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
extension View {
func cardStyle() -> some View {
modifier(CardModifier())
}
}
네비게이션
타입 안전 NavigationStack
프로그래밍 방식의 타입 안전 라우팅을 위해 NavigationStack과 NavigationPath를 사용하세요.
@Observable
final class Router {
var path = NavigationPath()
func navigate(to destination: Destination) {
path.append(destination)
}
func popToRoot() {
path = NavigationPath()
}
}
enum Destination: Hashable {
case detail(Item.ID)
case settings
case profile(User.ID)
}
struct RootView: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Destination.self) { dest in
switch dest {
case .detail(let id): ItemDetailView(itemID: id)
case .settings: SettingsView()
case .profile( id): (userID: id)
}
}
}
.environment(router)
}
}
성능
대규모 컬렉션에 Lazy 컨테이너 사용
LazyVStack과 LazyHStack은 화면에 보일 때만 뷰를 생성합니다.
ScrollView {
LazyVStack(spacing: 8) {
ForEach(items) { item in
ItemRow(item: item)
}
}
}
안정적인 식별자
ForEach에서는 항상 안정적이고 고유한 ID를 사용하세요. 배열 인덱스 사용을 피하세요.
ForEach(items, id: \.stableID) { item in
ItemRow(item: item)
}
body 내부에서 무거운 작업 피하기
body 내부에서 I/O, 네트워크 호출 또는 무거운 계산을 수행하지 마세요.
- 비동기 작업에는
.task {}를 사용하세요. 뷰가 사라질 때 자동으로 취소됩니다.
- 스크롤 뷰에서
.sensoryFeedback() 및 .geometryGroup() 사용을 절제하세요.
- 리스트에서
.shadow(), .blur(), .mask() 사용을 최소화하세요. 오프스크린 렌더링을 유발합니다.
Equatable 준수
body 연산이 매우 무거운 뷰의 경우, Equatable을 준수하여 불필요한 재렌더링을 건너뛰게 하세요.
struct ExpensiveChartView: View, Equatable {
let dataPoints: [DataPoint]
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.dataPoints == rhs.dataPoints
}
var body: some View {
}
}
프리뷰 (Previews)
빠른 반복 개발을 위해 인라인 모킹 데이터를 포함한 #Preview 매크로를 사용하세요.
#Preview("비어 있는 상태") {
ItemListView(viewModel: ItemListViewModel(repository: EmptyMockRepository()))
}
#Preview("데이터 있음") {
ItemListView(viewModel: ItemListViewModel(repository: PopulatedMockRepository()))
}
피해야 할 안티 패턴
- 새로운 코드에서
ObservableObject / @Published / @StateObject / @EnvironmentObject 사용 — @Observable로 마이그레이션하세요.
body나 init에서 직접 비동기 작업 수행 — .task {}나 명시적인 로드 메서드를 사용하세요.
- 데이터를 소유하지 않은 하위 뷰 내부에서 뷰 모델을
@State로 생성 — 부모로부터 전달받으세요.
AnyView 타입 지우기 사용 — 조건부 뷰에는 @ViewBuilder나 Group을 선호하세요.
- 액터(actor)와 데이터를 주고받을 때
Sendable 요구 사항 무시.
참조
액터 기반 영속성 패턴은 swift-actor-persistence 스킬을 참조하세요.
프로토콜 기반 DI 및 Swift Testing을 사용한 테스트는 swift-protocol-di-testing 스킬을 참조하세요.