개요
Swift를 공부하다보면 lambda와 비슷한 개념으로 클로져(Closure)라는게 나온다.
내용 자체는 익명 함수와 비슷한데, 초반부터 나오는 캡쳐(Capture)라는 용어가 굉장히 이해하기가 힘들었다. ▼
공식문서에서도 이에 대해 다루기는 하지만 캡쳐를 하는 방법에 대한 내용 위주고, 이해 위주는 아니었기에 이해하기 쉽게 정리해보기로 했다. ▼
캡쳐(Capture)가 정확히 뭐야?
캡쳐의 정의
클로져의 캡쳐는 클로져의 내용에서 주변에 있는 외부 context의 상수와 변수를 사용하기 위해 참조하는 것을 말한다.
이렇게 하면 설령 외부 context가 사라지더라도, 클로져에서는 계속해서 연산을 수행할 수 있게 된다.
공식문서의 예제를 보자. ▼
func makeIncrementer(forIncrement amount: Int) -> () -> Int {
var runningTotal = 0
func incrementer() -> Int {
runningTotal += amount
return runningTotal
}
return incrementer
}
makeIncrementer()
함수는 incrementer()
함수를 클로져로 반환한다.
그리고 incrementer
이라는 클로져에서 외부 context인 runningTotal
을 사용하고 있다. ▼
이렇게 클로져 내부의 것이 아니라, 클로져 외부의 것들을 사용하는 것을 캡쳐라고 한다.
캡쳐는 참조를 하는 것
그런데 그냥 가져다 사용하는거를 왜 따로 캡쳐라고 부르는 걸까? 위에서 들은 예시는 변수의 생명주기와 관련된 내용이 아닌가?
이에 대한 내용을 좀 더 자세히 설명하기 위해 다시 아까의 예제로 돌아와서, makeIncrementer()
함수 내부에 있는 incrementer()
함수를 보자. ▼
func incrementer() -> Int {
runningTotal += amount
return runningTotal
}
incrementer()
함수는 makeIncrementer()
함수가 클로져로서 반환하는 것인데, 저 형태 그대로 반환되는 것은 뭔가 이상하다.
makeIncrementer()
안에 있을 때는 문제가 없지만, makeIncrementer()
을 떠나는 순간, runningTotal
과 amount
변수를 찾을 수 없기 때문이다.
그런데 아래의 코드는 정상적으로 동작한다! ▼
let incrementByTen = makeIncrementer(forIncrement: 10)
위의 코드가 정상적으로 동작하는 이유는 클로져의 캡쳐는 참조를 하여 기억하는 것이기 때문이다.
makeIncrementer()
함수의 runningTotal
과 amount
의 값만 기억하는 것이 아니라, 두 변수를 참조한 채로 incrementByTen
이라는 변수에 할당이 되는 것이다. ▼
조금 난해하다면 call by value와 call by reference, address의 차이를 생각하면 쉽다. ▼
그리고 이 incrementByTen이라는 변수에 참조에 대한 정보가 기억되어있기 때문에, 일반 함수와 같이 실행 후에 변수가 사라지지 않고 값을 기억하게 된다. ▼
Capturing by reference
이렇듯 클로져에서의 캡쳐는 값 타입이 무엇인지에 상관없이 참조로 캡쳐 한다.
참조 타입과 값 타입, 상관하지 않고 모두 참조로 캡쳐를 한다.
그런데 한 가지 의문이 들었다.
“값 타입은 어떻게 참조로 캡쳐를 하는거지…?”
클로져가 값 타입을 캡쳐하는 방법
Swift에서의 참조라는 개념은 일반 변수에게는 존재하지 않는 개념이다.
Class의 인스턴스와 같은 참조 타입 변수에게는 참조라는 개념이 존재하는게 맞지만, Int와 String과 같은 일반 변수 타입들에게는 참조라는 개념을 적용할 수 없다.
애초에 Swift에서의 참조는 인스턴스를 가리키는 값으로, 이 값은 메모리상의 인스턴스의 위치를 나타내며, 인스턴스 자체가 아니라 인스턴스에 대한 참조다.
즉, 참조는 인스턴스의 주소를 나타내는 것이다.
하지만 값 타입 변수들은 인스턴스를 따로 가지는 것이 아니라 그 자체를 가지는 것이기 때문에 인스턴스의 주소라는 개념을 적용할 수는 없다.
그렇다고 값 타입들이 주소를 안가진다는 것은 아니다.
참조라는 개념에 대해서 적용이 안된다는 것 뿐이다.
값 타입에도 결국엔 주소가 있다는 생각을 하고나니 머릿속에는 클로져가 어떻게 값 타입을 캡쳐해오는지가 대강 예측이 갔다.
그러나 이건 어디까지나 나 스스로 생각한 것이고 Swift가 실제로 캡쳐하는 방식과 다를 거라는 생각이 들어서 공식적인 동작 방식에 대해서 찾아보았지만… ▼
찾은 것은 이게 전부였다. ▼
클로져가 값 타입을 캡쳐하는 방법
여기서도 어떻게?는 모르고 클로져가 표면적으로 이렇게 동작하는 것 정도만 알 수 있다고 이야기를 했다.
다른 글들도 찾아보았지만, 내가 원하는 내용과는 거리가 조금씩 먼 내용들이었다.
다른 글에서 이에 대한 내용의 해답을 조금은 알 수 있었는데, ▼
여기서 이야기하는 두 항목,
- value type도 만들어질 때 내부에 레퍼런스 카운터가 생긴다.
- value type이더라도 클로저가 캡쳐하면 reference type으로 결정해서 heap 영역으로 옮길 것 같다.
이 두 항목도 확신에 가득해서 말하는 것이 아닌 것을 보면(옮길 것 같다…), 정확한 것은 Swift 개발자가 아닌 이상은 알 수 없는 것 같다.
정답과 근접한 하나의 가설 정도로 기억하는게 좋아보인다.
결국 기억해야하는 것
내부적으로 어떻게 동작하는 지 알 길이 없기에 표면적인 것이라도 기억을 하는 것이 좋다고 생각한다.
클로져에서의 캡쳐는 값 타입이 무엇인지에 상관없이 참조로 캡쳐를 한다는 것과,
참조 타입과 값 타입, 상관하지 않고 모두 참조로 캡쳐를 한다는 것 두 가지를 기억해두자.
기본 캡쳐 방식
앞에서도 캡쳐를 하는 방식에 대해서 설명을 했지만, 코드와 함께 그 흐름을 확인해보자.
아래와 같이 CustomDebugStringConvertible
이라는 프로토콜을 채택한 Deer
클래스가 있다고 해보자. ▼
class Deer : CustomDebugStringConvertible {
let name: String
init(name: String) {
self.name = name
}
var debugDescription: String { return "[Deer \(name)]"}
deinit { print("\(self) disappeared...") }
}
Deer
클래스는 name
속성만 가지며, 할당 해제가 되면 [Deer \(name)] disappeared…
라는 메세지를 출력한다.
Q. CustomDebugStringConvertible 이라는 프로토콜은 왜 쓴거죠?
A. 이 프로토콜을 채택하면, print()를 통해 클래스를 출력할 때 원하는 형식으로 출력이 가능해진다.
해당 프로토콜을 채택하면 아래의 debugDescription에 대한 내용을 채워야한다. ▼
var debugDescription: String { return "[Deer \(name)]"}
이를 통해 우리는 Deer 클래스의 인스턴스를 출력할 때, Deer 클래스의 name 속성으로 출력이 가능해진다. ▼
let gorani = Deer(name: "gorani")
print(gorani)
// print result = "[Deer gorani]"
다음으로는 1초를 기다린 후, 매개변수로 들어온 클로져를 실행시키는 oneSecDelay()
함수를 선언했다.
매개변수로 들어온 클로져는 escaping 클로져이고, 1초를 비동기적으로 기다리기 위해 DispatchQueue.main.asyncAfter()
를 사용했다. ▼
func oneSecDelay(closure: @escaping ()->()) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
closure()
}
}
마지막으로 위의 두 코드를 이용하는 catchDeer()
라는 함수를 만들었다.
이 함수는 내부에서 gorani
라는 Deer
인스턴스를 생성하고, oneSecDelay
를 통해 비동기적으로 gorani
에 대해 출력하는 클로져를 1초 뒤에 실행하게끔 전달한다.
그리고 catchDeer()
함수는 catchDeer finished!
라는 문구와 함께 종료된다. ▼
func catchDeer() {
let gorani = Deer(name: "gorani")
print("before closure : \(gorani)")
oneSecDelay {
print("executing closure : \(gorani)")
}
print("catchDeer finished!")
}
그러면 catchDeer()
를 실행하면 어떤 결과가 나올까?
catchDeer()
는 아래의 흐름도와 같이 실행이 될 것이다. ▼
함수 내부에서 생성된 지역 변수들은 함수가 끝나면 소멸한다는 것을 다들 알고 있을 것이다.
그렇기에 위의 catchDeer()
에서도 내부에서 생성된 gorani
인스턴스가 catchDeer()
가 종료되면서 사라질 것이고,
gorani
인스턴스가 사라지게 되면서, 1초 뒤에 실행되는 클로져에서 출력할 gorani
정보가 없어 런타임 에러가 날 것이라고 예측할 것이다. ▼
하지만 위의 catchDeer()
를 실행한 결과는 아래와 같다. ▼
catchDeer()
가 끝나서 gorani
인스턴스가 사라질 것으로 예상했지만, gorani
인스턴스는 클로져에 의해 캡쳐되어 있어 사라지지 않는다. ▼
클로져가 끝나고 나서는 gorani
인스턴스에 대한 strong reference가 사라지기에 소멸자가 실행이 된다.
GCD
Q. 제 환경에서 실행하면 DispatchQueue.main.asyncAfter() 블록이 아예 실행이 안돼요…
A. 커맨드 라인 툴로 프로젝트를 생성하고 실행을 시킨 것으로 예상이 되는데, 만약에 맞다면 이는 메인 쓰레드가 먼저 종료가 되기에 일어나는 현상이다.
이는 GCD에 대해서 알아보고 오면 이해가 빠를 것이다.
GCD의 main 큐는 serial 큐이기 때문에, 한 Task의 실행이 끝나야 다음 Task의 실행이 시작된다.
DispatchQueue.main.asyncAfter()가 실행이 되면, 내부의 클로져를 바로 실행하는 것이 아니라 전달만 하고 방금 실행된 catchDeer와, oneSecDelay()가 끝나기를 기다린다.
그러나 oneSecDelay()는 전달만 하고 종료되기에, oneSecDelay()가 종료됨과 동시에 catchDeer도 종료되면서 메인 쓰레드가 종료되고, DispatchQueue.main.asyncAfter가 실행이 되지 않게 된다.
이를 UIKit을 사용하는 프로젝트에서 UI를 담당하는 viewDidLoad() 메소드 안에서 돌려보면 정상적으로 실행되는 것을 알 수 있다.
(UI는 메인 쓰레드에서 실행이 되기에, UI인 viewDidLoad()는 메인 쓰레드에서 실행된다.)
캡쳐된 변수의 값
캡쳐는 기본적으로 참조
변수를 캡쳐한다고 하면, 해당 클로져 안에 변수가 저장되는 것을 볼 수 있었다.
"그러면 변수는 한 번 캡쳐되면 그 값 그대로 고정이 되는 것일까?"
아래의 예제를 확인해보자.
앞의 Deer
클래스와 oneSecDelay()
함수를 다시 사용하여 catchAnotherDeer()
라는 함수를 정의했다. ▼
func catchAnotherDeer() {
var deer = Deer(name: "gorani")
print("before closure : \(deer)")
oneSecDelay {
print("executing closure : \(deer)")
}
deer = Deer(name: "elk")
print("catchDeer finished!")
}
아까와 똑같은 흐름이지만, 이번에는 클로져 전달 후에 deer
가 name
이 elk
인 다른 인스턴스로 바뀐다.
"그러면 클로져가 실행이 됐을 때, gorani를 출력할까 아니면 elk를 출력할까?"
사실 이는 꽤나 해답이 단순한 문제다.
클로져는 elk를 출력하는데, 이 이유는 클로져의 캡쳐 방식이 참조이기 때문이다. ▼
캡쳐를 할 때 참조, 조금 직관적으로 이야기를 하면 메모리의 주소를 저장하기 때문에, 해당 주소의 값이 바뀌게 되면 클로져에서도 바뀐 값을 가지고 연산을 하게 된다. ▼
그렇기에 클로져는 실행 될 때 캡쳐한 변수들의 값을 불러온다고 할 수 있다.
“그런데 개발자가 의도적으로 해당 시점의 값을 캡쳐하려고 할 때는 어떻게 해야할까?”
값을 캡쳐시키는 방법 : Capture List
이는 Capture List를 통해 해결할 수 있다.
Capture list의 용도는 참조의 강도(strong, weak, unowned)를 조절하기 위해 하나의 규칙을 세우는 것으로 등장했는데, 값을 캡쳐하는 용도로도 사용할 수 있다.
예제와 함께 확인해보자. ▼
func catchGorani() {
var gorani = Deer(name: "gorani")
print("before closure : \(gorani)")
oneSecDelay { [goraniTheDeer = gorani] in
print("executing closure : \(goraniTheDeer)")
}
gorani = Deer(name: "elk")
print("catchDeer finished!")
}
이번에는 Capture list에 goraniTheDeer = gorani
라고 명시를 해주었다.
이를 통해 goraniTheDeer
라는 변수에 값이 캡쳐된다.
클로져 내부에 name이 gorani인 Deer 인스턴스가 기록되었기 때문에 클로져를 실행할 때 elk가 아닌 gorani에 대한 정보를 출력한다. ▼
마치며
이번 포스트에서 클로져 캡쳐에 대한 전체적인 이해를 해보았다.
그동안 클로져에 대해서는 얼추 이해는 했지만, 클로져 내부의 캡쳐에 대해서는 명확히 이해하지 않고 클로져 사용을 했었다.
그 상태로 계속해서 코드를 짜다보니 여기서 이렇게 코드를 짜야하는 지도 모르고, 왜 오류가 나는 지 알 수가 없어서 클로져에 대해 완벽히 이해하고 들어가야겠다고 생각이 들어 이해를 위한 글을 작성했다.
아무래도 공개적으로 올라가는 글이라 최대한 다른 사람들도 이해하기 쉽게 작성을 하려고 했지만, 어느정도는 나 스스로가 읽기 좋게 작성을 한 것도 있어 모두가 읽기 쉬울 지는 잘 모르겠다.
그래도 조금이나마 도움이 되기를 바라며 이번 글은 여기서 마친다.
다들 화이팅이다~
'Develop > iOS' 카테고리의 다른 글
[iOS][ERROR] Button AddTarget 에러 (0) | 2024.02.15 |
---|---|
[iOS][Swift] Swift의 메모리 관리 : ARC 2 (0) | 2024.02.11 |
[iOS][Swift] Two Phase Initialization (0) | 2024.02.09 |
[iOS][Swift] Swift의 메모리 관리 : ARC 1 (0) | 2024.02.09 |
[iOS][UI] Auto Layout에 대해 (0) | 2024.01.23 |