-
클로저 종결판 2Apple🍎/Swift 2025. 2. 26. 16:31
죽지도 않고 다시 돌아온 클로저 종결판
아직 못 보신 분은 종결판 1부터 보시고 오세요. 츄라이~
클로저 종결판
앱을 개발할 때 꼭 마주치게 되는 '클로저'라는 놈, 이 정도 봤으면 정들 때도 되었는데이놈이 모양도 야시꾸리하고 형태도 왔다 갔다 해서 뭔가 찝찝한 녀석입니다. 그래서 오늘날 잡고 산산조
people-analysis.tistory.com
아직 좀 남은 거 마저 해치워 버립시다. 레츠기딧~~
Swift에서 클로저의 생명주기와 메모리 관리를 이해하기 위해서는
이스케이핑(Escaping)과 논이스케이핑(Non-escaping)에 대한 이해가 필요한데요.
같이 한번 차근차근히 알아볼까요?
일단 저번 시간에 배운 클로저는
"생성 시점에 환경을 캡쳐(기억)하기 위해 만들어진 녀석"을
머리에 붙들어 메두시고여.
논이스케이핑(Non-escaping) 클로저는
함수의 실행이 완료되기 전에 모든 실행이 종료되는 클로저에요.
즉 클로저가 함수의 범위(스코프)를 벗어나지 않기 때문에, 함수가 실행을 마치고 반환되면
클로저 또한 더 이상 존재하지 않죠.
논이스케이핑 클로저는 함수와 생명주기를 같이 해요.
그에 비해 이스케이핑(escaping) 클로저는
함수가 반환된 이후에도 실행될 수 있는 클로저이기 때문에
함수의 범위를 '탈출(escaping)'하고 나중에도 실행될 수 있어요.
그래서 이름이 이스케이핑 클로져이죠.
이스케이핑 클로저는 클로저를 생성한 함수가 반환되어도 살아남아 나중에도 실행될 수 있어요.
이렇게 설명해도 아직 감이 안 오실 수 있으니 예제 코드를 보시죠.
Swift에서는 함수 파라미터로 전달되는 클로저는 기본적으로 논이스케핑이에요.
// 논이스케이핑 클로저 (기본값) func performOperation(with value: Int, operation: (Int) -> Int) { // 함수 내에서만 사용 let result = operation(value) print(result) }
위 코드에서 performOperation 함수에 파라미터로 전달된 operation 클로저의 경우에는
아무런 키워드도 붙지 않았으니 논이스케핑으로 동작해요.
즉 perfomrOperation 함수가 반환되면 operation 클로저도 함께 사라져서 이후에는 사용할 수 없어요.
var storedOperations:[Int:(Int)->Int] = [:] // 이스케이핑 클로저 (@escaping 키워드 필요) func storeOperation(with value: Int, operation: @escaping (Int) -> Int) { // 함수 외부에 저장됨 storedOperations[value] = operation }
하지만 storeOperation 함수에는 파라미터로 클로저를 전달할 때 @escaping 키워드가 붙었어요.
그러면 이 클로저는 이제 이스케이핑 방식으로 동작해요.
따라서 storeOperation 함수가 반환된 이후에도
매개변수로 받은 operation 클로저를 어딘가에 저장해 두었다가 사용할 수 있게 되죠.
그러면 이스케이핑과 논 이스케이핑 클로저가 메모리상에서는 어떻게 동작하는지 봐볼까여?
// 함수 정의 func performOperation(with value: Int, operation: (Int) -> Int) { // operation 클로저 즉시 실행, 함수 내에서만 사용 let result = operation(value) print(result) // 함수가 반환되면 클로저는 더이상 존재하지 않음 } // 함수 호출 performOperation(with value: 10, operation:{ $0 * 2 } )
논 이스케이핑 클로저의 경우 함수와 생명주기를 같이 하기 때문에 컴파일러 단에서
함수의 콜 스택 안에 할당해 최적화를 할 수 있어요. 단계별로 메모리 흐름을 살펴보면
- 함수 호출 시: 클로저가 함수에 전달되고 스택 프레임 내에서 관리되고
- 함수 실행 중: 클로저가 실행된 다음
- 함수 반환 시: 클로저와 관련된 메모리가 스택에서 해제돼요.
컴파일러는 클로자가 함수를 벗어나지 않는다는 것을 알기 때문에 클로저 인라이닝과 같은 최적화가 가능하죠.
class NetworkManager { var completionHandler: ((Data) -> Void)? func fetchData(completion: @escaping (Data) -> Void) { // 클로저를 저장 ( 함수 외부로 '이스케이핑' ) self.completionHandler = completion // 나중에 비동기적으로 실행 // ... } // 함수 호출 manger.fetchData { data in print(data) }
이스케이핑 클로저의 경우에는 함수가 실행을 종료된 이후에도 존재해야 하므로
반드시 힙에 할당돼요.
그래서 함수가 반환된 이후에도 힙상에 살아남아있는 클로저를 사용할 수 있죠.
- 함수 호출 시: 클로저 객체가 힙에 생성되고
- 함수 실행 중: 클로저에 대한 참조가 저장해요. (예: 속성에 저장, 배열에 추가 등).
- 함수 반환 시: 함수의 스택 프레임은 제거되지만, 클로저는 힙에 계속 존재하고
- 나중에 실행: 저장된 참조를 통해 클로저가 실행될 수 있어요.
- 참조 제거 시: 클로저에 대한 모든 참조가 제거되면 ARC(자동 참조 카운팅)에 의해 메모리에서 해제돼요.
힙 상의 객체를 다룰 때 중요한 게 뭔지 아시나요?
바로 메모리 누수(memory leak)의 가능성을 염두해야 한다는 거예요.
더 이상 사용하지 않는 객체가 힙상에 계속 남아서 메모리를 차지하고 있으면 아깝잖아요?
논 이스케이핑 클로저에서 self를 암시적으로 사용할 수 있어요.
왜냐하면 클로저가 함수와 생명주기를 같이 해서
어차피 함수가 반환되면 클로저도 함께 사라지기 때문이죠.
class MyViewController { var count = 0 func updateUI() { // 논이스케이핑 클로저에서는 self를 암시적으로 사용 가능 performAnimation { count += 1 // self.count와 동일 print("새로운 카운트: \(count)") } } func performAnimation(animations: () -> Void) { // 애니메이션 설정 animations() // 애니메이션 완료 } }
그에 비해 이스케이핑 클로저에서는 self를 명시적으로 참조해야 돼요.
class MyViewController { var count = 0 func fetchData() { // 이스케이핑 클로저에서는 self를 명시적으로 참조해야 함 networkManager.fetchData { [weak self] data in guard let self = self else { return } self.count += 1 print("새로운 카운트: \(self.count)") self.updateUI(with: data) } } }
이스케이핑 클로저는 힙상에 생성돼서 함수가 종료되어도
클로저 객체가 계속 살아남아 있기 때문에
1. 컴파일러가 개발자에게 메모리 관리에 주의를 기울이도록 하고
2. 강한 참조 사이클 가능성이 생길 수 있음을 알려서
3. [weak self]나 [unowned self] 같이 캡쳐 리스트를 통해 적절한 메모리 관리를 유도하기 위함이죠.
그러면 어떨 때 논이스케핑 클로저를 사용하고 어떨 때는 이스케이핑 클로저를 사용해야 할까여?
논 이스케이핑 클로저
1. 즉시 실행되는 작업 : 함수 내에서 바로 실행되고 함수 반환 전에 완료되는 작업
2. 동기 작업 : 비동기적으로 실행될 필요가 없는 작업
이스케이핑 클로저
1. 비동기 작업 : 함수가 반환된 이후에도 실행되어야 하는 비동기 작업
2. 콜백 및 완료 핸들러 : 특정 작업이 완료된 후 호출되는 콜백 함수
3. 이벤트 핸들러 : 특정 이벤트가 발생했을 때 호출되는 핸들러
4. 저장되는 클로저 : 프로퍼티나 컬렉션에 저장되었다가 나중에 사용되는 클로저
진짜 마지막으로 이스케이핑 클로저를 잘 사용하기 위한
키워드 두 개만 마저 봐봅시다.
weak과 unowned는
Swift에서 강한 참조 사이클(strong reference cycle)을 방지하기 위해 사용하는 참조 타입 수식어에요.
두 가지 모두 ARC(Automatic Reference Counting)가 참조를 관리하는 방식을 변경하지만
둘은 미묘하게 다른점이 있어요.
weak 참조는 약한 참조로, 참조하는 객체에 대해 소유권을 주장하지 않으며 다음과 같은 특징을 가지죠.
- 옵셔널 타입: weak 참조는 항상 옵셔널 타입이에요. 즉, nil이 될 가능성이 있어요.
- 자동 nil 설정: 참조 대상 객체가 메모리에서 해제되면 weak 참조는 자동으로 nil로 설정됩니다.
- 안전성: 참조하는 객체가 해제될 가능성이 있을 때 사용해요.
- ARC 동작: 참조 카운트를 증가시키지 않아요.
class Person { let name: String weak var apartment: Apartment? init(name: String) { self.name = name } deinit { print("\(name) is being deinitialized") } } // 사용 예: var john: Person? = Person(name: "John") var unit4A: Apartment? = Apartment(unit: "4A") john?.apartment = unit4A // weak 참조 unit4A?.tenant = john // 강한 참조 john = nil // John 객체가 해제됨 // 이제 unit4A?.tenant는 자동으로 nil이 됨
예제 코드와 같이 Person이 Apartment 객체를 참조하고 Apartment도 Person 객체를 참조해야하는 경우 같이
강한 참조 사이클(strong reference cycle)이 발생할 수 있는 경우, 한쪽에 weak 키워드를 사용하면
순환 참조를 방지하여 인스턴스가 힙 상에서 안정적으로 해제 할 수 있어요.
unowned 참조는 비소유 참조로, 마찬가지로 소유권을 주장하지 않지만 다른 특성을 가져요.
- 비옵셔널 타입: unowned 참조는 기본적으로 비옵셔널이에요.
- nil이 되지 않음: 참조 대상 객체가 해제되어도 nil로 설정되지 않아요.
- 접근 위험성: 참조 대상 객체가 해제된 후 접근하면 런타임 오류(crash)가 발생해요.
- 수명 가정: 참조 대상 객체가 자신보다 더 오래 존재할 것이라고 확신할 때 사용해요.
- ARC 동작: 참조 카운트를 증가시키지 않아요.
class Customer { let name: String var card: CreditCard? init(name: String) { self.name = name } deinit { print("\(name) is being deinitialized") } } class CreditCard { let number: UInt64 unowned let customer: Customer // 항상 존재한다고 가정 init(number: UInt64, customer: Customer) { self.number = number self.customer = customer } deinit { print("Card #\(number) is being deinitialized") } } // 사용 예: var john: Customer? = Customer(name: "John") john?.card = CreditCard(number: 1234_5678_9012_3456, customer: john!) john = nil // John 객체가 해제됨 // 카드 객체도 함께 해제됨 // 만약 이 후에 card.customer에 접근하면 크래시 발생
참조하는 객체가 더 수명이 길어서 동작하는 동안 해당 객체가 무조건 살아있다를 확신할 수 있을 때
unowned 키워드를 사용해요. 일단 있다고 가정했기 때문에 옵셔널이 아니고 언래핑을 할 필요가 없어요.
하지만 있다고 했는데 없다? 그러면 이제 에러가 나는 거죠.
unowned 키워드 특성상 불확실성이 있기 때문에 이를 안전하게 사용하기 위해
Swift 5부터 unowned는 기본적으로 안전하게 작동해요.(unowned(safe)와 동일).
그래도 unowned(unsafe)라는 옵션도 남겨놨답니다.
- unowned(safe): 참조 대상이 해제되었는지 확인 후 접근하고, 안전하지만 약간의 오버헤드가 있어요.
- unowned(unsafe): C의 unsafe 포인터처럼 작동하며, 대상이 해제되었는지 확인하지 않고 직진. 성능은 좋지만 위험해요.
그렇다면 언제 weak 키워드를 사용하고 언제 unowned를 사용할까요?
- weak 사용하는 경우
- 참조 대상의 수명이 불확실할 때
- 참조 대상이 먼저 해제될 가능성이 있을 때
- 참조가 nil이 될 수 있다는 것을 코드에서 처리해야 할 때
- 델리게이트 패턴 등에서 순환 참조를 방지할 때
- unowned 사용하는 경우
- 참조 대상이 자신보다 항상 더 오래 존재함이 확실할 때
- 참조가 절대 nil이 되어서는 안 될 때
- nil 확인의 오버헤드를 피하고 싶을 때
- 클로저의 캡처 리스트에서 self를 참조할 때(self가 클로저보다 더 오래 존재함이 확실한 경우)
드디어 클로저와 관련된 내용을 모조리 살펴봤는데요. 다봤겠지...??
여기까지 따라 오시느라 고생 많으셨고요.
이제 당분간 클로저때문에 헷갈릴 일은 없겠네요. 제발 ... 플리주
'Apple🍎 > Swift' 카테고리의 다른 글
inout 파라미터 작동 방식 파해치기 (0) 2025.03.11 Swift의 컬렉션 타입 : 값 의미론과 실제 구현 (0) 2025.02.28 Swift를 위한 람다 계산법 핵심 (0) 2025.02.25 고차함수 for Swift (0) 2025.02.25 클로저 종결판 (0) 2025.02.23