iOS·Swift 내부 구조 심화 — ARC, 증인 테이블, UIKit/SwiftUI
이 글의 핵심
Swift 런타임의 참조 계수(ARC), 프로토콜 증인 테이블(PWT), 값·참조 타입의 스택·힙·COW, UIKit과 SwiftUI의 렌더링·상태 모델, 그리고 MainActor·DI 중심의 프로덕션 패턴을 한 문서에서 연결해 설명합니다.
들어가며
Swift 코드가 느리거나 메모리가 새거나 SwiftUI 화면 상태가 이유 없이 초기화될 때, 원인은 대개 문법이 아니라 런타임과 프레임워크가 맞물리는 지점에 있습니다. 이 글은 ARC가 참조 계수를 관리하는 방식, 프로토콜 증인 테이블과 existential의 비용, 값 타입과 COW의 실제 동작, UIKit과 SwiftUI의 상태 모델, 그리고 MainActor를 중심으로 한 동시성 경계를 내부 구현과 설계 트레이드오프 관점에서 설명합니다.
ARC(Automatic Reference Counting) 메커니즘
참조 계수의 갱신
클래스 인스턴스는 힙에 할당되며, Swift는 참조 계수로 수명을 추적합니다. 강한 참조(strong)가 하나 생길 때마다 카운트가 증가하며, 참조가 사라지면 감소합니다. 0이 되면 디이니셜라이저 실행 후 메모리가 회수됩니다. 이 과정은 컴파일러가 적절한 지점에 retain/release 호출을 삽입하는 형태로 구현되며, 개발자가 수동으로 호출하지 않습니다.
객체의 수명은 마지막 강한 참조가 언제 사라지느냐로 정해지므로, 클로저가 self를 어떻게 캡처하는지가 수명을 좌우합니다. 비동기 클로저에서 [weak self]를 쓰는 이유는, 그렇지 않으면 self가 클로저 수명만큼 연장되어 의도치 않은 강한 참조 사이클이나 지연된 해제가 생길 수 있기 때문입니다.
여기서 “사이클”과 “지연된 해제”는 구분해야 합니다. URLSession의 완료 핸들러처럼 한 번 실행되고 버려지는 클로저가 self를 강하게 잡으면, 요청이 끝날 때까지 self의 수명이 늘어날 뿐 클로저가 해제되면 self도 해제됩니다. 진짜 순환은 self가 그 클로저를 프로퍼티로 저장할 때(예: self.onUpdate = { self.reload() }, 저장된 Combine 구독, 반복 타이머) 생깁니다. 모든 클로저에 습관적으로 [weak self]를 붙이면 안전해 보이지만, 화면이 닫힌 뒤 저장 작업이 self == nil이라 조용히 건너뛰어지는 반대 방향의 버그를 만들 수도 있습니다. 사용자가 저장 버튼을 누르고 곧바로 화면을 닫았는데 저장이 되지 않는 증상이 대표적입니다.
Swift Concurrency의 Task { }도 비슷한 함정이 있습니다. Task는 클로저를 실행이 끝날 때까지 붙잡으므로 self를 캡처하면 그동안 self가 살아 있습니다. 무한 루프로 스트림을 소비하는 Task라면 self가 영원히 해제되지 않으므로, Task를 프로퍼티에 저장해 두었다가 화면이 사라질 때 cancel()하거나 SwiftUI의 .task 수정자처럼 뷰 수명에 묶인 방식을 써야 합니다. [weak self]를 쓰더라도 클로저 첫 줄에서 guard let self else { return }으로 강한 참조를 다시 만들면 루프가 도는 내내 강하게 잡는 것과 같아진다는 점도 흔히 놓칩니다.
순환 참조와 weak, unowned
두 객체가 서로를 강하게 참조하면 참조 계수가 0이 되지 않습니다. 한쪽을 weak으로 두면 참조는 유지하되 참조 계수에는 포함되지 않아 순환이 끊깁니다. weak 참조는 옵셔널이며, 상대가 해제되면 nil이 됩니다.
unowned도 참조 계수를 올리지 않지만, 참조 대상이 참조하는 쪽보다 먼저 해제되지 않는다는 전제가 있을 때 사용합니다. 전제가 깨지면 기본 unowned(safe)는 이미 해제된 객체에 접근하는 순간 “Attempted to read an unowned reference but object was already deallocated” 메시지와 함께 즉시 크래시합니다. 검사를 끈 unowned(unsafe)는 크래시 대신 이미 해제된 메모리를 읽는 정의되지 않은 동작으로 이어져 증상이 더 엉뚱하게 나타납니다. 그래서 부모-자식처럼 명확한 소유 관계가 아니면 weak이 더 안전한 경우가 많습니다.
내부적으로 weak 참조는 사이드 테이블(side table) 등을 통해 원본 객체와 동기화되는 구현을 사용합니다(버전·런타임에 따라 세부사항은 발전합니다). 개발자 입장에서는 weak은 언제든 nil이 될 수 있는 참조이고 unowned는 수명을 확신할 때만 쓰는 참조라는 규칙으로 판단하면 충분합니다.
autoreleasepool과 실행 런루프
일부 API는 객체를 즉시 해제하지 않고 autorelease 풀에 넣었다가 런루프 사이클 등에서 정리합니다. 대량의 임시 객체를 짧은 루프에서 만들 때 autoreleasepool { }로 범위를 나누면 피크 메모리를 줄이는 데 도움이 됩니다.
// 데이터 청크를 순회하며 임시 객체가 많이 생기는 경우
func processChunks(_ urls: [URL]) {
for url in urls {
autoreleasepool {
// 이 블록 안에서 생성된 autoreleased 객체가 블록 종료 시점에 더 일찍 정리되도록 유도
let data = try? Data(contentsOf: url)
_ = data?.count
}
}
}
핫 루프에서 Foundation·Core* 계열 API가 autoreleased 객체를 많이 만들면, 풀을 쪼개지 않았을 때 메모리 피크가 커질 수 있습니다. 반면 모든 코드에 남발할 필요는 없으며, 계측(Instruments, 메모리 그래프)으로 병목이 보일 때 적용하는 것이 일반적입니다.
autorelease는 Objective-C 런타임의 개념이라, 순수 Swift 객체만 다루는 코드에서는 autoreleasepool이 효과가 없습니다. 효과가 나는 것은 내부적으로 Objective-C 객체를 만드는 API, 예를 들어 UIImage 디코딩, NSString 브리징, Core Graphics·Core Image 처리 루프입니다. 사진 수백 장의 썸네일을 한 루프에서 만들 때 메모리가 끝없이 올라가다 루프가 끝나야 한꺼번에 떨어지는 그래프가 전형적인 증상이며, 메인 스레드의 런루프는 매 반복마다 풀을 비우지만 하나의 긴 루프 안에서는 그 기회가 오지 않기 때문입니다.
프로토콜 증인 테이블(Protocol Witness Table)
증인 테이블이 하는 일
타입 T가 프로토콜 P를 준수하면, 컴파일러는 P의 요구사항을 T의 실제 구현에 연결하는 테이블을 둡니다. 이것이 프로토콜 증인 테이블(PWT)입니다. 제네릭이 구체 타입으로 고정되면 전문화(specialization)되어 간접 호출이 줄거나 인라인될 여지가 생기고, 프로토콜 타입(existential)으로 값을 들고 다니면 동적 디스패치에 가까운 경로를 탈 수 있습니다.
C++에 익숙하다면 vtable과 비교하는 것이 이해하기 쉽습니다. vtable은 클래스에 붙어 객체 안의 포인터로 찾아가지만, 증인 테이블은 (타입, 프로토콜) 쌍마다 하나씩 존재하고 값과 따로 전달됩니다. 그래서 구조체나 열거형처럼 객체 헤더가 없는 값 타입도 프로토콜을 준수할 수 있고, 이미 있는 타입에 extension으로 나중에 프로토콜 준수를 추가할 수도 있습니다. 제네릭 함수 render<T: Drawable>가 전문화되지 않은 채 호출되면, 컴파일러는 T의 타입 메타데이터와 Drawable 증인 테이블을 숨은 인자로 함께 넘깁니다.
정적 디스패치 vs 프로토콜 타입
구체 타입의 메서드 호출은 컴파일 시점에 목적지가 명확한 경우가 많습니다. 반면 any P(또는 과거 스타일의 프로토콜 타입)처럼 저장된 값의 실제 타입이 런타임에만 결정되면, P의 메서드를 호출하기 위해 증인 테이블을 통한 간접 호출이 필요합니다. 그 결과 추상화의 유연성과 최적화 여지 사이에 트레이드오프가 생깁니다.
protocol Drawable {
func draw()
}
struct Circle: Drawable {
func draw() { /* ... */ }
}
struct Square: Drawable {
func draw() { /* ... */ }
}
// 제네릭 + 구체 제약: 컴파일러가 T에 대해 더 많은 정보를 유지
func render<T: Drawable>(_ item: T) {
item.draw()
}
// existential: 컬렉션에 서로 다른 Drawable 구현을 담을 수 있지만 추상화 비용이 커질 수 있음
func renderAny(_ items: [any Drawable]) {
for item in items {
item.draw()
}
}
render는 T가 각 호출 지점에서 구체화될 수 있어 최적화에 유리한 편입니다. 다만 전문화는 컴파일러가 제네릭 함수의 본문을 볼 수 있을 때만 일어납니다. 다른 모듈(프레임워크)에 있는 제네릭 함수는 @inlinable로 공개되지 않는 한 본문이 보이지 않으므로, 제네릭이라도 증인 테이블을 통한 간접 호출로 실행됩니다. “제네릭은 항상 빠르다”가 모듈 경계에서는 성립하지 않는 이유입니다. renderAny는 타입이 균일하지 않은 값을 한 배열에 담는 데 유리하지만, 증인 테이블·existential 컨테이너 비용을 감수합니다. 고성능 경로에서는 프로토콜 타입 배열 대신 열거형으로 케이스를 닫거나, 제네릭 알고리즘으로 분리하는 식의 설계를 검토합니다.
existential 컨테이너의 비용은 구체적으로 이렇습니다. any Drawable 값 하나는 값을 담는 3워드(64비트에서 24바이트) 인라인 버퍼와 타입 메타데이터 포인터, 증인 테이블 포인터로 구성됩니다. 값이 3워드보다 크면 인라인 버퍼에 들어가지 못해 힙에 박싱되므로, 큰 구조체를 [any P] 배열에 넣으면 요소마다 힙 할당이 생기고 순회할 때 포인터를 따라가야 합니다. Swift 5.6부터 any 키워드를 쓰도록 바뀐 것도, 이 비용이 드는 지점을 코드에서 눈에 보이게 하려는 의도입니다.
Opaque 타입(some P)과 캡슐화
some Drawable은 호출자에게 구체 타입을 숨기면서도, 반환 지점에서는 단일 구체 타입으로 일관되게 유지됩니다. 이는 모듈 API 설계에서 유용하며, existential any P와 의미와 비용이 다릅니다. 공개 인터페이스를 설계할 때 some/any를 의도적으로 구분하는 것이 중요합니다.
값 타입 vs 참조 타입의 내부
복사 의미론과 COW
구조체·열거형은 값 의미론을 가집니다. 대입 시 바이트 단순 복사처럼 보이지만, Array, Dictionary, String 등은 내부적으로 COW(Copy-on-Write) 저장소를 공유했다가 변경 시 복사합니다. 따라서 “값 타입은 항상 저렴하다”가 아니라 공유·복사 시점을 이해해야 합니다.
클래스는 참조를 복사합니다. 같은 인스턴스를 가리키므로 한쪽 변경이 다른 쪽에 보입니다. UI 상태·도메인 객체를 어디에 둘지는 공유 범위와 동시성에 따라 결정됩니다.
COW는 표준 라이브러리 컬렉션이 직접 구현한 최적화이지, 모든 구조체에 자동으로 적용되는 언어 기능이 아닙니다. 직접 만든 큰 구조체(프로퍼티가 수십 개인 설정 값 등)는 대입할 때마다 필드 전체가 복사됩니다. 필요하다면 내부 저장소를 클래스로 두고, 변경 직전에 isKnownUniquelyReferenced(&storage)로 공유 여부를 확인해 공유 중일 때만 복사하는 방식으로 직접 COW를 구현합니다. 반대로 Array의 COW가 기대대로 동작하지 않는 흔한 경우는, 같은 배열을 다른 변수나 클로저가 잡고 있는 상태에서 루프로 요소를 수정할 때입니다. 첫 수정에서 배열 전체가 복사되므로, 성능이 이상하게 느리면 그 배열의 참조가 몇 개인지부터 확인합니다.
값 타입 안에 참조 타입
구조체에 클래스 프로퍼티가 있으면, 구조체 복사는 저렴해 보여도 내부 클래스 인스턴스는 공유됩니다. “값으로 감쌌다”고 해서 깊은 복사가 보장되지는 않습니다.
final class Counter {
var value = 0
}
struct Box {
var counter = Counter()
}
var a = Box()
var b = a
b.counter.value = 1
// a.counter.value도 1 — 내부 참조 타입이 공유됨
이런 혼합은 멀티스레드 환경에서 데이터 레이스를 만들기 쉽습니다. 공유 가변 상태는 한 클래스가 소유하고 직렬 큐로 접근을 직렬화하는 방식, 또는 Swift Actor/@MainActor로 동기화 경계를 두는 편이 안전합니다.
UIKit과 SwiftUI 아키텍처
UIKit: 뷰 계층, 런루프, 응답자
UIKit은 명령형(imperative)입니다. UIView는 서브뷰 트리·레이어·제약 조건을 기반으로 화면을 구성하며, RunLoop가 입력·타이머·디스플레이 링크를 처리합니다. 이벤트는 응답자 체인(UIResponder)을 따라 전달되며, hitTest로 타깃 뷰가 결정됩니다.
상태 변경 시 어느 뷰가 무엇을 다시 그릴지를 개발자가 호출(setNeedsLayout, setNeedsDisplay 등)로 조율합니다. 오래된 코드베이스·복잡한 제스처·세밀한 애니메이션 제어에는 여전히 UIKit이 강합니다.
SwiftUI: 선언적 UI와 상태 기반 재구성
SwiftUI는 선언적(declarative)입니다. body는 “현재 상태에 대한 뷰 설명”이고, 상태가 바뀌면 프레임워크가 영향을 받는 하위 트리를 다시 계산합니다. @State, @Binding, ObservableObject, @Observable 등은 변경 전파와 수명을 연결하는 역할을 합니다.
뷰 타입은 값 의미의 설명서에 가깝으며, 실제 렌더링은 플랫폼 백엔드가 담당합니다. UIKit/AppKit과 통합할 때는 UIHostingController 등으로 한 트리 안에 혼합할 수 있습니다.
이 모델에서 가장 흔한 버그는 뷰 구조체의 수명과 상태의 수명을 혼동하는 데서 나옵니다. SwiftUI는 부모가 다시 그려질 때마다 자식 뷰 구조체를 새로 만듭니다. @ObservedObject var model = ProfileViewModel()처럼 뷰 안에서 직접 객체를 생성하면, 부모가 갱신될 때마다 새 뷰모델이 만들어져 입력 중이던 텍스트나 로딩 상태가 초기화됩니다. 원인이 부모 쪽 상태 변화라서 화면만 보고는 이유를 찾기 어렵습니다. 뷰가 소유하는 객체는 @StateObject(iOS 17의 @Observable 모델이라면 @State)로 선언해야 프레임워크가 뷰의 정체성(identity)에 묶어 한 번만 생성합니다. 같은 이유로 ForEach의 id가 안정적이지 않으면(예: 매번 새 UUID()), 목록이 갱신될 때마다 모든 행의 상태가 초기화되고 애니메이션이 깨집니다.
혼합과 마이그레이션
실무에서는 화면 단위로 SwiftUI를 도입하거나, 반대로 SwiftUI 안에 UIViewRepresentable로 UIKit을 끼워 넣습니다. 이때 제스처·키보드·수명(viewDidLoad vs .onAppear)의 차이로 버그가 생기기 쉬우므로, 경계에서 상태 소스를 하나로 정하는 규칙이 중요합니다.
프로덕션 iOS 패턴
MainActor와 UI 일관성
UI 관련 API는 메인 스레드(엄밀히는 메인 액터)에서 다루는 것이 안전합니다. Swift Concurrency에서는 @MainActor로 타입·메서드를 격리해 컴파일러가 검사할 수 있습니다. 네트워크·디스크는 백그라운드에서 처리하며, 결과만 메인에서 UI 모델에 반영하는 패턴이 일반적입니다.
@MainActor
final class ProfileViewModel: ObservableObject {
@Published var name = ""
func load() async {
let raw = await fetchNameFromNetwork() // 네트워크는 비-MainActor 서비스에 두는 편이 일반적
self.name = raw
}
}
ObservableObject와 UI 업데이트를 한 액터에 묶으면 데이터 레이스를 줄이기 쉽습니다. 다만 무거운 작업을 MainActor에서 실행하면 스크롤 버벅임이 생기므로, 경계 설계가 핵심입니다.
await fetchNameFromNetwork()에서 실제로 어느 스레드가 일하는지는 그 함수가 어디에 격리되어 있느냐로 정해집니다. 액터에 격리되지 않은(nonisolated) async 함수는 Swift 5.7 이후 규칙에서 메인 액터를 떠나 전역 동시 실행기에서 돌고, await 동안 메인 액터는 다른 일을 처리할 수 있습니다. 반면 load() 안에서 await 없이 동기 함수로 JSON 수백 KB를 파싱하면 그 작업은 메인 액터에서 실행되어 화면이 멈춥니다. Swift 6.2에서 도입된 옵트인 설정(nonisolated async 함수를 호출자의 액터에서 실행하도록 바꾸는 기능)을 켠 프로젝트라면 이 기본값이 달라지므로, 무거운 작업은 명시적으로 @concurrent를 붙이거나 별도 액터로 옮겨 의도를 코드에 드러내는 편이 안전합니다. 또 await 전후로 self의 상태가 바뀌어 있을 수 있다는 재진입(reentrancy) 문제도 있습니다. 사용자가 새로고침을 두 번 누르면 load() 두 개가 겹쳐 실행되고, 늦게 시작한 요청이 먼저 끝나면 오래된 응답이 최신 값을 덮어씁니다. 진행 중인 Task를 저장해 두고 새 요청 전에 취소하는 식으로 막아야 합니다.
의존성 주입과 테스트
프로토콜로 추상화한 서비스를 이니셜라이저·환경 값으로 주입하면, 테스트에서 목(mock)을 끼우기 쉽습니다. 싱글톤 전역 상태는 편하지만 숨은 의존성이 되어 병렬 테스트·프리뷰를 어렵게 만듭니다.
관측성과 릴리스 품질
프로덕션에서는 크래시 리포팅, 성능 프로파일링(Instruments), 네트워크 실패·취소 로깅을 기본선으로 둡니다. 메모리 누수는 순환 참조, Notification/클로저, Combine 구독 해제에서 자주 발견되므로, Instruments의 Leaks, Memory Graph로 주기적으로 확인하는 것이 좋습니다.
Combine에서 가장 흔한 누수 형태는 publisher.sink { value in self.update(value) }.store(in: &cancellables)입니다. 구독(AnyCancellable)은 self.cancellables에 저장되고, 구독의 클로저는 self를 강하게 잡으므로 순환이 완성됩니다. sink 클로저에 [weak self]를 쓰거나, 값을 프로퍼티에 넣기만 한다면 assign(to: &$property) 형태를 쓰면 순환이 생기지 않습니다. 화면을 닫았다 여는 것을 몇 번 반복한 뒤 Xcode의 Memory Graph Debugger에서 같은 뷰컨트롤러가 여러 개 남아 있는지 보는 것이 누수를 찾는 가장 빠른 방법입니다.
모듈·기능 플래그
대규모 앱은 기능 모듈화와 원격 설정·플래그로 점진적 배포를 합니다. 이때 공개 API를 좁게 유지하며, 도메인 로직을 UI 프레임워크에서 분리하면 UIKit/SwiftUI 전환에도 유리합니다.
같이 보면 좋은 글
- Swift 클래스와 구조체 | Class, Struct, Enum
- Swift 프로토콜과 확장 | Protocol, Extension
- SwiftUI 입문 | 선언적 UI, 상태, MVVM
- Swift 비동기 프로그래밍 | async/await, Task