Swift : Closure & Capture Lists (1)

Closure 에서의 Capture List 가 동작하는 방식을 알아보자~

Jay Kim
11 min readJan 15, 2023
Photo by Philipp Katzenberger on Unsplash

Capture List 란 무엇인가?

평소에 closure 를 사용하는 분들은 capture list 에 대해서 한 번쯤은 들어봤을 것이다.

보통 아래 코드예시와 같은 형태로 많이 사용할 것이다.

...

var printTitleClosure = { [weak self] in
print(self?.title)
}
...

위의 코드에서 [weak self]부분이 capture list 이다.

  • capture list 란? : closure 가 정의되는 시점에 복사되는 변수들의 list 를 이야기한다. capture list 에서 나열된 변수의 복사본들은 closure 가 메모리에서 소멸되는 순간까지 내용이 변경되지 않는채로 유지된다.

capture list 에 대해 본격적으로 이야기하기 전에, closure 에서의 memory capturing 에 대해서 먼저 설명이 필요할 것 같다.

Closure 에서의 memory capturing

closure 를 구현할 때 closure 외부의 변수들을 참조해서 사용하도록 구현이 가능하다. 이렇게 closure 내부에서 외부 변수를 참조하는 순간, 해당 외부변수는 closure 가 더 이상 필요없게 되어 memory 에서 사라지는 순간까지 closure 에게 잡혀있게 되는데 이것을 memory capture 라고 부른다.

func runClosure(_ closure: () -> Void) {
print("Running closure")
closure()
}

struct Foo {
var x: Int = 5
}

var foo = Foo()

let closure1 = {
print("struct foo.x = \(foo.x)\n")
}


runClosure(closure1)

foo.x = 6

runClosure(closure1)

위의 코드를 예로 들어보자 실제 위 코드를 실행시키면 다음과 같은 결과를 확인할 수 있다.

예상했던 결과인가?
예상을 했다면 지금 이 글에서 이야기하고자 하는 내용을 파악하고 있을 가능이 크다. 예상하지 못했다면 코드를 하나하나 뜯어보자.

(1) line-13 : Foo 형태의 struct instance 가 하나 생성되어 foo 변수에 할당된다.
(2) line-16 : closure1 내부 구현체에서 외부 변수인 foo 를 참조한다. (이 과정이 memory capture 되는 상황이다.)
(3) closure 내부에서 외부 변수가 memory capture 된다는 것은, 해당 변수의 memory address 가 closure 에게 capture 되는 것이고
(4) capture 된 foo 변수는 실제로 외부에서 사용이 모두 끝나고 stack 에서 제거된 이후에도 closure1 이 어딘가로 전달되고-저장된 상태에서 사용된다면, closure1 내부에서는 계속해서 사용이 가능하게 된다. (단어 그대로 capture 되었다.)

계속해서 코드를 살펴보자.

ㅁ

(5) line-19 : runClosure 함수를 통해서 closure1 이 실행되면, line-16 이 동작되면서 foo.x 내용을 화면에 출력한다.
(6) 이때 foo.x 값은 foo 가 생성된 시점의 기본값을 5를 가지고 있기 때문에 다음과 같이 출력된다.

Running closure
struct foo.x = 5

(7) line-21 : foo.x 에 6 이라는 값이 할당된다.
(8) line-23 : runClosure 함수를 통해서 closure1 이 다시 실행된다. closure1 은 현재 foo 변수를 capture 하고 있는 상황이고, 그 값을 보는 것이 아니라 memory address 를 참조하고 있기 때문에 foo 의 변경된 값인 6 이 출력된다.

Running closure
struct foo.x = 6

이것이 왜 문제가 되는지에 대해서 설명하면, 일단 일반적인 Int, String 같은 변수 하나만을 capture 하는 것은 큰 문제가 되지 않는다. 하지만 만약 capture 하게되는 변수가 sturct 이고, 그 sturct 가 사용하고 있는 메모리가 아주 클 경우 해당 struct 가 예상치 않게 아주 오랫동안 메모리에 남아있게 되어 memory leak 과 비슷한 문제를 발생시킬 수 있다. 또 다른 예로 memory capture 된 sturct 가 내부에 class property 들을 다수 포함하고 있을 경우 이 class property 들 또한 원하는 시점에 deinit 되지 않고 문제를 일으키게 되는 경우들이 발생한다.

이번에는 코드를 조금 수정해서 struct Foo가 아닌 class Foo 를 사용하게 되는 경우를 살펴보자.

func runClosure(_ closure: () -> Void) {
print("Running closure")
closure()
}

class Foo {
var x: Int = 5
}

var foo = Foo()

let closure1 = {
print("class foo.x = \(foo.x)\n")
}

runClosure(closure1)

foo.x = 6

runClosure(closure1)

이 코드의 실행결과는 어떻게 될까?

struct 일 때와 결과는 동일하다.
그 이유는 foo 가 struct 이던지 class 이던지 상관없이 closure 내부에서는 foo 변수의 memory address 를 참조하고 있는 상황이라 출력된 결과물은 동일할 수 밖에 없다.

그런데 struct 와 달리 class instance 가 closure 내에서 memory capture 되는 것은 문제될 소지가 더 많다. 왜냐하면 memory capture 되는 순간 해당 class instance 에 대한 retain count 가 1 증가하기 때문이다. 이렇게 retain count 가 암묵적으로 1 증가하게 되면서 memory leak 이 발생할 가능성이 매우 높아지게 된다.

closure 내에서 class instance 를 memory capture 하게되어 발생할 수 있는 memory leak 관련해서는 Strong Reference Cycles for Closures 라는 swift 공식 문서에 잘 설명되어 있다.

위의 문제점들은 어떻게 해결해야 할까? 바로 capture list 가 이를 해결할 수 있는 방법이다.

Closure 에서의 capture list 사용

먼저 최초의 struct Foo 에서 capture list 를 사용해보자.

func runClosure(_ closure: () -> Void) {
print("Running closure")
closure()
}

struct Foo {
var x: Int = 5
}

var foo = Foo()

let closure1 = { [foo] in
print("struct foo.x = \(foo.x)\n")
}

runClosure(closure1)

foo.x = 6

runClosure(closure1)

위 코드의 실행결과는 어떻게 될까?

closure1 을 두 번 실행했고 두 번의 실행 사이에 foo.x 의 값을 변경했지만, closure1 내에서의 foo.x 값은 변경되지 않은 상태로 동일하게 출력된다.

왜 이렇게 되는지 코드를 한번 해체해보자.

(1) line-15 : [foo] 라고 capture list 를 선언하게 되면서 외부변수 foo 의 복사본이 closure 내에 생성되고, 이후부터의 closure 내에서는 외부변수 foo 가 아니라 복사본-foo 가 사용된다.

단, 여기서 매우 중요하게 기억해야 할 점이 있는데 capture list 에서 선언된 변수 값이 복사되는 시점은 closure 가 사용되는 시점 이 아니라 closure 가 정의되는 시점 이라는 것이다. 위의 코드에서 보면 closure 가 언제 실행되는지에 관계없이 line-15 시점의 foo(x = 5)의 내용이 복사 되는 것이다.

(2) line-19 : closure1 이 실행되면 복사본-foo 의 값이 사용되므로 5 가 출력된다.
(3) line-21 : 외부변수 foo 의 x 값을 6 으로 변경했다.
(4) line-23 : closure1 이 다시 실행되고, 외부변수 foo 값이 line-21 에서 변경되었지만, closure 에서는 복사본-foo 를 사용하므로 5 가 출력된다.

이번에는 class Foo 에서 capture list 를 사용해보자.

func runClosure(_ closure: () -> Void) {
print("Running closure")
closure()
}

class Foo {
var x: Int = 5
}

var foo = Foo()

let closure1 = { [weak foo] in
print("class foo.x = \(foo?.x)\n")
}

runClosure(closure1)

foo.x = 6

runClosure(closure1)

이제는 어느 정도 맥락이 파악 되었으니 위 코드의 결과가 예상 가능할 것으로 생각된다.

일단 class instance 를 capture list 에 선언할 때는 retain count 를 증가시키지 않기 위해서 weak 혹은 unowned 를 이용하여 capture list 를 선언해야 한다.

(1) line-15 : weak keyword 를 사용하여 foo 의 복사본을 만들되 retain count 는 증가되지 않도록 하였다. 여기서 생각해봐야 할 점이 있는데, 위에서 이야기했듯이 capture list 를 선언한다는 것은 외부 변수의 복사본을 만드는 것이다. 그런데 이 복사본이라는 것이 value type 일 경우에는 내용 자체이겠지만 reference type 일 경우에는 실제 내용물이 저장되어 있는 곳의 memory address 이다. 그래서 사실 복사라는 동작에 큰 의미는 없지만, 중요한 포인트는 capture list 선언시 weak, unowned 와 같은 keyword 를 사용하여 외부 변수에 저장된 class instance 를 사용하려고 가지고는 오되, retain count 는 증가시키지 않겠다는 선언이 가능하다는 것이다.

(2) line-16 : weak keyword 를 이용하여 foo 복사본을 만들었기 때문에 복사되는 시점에 retain count 를 증가시키지 않는다. 그래서 closure 가 동작될 시점에는 foo 라는 instance 가 이미 사라지고 없을 수 있다. 그래서 optional 형태로 접근해야 한다. 다만 출력결과를 깔끔하게 만들기 위해서 아래와 같이 코드를 개선할 수는 있다.

이제 실제 closure 가 실행되는 부분을 보자.

(3) line-21 : foo 의 초기 x 값인 ‘5’가 출력된다.
(4) line-23 : 외부변수 foo 의 x 값이 ‘6’으로 변경된다.
(5) line-25 : 외부변수 foo 는 closure 내부에서 바라보는 복사본 foo 와 동일한 reference 값을 가지고 있으므로 동일한 class instance 를 가리킨다. 그러므로 여기서는 변경된 x 값인 ‘6’ 이 출력된다.

오늘은 capture list 란 무엇이고, closure 에서의 capture list 가 어떤 식으로 동작하는지를 살펴보았다.

사실 이런 내부적인 세세한 동작에 대해서 명확하게 한 곳에 정리된 문서가 없어서 본인도 필요한 내용을 여기저기서 찾아보면서 공부했던 내용이기도 하고 그렇기 때문에 하나의 글로 정리하기가 쉽지 않았다.

일반적으로 reference type 의 capture list 사용에 대한 글들은 매우 많지만, value type 을 capture list 에서 사용하는 것에 대한 글들은 찾아보기 쉽지 않아 그 부분을 조금 더 자세히 이야기해보고 싶었다.

여기까지 읽어주셔서 감사하고,
모든 iOS 개발자에게 행복 있기를~

--

--

Jay Kim

iOS를 사랑하는 Software Engineer, 반복 설명하는 것이 점점 귀찮아져서 글을 씁니다 ^^;;;;