-
클로저 종결판Apple🍎/Swift 2025. 2. 23. 21:58
앱을 개발할 때 꼭 마주치게 되는 '클로저'라는 놈, 이 정도 봤으면 정들 때도 되었는데
이놈이 모양도 야시꾸리하고 형태도 왔다 갔다 해서 뭔가 찝찝한 녀석입니다.
그래서 오늘날 잡고 산산조각 내보려고 합니다. 가보시지요. 렛츠기딧
물 불 바람 대지 클로저는 보통 어떤 동작을 정의할 때 사용하는데요.
컴포넌트 재활용시 동작부만 따로 프로퍼티로 만든다던가 동작의 일부를 런타임 중에 결정한다던가 등...
근데 생각해보면 이미 동작을 나타낼 수 있는 것이 이미 존재합니다.
이름하여
함수
그쵸 함수를 이용하면 여러 가지 동작을 미리 정의하고 호출을 통해 코드의 여러 부분에서 활용할 수 있졍
그럼 이미 있는데 뭐 한다고 클로저라는 것을 만들었을까요????
전통적인 함수의 한계
전통적인 프로그래밍에서 함수가 연산을 위해 사용 가능한 값,
즉 함수의 본문에서 접근할 수 있는 값은 다음 세개뿐이었요.
- 함수 안에 쏙 ~~ 함수 내부(바디)에 선언돼 있는 지역변수
- 함수 밖에 누구든 접근 가능한 전역변수
- 그리고 함수를 호출할 때 호출한 사람이 넣어주는 인자 값(매개변수로 전달된 값)
그럼 위의 세 개면 충분하지 뭐가 더 필요하냐???
가만히 살펴봅시다.
지역변수와 매개변수의 경우는 함수와 생명주기를 같이하져,
그럼 함수가 실행이 종료되면 지역변수, 매개변수도 함께 날아갑니다.
(콜스택 아시죠 기억 안 나실까 봐 준비했습니다. )
콜 스택 동작을 통해 알아보는 함수 호출
콜 스택이란?프로그램이 실행되어 메모리에 올라오면, 운영체제는 해당 프로그램을 프로세스라는 실행단위로 관리하면서 필요한 컴퓨팅 자원 (CPU 시간, 메모리 등) 을 할당합니다. 각 프로세스
people-analysis.tistory.com
전역 변수의 경우에는 현재 호출되는 함수'도' 접근할 수 있지만 이 함수'만' 접근할 수 있는 건 아니져.
다른 곳에 살고 있는 함수들도 얼마든지 여기에 접근해서 값을 가져다 쓰고 바꿔 놓을 수도 있습니다. '전역'이니까.
그니까 뭐가 문제냐고!!!
진정하시고. 좀 더 들어봐여.
기존의 함수가 사용할 수 있는 값들만(지역변수, 전역변수, 매개변수) 가지고 할 수 없는 게 있다~~ 이 말입니다.
바로 "함수가 생성된 환경의 상태를 기억할 수가 없다." 이거 아니겠습니까
그게 뭔말인데여???
자 이제 코드 타임입니다. 다음 같은 상황을 봐보세여.
func makeCounter() -> (Int) -> Int { // 이 변수는 함수가 종료되면 사라져야 함 var count = 0 // 내부 함수 func increment(by amount: Int) -> Int { count += amount // 외부 함수의 변수에 접근 return count } return increment // 내부 함수를 반환 }
앞에서 말한 대로 옛날 규칙에 따르면 'makeCounter' 함수가 종료될 때
지역변수인 count 변수도 함께 메모리에서 사라져야 되잖아요.
근데 반환된 increment 함수를 나중에 다시 사용되려면 count 변수에 계속 접근할 수 있어야 되고요.
즉 increment 함수를 사용하기 위해서는
함수가 호출(생성)되었을 때의 환경(ex. 지역변수)을 기억할 방법이 필요해요.
그리고 여기서 추가로
혹시 '함수형 프로그래밍'이라고 들어보셨을지 모르겠네여.
함수형 프로그래밍의 핵심 개념 중 하나가 "함수를 일급 객체"로 취급할 수 있어야 된다인데요.
자세한 내용은 여기서
일급객체가 뭔데?
프로그래밍을 하다 보면 '일급 객체'라는 단어를 접하곤 하는데요. 이 '일급'이라는 어감과 실제 설명하는 내용 사이에 간극이 있습니다. 그래서 오늘은 어원부터 출발해서 일급객체가 실질적
people-analysis.tistory.com
간단히 정리해서 함수가 일급객체가 되려면
함수를 변수에 할당할 수 있고
다른 함수의 인자로 전달로 전달할 수 있으며
함수의 반환 값으로 사용할 수 있어야 한다.!!
위의 말이 뭘 의미할까요? 쉽게 말해서
함수를 만들고 난 뒤에도 어디다가 담고, 다른 함수 애들과도 교환도 할 수 있어야 한다.
그러면 함수가 보관과 이동이 가능해지려면 어떻게 되어야겠어요?
만들고 바로 사라지면 안 되고 형체가 유지가 되어야겠죠?
여기까지 정리해 보면
'전통'적인 함수만으로는 함수가 호출(생성) 되었을 때의 값을 '기억'할 방법이 없다.
근데 이런저런 연유로 인해
함수가 호출(생성)되었을 당시의 '환경'을 기억할 방법이 필요하다.
입니다.
그래서 이 문제를 해결하기 위해
클로저
클로저의 핵심은
첫 번째, 값을 캡쳐할 수 있다.
자신이 선언된 환경의 변수들을 "기억"할수 있어요.
두 번째, 환경과 함께 패키징이 된다.
실행 코드뿐만 아니라 필요한 환경(변수들)까지 함께 묶어 하나의 객체로 다룰 수 있어요.
일단 이해를 위해서 '환경'이 뭔지 간단히 설명하면
프로그래밍에서 '환경'이라는 용어는 함수가 접근할 수 있는 값 들로 다음과 같은 것들이 있어요.
1. 함수 내부에 선언된 지역 변수들
2. 함수가 정의된 외부 스코프의 변수들
3. 전역 변수
4. 함수에 전달된 매개 변수들
이렇게 클로저는 함수가 생성된 환경(컨텍스트)를 "닫아서(close)"서 가져가므로 "closure라는 이름이 붙었어요.
그러면 함수가 생성 환경을 기억해야 하는 상황들, 그니까 클로저가 필요한 경우는 뭐가 있을까요??
첫 번째로는 사용자별 맞춤 설정을 기반으로 함수를 생성하는 경우가 있어요.
예를 들어서 사용자가 어느 나라 사람인지에 따라 별도의 인사말을 제공해야 할 때
모든 나라를 위한 함수를 미리 만들어 놓는 것이 아니라
런 타임 때 사용자가 자신의 국적을 선택하면 그것에 맞춰 함수를 결정하고
나중에 인사가 필요한 부분에는 이 함수를 계속 호출하는 거죠.
func createGreeter(for name: String, in language: String) -> () -> String { let greetings = [ "영어": "Hello", "한국어": "안녕하세요", "스페인어": "Hola", "일본어": "こんにちは" ] let greeting = greetings[language] ?? "Hi" return { return "\(greeting), \(name)!" } } // 사용자별 맞춤 인사 함수 생성 let greetJohn = createGreeter(for: "John", in: "영어") let greetMinSu = createGreeter(for: "민수", in: "한국어") print(greetJohn()) // "Hello, John!" print(greetMinSu()) // "안녕하세요, 민수!"
위 예제에서 인사함수가 생성될 때 매개변수 name, language로 받은 값을 기억해 뒀다가. 변수를 통해 실행 시 이 값을 다시 사용할 수 있어요. 원래 스코프에서는 더 이상 접근할 수 없었겠지만, 클로저가 만들어질 때 값들을 '캡쳐' 했기 때문에 여전히 사용할 수 있어요.
두 번째로는 상태를 유지해야 하는 함수가 필요한 경우예요.
어떤 값을 계속 추적하면서 작동해야 하는 함수를 만들 때 클로저를 활용할 수 있어요.
func createBankAccount(initialBalance: Double) -> (Double) -> Double { var balance = initialBalance return { amount in balance += amount return balance } } let myAccount = createBankAccount(initialBalance: 1000) print(myAccount(50)) // 1050 (입금) print(myAccount(-200)) // 850 (출금) print(myAccount(300)) // 1150 (입금)
여기서 balance 변수는 원래 createBankAccount 함수의 지역 변수인데, 반환된 클로저가 이 변수를 캡처해서 계속 사용하고 있어요
. 이렇게 하면 은행 계좌의 잔액을 추적할 수 있는 함수를 쉽게 만들 수 있죠. 매번 함수를 호출할 때마다 이전 상태가 유지되는 거예요.
세 번째로는 데이터 처리 단계를 유연하게 구성할 때 클로저가 유용해요.
설정 값을 기억하는 함수로 데이터 변환 파이프라인을 구축할 수 있죠.
func createFilter(threshold: Int) -> ([Int]) -> [Int] { return { numbers in return numbers.filter { $0 > threshold } } } func createMapper(multiplier: Int) -> ([Int]) -> [Int] { return { numbers in return numbers.map { $0 * multiplier } } } // 데이터 처리 파이프라인 구축 let filterGreaterThan10 = createFilter(threshold: 10) let multiplyBy2 = createMapper(multiplier: 2) let numbers = [5, 12, 3, 16, 8, 20] let result = multiplyBy2(filterGreaterThan10(numbers)) print(result) // [24, 32, 40] (10보다 큰 값들만 필터링한 후 2를 곱함)
각 함수는 생성될 때의 설정값(threshold나 multiplier)을 기억하고 있다가, 나중에 데이터가 들어오면 그 설정에 따라 처리해요.
이런 방식으로 재사용 가능한 데이터 처리 단계를 쉽게 구성할 수 있어요.
네 번째로는 이벤트 처리나 비동기 작업의 콜백에서 원래 컨텍스트 정보가 필요한 경우예요.
class SearchViewController: UIViewController { var searchResults: [SearchResult] = [] func performSearch(query: String) { // 검색 시작 시간 기록 let searchStartTime = Date() APIClient.search(query: query) { [weak self] results, error in guard let self = self else { return } // 검색에 걸린 시간 계산 let searchDuration = Date().timeIntervalSince(searchStartTime) if let error = error { self.showError("검색 실패: \(error.localizedDescription)") return } // 검색 결과 저장 및 표시 self.searchResults = results self.updateUI() // 분석 데이터 기록 Analytics.logEvent("search_completed", parameters: [ "query": query, "results_count": results.count, "duration": searchDuration ]) } } // 기타 메서드... }
여기서 네트워크 요청의 완료 핸들러(클로저)는 다음 값들을 캡처하고 있어요.
self( 뷰 컨트롤러 인스턴스 ), query( 검색어 ), searchStartTime ( 검색 시작 시간 )
이렇게 하면 비동기 작업이 나중에 완료되더라도 검색 요청의 원래 컨텍스트를 유지할 수 있어요.
요청을 보낸 시간이나 어떤 검색어를 썼는지 같은 정보를 기억하고 있는 거죠.
다섯 번째로는 계산 비용이 큰 작업의 결과를 캐시 하거나 지연 계산이 필요한 경우예요.
func createFibonacciCalculator() -> (Int) -> Int { var cache = [0: 0, 1: 1] // 메모이제이션 캐시 func fibonacci(_ n: Int) -> Int { if let cachedResult = cache[n] { return cachedResult } let result = fibonacci(n - 1) + fibonacci(n - 2) cache[n] = result // 결과 캐싱 return result } return fibonacci } let fib = createFibonacciCalculator() print(fib(10)) // 55 print(fib(20)) // 6765 (이미 계산된 값들은 재계산하지 않음)
이 예제에서는 cache 딕셔너리가 createFibonacciCalculator 함수의 지역 변수인데, 반환된 fibonacci 함수가 이걸 캡처해서 사용해요. 덕분에 이미 계산한 결과를 저장해 두고 재활용할 수 있어서 성능이 크게 향상돼요. 중복 계산을 피할 수 있는 거죠.
클로저를 끝장내려고 했는데 여기까지 오니까 dyson Naver... 그래도 조금만 더 해볼까여?
클로저가 주변 환경(컨텍스트)을 기억(캡쳐)해뒀다가 이후 동작에서 사용하기 위해 만들어진 녀석이라고 했으니
그러면 그 '캡쳐'가 어떻게 이루어지는지도 알아봐야겠죠??
그만해 ~~ 이러다 다 ~~ 죽어~~ 값타입, 참조 타입 메모리 모델 헷갈리는 분들 아래 더 보기를 참고해 주시고요. 자 후딱 끝내죠.
더보기값 타입은 현재 스코프에 위치한 스택 프레임 위에 위치합니다. 그리고 복사 시 깊은 복사를 수행하여 값 자체를 복사하고 새로운 메모리를 할당합니다. 그에 비해 참조 타입의 경우 생성시 힙상에 위치하며 스택 프레임에 있는 변수는 힙에 생성된 인스턴스의 주소값을 저장합니다. 또한 복사시 얕은 복사를 수행하여 인스턴스 자체가 아닌 인스턴스의 주소값을 넘겨줍니다.
Swift에서 클로저는 기본적으로 '참조' 방식으로 캡쳐합니다.
이는 값 타입(기본 타입, 구조체, 열거형) 값을 캡쳐하든 참조 타입(클래스) 값을 캡쳐하든 모두 적용됩니다.
일단 참조 타입 값을 캡쳐하는 경우부터 볼까요?
func exampleFunction() { let person = Person(name: "Kim") // 참조 타입 인스턴스 생성 let closure = { print(person.name) } // 클로저 생성 및 참조 타입 캡처 person.name = "Lee" // 참조 타입 값 변경 closure() // "Lee" 출력 }
exampleFunction()을 호출했을 때 메모리 구조는 아래와 같아요.
스택 메모리
- exampleFunction()의 스택 프레임이 생성됩니다.
- person 변수가 스택에 생성되고, 힙의 Person 객체를 가리키는 참조(주소값 0x001)를 저장합니다.
- closure 변수는 아직 초기화되지 않았습니다.
힙 메모리
- Person 클래스의 인스턴스 객체가 힙에 생성됩니다(주소 0x001).
- 이 객체의 name 속성은 "Kim" 값을 가집니다.
그리고 클로저가 생성돼서 person 변수를 캡쳐할 때, person의 참조 값을 캡쳐해요.
즉 클로저는 Person 객체 자체를 복사하지 않고, 힙상의 동일한 객체를 가리켜요.
스택 메모리
- person 변수는 여전히 힙의 Person 객체를 가리킵니다.
- closure 변수가 초기화되어 힙에 생성된 클로저 객체를 가리키는 참조(주소값 0x002)를 저장합니다.
힙 메모리
- Person 객체는 그대로 유지됩니다.
- 새로운 클로저 객체가 힙에 생성됩니다(주소 0x002).
- 이 클로저 객체는 person 변수가 가리키는 Person 객체에 대한 참조(0x001)를 캡처합니다.
이후 함수 내에서 지역변수 person을 사용해 값을 변경하면 Person 객체의 내부 값만 변경되고, 참조 구조는 그대로 유지되고
person 변수와 클로저는 여전히 동일한 객체를 가리키고 있으므로, 한 곳에서 발생한 변경이 다른 곳에서도 보이게 돼요.
스택 메모리
- 스택의 변수들은 변경되지 않습니다.
힙 메모리
- Person 객체의 name 속성이 "Kim"에서 "Lee"로 변경됩니다.
- 클로저 객체는 변경되지 않고, 여전히 동일한 Person 객체를 가리킵니다.
이후 클로저가 실행되면
클로저가 참조로 person을 캡처했기 때문에, 클로저가 실행될 때는 Person 객체의 최신 상태를 볼 수 있고
따라서 원래 값 "Kim"이 아닌 변경된 값 "Lee"가 출력돼요.
참조 타입의 경우 애초에 메모리 주소를 공유하기 때문에 예상한 대로 동작하는 모습을 볼 수 있었는데요.
그렇다면 값 타입은 도대체 값 타입 주제에 참조 캡쳐를 한다는 걸까요??
func exampleFunction() { var number = 10 // 값 타입 변수 생성 let closure = { print(number) } // 클로저 생성 및 값 타입 캡처 number = 20 // 값 타입 변경 closure() // "20" 출력 (참조로 캡처됨) }
함수가 처음 호출되었을 때는 일반적인 Swift 값 타입 동작을 볼 수 있어요.
값 타입의 데이터는 스택에 직접 저장이 되었고, 함수의 스택 프레임 내에 위치해요. 그리고 당연히 힙상에는 아무것도 없죠.
스택 메모리
- exampleFunction()의 스택 프레임이 생성됩니다.
- number는 값 타입(Int)이므로 스택에 직접 값 10이 저장됩니다.
- closure 변수는 아직 초기화되지 않았습니다.
힙 메모리
- 아직 아무것도 할당되지 않았습니다.
이 단계가 중요해요!!! 눈 크게 뜨고 보세용.
Swift는 클로저가 값 타입을 캡쳐할 때 "값 타입의 힙으로의 이스케이프(excaping to heap)" 또는 "박싱(boxing)"라는
작업을 수행해요.
- 원래 스택에 있던 값 타입 데이터를 위한 특별한 "상자"를 힙에 생성해요.
- 원래 스택에 있던 값을 이 힙 상자로 복사해요.
- 원래 스택에 있던 변수를 이 힙 상자를 가리키는 참조로 변경해요.
- 클로저는 이 힙 상자에 대한 참조를 캡처해요.
스택 메모리
- number 변수는 이제 힙에 있는 "값 타입 상자"를 가리키는 참조로 변경됩니다.
- closure 변수는 힙에 생성된 클로저 객체를 가리키는 참조를 저장합니다.
힙 메모리
- 값 타입 상자(0x001 주소)가 생성되고 number의 값 10을 저장합니다.
- 클로저 객체(0x002 주소)가 생성되고, 힙의 값 타입 상자에 대한 참조를 저장합니다.
이후 지역변수 number를 통해 number = 20 명령이 실행되면,
Swift는 스택에 있는 number 변수가 참조하는 힙의 상자 내부 값을 변경해요.
이는 일반적인 값 타입의 할당 동작과는 다르죠.
일반적으로 값 타입에 새 값을 할당하면 완전히 새로운 값이 생성되지만, 클로저에 의해 캡처된 후에는 힙 상자 내부의 값이 변경돼요.
스택 메모리:
- 스택의 변수들은 변경되지 않고, 여전히 동일한 힙 객체들을 참조합니다.
힙 메모리:
- 값 타입 상자 내의 number 값이 10에서 20으로 변경됩니다.
- 클로저 객체는 변경되지 않습니다.
마지막으로 클로저가 실행되면
클로저는 자신이 캡쳐한 참조를 통해 힙상에 있는 값 타입 상자에 접근해서 상자 내부의 현재 값인 20을 출력해요.
이 결과가 바로 값 타입이 "참조로 캡처"되었다는 증거에요.
만약 값 타입이 "값으로 캡처"되었다면, 클로저는 캡처 시점의 원래 값 10을 출력했을 거에요.
하지만 클로저가 힙의 상자에 대한 참조를 유지하기 때문에, 변경된 값 20을 볼 수 있죠.
이의 있소!!
나는 데이터를 안정적으로 다루기 위해서 struct 값 타입을 사용했는데 마음대로 참조로 바꿔버리면
내 의도와 다르게 동작을 해버리잖소!!!
그래서 준비했습니다. 바로 캡쳐 리스트 ~
Swift에서 클로저는 기본적으로 값 타입도 참조로 캡쳐하지만,
캡쳐 리스트(Capture list)를 사용하면 값타입을 값 타입으로 캡쳐할 수 있어요.
캡쳐 리스트를 통해 개발자에게
클로저가 주변 환경의 값을 어떻게 캡쳐할지 명시적으로 지정할 수 있는 옵션을 제공해주죠.
func exampleFunction() { var number = 10 // 값 타입 변수 생성 let closure = { [number] in print(number) } // 캡처 리스트 사용 number = 20 // 값 타입 변경 closure() // "10" 출력 (값으로 캡처됨) }
위의 코드에서 대괄호 [ ] 를 사용한 [number]가 바로 캡쳐 리스트에요.
이렇게 하면 클로저가 number 변수의 '현재 값'을 '복사'해서 클로저 내부에 저장해요.
첫번째 단계는 일반적인 함수 호출과 동일해요. 값 타입은 스택 메모리 안에 직접 저장이 되죠.
스택 메모리
- exampleFunction()의 스택 프레임이 생성됩니다.
- number 변수가 스택에 생성되고 값 10이 직접 저장됩니다.
- closure 변수는 아직 초기화되지 않았습니다.
힙 메모리
- 아직 아무 것도 할당되지 않았습니다.
두번째로 클로저에서 캡쳐리스트를 이용해 값을 캡쳐하면
이번에는 값을 박싱(boxing)하지 않고 값을 복사해서 클로저 내부에 저장해요.
스택 메모리
- number 변수는 여전히 스택에 값 10을 직접 저장하고 있습니다. (이 부분이 일반 캡처와 다릅니다!)
- closure 변수는 힙에 생성된 클로저 객체를 가리키는 참조를 저장합니다.
힙 메모리
- 클로저 객체가 생성됩니다.
- 클로저 객체 내부에 number의 값 복사본(10) 이 저장됩니다.
그냥 클로저에서 값을 캡쳐할때랑 캡처 리스트를 사용할 때의 핵심 차이점을 정리하면
- 원래 스택에 있던 number 변수는 그대로 스택에 남아 있어요.
- 힙에 "상자"가 생성되지 않아요.
- 클로저는 number의 현재 값(10)의 복사본을 자신의 내부 저장 공간에 따로 보관해요.
- 원래 변수와 클로저 내부 복사본 사이에는 더 이상 연결이 없어요.
이제 스택의 지역변수인 number를 통해 값을 변경해도
클로저가 보관하고 있는 복사본에는 영향을 주지 못하죠. (연결이 안되있으니까)
스택 메모리
- number 변수의 값이 10에서 20으로 변경됩니다.
- closure 변수는 그대로 힙의 클로저 객체를 참조합니다.
힙 메모리
- 클로저 객체는 변경되지 않습니다.
- 클로저 내부에 저장된 number의 복사본은 여전히 10입니다.
마지막으로 클로저가 실행되면 클로저는 자신이 내부적으로
저장한 number 값의 복사본(변경전)을 사용하기 때문에 10이 출력되죠.
이렇게 캡쳐 리스트를 사용하면
1. 클로저가 생성 시점의 값을 사용하도록 보장하여 코드의 동작을 더 예측가능하게 해줘요.
2. 외부 변수 변경에 의한 예상치 못한 영향을 방지해주죠.
3. 코드에서 클로저가 값의 '스냅 샷'을 사용하려는 의도를 명확히 표현할 수 있어요.
4. 특히 참조 타입을 캡쳐할 때 [weak], [unowned] 수식어와 함께 사용하여 강한 참조 사이클을 방지할 수 있어요.
아니 캡쳐리스트가 값 타입을 값 타입으로 캡쳐할 수 있게 해주는 기능 아닌가요?
마지막 말은 뭐죠?? 갑자기 weak, unowned?
캡쳐 리스트는 정확히 말하면 클로저가
주변 환경과 상호작용하는 방식을 세밀하게 제어할 수 있게 해주는 도구에요.
단순히 값 타입 캡쳐만 도와주는게 아니라 주변 값을 캡쳐 할때 '캡쳐할 값'과 '클로저'간의 관계를 조정할 수 있어요.
// 여러 변수 캡처 let closure = { [number, text] in ... } // weak 참조 사용 let closure = { [weak self] in ... } // unowned 참조 사용 let closure = { [unowned delegate] in ... } // 변수에 다른 이름 부여 let closure = { [myNumber = number] in ... }
아직 다룰게 좀더 남았는데 더하면 머리가 깨질 것 같으니까 미래에 나에게 맡기도록 하겠습니당. 그럼 ㅃㅇ
'Apple🍎 > Swift' 카테고리의 다른 글
Swift를 위한 람다 계산법 핵심 (0) 2025.02.25 고차함수 for Swift (0) 2025.02.25 일급객체가 뭔데? (0) 2025.02.18 [Swift] Property 제대로 써보자. (0) 2023.12.12 [Swift] Enum을 다양하게 활용해보자. (0) 2023.12.11