🟣 body가 자꾸 길어진다
기본기를 익히고 실제 화면을 만들기 시작하면 곧 다른 종류의 어려움을 만난다. 문법이 아니라 구조의 문제다. body 안에 뷰가 쌓이면서 수십, 수백 줄이 되고, 어디를 고쳐야 할지 눈에 안 들어온다. 조건 분기와 스택이 겹치면 들여쓰기만 깊어진다.
이 장은 그 시기에 반복해 쓰게 된 구성 기법을 모은 것이다. 대단한 기술은 아니고, 뷰를 읽기 좋게 나누고 조립하는 실전 습관에 가깝다. 앞의 세 장이 "무엇을 어떻게 흘리는가"였다면 이 장은 "어떻게 정리해서 쓰는가"다.
🟣 뷰를 잘게 나눈다
가장 먼저 몸에 붙여야 할 습관은 큰 body를 작은 뷰로 쪼개는 것이다. 앞서 봤듯이 View는 가벼운 값 타입이라 잘게 나눠도 부담이 없다. 오히려 잘게 나누면 SwiftUI가 바뀐 부분만 다시 계산하기에 유리하다.
간단한 조각은 계산 프로퍼티로 뽑는다.
struct ProfileScreen: View {
let user: User
var body: some View {
VStack {
header // 조각으로 분리
details
}
}
private var header: some View {
HStack {
Image(user.avatar)
Text(user.name).font(.headline)
}
}
private var details: some View { /* ... */ }
}조각이 자기 상태를 갖거나 재사용된다면 계산 프로퍼티가 아니라 별도 struct로 뺀다. 상태를 가진 조각을 독립된 뷰로 만들면 그 상태 변화가 그 뷰의 body만 다시 계산하게 만들어, 화면 전체가 아니라 필요한 부분만 갱신된다. 뷰를 나누는 것은 가독성만이 아니라 갱신 범위를 좁히는 일이기도 하다.
🟣 @ViewBuilder로 조건부 뷰를 함수에 담는다
뷰를 돌려주는 함수를 직접 만들 때, 그 안에서 if로 분기하려 하면 컴파일이 안 된다. 여러 뷰를 나열하거나 조건에 따라 다른 뷰를 돌려주는 문법은 @ViewBuilder가 있어야 동작한다. body가 자연스럽게 여러 뷰를 나열할 수 있는 것도 body에 이 기능이 이미 붙어 있기 때문이다.
직접 만든 함수나 계산 프로퍼티에서 분기가 필요하면 @ViewBuilder를 붙인다.
@ViewBuilder
private func statusLabel(for state: State) -> some View {
if state.isError {
Text("오류").foregroundColor(.red)
} else {
Text("정상")
}
}원리까지 파고들 필요는 없다. "뷰를 나열하거나 분기하는 곳에는 @ViewBuilder가 필요하다" 정도만 알면 실전에서 막히지 않는다.
🟣 renderingMode로 아이콘 색을 코드에서 정한다
이미지를 다룰 때 자주 쓰는 팁이 렌더링 모드다. 에셋으로 넣은 아이콘이 원래 색 그대로 나와서 테마 색과 안 맞을 때가 있다. 이럴 때 이미지를 템플릿 모드로 바꾸면 아이콘의 모양만 남기고 색을 코드에서 입힐 수 있다.
Image("bell")
.renderingMode(.template) // 원래 색을 버리고 모양만 남긴다
.foregroundColor(.accentColor)같은 아이콘을 상태에 따라 다른 색으로 보여줘야 할 때 유용하다. 활성일 때 강조색, 비활성일 때 회색으로 두는 식이다. 색을 이미지마다 따로 만들지 않고 하나의 에셋으로 처리한다.
🟣 고정 헤더가 필요할 때: LazyVStack과 pinnedViews
스크롤할 때 섹션 헤더가 위에 붙어 따라오는 화면을 자주 만든다. UIKit의 테이블 뷰가 기본으로 해 주던 동작이다. SwiftUI에서는 LazyVStack이나 LazyVGrid에 pinnedViews 옵션을 주고, Section으로 내용을 감싸면 헤더가 스크롤 상단에 고정된다.
ScrollView {
LazyVStack(pinnedViews: [.sectionHeaders]) {
ForEach(groups) { group in
Section(header: SectionHeader(title: group.name)) {
ForEach(group.items) { item in
ItemRow(item: item)
}
}
}
}
}Lazy가 붙은 스택은 화면에 보이는 만큼만 뷰를 만든다. 항목이 수백 개여도 스크롤에 들어오는 것만 계산하므로 긴 목록에 적합하다. 짧은 목록이면 굳이 Lazy를 쓸 필요 없이 일반 VStack으로 충분하다.
🟣 로딩 상태를 열거형 하나로 표현한다
마지막으로, 앞 장에서 예고한 로딩 상태 정리다. 화면은 대개 네 가지 처지에 놓인다. 아직 시작 전, 불러오는 중, 성공, 실패다. 이걸 isLoading, error, data 같은 여러 개의 @State로 나눠 두면 조합이 어긋난다. 로딩 중인데 에러도 남아 있는 애매한 상태가 생긴다.
상태를 열거형 하나로 묶으면 불가능한 조합 자체가 사라진다. 성공에는 데이터를, 실패에는 오류를 함께 담는다.
enum LoadState {
case idle
case loading
case loaded([Post])
case failed(Error)
}body에서는 이 열거형을 switch로 갈라 상황마다 다른 화면을 그린다. 성공 케이스에서만 데이터를 꺼낼 수 있으니, 데이터가 없는데 목록을 그리려는 실수가 원천적으로 막힌다.
var body: some View {
switch state {
case .idle, .loading:
ProgressView()
case .loaded(let posts):
List(posts) { Text($0.title) }
case .failed(let error):
Text(error.localizedDescription).foregroundColor(.red)
}
}비동기 결과를 받는 지점에서는 성공과 실패를 함께 담는 Result가 이 열거형과 잘 맞는다. 요청이 끝나면 Result를 받아 .loaded나 .failed로 상태를 바꾸면, 그 변화가 body를 다시 계산해 화면을 맞춘다. 결국 이 책 전체가 말한 하나의 원칙으로 돌아온다. 화면을 직접 바꾸지 않고 상태를 바꾼다.
🟣 정리
- 큰
body는 계산 프로퍼티나 별도struct로 쪼갠다. 가독성과 갱신 범위 축소를 함께 얻는다. - 뷰를 나열하거나 분기하는 함수에는
@ViewBuilder가 필요하다. renderingMode(.template)로 아이콘의 색을 코드에서 입힌다. 상태에 따라 색을 바꿀 때 유용하다.- 고정 헤더는
LazyVStack의pinnedViews와Section으로 만든다. 긴 목록은Lazy로 보이는 만큼만 그린다. - 로딩 상태는 열거형 하나로 묶어 불가능한 조합을 없앤다. 비동기 결과는
Result로 받아 상태로 변환한다.
면접에서 로딩 상태 관리를 물으면 이 열거형 패턴이 좋은 답이 된다. 여러 불리언 대신 하나의 열거형으로 상태 공간을 좁혀 잘못된 조합을 컴파일 단계에서 막는다는 판단을 보여줄 수 있기 때문이다. 여기까지가 SwiftUI를 처음 쓸 때 잡아야 할 기본이다. 선언형 감각, 데이터 흐름의 두 원칙, 상태 기반 화면 제어, 그리고 뷰 구성 습관이다. 이 기본 위에서 body가 실제로 어떻게 다시 계산되고 어떤 부분만 갱신되는지, 렌더 루프와 diffing의 내부로 내려가고 싶다면 같은 저장소의 애플 플랫폼 백과사전으로 이어 가면 된다.