WWDC Understanding Swift Performance 영상을 참조하여 좀 더 원리적인 이해를 돕기 위해 작성한 포스트입니다.
기초적인 스레드와 메모리, 그리고 ARC에 대한 지식이 있어야 이해하기 편하실거에요!
1. 미리 요약
좀 자세하게 이야기를 할 예정이라 먼저 요약을 하고 들어갈게요!
(공부하고 싶은 부분만 집중해서 보시면 훨씬 이 글이 도움이 되시지 않을까 하는...)
Struct와 Class 모두 초기화되기 전까지는 청사진에 불과합니다. 둘 다 프로퍼티를 가지고 메서드를 가질 수 있으며, Extension을 통해 기능을 확장할 수 있습니다. 또 Protocol을 채택하여 준수하도록 할 수 있습니다.
차이점은 Struct는 Value타입이고, Class는 Reference타입이기 때문에 나타납니다.
이 때문에 앞으로 이야기 할 차이점을 가지는데 설명에 앞서 미리 세가지로 요약해볼게요!
1. Struct는 Stack에 저장되고, Class는 Heap에 저장됩니다. 덕분에 Struct는 상대적으로 할당/할당 해제가 빠르고 Thread Safe 합니다. 반면에 Class는 Heap에 할당되기 때문에 오버헤드가 있고 Thread Safe하지 않죠.
2. Class는 ARC가 메모리를 관리합니다. 그렇다면 Struct는 항상 Reference Count를 증가시키지 않을까요??
아니죠..ㅎㅎ 내부에 Reference Type의 변수들이 존재한다면 마찬가지로 각각 ARC가 관리하게 됩니다.
이 Reference Count는 유의미한 오버헤드를 일으키고, 우리는 이를 줄이는 방향으로 성능 개선을 이루어 나가야합니다.
3. Class는 메소드가 Dynamic Dispatch됩니다.
Class에 Final을 붙이면 더 이상 상속이 불가능하면서, 동시에 vtable을 사용하지 않고 컴파일시점에 메서드가 직접 호출되는 것과도 관련이 있습니다.
2. 메모리 할당 방식의 차이로 인한 성능 차이
먼저 Struct는 값 타입으로서 스택에 저장됩니다! (다 아시죠?)
스택은 정적 메모리 할당에 사용되는데, 정적 할당이 무엇이냐!
일단 스택 자체의 크기는 컴파일 타임에 결정됩니다.
혹시 C언어를 하셨다면 조금 더 편하게 이해할 수 있게 예시를 드릴게요..!
(제가 CPP이랑 Swift밖에 몰라서... 각자 하시는 언어의 저 형태를 찾아보면 좋을 것 같습니당..ㅎㅎ)
int staticArr[20]; // 정적 할당
int *dynamicArr = malloc(sizeof(int) * 21); // 동적 할당
이 두 할당의 차이를 보면 스택의 크기가 컴파일 타임에 결정된다는 말을 이해할 수 있을거에요!
먼저 arr[20]은 컴파일을 할 때, 이미 sizeof(int) * 21 만큼의 크기로 할당되겠죠?
(21인 이유는 마지막에 Null이 들어가야 하기 때문이에요)
물론 저렇게 딱 맞춰서 공간을 할당하지는 않고, stack overflow를 피할 수 있을 정도로 충분히 크게 공간이 할당됩니다 :)
일단 이 stack은 매우 빠릅니다.
LIFO(후입선출) 방식으로 작동하는 정말 간단한 자료구조 형태이기 때문에 O(1) 이면 할당하고 해제하는 것이 가능하죠!
왜 LIFO인지 이해가 안가신다고요?
func firstFunction() {
let varA = 1
let varB = Human(name: "Sdaq")
func secondFunction() {
let varC = 5.5
let varD = "Memory"
}
}
요 코드를 보시면,
Human Class는 Heap에 저장되는거 아니야?
우리의 Stack 메모리에는 varA, varB, varC, varD 순서대로 들어가게 되죠?
하지만, secondFunction은 항상 firstFunction 보다 먼저 종료되게 되고,
그러다 보니, varD, varC, varB, varA 순으로 pop 됩니다!
그리고 우리의 컴파일러가 이런 일련의 과정들을 쭉 검사해서 Stack 메모리를 이러한 작업들이 모두 가능할 만큼 충분히 할당해주기 때문에 믿고 쓸 수 있는것이죠!ㅎㅎ
아 물론... 이런 경우에는 스택 오버플로우가 날 수도 있어요..!
함수가 무한대로 스택에 쌓이니깐요!
func infiniteRecursion() {
infiniteRecursion()
}
infiniteRecursion()
그리고 stack에 쓰이기 때문에 Thread Safe하죠!
스레드마다 stack을 가지고 있기 때문에 변수가 로컬하거든요!!
반면에 Class는 Reference 타입으로 값이 Heap 영역에 저장되죠?
일단 참조타입은 Stack에 저장되긴 하는데, 값이 저장되는 것이 아니라 주소가 저장됩니다! 그럼 값은 어디에?
바로 Heap에 저장되는 것이죠.
즉, 참조타입은 Heap 영역에 값을 저장하고 이를 가리키는 주소값을 Stack 영역에 저장하는 거에요!!
왜 참조타입의 오버헤드가 더 클까?
[할당과 해제할 때 ]
Heap 영역의 메모리는 물리 메모리에 연속적으로 존재하지 않습니다.
다시 말하면, Heap영역이 Stack처럼 연속된 메모리 공간에 할당되는 것이 아니라
페이지가 들어갈만한 프레임을 고르는 페이징 과정을 거쳐야 합니다!
그리고 페이지 테이블에 주소값 변환에 대한 데이터를 저장하는 작업도 필요하게 되죠...
만약에 Heap에서 메모리를 해제하면 MMU를 업데이트 해줘야 합니다.
단순히 push, pop만 하는 stack에 비해 엄청 할게 많죠??
[값 접근할 때]
스택은 저장된 값에 직접적으로 접근할 수 있습니다.
반면에 Heap에 할당된 데이터의 경우, 저장된 값에 직접 접근하는 것이 아니라,
클래스의 인스턴스가 저장되어 있는 Heap 메모리에 주소를 통해서 접근하기 때문에
즉, 역참조 해야하기 때문에 오버헤드가 더 걸리죠.
단순히 주소값을 가지고 접근하는건데 더 왜 오래걸려? 라는 의문을 가지실 수 있을거에요!
하지만 MMU로 메모리 범위를 검사하고, 가상 메모리 주소를 물리 메모리 주소로 변환해야 하고 그 때 페이지 테이블을 검사해야해요
또 다시 사용할 것을 대비해서 캐시에 저장하거나 하는 작업도 이루어져야 하고요.
이는 작은 오버헤드가 아닙니다.
왜 작은 오버헤드가 아니라는 걸 확실하게 말할 수 있느냐..!!
직접 테스트 해봤기 때문이죠!!!ㅋㅋ
struct와 class 값에 접근해서 그 값을 수정하는 작업을 10만번 하는 테스트를 10번 한 결과 꽤 유의미한 차이가 났습니다.
물론 제 맥북 환경에서 한 테스트이기 때문에 절대 정확하다고는 할 수 없지만,
평균적으로 약 4프로의 성능차이가 나더라고요.
Struct 10만번 값 접근, 수정 평균 소요시간: 932694008(ns)
Class 10만번 값 접근, 수정 평균 평균 소요시간: 976562475(ns)
Struct가 43868467(ns) 만큼 더 빨랐습니다.
테스트 결과와 테스트 코드는 아래 접은글로 붙여놓겠습니다!!
테스트 결과
1/10 테스트
Struct time: 992147791(ns)
Class time: 993577167(ns)
Struct가 1429376(ns) 만큼 더 빨랐습니다.
2/10 테스트
Struct time: 937106125(ns)
Class time: 990402459(ns)
Struct가 53296334(ns) 만큼 더 빨랐습니다.
3/10 테스트
Struct time: 937052833(ns)
Class time: 977676625(ns)
Struct가 40623792(ns) 만큼 더 빨랐습니다.
4/10 테스트
Struct time: 931608250(ns)
Class time: 976830375(ns)
Struct가 45222125(ns) 만큼 더 빨랐습니다.
5/10 테스트
Struct time: 929463583(ns)
Class time: 969436833(ns)
Struct가 39973250(ns) 만큼 더 빨랐습니다.
6/10 테스트
Struct time: 921957167(ns)
Class time: 967116250(ns)
Struct가 45159083(ns) 만큼 더 빨랐습니다.
7/10 테스트
Struct time: 919725125(ns)
Class time: 964758125(ns)
Struct가 45033000(ns) 만큼 더 빨랐습니다.
8/10 테스트
Struct time: 917707125(ns)
Class time: 991126875(ns)
Struct가 73419750(ns) 만큼 더 빨랐습니다.
9/10 테스트
Struct time: 920758875(ns)
Class time: 968539584(ns)
Struct가 47780709(ns) 만큼 더 빨랐습니다.
10/10 테스트
Struct time: 919413208(ns)
Class time: 966160459(ns)
Struct가 46747251(ns) 만큼 더 빨랐습니다.
Struct 평균 소요시간: 932694008(ns)
Class 평균 소요시간: 976562475(ns)
Struct가 43868467(ns) 만큼 더 빨랐습니다.
테스트 코드
import Foundation
struct StructSample {
var x: Int
var y: Int
}
class ClassSample {
var x: Int
var y: Int
init(x: Int, y: Int) {
self.x = x
self.y = y
}
}
var totalStructTime = 0
var totalClassTime = 0
for idx in 1 ... 10 {
print("\(idx)/10 테스트")
let structSample = StructSample(x: 0, y: 0)
var classSample = ClassSample(x: 0, y: 0)
let structStart = DispatchTime.now()
for _ in 0...100000 {
var structCopy = structSample
structCopy.x += 1
}
let structEnd = DispatchTime.now()
let classStart = DispatchTime.now()
for _ in 0...100000 {
var classCopy = classSample
classCopy.x += 1
}
let classEnd = DispatchTime.now()
print("Struct time: \(Int(structEnd.uptimeNanoseconds) - Int(structStart.uptimeNanoseconds))(ns)")
totalStructTime += Int(structEnd.uptimeNanoseconds) - Int(structStart.uptimeNanoseconds)
print("Class time: \(Int(classEnd.uptimeNanoseconds) - Int(classStart.uptimeNanoseconds))(ns)")
totalClassTime += Int(classEnd.uptimeNanoseconds) - Int(classStart.uptimeNanoseconds)
print("Struct가 \((Int(classEnd.uptimeNanoseconds) - Int(classStart.uptimeNanoseconds)) - (Int(structEnd.uptimeNanoseconds) - Int(structStart.uptimeNanoseconds)))(ns) 만큼 더 빨랐습니다.")
print("")
print("")
}
print("Struct 평균 소요시간: \(totalStructTime / 10)(ns)")
print("Class 평균 소요시간: \(totalClassTime / 10)(ns)")
print("Struct가 \((totalClassTime - totalStructTime) / 10)(ns) 만큼 더 빨랐습니다.")
print("")
3. 참조 카운트 오버헤드를 줄이는 쪽으로 성능개선을 해야한다.
그렇다면 무조건 Struct가 Class보다 성능이 좋을까요??
아닙니다..!! 그랬으면 좋겠지만 그럴 수가 없죠 ㅜ
ARC가 Reference Count를 관리하는 과정은 오버헤드가 발생합니다.
ARC에 대해서 모르신다면....!
이 포스트를 읽고 오시면 됩니다ㅎㅎ
여기서는 Reference Count가 Retain되고 Release되는 이야기는 하지 않고,
단순히 참조에 대한 오버헤드에 대해서만 이야기하겠습니다!!
참조 타입 안에 있는 참조 타입은 그대로 해당 Heap메모리 영역에서 관리되지만,
값 타입안에 있는 참조 타입은 각각 Heap에 할당되며, 당연히 각각 ARC가 관리하게 됩니다.
이게 무슨 말이냐...!
예시를 보면서 이야기해보시죠ㅎㅎ
WWDC 영상에서 나온 예시를 가져와봤습니다.
struct Label {
var text: String
var font: UIFont
func draw() { }
init(text: String, font: UIFont) {
self.text = text
self.font = font
}
}
var sampleLabel = Label(text: "Hi", font: font)
여기서 UIFont는 Class로 참조타입이고,
String은 값타입이지만 실제로는 값이 Heap에 할당되죠..!
즉, Label 이라는 Struct 안에 2개의 참조타입이 존재합니다..!
결국 이 sampleLabel 인스턴스 자체는 Stack에 존재하지만,
내부의 참조 타입 변수들은 각각 Heap에 저장되면서 두 개의 참조 카운트 관리가 필요한 것이죠..!!
이 상황에서 Class의 성능이 더 좋다는 건 절대 아니지만...!!!!!!
만약에 Label이 Class로 만들어졌다면 할당이 어떤 형태였을까요?
다음과 같이 referece count가 1번만 일어나게 되죠?
그래서 우리는 Struct를 사용할 때, 참조 타입 프로퍼티를 최소한으로 줄이는 성능 개선을 해야합니다.
다음 리팩토링 과정을 볼까요?
WWDC에서는 String타입의 uuid와 String타입의 mimeType을 리팩토링합니다.
아까 String은 값타입이지만 값이 Heap에 할당된다고 했죠?
그래서 참조 카운트가 증가하기 때문에 오버헤드가 발생합니다!!
어떻게 수정할 수 있을까요?
먼저 애플은 uuid를 표현하는 방법으로 구조체인 UUID 타입을 만들어두었습니다.
그냥 UUID를 사용하면 이 친구는 128비트를 구조체에 직접 저장하기 때문에 heap할당이 일어나지 않죠!
그리고 String에는 사실 아무 값이나 들어갈 수 있기 때문에 중복된 값이나 의도하지 않은 값이 들어갈 수도 있는데, UUID 타입에서는 그럴 일이 없기 때문에 안전하죠..!
그리고 mimeType도 String으로 들어오는데,
enum으로 처리하면 값타입으로 처리할 수 있고 훨씬 깔끔하게 처리할 수 있습니다.
저 코드에서는 실패할 수 있는 이니셜라이저를 사용해, guard문을 이용해서 틀린 type이 들어올 경우 초기화 실패를 반환해주기 때문에 더 안전하기까지 하죠...!
만약에 String으로 가지고 있다면, 저 String값의 유효성 검사를 if else 문으로 처리해야 할 생각을 하면... 끔찍하네요ㅋㅋㅋㅋ
이런 방식을 통해 구조체를 사용할 때, 참조 오버헤드를 줄이고 변수의 안전성을 높일 수 있습니다ㅎㅎ
아주 좋은 예시인 것 같아요..!
4. 메소드 디스패치 방식에 따른 성능 차이
메소드 디스패치에 대해 생소할 수도 있는 분들을 위해, 간단하게 설명할게요!!
먼저 메소드 디스패치는 메소드의 적절한 구현을 선택하는 작업을 뜻해요!
컴파일 타임에 어떤 구현을 실행하도록 결정할 수 있으면, Static Dispatch.
컴파일 타임에 어떤 구현을 실행할지 알 수 없으면, Dynamic Dispatch 입니다.
오... 구현이 뭐지?? 적절한건 또 뭐야?? 하실 수도 있어요.
Struct의 메서드는 값타입 변수처럼 stack에 들어가서 쓰이기 때문에 정적 디스패치는 새로울 것이 없죠!
Dynamic 디스패치에 대한 예시를 하나 들어볼게요..!
class Shape {
func area() -> Double { fatalError("오버라이딩이 필요한 함수입니다.") }
}
class Circle: Shape {
var radius: Double
init(radius: Double) {
self.radius = radius
}
override func area() -> Double {
return .pi * radius * radius
}
}
class Rectangle: Shape {
var width: Double
var height: Double
init(width: Double, height: Double) {
self.width = width
self.height = height
}
override func area() -> Double {
return width * height
}
}
let shapes: [Shape] = [Circle(radius: 7.0), Rectangle(width: 3.0, height: 4.0)]
for shape in shapes {
let area = shape.area()
print(Int(area))
}
이 함수에서 shapes 는 Shape 배열인데, 그 안에 어떤 Shape(Rectangle인지 Circle인지)가 오게 될지 모르죠??
만약 어떤 버튼을 누르면, 저 shapes 안에 Circle과 Rectangle이 랜덤으로 들어오는 프로그램을 만들었다고 해봅시다..!
저 Shape이 어떤 인스턴스를 가지는지 절대 예측할 수 없겠죠?
그래서 상속이 가능한 클래스 메소드의 실제 구현은 실제 인스턴스를 기반으로 런타임에 결정합니다.
그리고 이는 클래스가 다형성을 지원할 수 있게 하는 이유이기도 해요!ㅎㅎ
예시는 다르지만, 이런 모양으로요!!
V-Table 조회
그럼 동적 디스패치가 일어나는 과정은 어떻게 될까요??
먼저 컴파일 단계에서, 런타임 단계에서 구현을 검색할 때 사용하는 vTable을 클래스마다 만들어서 해당 클래스와 함께 Heap에 저장합니다.
그리고 런타임에서 메소드가 콜 되면, 컴파일 단계에서 만들어진 vTable을 참조합니다.
참조해서 해당 클래스에 구현이 있는 경우 그 구현을 실행하는거죠!!
근데 만약에 클래스에 구현이 없다???
그럼 부모 클래스의 vTable을 봅니다!
그래도 없으면 더 부모로 올라가겠죠???
이렇게 vTable을 조회하는 과정에서 오버헤드가 생깁니다.
Final 키워드
자 그렇다면 동적 디스패치가 필요없을 때는 사용하지 않을 수는 없을까요??
괜히 vTable을 조회하고 이러면 오버헤드만 생기고 하니깐요..!
그래서 사용하는 것이 Final 키워드입니다.
Final은 이 클래스가 이제 더 이상 상속되지 않는다!를 암시합니다.
아까의 코드를 다시 가져와볼게요!
이번엔 더 이상 상속되지 않는 class에 final을 붙였습니다.
그럼 어떻게 될까요??
class Shape {
func area() -> Double { fatalError("오버라이딩이 필요한 함수입니다.") }
}
final class Circle: Shape {
var radius: Double
init(radius: Double) {
self.radius = radius
}
override func area() -> Double {
return .pi * radius * radius
}
}
final class Rectangle: Shape {
var width: Double
var height: Double
init(width: Double, height: Double) {
self.width = width
self.height = height
}
override func area() -> Double {
return width * height
}
}
let shapes: [Shape] = [Circle(radius: 7.0), Rectangle(width: 3.0, height: 4.0)]
for shape in shapes {
let area = shape.area()
print(Int(area))
}
이제 컴파일러는 final class안의 메소드를 정적으로 디스패치합니다..!!
즉, struct처럼 컴파일타임에 저 메소드의 구현을 결정하는 것이죠
왜?? 더 이상 오버라이드되지 않는 것이 확실하기 때문에!!!
저 자식클래스들의 area() 메소드는 컴파일 단계에서 구현이 결정되게 됩니다!
5. 그래서 Struct vs Class?
사실 성능 때문에 Struct를 선택하느냐, Class를 선택하느냐가 나뉘진 않습니다!
둘의 쓰임이 다르니깐요!!
다만 구조체를 선택하면, 참조 카운트에 대한 오버헤드를 줄이는 방식으로 가야하고,
클래스를 선택하면 동적 디스패치에 대한 오버헤드를 final을 통해 줄이는 방식으로 가야합니다..!
그럼 언제 Struct를 선택하고 언제 Class를 선택해야할까요?
먼저 상속은 Class에만 있습니다.
상속을 사용하고 싶다면 Class를 사용해야하겠죠?
하지만 다형성자체가 필요할 때는 Class를 사용해야 할까요?
프로토콜로 Struct에도 다형성을 부여할 수 있기 때문에 이는 완전히 맞는 말이라고는 하기 힘들 것 같아요..!
아까 위의 코드를 다형성을 가진 Struct로 고치면 이렇게 가능하니깐요ㅎㅎ
protocol Shape {
func area() -> Double
}
struct Rectangle: Shape {
var width: Double
var height: Double
func area() -> Double {
return width * height
}
}
struct Circle: Shape {
var radius: Double
func area() -> Double {
return .pi * radius * radius
}
}
let shapes: [Shape] = [Rectangle(width: 2, height: 3), Circle(radius: 2)]
for shape in shapes {
shape.area()
}
그리고 Objc 요소가 필요한 경우, 예를 들어 KVO를 사용한다던지...
그럴때는 Class를 사용해야 합니다.
그리고 인스턴스를 복사했을 때, 같은 객체를 가리키게 하고싶으면 Class를 사용해야겠죠?
Struct는 값이라 값 자체가 복사되니깐요ㅎㅎ
그리고 크기가 큰 인스턴스같은 경우에는 struct보다 class가 좀 더 좋습니다.
왜냐면 복사할 때 struct는 값 전체가 복사되는데 class는 주소값만 복사해주면 되니깐요ㅎㅎ
보통 이런 경우가 아니라면 전 Struct를 선호합니다.
빠르고 Thread safe하고 메모리 leak도 없으니깐요!!
6. 참조
'iOS 개발 > Swift' 카테고리의 다른 글
Swift Hashable을 이해하자 (0) | 2023.12.26 |
---|---|
[Swift] iOS는 화면을 어떻게 렌더링할까? (1) | 2023.10.04 |
[ARC] 성능을 위해 unowned를 꼭 써야할까? (0) | 2023.02.03 |
[ARC] Lazy 변수 클로저에서 Unowned 캡처가 항상 안전할까? (0) | 2023.02.02 |
[Concurrency] Semaphore로 비동기적 이벤트를 동기적으로 발생시키기 (0) | 2023.01.20 |
[ARC] 약한참조(Weak, Unowned)에 대해서 (0) | 2022.11.06 |
[Swift] 지정한 For-Loop 탈출하기 (0) | 2022.10.03 |
[Swift] Stride 함수를 사용하자 (0) | 2022.10.02 |