SwiftUI 패턴
Apple 플랫폼에서 선언적이고 성능이 우수한 사용자 인터페이스를 구축하기 위한 최신 SwiftUI 패턴입니다. Observation 프레임워크, 뷰 구성, 타입 안전 네비게이션 및 성능 최적화를 다룹니다.
활성화 시점
- SwiftUI 뷰를 빌드하고 상태(
@State,@Observable,@Binding)를 관리할 때 NavigationStack으로 네비게이션 흐름을 설계할 때- 뷰 모델과 데이터 흐름을 구조화할 때
- 목록 및 복잡한 레이아웃의 렌더링 성능을 최적화할 때
- SwiftUI에서 환경 값과 의존성 주입을 사용할 때
상태 관리
프로퍼티 래퍼 선택
상황에 가장 적합한 가장 간단한 래퍼를 선택하세요:
| 래퍼 | 사용 사례 |
|---|---|
@State | 뷰 지역 값 유형 (토글, 폼 필드, 시트 표시) |
@Binding | 부모의 @State에 대한 양방향 참조 |
@Observable 클래스 + @State | 여러 프로퍼티를 가진 소유 모델 |
@Observable 클래스 (래퍼 없음) | 부모로부터 전달된 읽기 전용 참조 |
@Bindable | @Observable 프로퍼티에 대한 양방향 바인딩 |
@Environment | .environment()를 통해 주입된 공유 의존성 |
@Observable ViewModel
ObservableObject 대신 @Observable을 사용하세요. 속성 수준 변경을 추적하여 SwiftUI가 변경된 프로퍼티를 읽는 뷰만 다시 렌더링합니다:
@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()) ?? []
}
}
ViewModel을 사용하는 뷰
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() }
}
}
환경 주입
@EnvironmentObject를 @Environment로 대체하세요:
// 주입
ContentView()
.environment(authManager)
// 사용
struct ProfileView: View {
@Environment(AuthManager.self) private var auth
var body: some View {
Text(auth.currentUser?.name ?? "게스트")
}
}
뷰 구성
무효화를 제한하기 위한 서브뷰 추출
뷰를 작고 집중된 구조체로 나누세요. 상태가 변경되면 해당 상태를 읽는 서브뷰만 다시 렌더링됩니다:
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(let id): ProfileView(userID: id)
}
}
}
.environment(router)
}
}
성능
대규모 컬렉션에는 지연 컨테이너 사용
LazyVStack과 LazyHStack은 보일 때만 뷰를 생성합니다:
ScrollView {
LazyVStack(spacing: 8) {
ForEach(items) { item in
ItemRow(item: item)
}
}
}
안정적인 식별자
ForEach에서는 항상 안정적이고 고유한 ID를 사용하세요. 배열 인덱스는 피하세요:
// Identifiable 준수 또는 명시적 id 사용
ForEach(items, id: \.stableID) { item in
ItemRow(item: item)
}
body에서 비용이 많이 드는 작업 피하기
body내에서 I/O, 네트워크 호출 또는 무거운 연산을 절대 수행하지 마세요- 비동기 작업에는
.task {}를 사용하세요. 뷰가 사라지면 자동으로 취소됩니다 - 스크롤 뷰에서는
.sensoryFeedback()과.geometryGroup()을 드물게 사용하세요 - 목록에서
.shadow(),.blur(),.mask()를 최소화하세요. 이들은 오프스크린 렌더링을 유발합니다
Equatable 준수
본문이 비용이 많이 드는 뷰의 경우, Equatable을 준수하여 불필요한 재렌더링을 건너뛰세요:
struct ExpensiveChartView: View, Equatable {
let dataPoints: [DataPoint] // DataPoint가 Equatable을 준수해야 함
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.dataPoints == rhs.dataPoints
}
var body: some View {
// 복잡한 차트 렌더링
}
}
미리보기
빠른 반복을 위해 인라인 모의 데이터와 함께 #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을 선호하세요 - 액터로/부터 데이터를 전달할 때
Sendable요구 사항 무시
참고
액터 기반 지속성 패턴은 swift-actor-persistence 기술을 참조하세요.
프로토콜 기반 DI 및 Swift Testing을 사용한 테스트는 swift-protocol-di-testing 기술을 참조하세요.