[iOS] UIKit은 왜 class고, SwiftUI는 왜 struct인가

Zerom·2026년 6월 16일

iOS 정리

목록 보기
12/14

들어가며

UIKit과 SwiftUI를 함께 다루다 보면 자연스럽게 한 가지 의문이 생깁니다. UIKit은 UIView를 class로 정의하고, SwiftUI는 View를 struct로 정의합니다. 단순히 "Apple이 그렇게 만들었다"로 넘어가기엔 이 차이가 두 프레임워크의 설계 철학 전체를 관통하는 핵심입니다. 이 글은 그 이유를 ARC, 렌더링 파이프라인, identity 모델까지 파고들어 정리합니다.


UIKit - class와 참조 의미론

UIKit에서 View는 UIView를 상속받는 class입니다.

class MyView: UIView {
    var label: UILabel = UILabel()

    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(label)
    }

    required init?(coder: NSCoder) {
        fatalError()
    }
}

class를 사용한다는 건 참조 의미론(reference semantics) 을 쓴다는 뜻입니다. MyView 인스턴스를 두 변수에 할당하면 두 변수는 같은 객체를 가리킵니다.

let a = MyView()
let b = a
b.backgroundColor = .red
// a.backgroundColor도 .red — 같은 객체이므로

ARC와 retain cycle

class 인스턴스는 ARC(Automatic Reference Counting) 로 메모리를 관리합니다. ARC는 객체를 참조할 때마다 retain count를 올리고, 참조가 사라질 때 낮춰서 0이 되면 메모리를 해제합니다.

문제는 두 객체가 서로를 강하게 참조하면 retain count가 절대 0이 되지 않는다는 점입니다.

class ViewController: UIViewController {
    var onComplete: (() -> Void)?

    override func viewDidLoad() {
        super.viewDidLoad()

        // ⚠️ retain cycle
        // self → onComplete(클로저) → self
        onComplete = {
            print(self.title ?? "")
        }
    }
}

이 패턴은 iOS 개발에서 흔히 만나는 retain cycle입니다. ViewController가 클로저를 소유하고, 클로저가 self를 캡처하면서 서로 강하게 붙잡습니다. [weak self]로 끊지 않으면 ViewController는 화면에서 사라져도 메모리에서 해제되지 않습니다.

onComplete = { [weak self] in
    print(self?.title ?? "")
}

UIKit은 이런 class 기반 참조 구조 때문에 개발자가 메모리 그래프를 직접 관리해야 합니다. delegate 패턴에서 weak var delegate를 쓰는 것도 같은 이유입니다.

UIView가 class여야 하는 이유

UIKit에서 View는 단순한 데이터 모델이 아니라 실제 화면 위의 객체입니다. UIView는 CALayer를 소유하고, 레이어는 GPU 버퍼와 연결됩니다. 부모 뷰가 자식 뷰 배열을 유지하고, 하나의 View 인스턴스가 여러 곳에서 참조될 수 있어야 합니다. 이런 구조는 참조 타입이 아니면 성립하지 않습니다.

또한 UIKit View는 상태를 직접 들고 있습니다. isHidden, alpha, frame 같은 프로퍼티가 View 인스턴스 안에 살아있고, 외부에서 직접 변경됩니다. 이것이 UIKit의 명령형(imperative) 패러다임입니다.


SwiftUI - struct와 값 의미론

SwiftUI는 View를 struct로 정의합니다.

struct ContentView: View {
    var body: some View {
        Text("Hello")
    }
}

struct는 값 타입(value type) 입니다. 할당하거나 함수에 전달할 때 복사됩니다.

var a = ContentView()
var b = a  // 독립적인 복사본

SwiftUI에서 View struct는 화면에 실제로 표시되는 객체가 아닙니다. 화면에 뭘 그려야 하는지를 기술하는 청사진(blueprint) 입니다. SwiftUI 런타임이 이 청사진을 읽고 실제 렌더링을 결정합니다.

그러니 View struct는 생성되고, 비교되고, 버려지는 걸 반복해도 됩니다. 가볍고, 빠르고, 사이드 이펙트가 없어야 합니다. struct가 그 역할에 딱 맞습니다.

불변성이 주는 안전성

struct는 기본적으로 불변(immutable)입니다. 메서드 안에서 프로퍼티를 바꾸려면 mutating 키워드가 필요하고, SwiftUI View의 body는 순수하게 현재 상태만 보고 뷰를 계산해야 합니다.

이 불변성 덕분에 SwiftUI는 같은 상태면 항상 같은 View 구조가 나온다고 보장할 수 있습니다. 사이드 이펙트 없이 View를 재계산할 수 있으므로 런타임이 언제든 body를 다시 호출해도 안전합니다.

UIKit에서는 View 객체가 외부 상태를 직접 품고 있어서 "지금 이 View가 어떤 상태인가"를 추적하기가 복잡합니다. SwiftUI는 이 문제를 View에서 상태를 분리함으로써 해결했습니다.


Identity vs Equality

여기서 중요한 개념이 등장합니다. class와 struct는 "같다"는 개념 자체가 다릅니다.

class: Identity (===)

class 인스턴스는 identity로 구분합니다. 메모리 주소가 같으면 같은 객체입니다.

let viewA = UIView()
let viewB = viewA
let viewC = UIView()

viewA === viewB  // true — 같은 객체
viewA === viewC  // false — 다른 객체 (내용이 같아도)

UIKit에서 "이 View가 저 View냐"는 메모리 주소로 결정됩니다. 객체가 존재하는 한 identity는 변하지 않습니다.

struct: Equality (==)

struct는 identity가 없습니다. 복사본은 원본과 구분되지 않습니다. 두 struct가 같은지는 값을 비교(Equatable)해서 판단합니다.

struct Point: Equatable {
    var x: Int, y: Int
}

let p1 = Point(x: 1, y: 2)
var p2 = p1
p2.x = 99

p1 == p2  // false — 값이 다르므로

SwiftUI가 Identity를 따로 관리하는 이유

SwiftUI의 View struct는 매 상태 변경마다 새로 생성됩니다. struct이기 때문에 매 body 계산마다 새 인스턴스가 만들어집니다. 그런데 화면에서 "이 TextField가 포커스를 갖고 있는 것"을 추적하려면 연속적인 identity가 필요합니다.

SwiftUI는 이 문제를 구조적 identity(structural identity) 로 해결합니다. View 트리에서의 위치(타입 + 계층 경로)가 identity를 결정합니다.

VStack {
    if showDetails {
        Text("Details")  // identity: VStack > if-true > Text
    }
    Button("Toggle") { showDetails.toggle() }
}

showDetails가 바뀌어도 Button은 같은 위치에 있으므로 같은 identity를 유지합니다. Text는 조건에 따라 나타나고 사라지지만, 나타날 때마다 같은 위치의 같은 타입이므로 SwiftUI는 이를 추적할 수 있습니다.

명시적으로 identity를 부여하고 싶을 땐 .id(_:) 수식어를 씁니다.

Text(item.name)
    .id(item.id)  // 이 값이 바뀌면 SwiftUI는 새 View로 취급

렌더링 내부 - Render Tree와 AttributeGraph

SwiftUI는 내부적으로 두 개의 트리를 분리해서 관리합니다.

View 트리 vs Render 트리

View 트리는 매 상태 변경마다 재생성되는 struct들의 집합입니다. body가 호출될 때마다 새 View struct가 만들어지고, 이전 것은 버려집니다. 이 작업은 매우 가볍습니다. struct는 스택에 할당되고 크기가 작으며, ARC 오버헤드가 없습니다.

Render 트리는 실제 화면에 무엇이 그려지는지를 담는 영속적인 구조입니다. View 트리가 바뀔 때 SwiftUI는 이전 View 트리와 새 View 트리를 비교(diff)해서 Render 트리를 최소한으로 업데이트합니다.

AttributeGraph와 4단계 업데이트 사이클

SwiftUI는 내부적으로 AttributeGraph라는 private 프레임워크를 통해 의존성을 추적하고 업데이트를 최적화합니다.

상태가 변경될 때 SwiftUI의 업데이트 사이클은 크게 4단계로 진행됩니다.

1. 무효화(Invalidation): 변경된 상태에 의존하는 속성들이 "더럽혀짐(dirty)"으로 표시됩니다. 변경과 직접 관련 없는 부분은 건드리지 않습니다.

2. Body 재계산: 무효화된 View들의 body가 다시 호출됩니다. 새로운 View struct들이 생성됩니다.

3. 구조적 diff: 이전 View 트리와 새 View 트리를 비교합니다. View의 타입과 위치가 같으면 내용만 업데이트하고, 타입이 바뀌면 해당 서브트리 전체를 교체합니다.

4. Render 트리 업데이트: diff 결과를 Render 트리에 적용합니다. 변경된 부분만 실제 화면에 반영됩니다.

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        VStack {
            Text("\(count)")            // count 변경 시 이 부분만 재계산
            Button("+") { count += 1 } // 버튼은 변하지 않으므로 재계산 안 함
        }
    }
}

UIKit에서는 개발자가 setNeedsLayout(), layoutIfNeeded(), reloadData() 등을 직접 호출해서 화면 갱신을 지시했습니다. SwiftUI는 AttributeGraph가 의존성을 자동 추적하므로 개발자가 "언제 업데이트할지"를 신경 쓰지 않아도 됩니다.


@State의 비밀 - struct인데 왜 상태가 유지될까

SwiftUI의 View는 struct입니다. 상태가 바뀌면 이전 View struct는 버려지고 새 struct가 생성됩니다. 그런데 @State로 선언한 값은 View가 재생성되어도 유지됩니다. 어떻게 가능할까요?

struct CounterView: View {
    @State private var count = 0  // struct가 재생성되어도 값이 유지됨

    var body: some View {
        Button("\(count)") { count += 1 }
    }
}

@State는 struct 밖에 존재합니다

@State는 값을 View struct 안에 저장하지 않습니다. 대신 SwiftUI 런타임이 관리하는 외부 힙(heap) 스토리지에 저장합니다. View struct는 이 스토리지를 가리키는 얇은 토큰만 갖습니다.

CounterView (struct, 스택)
    └── @State<Int>: 토큰 → [SwiftUI 힙 스토리지: count = 3]

View struct가 재생성될 때 SwiftUI는 새 struct에게 같은 힙 스토리지의 토큰을 연결해줍니다. 그래서 값이 유지됩니다.

Identity가 스토리지를 결정합니다

중요한 건 이 힙 스토리지가 View의 identity에 묶여 있다는 점입니다. View의 identity(타입 + 트리 내 위치)가 유지되는 한 스토리지도 유지됩니다. identity가 바뀌면 — 예를 들어 .id() 값이 변경되면 — 스토리지가 초기화되고 @State는 초기값으로 돌아갑니다.

// id가 바뀌면 CounterView의 @State가 리셋됩니다
CounterView()
    .id(resetFlag ? "a" : "b")

이 구조 덕분에 struct이면서도 영속적인 상태를 다룰 수 있습니다. struct의 불변성과 값 의미론을 유지하면서, 상태만 런타임이 별도로 관리하는 방식입니다.

@StateObject는 같은 원리로 class 인스턴스를 외부에서 관리합니다. class의 참조 의미론이 필요한 경우(ObservableObject, 네트워크 매니저 등)에만 class를 쓰고, View 자체는 struct로 유지합니다.


정리

구분UIKit (UIView)SwiftUI (View)
타입class (참조 타입)struct (값 타입)
메모리 관리ARC, retain count스택 할당, ARC 불필요
상태 위치View 객체 내부런타임 힙 스토리지 (@State)
Identity메모리 주소 (===)트리 내 위치 (구조적 identity)
업데이트 방식개발자가 명시적 호출AttributeGraph 의존성 추적 자동화
렌더링 철학명령형 (어떻게 그릴지)선언형 (무엇을 그릴지)
Retain Cycle 위험있음 (delegate, 클로저)View 레벨에서는 없음

SwiftUI가 struct를 선택한 건 단순한 성능 최적화가 아닙니다. View를 "화면 위의 객체"가 아니라 "상태의 함수"로 재정의하는 패러다임의 전환입니다. struct의 값 의미론과 불변성 덕분에 SwiftUI 런타임은 View를 자유롭게 생성하고, 비교하고, 버릴 수 있습니다. 상태는 View struct 밖에서 identity에 묶어 관리하고, 렌더링은 AttributeGraph가 diff해서 최소한의 변경만 반영합니다.

UIKit의 class 기반 설계가 틀린 것이 아닙니다. "View는 화면을 직접 제어하는 객체"라는 전제에서는 참조 타입이 자연스럽습니다. 하지만 SwiftUI는 그 전제 자체를 바꿨습니다.


참고 자료

글에 대한 피드백이나 더 좋은 방법이 있다면 댓글로 공유해주세요.

profile
꼼꼼한 iOS 개발자 /
Apple Developer Academy @ POSTECH 2기 / 멋쟁이사자처럼 앱스쿨 1기

0개의 댓글