J
[Swift] Optimization Tips 본문
최적화 활성화
Swift는 기본적으로 세 가지의 최적화 수준을 제공함.

- Onone: 디버깅 목적으로 최적화를 거의 수행하지 않으며, 디버깅 정보가 보존.
- Osize: 일반적인 생산 코드에 적합한 최적화 수준으로 코드 크기를 줄임.
- O: 성능을 최우선으로 하는 최적화로, 코드 크기보다는 성능을 중요시.
전체 모듈 최적화 (WMO)
기본적으로 Swift는 파일 단위로 컴파일하지만, WMO를 활성화하면 전체 프로그램을 하나의 단위로 컴파일하고 최적화를 수행할 수 있음.
이는 컴파일 시간을 늘리지만 프로그램 실행 속도는 향상.
1. 동적 디스패치 줄이기
파일 외부에서 접근할 필요가 없는 선언에는 private 또는 fileprivate를 사용하자
오버라이드가 필요 없는 선언에는 final을 사용하자
private, fileprivate를 사용하면 컴파일러가 재정의 불가능을 알기 때문에, 동적 디스패치를 피할 수 있음.
final 키워드를 사용하면 함수 호출이 직접적으로 이루어져 동적 디스패치를 피할 수 있고 성능이 개선.
2. 컨테이너 타입 효율적으로 사용하기
Array에서는 값 타입(Value Type)을 사용하자
참조 타입(Reference Type)을 사용해야 할 때는 ContiguousArray 사용하자
In-place mutation을 사용하자
Swift에는 값 타입과 참조 타입이 있는데, 배열에서는 값 타입을 사용하는 것이 더 효율적일 수 있음.
왜냐하면 값 타입을 사용하면 컴파일러가 더 많은 최적화를 할 수 있기 때문에.
예를 들어 구조체, 즉 값 타입을 배열에 저장하면 Swift는 이를 효율적으로 처리할 수 있음.
struct PhonebookEntry {
var name: String
var number: Int
}
var phonebook: [PhonebookEntry] = [
PhonebookEntry(name: "Alice", number: 12345),
PhonebookEntry(name: "Bob", number: 67890)
]
// 여기서는 PhonebookEntry라는 구조체(값 타입)를 배열에 저장.
// Swift는 값 타입을 저장할 때 참조 타입보다 더 빠르게 처리.
만약 배열에 클래스 같은 참조 타입을 저장해야 한다면, ContiguousArray를 사용하면 더 성능이 좋을 수 있음.
Array는 내부적으로 Objective-C의 NSArray와 호환되기 때문에 성능이 조금 떨어질 수 있음.
하지만 ContiguousArray는 NSArray와의 호환성을 고려하지 않기 때문에 더 빠르게 작동 가능.
class Person {
var name: String
init(name: String) {
self.name = name
}
}
var people: ContiguousArray<Person> = [
Person(name: "Alice"),
Person(name: "Bob")
]
// 이렇게 ContiguousArray를 사용하면 참조 타입을 더 빠르게 처리 가능.
Swift에서 배열 같은 컨테이너는 COW(Copy-On-Write)라는 방식을 사용.
이는 데이터를 복사할 때, 실제로 그 값을 복사하지 않고, 필요할 때만 복사하는 방식.
하지만 배열에 새로운 값을 추가하거나 수정하면, 복사가 일어날 수 있음.
예를 들어 배열을 함수로 전달할 때 값이 복사될 수 있는데, 이를 막으려면 inout 키워드를 사용해 제자리에서 변경하는 방식이 효율적.
func addNumber(_ array: inout [Int]) {
array.append(100)
}
var numbers = [1, 2, 3]
addNumber(&numbers) // 배열을 복사하지 않고 직접 수정
3. 래핑 연산 사용하기
오버플로가 발생하지 않을 수 있음을 증명할 수 있을 때, 래핑된 정수 산술을 사용하자
래핑 연산은 숫자가 최대값 또는 최소값을 넘을 때, 그 범위를 초과한 값을 다시 처음부터 계산하는 방식.
일반적으로 숫자를 더하거나 곱할 때 오버플로우가 발생하면 오류가 발생.
그러나 래핑 연산을 사용하면 오류를 무시하고 값을 순환시켜 계산.
&+, &-, &* 같은 연산자를 통해 Swift에서 사용되며, 각각 래핑 덧셈, 래핑 뺄셈, 래핑 곱셈을 의미.
- &+: 래핑 덧셈 연산자. 오버플로우 시 값이 다시 최소값으로.
- &-: 래핑 뺄셈 연산자. 언더플로우 시 값이 다시 최대값으로.
- &*: 래핑 곱셈 연산자. 곱셈 결과가 최대값을 넘으면 최소값으로.
let maxInt = Int.max
let result = maxInt + 1 // 에러 발생: 오버플로우
let maxInt = Int.max
let result = maxInt &+ 1 // result는 Int.min.
래핑된 연산은 성능이 중요한 코드에서 많이 사용.
Swift는 기본적으로 오버플로우 체크를 자동으로 해주는데, 이는 프로그램의 안전성을 높이지만 성능에는 영향.
만약 특정 연산에서 오버플로우가 발생해도 문제가 되지 않거나, 그 결과가 자연스러운 경우에는 래핑 연산을 사용하여 성능을 높일 수 있음.
예를 들어, 그래픽이나 데이터 압축처럼 성능이 중요한 분야에서는 숫자가 래핑되는 것이 자연스러운 경우가 많음.
이때 오버플로우 체크를 하지 않고 빠르게 연산할 수 있도록 래핑 연산을 사용하는 것이 유리.
4. 제네릭 최적화하기
제네릭은 사용되는 모듈 내에 선언하자
Swift는 제네릭을 통해 다양한 타입에 대해 동일한 코드를 사용할 수 있음.
하지만 실제 실행할 때는 컴파일러가 제네릭에 대해서 사용되는 구체적인 타입에 맞춰 특수화된 코드를 생성.
예를 들어, 제네릭 클래스를 MySwiftFunc<Int>로 사용하면, 컴파일러는 이 클래스를 Int에 맞게 특수화된 버전으로 컴파일하고, MySwiftFunc<String>도 String에 맞게 별도의 특수화된 버전을 생성할 것임.
이렇게 하면, 제네릭의 오버헤드를 줄이고 더 빠르게 실행될 수 있음. (제네릭이 다양한 타입을 처리하려면 추가적인 처리 비용이 발생)
class MySwiftFunc<T> {
func doSomething(value: T) {
print(value)
}
}
let intFunc = MySwiftFunc<Int>() // 실제 실행 시 컴파일러는 이 클래스를 Int에 맞게 특수화된 버전으로 컴파일
intFunc.doSomething(value: 10) // 정수 타입에 대해 동작
let stringFunc = MySwiftFunc<String>() // 실제 실행 시 컴파일러는 이 클래스를 String에 맞게 특수화된 버전으로 컴파일
stringFunc.doSomething(value: "Hello") // 문자열 타입에 대해 동작
컴파일러가 특수화된 코드를 생성하려면, 제네릭 함수나 클래스의가 호출되는 코드와 같은 모듈 내에 있어야 함.
예를 들어, MySwiftFunc 클래스가 다른 모듈에 정의되어 있으면, 특수화가 제대로 이루어지지 않을 수 있음.
5. 클래스 전용 프로토콜 사용하기
클래스에서만 채택되는 프로토콜은 class-protocol로 선언하자
프로토콜이 클래스에만 적용된다면, 이를 class-protocol 로 선언하여 성능을 최적화할 수 있음.
이렇게 하면 컴파일러가 클래스만 프로토콜을 충족한다는 지식에 따라 객체에 대한 참조 카운팅을 최적화하게 됨.
예를 들어, ARC 메모리 관리 시스템은 클래스만 처리하고 있다는 것을 알고 있다면 쉽게 유지(객체의 참조 카운트 증가)할 수 있음.
이러한 지식이 없으면 컴파일러는 비용이 많이 들 수 있는 구조체가 프로토콜을 충족할 수 있다 가정해하고, 유지 및 해제할 준비를 해야 함.
6. Escaping 클로저에 의해 캡처된 var 의 비용 줄이기
실제로 클로저가 Escaping이 아닌 경우, var를 inout으로 전달하자
클로저에서 let과 var의 차이는 성능에 영향을 미칠 수 있음.
클로저가 변수를 캡처할 때, 계속 수정할 수 있도록 힙 메모리에 별도의 박스를 할당해서 저장해야 하기 때문에.
let은 상수로 클로저 안에서 값이 바뀌지 않으므로, 컴파일러는 이 값을 클로저 내부에 복사해서 간단하게 저장.
따라서, 별도의 힙 메모리 공간을 할당할 필요가 없고, 성능에 큰 영향을 미치지 않음.
let myConstant = 10
let closure = { print(myConstant) } // 값이 그대로 복사됨
closure() // 출력: 10
하지만 var는 값이 바뀔 수 있기 때문에, 컴파일러는 이 변수를 클로저가 끝난 후에도 수정할 수 있도록 힙 메모리 상자에 넣어서 관리.
따라서 var가 캡처되면 힙 메모리를 할당하고, 수정할 때마다 참조 카운트를 늘리고 줄이는 작업(retain/release)이 일어나서 성능 저하가 발생할 수 있음.
var myVariable = 10
let closure = { myVariable += 1 } // 힙 메모리 박스에 변수 저장
closure() // 값이 11로 변경됨
Escaping 클로저를 사용하지만, 실제로는 함수 내에서만 사용되고 함수가 끝나는 시점에 사라진다면, inout 키워드를 사용해서 따로 캡처 없이 변수 전달 가능.
inout을 사용하면 클로저는 변수를 힙 메모리로 복사하지 않고 직접 수정할 수 있기 때문에, 불필요한 힙 메모리 할당과 참조 카운트 관리를 피할 수 있음.
func modifyValue(_ value: inout Int) {
value += 1
}
var number = 10
modifyValue(&number) // 힙 박스를 사용하지 않고 직접 수정
print(number) // 출력: 11
참고 문헌 : https://github.com/swiftlang/swift/blob/main/docs/OptimizationTips.rst
'Swift' 카테고리의 다른 글
[Swift] Escaping Closure (@escaping) (0) | 2024.04.05 |
---|---|
[Swfit] Value Type, Reference Type (값 타입과 참조 타입) (0) | 2024.04.04 |
[Swift] WMO(Whole Module Optimization) (0) | 2024.04.03 |
[Swift] final 키워드와 Type에 따른 Dispatch (0) | 2024.04.03 |
[Swift] Access Control (접근 제어) (0) | 2024.04.03 |