[내일배움캠프] 260112 TIL - escaping Closure, swift 성능 이해하기

Bambu·2026년 1월 12일

내배캠 TIL

목록 보기
17/52

1. 프로그래밍 기초 주차 - 과제 제출

escaping 클로저와 non-escaping 클로저의 차이

  • non-escaping 클로저 : 호출한 함수가 반환되기 전 실행되는 클로저
  • escaping 클로저 : 호출한 함수가 반환된 후에도 실행 가능한 클로저
    → 호출한 함수 바깥에서 실행되기 때문에 '탈출한다'고 표현

⇒ 두 클로저를 구분하는 기준은 클로저가 언제 실행되느냐

비동기 함수 내에서 escaping 클로저를 사용해야하는 이유?

비동기 함수의 경우 클로저를 비동기 작업으로 호출해두고 함수가 먼저 종료되는 경우가 많음

클로저는 (필요한 경우) 자신이 호출된 함수를 캡처하여 해당 함수 내에서 선언된 데이터들을 사용 (캡처)
→ 함수가 아니라 다른 곳에서 클로저를 사용하더라도 동일함.

❗️ 클로저가 비동기 작업을 실행하기 전에 함수가 종료된다면?
→ 클로저가 캡처할 수 있는 실체가 사라지므로 작업이 불가능해짐
⇒ 이러한 불상사를 막기 위해 비동기 함수 내에서 escaping 클로저를 사용하도록 강제한 것이 아닐까.
(non-escaping 클로저를 사용하면 컴파일 에러가 발생)

2. Swift 성능 이해하기

Understanding Swift Performance - WWDC16

1) 구조체와 클래스

할당

  • 힙에 저장할 수록 메모리 사용량 ↑
  • 같은 구조의 구조체와 클래스 비교 시, 클래스는 힙에 저장되므로 할당되는 메모리 공간이 구조체 < 클래스
    → 클래스는 클래스 객체로써 사용되기 위해 필요한 부가 정보를 더 저장하기 때문

클래스는 정체성을 나타내거나 간접 저장(indirect storage)에 유리
→ 두 특징이 필요 없다면 성능 측면에서는 구조체를 사용하는 것이 유리

+) 구조체는 의도치 않은 상태 공유에서 자유로움
(클래스처럼 전혀 다른 곳에서 참조하지 않으므로 값의 변경을 알기 쉬움)

참조 카운팅(Reference Counting)

  • 클래스는 힙에 저장되므로 힙에 할당되는 데이터의 수명을 관리하기 위해 스위프트는 참조 카운팅을 사용 - 빈번하게 발생
  • 구조체는 참조 카운팅 발생 X (단, 참조가 존재하지 않을 경우에만!)

메소드 디스패치

  • Static Dispatch
    : 어떤 동작인지 이미 알고 있으므로 런타임에 바로 함수 실행 가능
    : 인라이닝(inline)과 같은 최적화 가능

  • Dynamic Dispatch
    : 런타임 시점에 어떻게 동작할지 인지
    : 인라이닝과 같은 최적화 불가

1개의 Static 디스패치와 1개의 Dynamic 디스패치를 비교하면 성능에 큰 차이점은 없음
최적화가 가능 여부에 따라 전체적인 코드의 성능에 차이
(최적화가 가능한 Static 디스패치가 더 빠름)

클래스는 기본적으로 모든 메소드가 Dynamic 디스패치로 동작하지만, final 키워드로 상속이 없음을 명시한다면 내부 메소드가 Static으로 동작할 수 있음

➡︎ 위와 같은 특징들을 고려하여 높은 성능의 코드를 구현할 수 있도록 고민하기

2) 프로토콜 타입

PWT(Protocol Witness Table)

프로토콜 필수 함수의 (해당 프로토콜을 채택한)타입별 구현을 담아두는 테이블

Existential Container

프로토콜 타입의 배열을 만들 때 사용하는 컨테이너

protocol Drawable { func draw() }

struct Point: Drawable {
	var x, y: Double
    func draw() { ... }
}

struct Line: Drawable {
	var x1, y1, x2, y2: Double
    func draw() { ... }
}

위와 같은 코드에서 [Drawable] 타입의 배열을 만들고 싶음
→ Swift는 한 배열에서 모든 요소들이 같은 형식으로 저장되기를 원함. 서로 크기가 다른 객체들을 어떻게 한 배열에 담을 수 있을까?

⇒ 이때 Existential Container를 사용

가. 값의 저장
프로토콜 타입의 배열은 Existential Container 형태로 저장

컨테이너의 일부 공간은 initial Value Buffer로 존재

인스턴스의 크기에 따라 컨테이너 내부에 직접적으로 저장될지, 주소 형태로 저장될지 결정됨

  • 인스턴스가 valueBuffer보다 작을 경우: 직접 저장
  • 인스턴스가 valueBuffer보다 클 경우
    : 인스턴스의 주소를 저장
    : 인스턴스의 값은 에 저장

파란 네모는 valueBuffer 영역을 의미

  • Point 인스턴스
    : valueBuffer보다 작기 때문에 컨테이너 내부에 바로 저장
  • Line 인스턴스
    : valueBuffer보다 크기 때문에 인스턴스의 주소를 저장
    : 인스턴스 값은 힙에 저장

나. VWT(Value Witness Table)
Existential Container는 VWT를 통해 자신에게 저장된 값의 수명을 관리

→ VWT에는 값의 수명 관리를 위한 함수들이 저장되어 있음
→ 저장된 타입 별로 VWT를 가짐(타입별 크기가 다르기 때문)

  • allocate : 메모리 할당

allocate 함수는 힙 메모리에 값이 저장될 공간을 할당하고, 컨테이너에 주소를 저장

  • copy : 값 복사

allocate가 할당한 공간에 실제 인스턴스의 값을 복사
→ 혹은, 컨테이너의 valueBuffer에 직접적으로 복사

  • destruct: 값 삭제

    → 객체의 수명이 다 했을 때 호출
    → 복사했던 값들을 삭제
    → (valueBuffer보다 큰 경우) 값으로의 RC를 감소

  • deallocate: 할당 해제

→ 힙 메모리에 할당했던 공간을 해제

성능 측면

크기가 작은 값일 때 (valueBuffer보다 작을 떄)

  • 힙 할당 없음
  • RC 없음
  • Dynamic 디스패치로 동작(VWT, PWT)

→ 비교적 빠른 코드

크기가 큰 값일 때 (valueBuffer보다 클 떄)

  • 힙 할당
  • 값 내부에 참조가 있을 경우 RC가 더 증가

3) 제네릭 타입

프로토콜 타입과의 차이점

프로토콜 타입보다 정적인 다형성

func foo<T: Drawable>(local :T) {
	bar(local)
}

func bar<T: Drawable>(local: T) { ... }

let point = Point()
foo(point) // 여기서 T는 Point 타입으로만 사용

→ 호출 시 사용한 하나의 타입만을 사용함

제네릭 타입의 동작

  • Existential Container 미사용
  • 하나의 동작을 공유
  • PWT, VWT 사용

지역 변수로의 저장
프로토콜 타입처럼 valueBuffer를 두고 버퍼보다 작으면 바로 저장, 크면 힙에 있는 주소를 저장

프로토콜 타입과의 비교

  • 제네릭은 정적 다형성(static polymorphism) : 호출 시의 타입을 사용
    → 컴파일러 최적화 가능(제네릭 타입을 호출 시 타입으로 대치시킴)
    → 사용 타입에 따라 버전을 구분함
    (foo(Point), foo(Line)이라면, Point타입 버전, Line 타입 버전과 같이 구분)

🧐 타입이 많아지면 버전이 많아지니까 코드가 커지는 게 아닌지?
⇒ 당장 사용하지 않는 코드는 컴파일러가 공격적을 최적화하므로 최적화 후 코드가 작아짐!

➡︎ 런타임에 동적인 경우를 최소화하는(가변성을 최소화하는) 알맞은 타입을 골라 사용할 것!

profile
안녕하세요, iOS 개발을 공부하고 있는 Bambu입니다. (프로필: Swifticons)

0개의 댓글