SwiftUI 뷰 계층 형성의 모든 것
SwiftUI를 처음 접하면서 가장 직관적이면서도 헷갈리는 부분 중 하나는 뷰가 어떻게 구성되고 계층이 형성되는지입니다. 겉보기에는 단순해 보이는 코드가 실제로는 복잡한 뷰 계층 구조를 만들어내는 마법 같은 일이 일어나죠. 오늘은 이 마법의 비밀을 함수형 빌더 패턴부터 차근차근 풀어보겠습니다.
함수형 빌더 패턴이란 무엇인가?
SwiftUI의 뷰 계층을 이해하기 위해서는 먼저 함수형 빌더 패턴(Functional Builder Pattern)을 이해해야 합니다. 이 패턴은 객체지향 프로그래밍에서 사용하는 빌더 패턴과는 근본적으로 다른 접근 방식입니다.
전통적인 빌더 패턴 vs 함수형 빌더 패턴
먼저 전통적인 빌더 패턴이 어떻게 작동하는지 살펴보겠습니다.
// 전통적인 빌더 패턴 (객체지향적 접근)
class UIViewBuilder {
private var view: UIView
init() {
self.view = UIView()
}
func setBackgroundColor(_ color: UIColor) -> UIViewBuilder {
view.backgroundColor = color // 기존 객체를 변경
return self
}
func setPadding(_ padding: CGFloat) -> UIViewBuilder {
view.frame = view.frame.insetBy(dx: -padding, dy: -padding) // 기존 객체를 변경
return self
}
func build() -> UIView {
return view // 동일한 객체 반환
}
}
// 사용법
let view = UIViewBuilder()
.setBackgroundColor(.red)
.setPadding(20)
.build()
이 방식에서는 하나의 객체의 프로퍼티(상태 값)을 계속 수정해나가면서 최종 결과물을 만듭니다. 하지만 함수형 빌더 패턴은 완전히 다릅니다.
// 함수형 빌더 패턴 (SwiftUI 방식)
struct MyView {
let content: String
}
struct BackgroundModifier {
let view: MyView
let color: Color
}
struct PaddingModifier {
let view: BackgroundModifier
let padding: CGFloat
}
// 각 단계마다 새로운 객체가 생성됨
let originalView = MyView(content: "Hello")
let viewWithBackground = BackgroundModifier(view: originalView, color: .red)
let finalView = PaddingModifier(view: viewWithBackground, padding: 20)
함수형 빌더 패턴의 핵심은 불변성(Immutability)입니다. 기존 객체(의 상태)를 변경하는 대신, 매번 새로운 객체를 생성하여 이전 객체를 감싸는 방식으로 작동합니다.
왜 함수형 빌더 패턴을 사용하는가?
이 패턴이 왜 중요한지 이해하기 위해 몇 가지 장점을 살펴보겠습니다.
1. 예측 가능성: 한 번 생성된 뷰는 절대 변경되지 않습니다. 이는 앱의 상태를 추론하기 쉽게 만들어줍니다.
2. 안전성: 여러 곳에서 동일한 뷰를 참조해도 서로 영향을 주지 않습니다.
3. 재사용성: 각 단계의 결과물을 다른 곳에서도 안전하게 재사용할 수 있습니다.
4. 디버깅 용이성: 각 변환 단계가 명확하게 분리되어 있어 문제가 발생한 지점을 찾기 쉽습니다.
SwiftUI에서의 함수형 빌더 패턴 적용
이제 SwiftUI가 어떻게 이 패턴을 실제로 구현하는지 살펴보겠습니다. SwiftUI의 모든 뷰와 modifier는 이 원리를 따릅니다.
Value Semantics와 구조체
SwiftUI는 모든 뷰를 구조체(struct)로 정의합니다. 구조체는 Swift에서 값 타입(Value Type)이며, 이는 함수형 빌더 패턴의 불변성을 자연스럽게 보장해줍니다.
SwiftUI의 View 재생성, ID 시스템, 그리고 View 계층 구조
구조체는 불변으로 유지하되, 상태는 별도로 관리한다. View 구조체의 불변성과 재생성SwiftUI에서 모든 View는 구조체(struct)로 구현됩니다. 구조체는 Swift의 값 타입(value type)이므로 한번 생성되면
people-analysis.tistory.com
struct Text: View {
let content: String
let modifiers: [ViewModifier] // 간소화된 표현
var body: some View {
// 실제 렌더링 로직
}
}
// Text는 구조체이므로, 복사될 때마다 새로운 인스턴스가 생성됩니다
let text1 = Text("Hello")
let text2 = text1 // text1의 복사본, 서로 독립적
ModifiedContent: 뷰 래핑의 핵심
SwiftUI에서 modifier를 적용할 때마다 생성되는 핵심 타입이 ModifiedContent입니다. 이 타입을 자세히 살펴보겠습니다.
// SwiftUI 내부 구조 (간소화된 버전)
public struct ModifiedContent<Content, Modifier> {
public let content: Content // 원본 뷰 또는 이전 단계의 뷰
public let modifier: Modifier // 적용할 modifier
// 이 구조체는 Content와 Modifier를 조합하여 새로운 뷰를 만듭니다
}
modifier를 체이닝할 때마다 다음과 같은 일이 일어납니다.
// 단계별 타입 변화 살펴보기
let step1 = Text("Hello")
// 타입: Text
let step2 = step1.foregroundColor(.blue)
// 타입: ModifiedContent<Text, _ForegroundColorModifier>
// 내부 구조: ModifiedContent(content: Text("Hello"), modifier: _ForegroundColorModifier(.blue))
let step3 = step2.padding()
// 타입: ModifiedContent<ModifiedContent<Text, _ForegroundColorModifier>, _PaddingLayout>
// 내부 구조: ModifiedContent(
// content: ModifiedContent(content: Text("Hello"), modifier: _ForegroundColorModifier(.blue)),
// modifier: _PaddingLayout()
// )
let step4 = step3.background(Color.red)
// 타입: ModifiedContent<ModifiedContent<ModifiedContent<Text, _ForegroundColorModifier>, _PaddingLayout>, _BackgroundModifier>
보시다시피, modifier 체인이 길어질수록 타입도 점점 복잡해집니다. 이는 각 단계가 이전 단계를 완전히 감싸는 새로운 뷰를 만들기 때문입니다.
ViewBuilder: 선언적 구문의 마법사
DSL과 SwiftUI: 도메인 특화 언어의 이해와 활용
들어가며우리가 일상적으로 사용하는 프로그래밍 언어들은 대부분 범용적인 목적으로 설계되었습니다. Swift, Java, Python 같은 언어들은 웹 개발부터 모바일 앱, 데이터 분석까지 다양한 분야에서
people-analysis.tistory.com
SwiftUI의 또 다른 핵심 요소는 ViewBuilder입니다. 이것이 어떻게 여러 뷰를 하나로 조합하는지 살펴보겠습니다.
ViewBuilder의 작동 원리
// 이런 코드를 작성할 때
VStack {
Text("첫 번째")
Text("두 번째")
Text("세 번째")
}
// 실제로는 이런 일이 일어납니다
VStack(content: {
// ViewBuilder가 이 클로저 내용을 분석합니다
let view1 = Text("첫 번째")
let view2 = Text("두 번째")
let view3 = Text("세 번째")
// 세 개의 뷰를 TupleView로 조합합니다
return TupleView((view1, view2, view3))
})
ViewBuilder는 Swift의 Function Builder(현재는 Result Builder) 기능을 사용하여 여러 뷰를 자동으로 조합합니다. 이 과정을 단계별로 살펴보겠습니다:
1단계: 개별 뷰 인식
// ViewBuilder는 클로저 내의 각 라인을 개별 뷰로 인식합니다
Text("첫 번째") // -> view1
Text("두 번째") // -> view2
Text("세 번째") // -> view3
2단계: 타입 조합
// 뷰의 개수에 따라 적절한 TupleView 타입을 생성합니다
// 3개의 뷰 -> TupleView<(Text, Text, Text)>
// 2개의 뷰 -> TupleView<(Text, Text)>
// 1개의 뷰 -> 그대로 해당 뷰 타입
3단계: 컨테이너 뷰 생성
// VStack은 이 TupleView를 자신의 content로 받아서
// 수직 배치 규칙을 적용합니다
VStack<TupleView<(Text, Text, Text)>>(content: tupleView)
조건부 뷰와 ViewBuilder
ViewBuilder의 진정한 힘은 조건부 뷰를 처리할 때 드러납니다:
VStack {
Text("항상 표시됨")
if isLoggedIn {
Text("로그인됨")
Button("로그아웃") { }
} else {
Button("로그인") { }
}
}
이 코드에서 ViewBuilder는 조건에 따라 다른 타입을 생성합니다:
// isLoggedIn이 true일 때
// TupleView<(Text, _ConditionalContent<TupleView<(Text, Button)>, Button>)>
// isLoggedIn이 false일 때
// TupleView<(Text, _ConditionalContent<TupleView<(Text, Button)>, Button>)>
_ConditionalContent는 SwiftUI가 조건부 뷰를 처리하기 위해 내부적으로 사용하는 타입입니다.
뷰 계층 형성의 구체적인 과정
이제 실제 SwiftUI 코드에서 뷰 계층이 어떻게 형성되는지 단계별로 따라가보겠습니다.
간단한 예시: Text with Modifiers
Text("Hello SwiftUI")
.font(.title)
.foregroundColor(.blue)
.padding()
.background(Color.gray.opacity(0.2))
.cornerRadius(10)
이 코드의 뷰 계층 형성 과정을 단계별로 분석해보겠습니다.
1단계: 기본 Text 뷰 생성
let baseView = Text("Hello SwiftUI")
// 타입: Text
// 내용: "Hello SwiftUI"라는 텍스트를 표시하는 기본 뷰
2단계: font modifier 적용
let fontModifiedView = baseView.font(.title)
// 타입: ModifiedContent<Text, _FontModifier>
// 구조: ModifiedContent(
// content: Text("Hello SwiftUI"),
// modifier: _FontModifier(.title)
// )
3단계: foregroundColor modifier 적용
let colorModifiedView = fontModifiedView.foregroundColor(.blue)
// 타입: ModifiedContent<ModifiedContent<Text, _FontModifier>, _ForegroundColorModifier>
// 구조: ModifiedContent(
// content: ModifiedContent(content: Text("Hello SwiftUI"), modifier: _FontModifier(.title)),
// modifier: _ForegroundColorModifier(.blue)
// )
4단계: padding modifier 적용
let paddingModifiedView = colorModifiedView.padding()
// 타입이 더욱 복잡해집니다...
// ModifiedContent<ModifiedContent<ModifiedContent<Text, _FontModifier>, _ForegroundColorModifier>, _PaddingLayout>
이 과정이 계속되면서 최종적으로는 매우 깊은 중첩 구조가 만들어집니다.
복잡한 예시: 컨테이너 뷰와 조합
VStack(spacing: 20) {
HStack {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
Text("즐겨찾기")
.font(.headline)
}
.padding()
.background(Color.blue.opacity(0.1))
Text("상세 설명이 들어갑니다.")
.font(.body)
.multilineTextAlignment(.center)
}
.padding()
.background(Color.white)
.cornerRadius(15)
.shadow(radius: 5)
최상위 레벨:
// 가장 바깥쪽 modifier들이 적용된 VStack
ModifiedContent<
ModifiedContent<
ModifiedContent<
ModifiedContent<VStack<내부_컨텐츠_타입>, _PaddingLayout>,
_BackgroundModifier
>,
_CornerRadiusModifier
>,
_ShadowModifier
>
VStack 내부 컨텐츠:
// ViewBuilder가 조합한 VStack의 내용
TupleView<(
ModifiedContent<ModifiedContent<HStack<내부_HStack_컨텐츠>, _PaddingLayout>, _BackgroundModifier>,
ModifiedContent<ModifiedContent<Text, _FontModifier>, _MultilineTextAlignmentModifier>
)>
HStack 내부 컨텐츠:
// HStack 안의 Image와 Text
TupleView<(
ModifiedContent<Image, _ForegroundColorModifier>,
ModifiedContent<Text, _FontModifier>
)>
보시다시피 실제 뷰 계층은 상당히 복잡합니다. 하지만 SwiftUI는 이 모든 복잡성을 추상화하여 개발자가 간단한 선언적 구문으로 UI를 구성할 수 있게 해줍니다.
렌더링과 레이아웃 과정
뷰 계층이 형성된 후에는 실제로 화면에 그려지는 과정이 필요합니다. SwiftUI의 렌더링 과정을 이해하면 뷰 계층의 중요성을 더 잘 알 수 있습니다.
3단계 레이아웃 과정
SwiftUI는 다음 3단계로 레이아웃을 처리합니다.
1단계: Size Proposal (크기 제안)
// 부모 뷰가 자식 뷰에게 크기를 제안합니다
parent.propose(size: CGSize(width: 300, height: 400), to: child)
// 이는 "이 정도 크기로 그려보세요"라는 요청입니다
// 자식 뷰는 이 제안을 받아들일 수도, 거부할 수도 있습니다
2단계: Size Reporting (크기 보고)
// 자식 뷰가 자신의 실제 크기를 부모에게 보고합니다
let actualSize = child.sizeThatFits(proposedSize)
// 예: CGSize(width: 250, height: 50) - 제안보다 작을 수 있음
3단계: Placement (배치)
// 부모 뷰가 자식 뷰의 최종 위치를 결정합니다
parent.place(child, at: CGPoint(x: 25, y: 175))
// 부모 뷰의 레이아웃 규칙에 따라 위치가 결정됩니다
뷰 계층을 따라 흐르는 레이아웃
이 과정이 뷰 계층을 따라 어떻게 진행되는지 구체적인 예로 살펴보겠습니다.
VStack {
Text("제목")
.font(.title)
.padding()
Text("내용")
.font(.body)
}
.padding()
레이아웃 흐름
- 최상위 padding modifier가 사용 가능한 전체 공간을 받습니다
- VStack이 패딩을 제외한 공간을 받습니다
- VStack이 자식들에게 높이를 분배합니다 (기본적으로 균등 분배)
- 첫 번째 Text의 padding modifier가 할당된 공간을 받습니다
- 첫 번째 Text가 패딩을 제외한 공간에서 자신의 크기를 계산합니다
- 두 번째 Text가 남은 공간에서 자신의 크기를 계산합니다
- 계산된 크기들이 다시 상위로 올라가며 최종 레이아웃이 결정됩니다