일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 앱 성능 개선
- react-native-fast-image
- ios
- react
- launchscreen
- 파노라마 뷰
- data driven construct
- 구조적 정체성
- 스켈레톤 통합
- 360도 이미지
- 뷰 생명주기
- 3b52.1
- 360도 이미지 뷰어
- 360도 뷰어
- React Native
- 뷰 정체성
- 리액트 네이티브
- React-Native
- 명시적 정체성
- privacyinfo.plist
- panorama view
- SwiftUI
- 리액트
- 네이티브
- launch screen
- ssot
- requirenativecomponent
- 라이브러리 없이
- Android
- native
- Today
- Total
Neoself의 기술 블로그
Understanding Swift Performance 정리 본문
앱이 실행될 때, 성능에 영향을 주는 요소는 아래와 같이 나열해볼 수 있습니다.
- Allocation
- Reference Counting
- Method Dispatch
0.배경
먼저 Swift가 제공하는 Value 타입과 Struct 타입을 먼저 설명드리겠습니다.
값 타입 (Value Type) | 참조 타입 (Reference Type) |
struct | Class |
enum | Closure |
기본 데이터 타입(Int, Double, String, Bool 등) | Function |
컬렉션 타입들(Array, Dictionary, Set) | |
튜플 |
이 두 타입은 아래와 같은 차이가 있습니다.
1.1 메모리가 할당되는 위치
값 타입: 실제 데이터가 Stack에 저장
참조 타입: 실제 데이터는 Heap에, 8바이트(운영체제 워드길이)길이의 데이터의 주소는 Stack에 저장
1.2 데이터가 전달되는 방식
값 타입: 깊은 복사 발생 (메모리 상에 별개의 객체로 복제됨)
참조타입: 얕은 복사 발생 (인스턴스 생성 및 변수에 저장 시, 주소값만 복제되고 실제 데이터는 동일한 인스턴스를 바라봄)
*인스턴스: 클래스, 구조체, 열거형 등의 타입을 실제로 메모리에 생성한 것
1.3 데이터 크기
값 타입:
- 컴파일 타임에 크기 결정됨 (ex. Double, Int는 8byte 크기를 지님)
- 메모리 크기가 고정되기에, 컴파일러가 미리 필요한 스택 메모리 공간 계산 가능함
참조 타입:
- 런타임에 크기가 결정
- 메모리 주소크기는 항상 동일하나, 실제 객체 크기는 가변적
- 실제 인스턴스 생성까지 크기 파악 불가
*컴파일 타임: 소스코드가 기계어로 변환되는 시점, 이때 코드의 타입체크, 문법오류검사, 코드 최적화 등이 이루어짐
*런타임: 프로그램이 실제로 실행되는 시점 / 컴파일된 프로그램이 메모리에 로드되는 시점 / 사용자 입력처리들을 진행하는 단계
1. Allocation(할당)
앱(Process)을 실행 시, iOS는 메모리(RAM)에 프로세스를 할당시켜줍니다. 이 메모리는 크게 4가지 영역으로 나뉩니다
Heap, Stack, Data, Code
Allocation은 진행되는 메모리영역에 따라 아래와 같이 분류할 수 있습니다.
1. Stack Allocation
2. Heap Allocation
1.1 Stack Allocation(스택 기반 메모리 할당)
모든 스택기반 메모리 할당 작업은 Call Stack의 컨텍스트 내에서 이뤄집니다. 대표적으로 iOS 앱 또한 메서드 호출로 시작됩니다.
위 사진은 제가 사이드로 제작중인 앱 실행 후, 디버그 네비게이터로 스레드의 콜스택을 조회한 캡처본인데요.
iOS앱이 실제 기기나 시뮬레이터에서 시작될 때, 시스템 수준의 리소스를 초기화하는 start와 start_sim 메서드가 main()함수를 호출하게 되면서 앱 프로세스에 대한 실질적인 첫 메서드가 실행됩니다.
// 1. main 함수가 가장 먼저 Call Stack에 push됨
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
// 2. UIApplicationMain이 호출되어 스택에 push
UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]))
// 3. AppDelegate의 메서드들이 순차적으로 호출되어 스택에 push
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions...) {
let window = UIWindow() // 스택 프레임 내 할당
let rootVC = RootViewController() // 인스턴스는 힙에, 포인터는 스택 프레임에
// ...
}
}
이후, main 메서드는 UIKit를 초기화하고, 앱 델리게이트, 메인 런 루프와 UIApplication 인스턴스를 생성하는 UIApplicationMain() 함수를 호출하게 됩니다. 여기서 UIApplicationMain()은 내부적으로 메인 런루프를 무한히 실행하기 때문에, 앱이 종료될 때까지반환되지 않으며, 메인스레드의 콜스택에 남아있게 됩니다. 이후 모든 UI 관련 작업은 이 스택 위에서 실행됩니다.
그럼 여기서 Call Stack은 무엇일까요?
Call Stack
- CallStack은 프로그램 실행 중에 발생하는 메서드의 호출 순서를 기록하는 데이터 구조
- 각 쓰레드(하나의 실행흐름)마다 보유하고 있음
- 각 호출마다 메서드에 대한 스택 프레임이 생성
- FILO 구조로 가장 최근에 호출된 메서드가 스택의 최상단에 위치하며, 함수가 반환 및 종료될때, 하나씩 해제
스택 프레임의 구성요소
지역변수, 매개변수, 반환주소(이 값을 통해 전체 프로세스에 대한 데이터 흐름을 명시)
CallStack에서의 push와 pop 동작방식
여기서 콜스택 구조는 최근 실행된 스택프레임 index 값을 의미하는 top 포인터 변수의 증감을 통해 push와 pop을 구현하는데요. push 간 스택프레임을 생성하면서 기존 데이터를 덮어쓰는 과정을 제외한다면 포인터 값만 변경시키기에 오버헤드가 상대적으로 적다는 장점을 지니고 있습니다.
*오버헤드: 어떤 처리를 하기위해 들어가는 간접적인 메모리 및 처리 시간
그러면 iOS 앱이 실행될때, 메모리의 Stack 영역이 Call Stack으로만 사용되는가 궁금해서 찾아보았는데요. UnsafeMutablePointer라는 Swift의 저수준 포인터 타입을 활용해 임시 메모리 영역을 할당받아 데이터를 저장할 수도 있다고 합니다.
하지만 이름에서도 알수있듯 메모리 안정성을 보장하지 않기 때문에, 사용 후 deallocate, deinitialize와 같은 메서드 호출을 통해 해제해주는 작업이 동박되어야 합니다.func someFunction() { let buffer = UnsafeMutablePointer<Int>.allocate(capacity: 10) // 스택에 임시로 버퍼 할당 buffer.deallocate() }
또한 컴파일러가 최적화 과정에서 임시 변수를 저장하는 과정에서도 임시 변수를 스택에 할당할 수 있다고 합니다.
func calculate() -> Int { let a = 10 let b = 20 let c = 30 // (a + b) * c 연산에서 // 1. a + b의 결과 30을 저장할 임시 공간 // 2. 30 * c의 결과 900을 저장할 임시 공간이 스택에 필요 return (a + b) * c }
1.2 Heap Allocation(힙 기반 메모리 할당)
Heap 메모리 섹션에서의 데이터 할당방식은 Stack Allocation과 달리 2개 측면에서 오버헤드(성능적 부하)가 발생합니다.
- Allocation Overhead
Memory Allocator가 적절한 크기의 빈 메모리 블록을 검색
메모리 단편화(Fragmentation) 현상으로 인해 검색 시간이 더 길어질 수 있음
런타임에서 적절한 크기의 미사용 메모리 블럭을 찾는 작업으로 인한 오버헤드 발생
- Thread Safety Overhead
순차적인 접근 및 호출을 통해 무결성을 보장하는 동기화 매커니즘의 오버헤드
Thread Safety Overhead가 필요한 이유는 아래 다이어그램을 확인하면 더 이해가 쉬울 것 같은데요.
쓰레드 별로 고유의 공간을 할당받는 스택 메모리영역과 달리, Heap 영역은 모든 스레드가 접근하고 공유합니다.
때문에, 멀티-스레드 환경의 경우, 두개 이상의 스레드에서 힙 메모리를 접근해야하는 상황이 발생합니다. 힙 내부에 있는 데이터를 접근해 Read하는 과정에서는 데이터 변경이 이루어지지 않기 때문에, 문제가 발생하지 않지만, Write 작업과 같이 메모리 할당작업을 하게 될 경우, 접근 순서에 따라 결과가 달라지는 Race Condition이 발생합니다. 이를테면 2개 스레드에서 동시에 동일한 힙 메모리 주소에 데이터 저장 작업을 수행하면 덮어씌워지는 상황이 있겠죠.
때문에, iOS에서는 Locking, SerialQueue, Semaphore과 같은 동기화 매커니즘을 사용해 메모리 할당 시 동시성 업데이트를 막으며, 클래스 종료 및 반환 시에도 순차적으로 반환하게끔 합니다.
이로써, 힙은 데이터의 일관성, 정확성을 생명주기동안 유지하는 무결성 성질을 갖게 되지만, 동기화 매커니즘 실행을 위해 Thread Safety Overhead가 발생합니다.
클래스의 특징
1. Identity: 클래스는 참조타입이기 때문에 같은 데이터에 대한 주소값만 복제하는 얕은복사를 통해 데이터 복사가 이루어집니다. 유일하다는 특징을 갖고 있으며. 이러한 특징은 오래 지속되어야하는 데이터에게 효율적입니다.
*UIKit는 뷰가 클래스로 구성되어있기 때문에, 그 자체로 Identity 성질을 보유하고 있으며, 생명주기가 컨트롤러와 함께 오래 유지될 수 있지만, SwiftUI는 값 타입 구조로 유일하지 않습니다. 때문에, 구조적 Identity와 명시적 Identity를 통해 뷰의 생명주기를 관리합니다.
2. Indirect Storage: Stack에 저장되지 않고 Heap에 저장된다는 특징을 갖고 있습니다.
위 특징이 필요로 하는 상황이 아니라면, Struct를 사용해 할당 오버헤드를 줄이는 것이 성능에 유리합니다.
2. Reference Counting
컴파일 시점에 컴파일러는 참조를 사용하는 코드(ex. 클래스를 다른 변수에 저장) 밑에 retain 명령어를, 참조 해제 코드 밑에 release 명령어를 삽입하는 방식으로 Reference Count를 +-1 씩 증감시킵니다.
클래스 인스턴스가 힙에 저장될 때에는, 실제데이터에 대한 정보 + 2개 워드(64비트 운영체제인 iOS에서는 8byte)로 구성되어있는데요.
2개의 워드는 아래를 위해 할당됩니다.
- 타입에 대한 정보: 상속을 통해 다형성을 갖게 하기 위해
- Reference count: 추후 클래스가 종료되는 시점 파악하기 위해
즉, Reference Counting에도 Heap 내부 데이터를 수정하는 것을 의미하기 때문에 마찬가지로 동기화 매커니즘이 적용되어야 합니다.
애플 공식 문서에도 아래와 같이 Reference Counting 원칙을 설명하는데요.
Reference count는 원자적으로(Atomically) 증감시켜야한다.
이는 Reference count 증감 작업을 순차적으로 진행해 동시성 업데이트 문제를 사전에 방지해야한다는 것을 의미합니다.
그렇다면 아래 코드 실행 시, 메모리할당 작업은 어떻게 진행될까요?
struct Todo {
var title: String
var fileManager: FileManager
var imageView: UIImageView
var locationManager: CLLocationManager
}
// 첫 번째 인스턴스 생성
var todo1 = Todo(
title: "할일",
fileManager: FileManager.default,
imageView: UIImageView(image: UIImage(named: "exampleImg")),
locationManager: CLLocationManager()
)
// 값 타입이므로 복사가 일어남
var todo2 = todo1
// 여러 번의 복사
var todo3 = todo2
값 타입은 깊은 복사를 진행합니다. 때문에 클래스 타입 속성들의 경우 해당 클래스 데이터의 주소를 복제해 스택에 추가할 것입니다. 즉 Todo라는 struct가 한번 복사될 때마다, 내부에 있는 클래스 타입 속성들 개수만큼 Reference를 1씩 증가시켜줘야 하기 때문에 총 4번의 Reference count가 발생합니다.
*String 타입 또한 기본 타입으로 Value type이지만, 내부적으로 캐릭터 컬랙션을 접근함에 따라, Heap을 사용합니다..!
만약 전체 Todo를 클래스로 정의해 복사할 경우, 얕은 복사로 전체 클래스에 대한 참조만 1 증가하게 될테니, 오버헤드를 줄일려다 오히려 증가시키는 상황이 되는 것이죠. 때문에 참조 타입과 값타입을 선정할때에는 신중한 고민이 필요합니다.
감사합니다.
'개발지식 정리 > Swift' 카테고리의 다른 글
Opaque Type 정리 (0) | 2025.01.10 |
---|---|
RxSwift 라이브러리 딥다이브 (0) | 2025.01.08 |
RxSwift 정리 (0) | 2025.01.06 |
CoreData를 활용한 오프라인 동기화 시스템 구축하기 (1) | 2024.12.30 |
전동 킥보드 대여 서비스 iOS 앱 개발기 (0) | 2024.12.25 |