소개
Swift가 발표되고 현재(2024-11-20)에 이르기까지 여러 기능들이 추가되고 삭제되며 점점 안전한 언어로 변모해가고 있습니다. 우리는 이런 Swift를 이해하기 위해 필수적으로 들어야하는 영상이 있는데요. 비교적 예전에 발표되었던 WWDC16의 Understanding Swift Performance입니다. 로우 레벨까지 들여다보며 성능을 개선할 수 있는 방법을 소개하고, Swift를 사용하는 모든 개발자에게 철학적 질문을 남겨주기도 하기에, 모든 iOS 개발자의 필수 시청각 자료라고 할 수 있지요.
오늘은 이번 영상을 보고 정리를 한 번 해보고자 합니다.
Struct와 Class로 보는 Stack과 Heap
여러분은 값을 캡슐화할 때 어떤 키워드를 작성하시나요?
저는 상속을 받아야할 필요가 있거나, 자신이 갖고있는 프로퍼티를 자주 수정하게 되는 경우에
class를 곧잘 사용하고, 그 이외에는 struct로 전부 감싸는 것 같습니다. 그만큼 struct가 갖고있는 이점이 많은데요.이번 세션에서도 struct와 class의 차이에 대해 자세하게 설명해주고 있습니다.
예시를 들어볼까요?

위 소스코드를 봐 봅시다.
Point는 x, y라는 프로퍼티와 draw메서드라는 간단한 구조체예요. 그리고 로컬 변수로 point1을 초기화하고, point2는 point1으로 재할당해주고 있습니다.우리는 struct가 값 타입이라는 것을 알고 있기 때문에, 이 역시 스택에서 초기화 된다는 것을 짐작해볼 수 있습니다. 그리고
point2에게 point1을 할당해줄 때, 복사가 일어난다는 것도요.따라서
point2의 프로퍼티를 변경해도, point1에게는 영향을 주지 않습니다.자료구조를 시각화 하면 아래처럼 보일 수 있어요.

여기서 추가로 이야기할 점은, Stack에 타입을 할당하고 해제할 때의 속도는 정수를 할당하는 속도와 맞먹습니다.
단순히 sp(stack pointer)를 감소시켰다가 증가시키면 그만이기 때문이죠.

반면 class는 어떤가요?
class는 레퍼런스 타입입니다. 실제 데이터들은 heap에 저장되고, stack에서는 해당 heap의 주소값을 참조합니다.
따라서 point2에 할당되는 건 point1의 주소값이기에, 상태가 독립적이지 않고 공유되는 상황이 발생합니다.

그리고 heap에 데이터를 할당하고 해제하는 비용은 stack보다 현저히 비쌉니다.
heap에 필요한 크기만큼 할당하려면, 그 크기만큼 할당되지 않은 공간을 찾아야 하며, 할당했다고 하더라도 해제할 때 다시 메모리를 해제했다는 표시로 업데이트해 주어야 합니다. 그 뿐일까요? 여러 스레드가 메모리에 마음대로 접근하여 할당 및 해제를 했다가는
data race에 걸리기 쉽습니다. 따라서 힙에 접근할 때 lock을 걸어주어 무결성을 보장해야 합니다. 게다가 stack에 없는 reference counting까지 고려한다면 복잡하기까지 하죠.따라서 코드를 작성할 때 불필요한 heap allocation을 하고 있는지 생각해보며 코드를 작성해야 합니다.
예시
enum Color { case blue, green, gray }
enum Orientation { case left, right }
enum Tail { case none, tail, bubble }
var cache = [String : UIImage]()
func makeBalloon(_ color: Color, orientation: Orientation, tail: Tail) -> UIImage {
let key = "\(color):\(orientation):\(tail)"
if let image = cache[key] {
return image
}
}
iMessage의 각 문자를
makeBalloon으로 만들고 있다고 가정해보죠.예시 코드에서는 조그마한 성능을 위해 이미지를 캐싱하여 처리하고 있습니다. 하지만, 여기서 cache의 key를
String으로 처리하고 있어요. 물론 문제가 되는 코드는 아니나, key에 대해 강력히 추천할만한 타입은 아닙니다. 왜냐하면, String은
struct타입이지만, 간접적으로 heap에 문자열을 저장하기 때문입니다. 따라서, cache hit가 있더라도, heap allocation은 불가피하게 일어납니다. key를 생성할 때 문자열을 조합해서 만들고 있으니까요.더 좋은 방법이 있다면 key에서 조합하고 있는 세 타입을 갖는 새로운 구조체를 만드는 게 하나의 방법이 될 수 있습니다.

struct도 일급객체일 뿐더러, heap allocation도 따로 필요하지 않기 때문입니다. 오버헤드가 없어진 것이지요.
Attributes는 stack에 할당될 것이고, 더 안전하며, 더 빨라질 것입니다.
실제로 String을 Attributes로 변환하고, 일백만 번 시도했을 때의 성능 측정은 아래와 같았습니다.
let iterations = 1_000_000
// String Key 성능 측정
let stringStart = CFAbsoluteTimeGetCurrent()
var stringCache = [String: UIImage]()
for _ in 0 ..< iterations {
let key = "\(Color.blue):\(Orientation.left):\(Tail.none)"
_ = stringCache[key]
}
let stringTime = CFAbsoluteTimeGetCurrent() - stringStart
// Struct Key 성능 측정
let structStart = CFAbsoluteTimeGetCurrent()
var structCache = [Attributes: UIImage]()
for _ in 0 ..< iterations {
let attributes = Attributes(color: .blue, orientation: .left, tail: .none)
_ = structCache[attributes]
}
let structTime = CFAbsoluteTimeGetCurrent() - structStart
print("String Key로 테스트한 시간: \(stringTime)")
print("Struct Key로 테스트한 시간: \(structTime)")

보시다시피 많은 시간차를 보이고 있습니다.
결론
지금까지 struct와 class에서 생기는 stack과 heap, 그리고 Heap Allocation의 오버헤드와 이를 제거하여 성능을 향상시킨 예시를 보았습니다.
그렇다면 이런 의문이 남을 수도 있습니다.
“어? 그러면 class는 전혀 쓰지 말아야하고, struct만 써야하는 건가?”
class는 죄악이라는 말을 하려는 게 아닙니다. class도 다형성과 같은 기능이 필요할 때 사용하는 것 처럼, 두 가지 전부 적절한 때에 사용해야 함을 이야기하는 것입니다. 맹목적 class 사용이라든지, struct 사용보다는 자신만의 기준을 가졌으면 좋겠습니다.