^(코딩캣)^ = @"코딩"하는 고양이;

[번역글] Swift 클로저 사용을 위한 궁극의 가이드(2)

Language/Objective-C & Swift
2021. 9. 20. 18:15

본 게시글은 The Ultimate Guide to Closures in Swift를 바탕으로 작성하였습니다.

Swift 클로저 사용을 위한 궁극의 가이드

이 튜토리얼은 Swift의 클로저(closure)를 여러분에게 상세히 안내해 줄 것입니다. 클로저란 여러분이 마치 변수에 값을 대입하거나 매개변수로서 전달하듯 소스 코드 안에서 주고 받을 수 있는 코드 블럭입니다. 클로저를 완전히 이해하는 것은 iOS 개발을 배우는 과정에서 중대한 부분입니다.

여러분이 옵셔널(optional)에 대해 이해하는 데 어려움이 있었다면 클로저를 이해하는 것은 더욱 큰 일이 될 지도 모릅니다. 하지만 걱정마시기 바랍니다. 클로저는 보기보다 해롭지 않습니다. 알고보면 매우 유용합니다.

이 튜토리얼에서는 다음과 같은 것을 설명할 것입니다.

  • 클로저란 무엇이고 어떻게 사용할 것인가
  • 클로저를 선언하고 호출하는 구문
  • 클로저의 타입을 구성하고 해체하는 방법
  • “클로징 오버(closing over)”란 무엇이고 캡처 리스트(capture list)는 어떻게 사용할 것인가?
  • completion handler로서 클로저를 어떻게 사용할 것인가

준비되었다면 지금부터 시작하겠습니다.

 

클로저의 타입에 대하여

변수, 상수 및 프로퍼티 등과 마찬가지로 모든 클로저는 타입을 가지고 있습니다. 개념적으로 보았을 때 Int 타입으로 선언된 변수와 (Int) -> () 타입으로 선언된 변수 사이에는 차이가 없습니다. 전자는 정수 형식으로 후자는 클로저 형식일 뿐입니다.

다음과 같이 하나의 매개변수를 갖는 클로저를 선언해 봅니다.

// Swift
let birthday: (String) -> () = { (name: String) -> () in 
    // ...
}

다소 복잡해보이지만, 이것을 상세히 살펴보겠습니다.

(1) 중괄호 {} 사이에 적힌 내용은 ‘클로저 표현식(closure expression)’입니다. 이러한 클로저는 let 키워드로 선언된 상수 birthday에 보관됩니다.

(2) 첫 번째로 등장하는 (String) -> ()는 ‘클로저 형식(closure type)’입니다. 이 클로저는 하나의 매개변수를 받는데 그 형식은 String이고, 아무것도 반환하지 않는다는 의미에서 반환 형식은 ()로 되어 있습니다.

(3) 두 번째로 등장하는 (name:String) -> ()은 첫 번째와 형식은 똑같으나 매개변수의 이름에 name이 붙었다는 차이가 있습니다. 물론 이 구문은 중괄호 안에 적혀있기 때문에 클로저 표현식의 일부입니다.

(4) in 키워드는 클로저 매개변수와 클로저 본문을 구분하는 역할을 합니다.

 

클로저가 어떻게 작동하는지를 이해하기 위해 이 클로저 타입을 분해해 보겠습니다. 클로저 구문은 다음과 같습니다.

// Swift
let closureName:(/* parameter types */) -> /* return type */ = {
    (/* parameter name */:/* parameter type */) -> /* return type */ in
    ...
}

먼저 우리는 3가지의 중요한 부분들을 얻을 수 있습니다.

(1) let closureName으로 선언된 클로저의 이름, 다시 표현하면 클로저가 보관될 상수의 이름

(2) 클로저 타입 구문: ( /* parameter type */ ) -> /* return type */

(3) 클로저 표현식: { ( /* parameter name */:/* parameter type */ ) -> /* return type */ in ... }

클로저를 보관하는 상수는 간단합니다. 이제부터 이 상수(또는 변수)의 이름을 사용하여 클로저를 다른 곳으로 전달할 수도 있습니다.

클로저 타입을 지정하는 구문은 매개변수의 타입과 반환 형식으로 구성됩니다. 함수와 마찬가지로 클로저도 매개변수와 반환 형식을 가질 수 있다고 했는데, 이 타입을 선언하는 구문은 괄호로 싸인 타입들과 화살표 기호와 또 다른 타입으로 구성됩니다. 몇 가지 예를 들어 이를 이해해 보겠습니다.

(1) (Int, Int) -> Double 두 개의 정수를 매개변수로 받아서 Double형 값을 반환하는 클로저 타입입니다.

(2) () -> Int 아무 매개변수도 받지 않지만 정수를 반환하는 클로저 타입입니다.

(3) (String) -> String 하나의 문자열을 매개변수로 받아서 문자열을 반환하는 클로저 타입입니다.

 

계속해서 클로저 표현식으로 넘어가겠습니다. 여기 비슷한 구문이 또 나타납니다.

// Swift
{ ( /* parameter name */:/* parameter type */ ) -> /* return type */ in
    ...
}

자세히 실펴보면 중괄호 밖에서 선언했던 클로저 타입이 표현식 안에서 반복됨을 알 수 있습니다. 클로저 타입이 여기에서는 조금 다르게선언이 되긴 했는데 이 곳에서는 매개변수에 대한 이름이 지정되고 있습니다. 앞서 살펴 보았던 예제를 이 곳으로 끌어와 보겠습니다.

(1) (Int, Int) -> Double 타입의 클로저는 표현식 안에서, { (width: Int, height: Int) -> Double in ... }으로 시작합니다.

(2) () -> Int 타입의 클로저는 표현식 안에서, { () -> Int in ... }로 시작합니다.

(3) (String) -> String 타입의 클로저는 표현식 안에서, { (text: String) -> String in ... }으로 시작합니다.

 

여러분은 이들 매개변수에 대해 필요한 대로 이름을 지정할 수 있습니다. 이렇게 지정한 매개변수와 그 이름은 함수(메소드)의 매개변수와 마찬가지로 클로저 내부에서 상수성을 가지고 쓰일 것입니다.

in은 무엇일까요? 통상의 영어라면 “in”를 쉽게 받아들일 수 있을 지 모르나, 우리는 지금 클로저를 다루고 있습니다. 이것을 문장으로 표현하자면 이렇습니다.

// Swift
{ (X: Int, Y: Int, Z: Int) -> () in
    ...
}
우리는 X, YZ 매개변수를 클로저인 이 코드 블록 안에서(in) 받았다.
We've got parameters X, Y, Z in this block of code; which is the closure

 

매개변수도 없고 반환 형식도 없는 클로저는 다음과 같은 타입을 갖습니다.

() -> ()

위 표현식은 두 개의 비어있는 튜플(tuple)과 하나의 화살표를 가지고 있는데 첫 번째 튜플은 클로저의 입력이고 두 번째 튜플은 클로저의 출력입니다. 참고로 튜플은 (a, b, c)와 같이 원소들의 순서 있는 목록이며 각 원소들은 컴마로 구분되고 전체적으로 괄호로 싸입니다. 예를 들어 다음과 같습니다.

// Swift
let flight = (airport: "LAX", airplane: 747)

또한 여러분은 Void를 클로저의 반환 형식으로 지정할 수도 있습니다.

() -> Void

Swift에서 Void는 아무것도 없다는 의미를 갖습니다. 여러분의 함수가 Void 반환 형식으로 지정된다면, 그 함수는 아무것도 반환하지 않습니다. nil이라든가 빈 문자열조차도 반환하지 않습니다. 애플 개발자 문서에 따르면 Void는 빈 튜플 ()의 별칭이라 합니다.

다음의 예를 보겠습니다. 클로저는 두 개의 매개변수를 받고 반환 형식도 있습니다.

// Swift
let greeting:(String, String) -> String = { (time: String, name: String) -> String in
    return "Good \(time), \(name)!"
}

let text = greeting("morning", "Arthur")
print(text)

클로저 greeting은 두 개의 매개변수를 갖는데 둘 다 String입니다. 또한 그 클로저의 반환 형식도 String입니다. greeting 클로저의 타입은 명시적으로 선언되었고 클로저 표현식 안에서 매개변수들도 명시적으로 타입이 선언되어 있습니다.

클로저가 호출될 때 문자열 타입 매개변수 두 개가 전달되고, 그 반환 값이 text에 보관된 다음 출력되는 구조입니다.

또한 여러분은 다음과 같이 정확히 똑같이 작동하는 클로저 표현식으로 작성할 수도 있습니다.

// Swift
let greeting: (String, String) -> String = { "Good \($0), \($1)1" }
let text = greeting("morning", "Arthur")
print(text)

지금까지 살펴본 내용을 요약해 보면 다음과 같습니다.

(1) 모든 클로저는 타입을 갖는데 이 타입은 여러분이 클로저 표현식을 정의함으로써 얻어집니다.

(2) 가장 기본적인 클로저 타입은 () -> Void로서, 아무 입력도 아무 출력도 없는 클로저를 나타냅니다.

(3) 여러분은 변수나 상수를 선언할 때 다음과 같이 명시적으로 클로저 타입을 선언할 수도 있습니다.

// Swift
let greeting: (String) -> Void ...

(4) 클로저 표현식에서는 클로저 타입이 한 번 더 적힌 다음, 그 매개변수들에 대한 이름이 다음과 같이 부여될 수 있습니다.

// Swift
... { (name: String) -> Void in ... }

 

참고 클로저에 옵셔널을 사용할 수 있을까요? 물론입니다! 클로저에 대한 매개변수 또는 클로저에 대한 반환 형식 모두 옵셔널을 사용할 수 있습니다(예: (String?, Int) -> Int?). 또한 여러분은 클로저 자체를 옵셔널로 만들 수 있는데, 이는 클로저를 참조하게 될 변수, 상수 또는 프로퍼티가 nil이 될 수도 있다는 것입니다. 이를 표현하기 위해서는 클로저 타입을 전체적으로 괄호로 한 번 감싼 다음 물음표를 넣으면 됩니다(예: ((String) -> ())?).

 

클로저와 타입 추론

Swift는 “타입 추론(type inference)”이라는 매우 강력한 기능을 가지고 있습니다. 타입 추론을 사용하면 여러분은 변수의 타입을 명시적으로 지정하지 않아도 됩니다. Swift는 변수가 가진 값의 타입을 스스로 찾아낼 수 있기 때문입니다. 예를 들어 보겠습니다.

// Swift
let age = 104

여기서 age의 타입은 무엇일까요? Swift는 위 코드의 맥락에 따라 age의 타입을 추론할 것입니다. 여기서 104는 정수의 리터럴 값이기 때문에 age의 타입은 Int가 됩니다. 이처럼 Swift는 여러분이 직접 타입을 명시하지 않아도 타입을 스스로 찾아낼 수 있습니다.

참고 Swift는 강한 타입(strong-typed)의 프로그래밍 언어입니다. 즉 추론된 타입이라 할 지라도 모든 값들은 타입을 가지고 있다는 뜻입니다. Swift가 타입 추론을 할 수 있다는 사실과 “이 값에는 타입이 없다”라는 낭설은 구분할 필요가 있습니다. 여러분이 직접 명시하지 않아도 값은 항상 타입을 가집니다. 타입 추론은 그래서 혼동을 야기할 수도 있습니다. 때문에 여러분은 여러분의 변수 하나하나에 대한 타입을 항상 알고 있어야 합니다.

 

타입 추론과 클로저가 이제 손에 잡히는 듯 합니다. 그 결과 여러분은 클로저 표현식의 않은 부분을 생략해가며 활용할 수 있을 것입니다. Swift는 생략된 부분을 스스로 추론할 수 있기 때문입니다. 다음 예를 보겠습니다.

// Swift
let names = ["Zaphod". "Slartibartfast", "Ford"]
let sortedNamed = names.sorted(by: <)
print(sortedNames)

위 코드에서 여러분은 문자열 배열을 하나 생성한 뒤 이를 sorted(by:) 메소드(함수)를 사용하여 알파벳 순으로 정렬하게 될 것입니다. 그 결과는 sortedNames에 보관될 것이고 정렬된 이름들이 출력될 것입니다.

여기서 핵심이 되는 부분은 names.sorted(by: <)입니다. 매개변수 by는 클로저를 받는데 이 클로저는 정렬 기준이 되어줄 것입니다. 그런데 이 클로저에 연산자 <만이 덩그러니 적혀 있습니다.

어째서 이런 표현이 가능한지 순서대로 생략해 나갑니다. 아무것도 생략되지 않은 풀 버전의 클로저는 다음과 같을 것입니다.

// Swift
names.sorted(by: { (s1: String, s2: String) -> Bool in
    return s1 < s2
})

그런데 이것은 다음과 같이 생략될 수 있습니다.

// Swift
names.sorted(by: { s1, s2 in
    return s1 < s2
})

이것을 다시 한 줄로 적으면...

// Swift
names.sorted(by: { s1, s2 in return s1 < s2 })

그리고 반환 구문까지 생략하면...

// Swift
names.sorted(by: { s1, s2 in s1 < s2 })

앞서 매개변수에 굳이 이름을 지정하지 않고 shorthand를 써도 된다고 했습니다. 한 번 더 축약하면

// Swift
names.sorted(by: { $0 < $1 })

또한 함수(메소드)의 매개변수의 맨 끝에 클로저가 오는 경우 매개변수의 이름이 생략될 수 있습니다. 이를 trailing closure syntax라고 하는데, 다음과 같이 호출 구문까지 간소화가 가능합니다.

// Swift
names.sorted { $0 < $1 }

이로써 < 연산자가 함수로서 축약될 수 있습니다.

// Swift
names.sorted(by: <)

이 각기 다른 구문들의 기능은 놀랍게도 서로 똑같습니다! 이는 모두 타입 추론과 클로저 표현식 덕입니다. 각 표현식들의 요점을 풀 버전의 클로저부터 하나씩 살펴보겠습니다.

(1) 처음 나온 구문은 가장 완전한 형태의 클로저 구문인데 두 String 매개변수에 s1, s2 이름도 붙이고 반환 형식도 Bool이라 명시합니다. 또한 in 키워드와 return 키워드도 빠짐없이 적혀있습니다.

(2) 두 번째 나온 구문은 매개변수 타입이 생략된 형태입니다. 왜냐하면 매개변수 타입은 맥락을 통해 추론이 가능하기 때문입니다. 우리는 문자열들의 배열에 대해 정렬을 하고자 하므로 여기에 사용될 클로저의 매개변수는 이미 String입니다. 또한 타입이 생략될 때 괄호 또한 생략이 가능하게 됩니다. 따라서 s1, s2 in과 같은 구문이 허용되는 이유입니다.

(3) 세 번째 나온 구문은 클로저가 여기서 한 줄의 코드만으로 구성되기 때문에 return 키워드가 생략되었습니다. 이러한 경우 Swift는 return을 적지 않아도 여러분이 어떤 값을 반환하고 싶다는 의도를 알아차립니다.

(4) 네 번째 나온 구문은 클로저의 첫 번째 및 두 번째 매개변수를 참조하기 위해 각각 $0$1 숏 핸드가 사용되었습니다. 매개변수가 클로저 타입 선언에 이미 있었기 때문에 여러분은 이름을 굳이 적어가며 쓸 필요가 없습니다. 또한 이들 매개변수는 String 형식임이 추론됩니다.

(5) 다섯 번째 나온 구문은 trailing closure syntax입니다. 클로저가 어떤 함수(메소드)의 마지막 매개변수이거나 또는 유일한 매개변수라면 여러분은 함수를 호출하기 위한 소괄호()를 적지 않고 곧바로 클로저를 적을 수 있습니다.

(6) 마지막에 나온 구문은 < 연산자를 클로저로서 사용한 것입니다. 연산자는 최우선 순위 함수(top-level function)이기도 합니다. 특히 비교 연산자는 그 형식이 (lhs:(), rhs:()) -> Bool인 클로저로서 볼 수도 있는데 이렇게 하면 sorted(by:) 함수(메소드)에서 요구하는 클로저의 형식과 완벽히 같습니다.

 

클로저의 힘, 클로저의 표현력, 그리고 Swift 프로그래밍 언어가 여러분에게 어떻게 우아한 코드를 작성할 수 있게 하고 있는지를 지금부터 여러분이 보기 시작하였다면 다행입니다. 여러분이 할 수 있는 한 자주 Swift 코딩을 해 보고 클로저를 가지고 작업해 보고 한다면 많은 도움이 될 것입니다. 연습이 완벽을 만들어 냅니다(Practive makes perfect)! 상황이 닥쳐서야 배우려고 미뤄두지 마시고, 여러분의 시간을 할애하여 새로운 프로그래밍 개념과 최고의 예제들을 발견하고 여러분의 것으로 만들어보시기 바랍니다.

개인적으로 필자는 sorted { $0 < $1 }과 같은 표현을 가장 좋아합니다. 하지만 명심하셔야 할 것은, 축약된 프로그래밍 접근법을 선택하는 것만이 가장 현명한 것만은 아니라는 것입니다. 때로는 재주를 부리는 것보다 서술적으로 작성하는 것이 더 도움이 될 때도 있습니다.

한편 클로저는 소스 코드 읽는 것을 환장하게 만들 때도 있습니다. 다음과 같은 예를 보시기 바랍니다.

// Swift
let squared = { $0 * $0 }(12)
print(squared)

클로저 { $0 * $0 }는 정의된 그 줄에서 즉시 (12)로써 실행이 됩니다. 그 결과 squared에는 클로저가 아니라 그 실행 값인 12 * 12 = 144가 보관됩니다. 정의되지마자 클로저를 실행하는 것은 lazy computered property에 기초를 두고 있습니다.

여기까지의 내용을 요약해보겠습니다.

(1) Swift는 타입 추론을 사용하는데, 변수나 상수의 타입이 명시적으로 제공되지 않았을 때 스스로 찾아낼 수 있습니다.

(2) 클로저와 타입 추론은 함께 어울리기 때문에 여러분은 소스 코드의 가독성을 높이기 위해 클로저 표현식에서 일부를 생략할 수 있습니다. 하지만 생략이 과도하면 안 하는 것만 못할 수 있습니다.

(3) 클로저 표현식은 때때로 여러분을 환장하게 만들 수도 있기 때문에 자주 연습해 보고 다루어 보는 것이 도움이 됩니다.

 

다음 장에서는 이어서 클로저의 변수 캡처에 대해 살펴보겠습니다.

카테고리 “Language/Objective-C & Swift”
more...