[Swift] Closure 완전 정복하기: 일급 객체부터 작성법, 그리고 @escaping까지

2021. 8. 12. 21:46Swift, iOS Foundation

1. 클로저(Closure)란?


솝트에서 서버 통신을 처음 배우다가 마주친 어려운 개념 2개가 있었다.

그중 하나가 Escaping Closure(탈출 클로저)였는데 (당연히, 클로저를 모르는데 탈출 클로저를 듣는다고 이해가 되겠ㄴ ㅏ....)
서버 통신을 배우기 위해서, 그리고 탈출 클로저를 이해하기 위해서, 스위프트에 자주 쓰이는 코드를 이해하기 위해서, "Closure(클로저)"에 대해 이번 글에서 자세하게 다뤄보겠다.

클로저는 정말 단순하게 말해, 코드를 중괄호("{}")로 묶어둔 "코드 블럭 (모음)"이다.
추가로 코드 블럭을 더 직관적으로 풀어 설명하면, "이름 없는 함수"라고 말할 수 있겠다.

함수는 이름 "있는" 클로저와 같고, 클로저는 이름 "없는" 함수와 같다.

코드를 바탕으로 더 자세하게 이해해보자.

먼저 위에 있는 sumFunction()이라는 함수를 살펴보자.
a와 b라는 Int형 값을 받아 두 값을 더하는 연산 이후, Int형 값으로 return하게 된다.

반면, 아래에 있는 클로저의 경우,
함수와 마찬가지로 a와 b라는 Int형 값을 받아 두 값을 더하는 연산 이후, Int형 값으로 return하는 작업은 동일하지만,
해당 블럭 자체에 대한 특정한 이름없이 sumClosure라는 이름의 변수에 할당되어 있는 것을 확인할 수 있다.

// 함수 = 이름 "있는" 클로저
func sumFunction(a: Int, b: Int) -> Int {
    return a + b
}

// 클로저 = 이름 "없는" 함수
var sumClosure: (Int, Int) -> Int = { (a: Int, b: Int) -> Int in
    return a + b
}

 

2. Swift 함수와 일급 객체 (first-class object)


 Swift에서 클로저가 중요한 이유는
무엇보다도 "코드 내에서 클로저의 활용도가 매우 다양하기 때문"이라고 말할 수 있다.
이는 함수형 프로그래밍 언어인 "Swift의 함수가 일급객체(first-class object)"인 것과 이어지는 내용이다.

일급객체 (First-class object)란 다른 객체들에 일반적으로 적용 가능한 연산을 모두 지원하는 객체를 말한다.

여기서 말하는 일반적으로 적용 가능한 연산이란 값 대입, 매개변수로 전달, 반환이 모두 가능해야한다는 것을 의미한다.

즉, 다시 말해 Swift에서는 함수를 상수 혹은 변수에 할당할 수도, 클로저가 함수의 매개변수가 될 수도, 함수의 반환값이 될 수도 있다는 것을 의미한다는 것이다! (클로저 = 이름없는 함수)

데이터 타입이라고 하면, Int, String, Array와 같이 데이터를 담기 위해서 사용했던 틀의 역할을 떠올리게 된다.
그리고 이 데이터 타입은 변수, 상수, 함수와 같은 곳에서 타입 명시 (Type Annotation)를 해줌으로써 "해당 데이터 타입이 사용됩니다"라고 컴파일러에 알려주는 역할을 하기도 했다.

Swift에서는 함수도 위와 같이 취급할 수 있다.
함수의 데이터 타입은 (함수 매개변수 데이터 타입) -> 함수 반환 데이터 타입의 형태로 표시한다.

예를들어, Int 값 하나를 받아 Int 값을 반환하는 함수의 경우에는 아래와 같이 표기한다.

(Int) -> Int  // 함수의 데이터 타입 예시

그럼 함수의 데이터 타입을 어떤식으로 표기할 수 있는지도 알게 되었으니,
함수의 일급객체 특성을 만족하는 예시에 대해서 하나씩 이해해보자.

2-1. 함수를 상수나 변수에 할당하는 것이 가능하다.

일급객체를 만족하는 첫 번째 특성.
일급객체(first-class object)는 값 대입, 즉 상수 혹은 변수와 같은 공간에 할당하는 것이 가능해야 한다.

nameFunction이라는 함수를 hello라는 상수에 대입해서 사용한 코드이다.

func nameFunction(name: String) -> String {
	return "My name in \(name)"
}

// 상수에 함수(nameFunction)를 할당하기
let hello: (String) -> String = nameFunction

// 할당 이후에는 원래 함수 이름 대신, 상수 이름으로 함수를 호출할 수도 있다.
let minjae = hello("Minjae")

 

2-2. 함수를 다른 함수의 인자로 전달할 수 있다.

일급객체를 만족하는 두 번째 특성.
일급객체(first-class object)는 함수의 매개변수로 전달하는 것이 가능해야 한다.

아래 예시는 introduceFunction이라는 함수의 파라미터로 함수를 받아 사용하는 코드이다.

// convertFunc이라는 이름의 파라미터로 함수를 받고 있다.
func introduceFunction(_ convertFunc: (String) -> String, value: String) {
	let result = convertFunc(value)
    print("Hello! \(result))
}

 

2-3. 함수를 함수의 반환값으로 사용될 수 있다.

일급객체를 만족하는 마지막 특성.
일급객체(first-class object)는 함수의 반환값으로 사용하는 것이 가능해야 한다.

아래 코드는 Bool값을 바탕으로 함수를 리턴하는 예제이다.

// (String) -> String이 함수의 반환값으로 쓰이고 있다.
func decideIntroduceFunction(_ isMe: Bool) -> (String) -> String {
...

 

3. 클로저(Closure)가 생소했던 이유는, 클로저를 작성하는 방법이 매우 다양하기 때문이었다.


많은 초보 iOS 개발자가 클로저를 생소해하고 어려워하는 이유는 "클로저를 작성하는 방법이 매우 다양하기" 때문이다.

Swift 공식문서를 보면, 다양한 클로저의 표현법을 하나하나 친절하게 작성해 둔 것을 볼 수 있다.
하나씩 친절하게 설명해 보도록 하겠다.

1. Trailing Closures (후행 클로저)
2. Inferring parameter and return value types from context (반환타입 생략)
3. Shorthand Argument Names (단축 인자이름)
4. Implicit Returns from Single-Expression Closures (암시적 반환 표현)

 

3-1. 후행 클로저 (Trailing Closure)

클로저 표현식이 함수의 마지막 전달(파라미터)인 경우에, 마지막 인수 라벨을 작성하지 않아도 된다.
또한, 이럴 경우에 대해서는 함수 소괄호 외부에 클로저를 구현할 수 있다.

// 기본
result = calculate(a: 10, b: 20, method: { (left: Int, right: Int) -> Int in
	return left + right
})

// 후행 클로저
result = calculate(a: 10, b: 20) { (left: Int, right: Int) -> Int in
	return left + right
}

 

3-2. 반환타입 생략

컴파일러 상에서 어떤 타입을 반환할지 알고 있다면, 클로저에서는 반환타입 (-> Int 부분)을 명시해주지 않아도 된다.
하지만, in 키워드는 생략할 수 없다.

// 기본
result = calculate(a: 10, b: 20) { (left: Int, right: Int) -> Int in
	return left + right
}

// 반환타입 생략
result = calculate(a: 10, b: 20) { (left: Int, right: Int) in
	return left + right
}

 

3-3. 단축인자 이름

가장 궁금했던 부분이다.
프로젝트를 하면서 iOS 선배님들의 코드를 뜯어보다 보면 $0, $1, $2처럼 뜬금없이 달러(?)가 쓰인 곳이 있었는데, 이 부분에 대한 해답이 바로, 클로저의 단축 인자이름이었다.

클로저에는 매개변수의 순서대로 이름대신 $0, $1, $2, $3을 사용해서 작성할 수 있다.

// 기본
result = calculate(a: 10, b: 20) { (left: Int, right: Int) -> in
	return left + right
}

// 단축인자 이름
result = calculate(a: 10, b: 20) { 
	return $0 + $1
}

 

3-4. 암시적 반환 표현

그리고 클로저에는 return을 쓰지 않아도, 암시적으로 "마지막줄이 반환값"이구나 하고 받아들인다고 한다.

// 기본
result = calculate(a: 10, b: 20) { 
	return $0 + $1
}

// 암시적 반환 표현
result = calculate(a: 10, b: 20) { $0 + $1 }

 

4. @escaping 키워드를 사용한 탈출 클로저 (Escaping Clousure) <중요!>


간혹 코드를 보다가 @escaping이라는 키워드를 본 적이 있지 않나?
이 부분은 탈출 클로저 (Escaping Closure), 이름에서도 바로 알 수 있는 것처럼 클로저를 함수 바깥으로 탈출시키는 의미였다.

지금부터는 원래 목적이었던
"탈출 클로저를 왜 사용해야 하는지""어떤 식으로 사용하는지"에 대해서 자세하게 알아보도록 하겠다.


위에서 클로저가 일급객체라고 설명하면서, 클로저가 함수의 파라미터가 될 수도 있다고 했었다.

그런데 이때 클로저의 중요한 속성이 한 가지 나오게 되는데,

그것은 바로 "함수의 파라미터(인자)로 전달된 클로저는 함수 외부에서 사용할 수 없다"라는 "탈출 불가 속성"이다.

상황 1)
아래 코드에서 빨간색으로 박스 쳐져있는 부분만 집중해서 살펴보자.
getLocationWeather라는 네트워크 통신 함수의 파라미터는 String 타입의 location, 그리고 클로저를 받는 completion이 있다.
함수 외부에 선언된 mainProvider()라는 변수에 의해 호출되는 request의 한 case에서, 파라미터로 받았던 completion 클로저를 호출하려고 하였지만 mainProvider()라는 변수는 getLocationWeather() 함수 외부에 존재하기 때문에 에러가 발생한 모습이다.
=> 함수의 파라미터로 받은 클로저는 외부 변수나 상수에서 사용할 수 없다.
상황 2)
비동기적으로 실행되는 부분에서 함수의 파라미터로 들어온 클로저를 호출하는 것도 불가능하다.
비동기 방식은 함수 안에서 실행될지, 해당 함수가 끝난 뒤에 호출될지 알 수 없다는 것이므로
함수의 파라미터로 전달된 클로저가 반드시 "함수 안에서만" 사용할 수 있다는 속성을 만족할 수 없게 되는 것이다.

이런 상황들을 해결하기 위해 등장한 것이 바로 탈출 클로저(Escaping Closure)다.

@escaping 키워드를 내가 탈출시키고자 하는 클로저 구문 앞에 추가시켜 주면, 함수 내부에서만 있어야 한다는 클로저의 탈출 불가 속성을 벗어날 수 있게 되는 것이다.
더 구체적으로 아래 코드를 기준으로 설명하면, 함수의 구문이 끝났을 때(= 네트워킹이 끝나고 데이터를 받아왔을 때) 클로저를 호출하면서 데이터를 안전하게(함수 외부에 있는 판별 함수로 접근하며) 받아올 수 있게 되는 것이다.

생각보다 엄청 어렵거나 대단한 내용은 아니었다.
앞으로 코드에서 탈출 클로저를 만나게 되더라도 당황하지말고 이 개념을 떠올리면 된다!