Swift: Closure와 Capturing

Heechan
HcleeDev
Published in
9 min readJun 5, 2021

--

Photo by Ludde Lorentz on Unsplash

Swift로 개발을 하다보면 필연적으로 closure를 사용하게 된다. 함수 자체가 클로저의 일종인 것도 있고, 디자인 패턴을 맞추어 구조를 쌓아올리다 보면 일련의 명령들을 클로저를 통해 넘겨줘야 하는 경우도 많다.

그런데 클로저 내에서 클로저 외부의 객체를 사용하게 될 때는 조금 애매해질 수 있다. 사실 함수형 프로그래밍 관점에선 그런 일 없게 argument만을 사용하게 잘 만들면 되겠지만 내 코딩 생활은 그리 호락호락하진 않다. 그래서 클로저 내부에서 외부 객체, 변수 등의 값을 참조해야 하는 경우가 있는데, 대부분 클로저가 실행 타이밍이 다르다보니 그런 값들을 어떻게 처리, 참조하는가 궁금해졌다.

이번 주는 클로저가 environment를 어떻게 capture하는지 알아보고, 그 내용을 정리해보고자 한다.

Capture?

Swift 공식 문서의 Closure장의 Capturing Value 첫 문장을 가져와봤다.

A closure can capture constants and variables from the surrounding context in which it’s defined.

클로저는 정의될 때 주변 context의 상수, 변수를 capture할 수 있다. 여기서 context가 어떤 의미인지 생각해봐야 하는데, 나도 확실치는 않지만 과거 PL 수업에서 배웠던 것을 떠올려보면 느낌은 온다.

Context는 문맥이라는 뜻을 가진 단어인데, 예를 들어 우리가 이런 코드를 짰다고 생각해보자.

var a = 1
var b = 2
a + b

이때 a + b 가 실행될 때 컴파일러는 a랑 b가 어떤 값을 가지고 있는지 찾아야 한다. 그럴 때 지금 이 명령이 실행되고 있는 ‘문맥’을 살펴 볼 필요가 있다.

Surrounding context라는 말이 주변 환경(?) 느낌으로 받아들일 수 있을 것 같은데, 이 명령어가 실행되는 시점에는 a는 1로, b는 2로 환경이 설정되어있다고 볼 수 있다.

여기서 이 ‘환경’은 Map의 형태로(언어에 따라 꼭 Map이 아닐 수도 있긴 한데, 아무튼 그렇다고 치자) a -> 1, b -> 2로 저장하고 있다. 그러면 명령을 실행하기 위해 이 ‘환경’에 접근해 a의 값인 1을 찾고, b의 값인 2를 찾아 두 값을 더할 수 있다. 사실 이 변수의 이름인 a와 b도 1과 2라는 진짜 값을 가리키기 위한 수단이기 때문에, 이런 식의 저장은 필요하다.

클로저는 정의될 때 그 ‘환경’에 있는 상수와 변수를 캡처해 사용한다. Surrounding context로부터 상수와 변수를 캡처한다는 것이 그런 뜻이다.

하지만 흔히 들어온 캡처와는 느낌이 살짝 다를 수 있다. 캡처한다는 말을 들으면 스냅샷이 제일 먼저 떠오른다. 그 순간을 그대로 사진처럼 담아 값도 정의되는 순간 그대로 가져올 것만 같은데, 실제로 그렇지 않다.

여기서 말하는 캡처는, 해당 값이 사라지지 않도록 잡아둔다는 뜻이 강하다. 사진을 찍다의 capture가 아니라, 뭔가를 잡을 때의 capture에 가깝다.

이 캡처 방식을 이해하려면 Swift의 ARC를 알고 있으면 좋다. Swift는 Reference Counting을 이용해 메모리 상의 변수, 상수, 객체 등을 관리한다.

class Test {
var a = 1
}
let obj = Test()

만약 이런 경우에는, obj에서 Test()를 저장하고 있으므로 Test()의 Reference count가 1이 된다. 만약 obj가 메모리에서 해제되게 된다면, Test() 또한 누가 가리키지도 않고 접근될 일도 없으니 Reference count가 0이 된다. RC가 0이면 자동으로 메모리에서 해당 객체도 해제시킨다. 이게 Swift가 메모리를 관리하는 방식이다.

클로저의 캡처는 문맥에 있는 변수, 상수 등의 Reference count를 하나 늘리는 것을 의미한다. 그냥 클로저가 생기면서 해당 변수와 상수를 가리키는 애가 하나 늘었다고 볼 수 있다.

Swift Document에 있는 예시를 하나 가져와서 보자.

func makeIncrementer(forIncrement amount: Int) -> () -> Int {
var runningTotal = 0
func incrementer() -> Int {
runningTotal += amount
return runningTotal
}
return incrementer
}

이 makeIncrementer라는 함수는 () -> Int 타입을 반환하는 함수로, 클로저를 반환한다고 볼 수 있다. 읽어보면 incrementer라는 함수를 반환하고 있는걸 확인할 수 있다.

여기서 incrementer 안을 보면, incrementer 외부에 있는 runningTotal이라는 변수를 참조하고 있다. 그러면 incrementer는 runningTotal과 amount를 캡처했다, 고 생각할 수 있을 것이다.

let incrementByTen = makeIncrementer(forIncrement: 10)
incrementByTen() //returns a value of 10
incrementByTen() //returns a value of 20

incrementByTen에는 makeIncrementer가 만들어준 runningTotal에 10씩 늘려주는 incrementer 클로저를 저장하게 된다. 그냥 생각해보면 makeIncrementer가 끝난 후엔 runningTotal와 amount는 사라져야 하는데, 사라지지 않고 클로저가 계속 이용하고 있다. 그 이유가 바로 incrementByTen에 저장된 클로저가 runningTotal을 참조하고 있기(RC를 하나 늘려주고 있기) 때문이다.

여러번 불러도 runningTotal 값이 0으로 초기화되지 않고 그냥 10씩 늘어나는 것도 incrementByTen이 동일한 변수를 참조하고 있기 때문이다. 만약 처음에 생각했던대로 잡아두는 방식이 아니라 환경을 사진처럼 찍어두는 것이었다면, runningTotal의 값은 실행할 때마다 0으로 시작했을 것이다.

캡처된 변수의 값은 기본적으로는 클로저가 실행될 때 계산한다. 정의될 때가 아닌 실행될 때 계산된다. 값을 그대로 찍어두는 것이 아니라 ‘참조’하고 있기 때문에 가능하다.

이 코드를 보면, DispatchQueue 안에 있는 클로저가 1초 뒤에 실행되도록 되어있다. 1초 딜레이가 되어있기 때문에 뒤에 있는 willEnd가 먼저 실행되고 makeTest()는 종료된다. 종료되어도 클로저가 runningTotal을 캡처하고 있기 때문에 runningTotal은 클로저가 끝나기 전까지는 사용할 수 있다.

확인할 수 있듯 클로저가 실행되기 전에 이미 runningTotal에 10을 늘려주었고, 따라서 클로저에서 값을 가져올 때도 10을 가져오는 모습이다.

다만 정의될 때의 값을 가지고 오고 싶을 수도 있다. 이럴 때 사용되는 것이 Capture List다. 캡처 리스트는 클로저에서 {[캡처하고 싶은 것] in …}으로 사용할 수 있다. 예시를 바로 보면 아래와 같다.

클로저가 runningTotal을 아예 캡처 리스트 안에 넣었다. 그러자 10을 더했음에도 클로저에서 0을 출력해주는 모습이다. 캡처 리스트에 들어간 값들은 실행될 때 계산되는 것이 아닌 클로저가 정의될 때 계산해주기 때문에 0이라는 값을 그대로 사용할 수 있다.

[weak self]

지금까지는 nested closure에 대해 다루었다. 즉 func 안에 또 다른 func, closure가 있는 경우를 소개했다. 이런 경우도 없는건 아니지만 뭔가 개발하면서 더 자주 만나는건 struct나 class 내에서 클로저가 struct와 class의 프로퍼티들을 사용할 때였다.

class Test {
var a: Int
init() {
a = 1
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.a = 3
}
print("Init: \(a)")
}
}

대충 이런 코드가 있을 때, 우리는 a를 넘겨주기보다는 self를 넘겨준다. 이유는 대충 이렇다.

  • 그냥 클로저에 { a = 3 }을 쓸 수 없다. 에러로 Reference to property ‘a’ in closure requires explicit use of ‘self’ to make capture semantics explicit라는 문구가 나온다. Nested 클로저의 경우와 다르게 explicit하게 self를 붙임으로써 캡처를 명시해달라고 하고 있다.
  • 그렇다고 캡처 리스트로 {[a] in … }을 붙여주면 Cannot assign to value: ‘a’ is an immutable capture라는 에러와 함께 캡처 리스트로 데려온 값은 상수임을 알려준다.

그래서 이를 구동하기 위해선 { self.a = 3 } 라는 형태가 나온다. 그러면 클로저는 self를 캡처해 사용할 수 있다.

여기서 흥미로운 점은 위에 있는 코드에는 왜 [weak self]를 붙여주냐는 것인데, 이는 강한 순환 참조 때문이다.

Reference count가 0이 되지 않으면 그 공간은 메모리가 해제되지 않는다. 클로저 안에서 self에 바로 접근할 시 클로저는 self가 해제되길 기다리고, self는 클로저가 해제되길 기다리는 경우가 생길 수 있다. 그러면 서로 RC가 0이 되지 않고 끝까지 1이라서… 아무도 접근하지 않는데 서로 가리키고 있어서 쓸데없이 메모리를 계속 차지하고 있는 문제가 생긴다.

이를 방지하기 위해 self 앞에 weak를 달아준 후 캡처 리스트에 넣어서, 강한 순환 참조로 메모리에서 계속 해제되지 않는 경우가 일어나지 않도록 한다.

참고로 weak self는 self가 class 일 때만 사용할 수 있다. struct일 때는 애초에 유동 메모리 영역인 heap에 있는 것이 아니라서 메모리 관리랑 살짝 거리가 있고, value-type이라 값을 그냥 받아오기만 하면 되기 때문이다.

결론

클로저라는 것이 일반적으로 코딩을 할 때 느껴지는 코드 흐름과는 다르게 흘러가는 코드 블럭을 만드는 것이다 보니 헷갈리는 경우가 많았다. 그래서 Capture하는 시점이나 방식도 잘 모르겠는 부분이 있었는데 이번에 알아보면서 좀 더 잘 알게된 것 같아 도움이 되었다.

참고한 것

Closures — The Swift Programming Language (Swift 5.4)

Swift’s closure capturing mechanics | Swift by Sundell

클로저 캡쳐에 대해서 (about closure capture) (velog.io)

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science