Starbucks Caramel Frappuccino
본문 바로가기
  • 그래 그렇게 조금씩
iOS Developer/Swift

closure, @escaping, completionHandler 콜백함수

by Toughie 2023. 5. 29.

클로저가 무엇인지?
@escaping이 무엇인지?

completionHandler/콜백 함수가 무엇인지? 

정리하는 시간.

처음 배웠을 때 너무 복잡하고 어렵게 느껴져서
스스로도 정리해 보고 누군가에게 조금이라도 이해에 도움이 되기를 바라며..


클로저(closure)

close는 '무언가를 닫다' 라는 의미이다. 클로저도 그럼 뭘 닫는건가?

클로저는 쉽게 말하면 그냥 '함수'라고 볼 수 있다. input -> output, x -> y 마법상자로 비유를 하는 그 함수.
{ ... } 형태로 되어 있고 중괄호를 사용한다. (이를 코드 블록이라고 한다.)

closure라는 이름에는 { ... }  코드블록이 외부에 있는 변수/상수를 닫아서 가져올 수 있다는 개념이 반영되어 있다.
-> 클로저는 외부의 변수/상수를 캡처하고 저장해서 이들을 유지하고 활용할 수 있는 닫힌 환경을 만든다는 뜻!

클로저는 1급 객체이기 때문에 변수/상수에 할당 될 수도 있고, ⭐️함수의 파라미터로 전달 될 수도 있다. 물론 return도 가능.
그런데 우리가 함수를 사용할 때는 항상 어떤 이름을 만들어 줬었다.
func doSomething() { } 과 같이.. doSomething이 함수의 이름인 것이다.

클로저의 또 다른 이름은 '익명 함수'이다. 즉 이름이 없는 함수라는 뜻이다.

정확하게 말하면 클로저는 크게 두 가지 종류가 있다.
Unnamed Closure, Named Closure.
클로저(Unnamed Closure)라는 큰 범위 안에 Named Closure가 있다고 볼 수도 있음!
여기서 Named Closure가 우리가 흔히 쓰는 이름이 있는 함수임.

그럼 클로저는 왜 이름이 없을까? 🤔

클로저(파이썬에서 람다와 같다.)와 함수는 기능은 완전 동일하고 형태만 다르다.

함수

이름이 있는 코드 묶음(블럭)

다른 코드에서 '함수의 이름으로 호출'하기 위해서 이름이 필요하다.

 

클로저

이름이 없는 코드 묶음(블럭)

다른 코드에서 직접적으로 호출되는 것이 아니라 다른 함수나 메서드에 전달되서 호출됨.

-> 그래서 굳이 이름이 필요가 없음. 이름 부를 일이 없기 때문이다.(다른 함수가 이름을 가지고 있을테니)

 

클로저의 표현식

{ (Parameters) -> Return Type in 
// do whatever you want
}

함수타입과 사실 똑같다. 파라미터와 리턴형을 적어주고 in 뒤에 실행 구문을 구현해준다.
이걸 정확히 표현하면

(Parameters) -> Return Type 이 부분을 Closure Head라고 한다. (머리)

그리고 //do whatever you want로 주석처리된 부분은 실행 구문이다.
실행 구문을 여기서는 Closure Body라고 한다. (몸)

중간에 in은 Closure Head와 Closure Body를 구분지어주는 역할을 한다. (목..쯤으로 보면 되려나 ㅋㅋ)

 

예시

let ageClosure = { (age: Int) -> Int in 
	return age
}

클로저는 1급 객체이니까 위와 같이 상수에 할당 되는 것은 자연스럽다.

실행

ageClosure(20) // OK
ageClosure(age: 20) // ERROR

오잉 분명 파라미터 이름이 age라서 평소에 함수 쓰는 것처럼 (age: 20)으로 해줬는데 에러가 난다.. 왜?
(age 앞에 따로 Argument Label을 달아주지 않았으니 age가 기본적으로 Argument Label로 사용됨)

클로저에서는 Argument Label을 사용하지 않는다!!
그래서 보통 (_ age: Int)와 같은 식으로 많이 작성함.
따라서 그냥 파라미터 타입에 해당하는 값만 바로 대입해서 호출하면 된다.

그래서 클로저는 보통 언제 많이 쓰나요?  탈출?

클로저는 순차적인 실행을 보장하기 위해서 사용한다. 라고 볼 수 있다.

좀 더 실용적인 측면에서는 네트워킹과 같은 비동기 코드에서 자주 사용된다.
예시를 하나씩 보면서 살펴보자.

func downloadData() -> String {
	return "NEW DATA"
}

일반적인 함수이다. 이 함수를 실행하면 스택 프레임을 형성하고 거의 즉시 String 타입인 "NEW DATA"를 반환한다.
근데 만약 위와 같이 리턴값이 바로 나오는 것이 아니라 시간이 좀 걸린다면?

    func downloadDatas() -> String {
        DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
            return "NEW DATA"
        }
    }

이렇게 코드를 작성하면

이러고 에러를 쿠에에엑 뱉는다.

왜일까? 

첫번째 오류
1. Cannot convert return expression of type '()' to return type 'String'

downloadDatas는 String을 반환해야하는데 DispatchQueue~에서는 Void를 반환하기 때문이다.

func downloadData() -> String {
	return "NEW DATA"
}

여기서는 바로 String 타입이 return 돼서 괜찮았는데

이번에는 비동기 코드를 활용하면서 String 타입을 return 하지 않는다..
즉 downloadDatas함수가 내부의 비동기 코드 실행이 완료되기 까지 기다리지 않고 즉시 리턴하려하기 때문에
String 타입 뱉어야 하는데 왜 아무것도 안뱉어? 라고 하는 것

두번째 오류

2. Cannot convert value of type 'String' to closure result type 'Void'

DispatchQueue.main.asyncAfter(deadline: .now() + 30)

이 클로저는 반환 값이 없도록(Void) 선언되어 있는데 "NEW DATA" 즉 String 타입을 반환하려 하기 때문에 발생한 오류
물론 이 클로저의 리턴형을 Void가 아닌 String으로 선언할 수도 잇겠지만, 

DispatchQueue는 보통 그런 식으로 사용하지 않는다.

 

요약하자면 

함수 내부에서 비동기 작업(오래 걸리는 작업, 네트워킹 등)을 실행하는 경우
그 작업이 끝날 때 까지 함수가 기다리지 않고 바로 return해버리는 문제가 있다.
그럼 비동기 작업을 통해 값을 얻어서 활용하려면 어떻게 코드를 수정해야 할까?

 

    func downloadDatas(completionHandler: (_ data: String) -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
            completionHandler("NEW DATA")
        }
    }

흠 그럼 함수에서 직접적으로 return이 어려우니 ( (parameter)-> String과 같이)
파라미터에 클로저를 넣고 비동기 작업이 끝난 후 그 데이터를 클로저로 넘겨주면 되겠다!

거의 다왔다. 근데 위와 같이 작성하면 아래의 에러가 발생한다.

탈출하는 클로저가 탈출하지 않는 클로저 파라미터를 캡쳐한다? 무슨 말일까

이 부분이 사실 이해하기도 어려웠고 헷갈렸다.
일단 DispatchQueue 부분만 보자.

DispatchQueue.main.asyncAfter() 메서드는 클로저를 힙 영역에 저장하고, 지정된 시간 이후에
메인 스레드의 이벤트 루프에서 실행되도록 예약하는 역할을 한다.

그런데
Swift에서 함수 내부에 정의된 클로저는 기본적으로 non-escaping이다. 
-> 해당 함수의 실행이 종료된 후에는 클로저가 더 이상 사용되지 않는다는 말이다.

-> 클로저가 함수의 수명 주기 내에서만 호출되고 사용될 수 있다는 말!
즉 함수가 리턴/종료되면 클로저가 자동으로 같이 소멸해버림.

그렇기 때문에 non-escaping클로저는 함수 외부에 저장되거나 다른 함수에 전달되는 등 수명을 확장시킬 수 없음.

 

쉽게 말하면 위 예시에서 DispatchQueue가 정상적으로 완료되려면 30초 이상 걸리는데
지금 downloadDatas 내부에서 정의를 했기 때문에 non-escaping 클로저이고
downloadDatas는 30초 이상 기다리지 않고 바로 종료되기 때문에 문제가 발생하는 것이다.

흠 그러면 어떻게 해야 할까?
함수가 종료되어도 클로저가 같이 사라지지 않게 해야한다.

-> 밖에 따로 빼놓자! 이것을 우리는 클로저의 탈출이라고 한다.

⭐️ 탈출한다는 뜻은(escaping) 클로저가 함수 실행이 끝나더라도

독립적으로 메모리의 힙(heap)영역에 유지되어서 계속 사용될 수 있다는 것을 의미한다.

    func downloadDatas(completionHandler: @escaping (_ data: String) -> Void) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
            completionHandler("NEW DATA")
        }
    }

이렇게 클로저의 파라미터 앞에 @escaping 어노테이션을 붙이면 된다!
이제 이 클로저는 함수가 끝나도 힙 영역에 안전하게 살아서 작업을 완료할 수 있게 되었다. (물귀신 안 당함)

 

탈출한 클로저는 독립적으로 힙 영역에 유지되기 때문에 클로저 내에서 캡처한 값들과 함께 상태를 유지하고 계속 사용가능하다.
(그렇기 때문에 강한 순환 참조를 방지하기 위해서 약한 순환 참조, 캡쳐리스트를 사용하는 것을 고려해야겠죠?)

 

좀 더 실용적이게, 예쁘게 형태를 바꿔보자.

(completionHandler: @escaping (_ data: String) -> Void)

이 코드는 보기에도 다소 복잡해 보이고 좀 제한적인 문제가 있다.

그리고 보통 이런 completion 방식을 사용할 때는 json 파싱을 하거나 외부에서 네트워킹을 통해 데이터를 받아오는 경우가 많다.

그런 경우에는 아래와 같이 할 수 있다.

struct DownloadResult {
    let name: String
    let age: Int
}

먼저 내가 받고자 하는 데이터의 형태대로 타입을 하나 만들어 준다.

    func downloadData4(completionHandler: @escaping (DownloadResult) -> ()) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
            let result = DownloadResult(name: "Toughie", age: 20)
            completionHandler(result)
        }
    }

그걸 클로저의 파라미터로 넣어준다!
이 코드를 보면 비동기 작업을 통해서(물론 예시라서 간단하지만 실제로는 fetching하는 코드가 많을 것이다.)
데이터를 정상적으로 받아왔다면,
이 데이터를 가지고 내가 미리 정의해둔 데이터 타입(DownloadResult)으로 찍어내는 것이다.
그리고 이렇게 찍어낸 것을 completionHandler의 파라미터로 넘겨준다!

 

타입 앨리어스를 활용하면 더 깔끔하게도 가능하다.

typealias DownloadCompletion = (DownloadResult) -> Void

func downloadData5(completionHandler: @escaping DownloadCompletion) {
    DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
        let result = DownloadResult(name: "Toughie", age: 20)
        completionHandler(result)
    }
}

 

그런데 왜 예시들에서 반환 값이 없을까? Void, ()

(DownloadResult) -> String, (DownloadResult) -> Int 이렇게 안하고..?

이렇게 할 수도 있지만 보통 이렇게 안한다. 

* Void == () 비어있는 튜플을 나타내고, 반환 값이 없음을 나타내는 타입이다.

 

위 예시의 downloadData5를 다른 함수 안에서 호출한다고 생각해보자.

여기서 엔터를 치면!

이렇게 확 줄어든다.
근데 어떻게 이렇게 줄어든 것일까?
풀어서 쓰면 아래와 같다.

 

1. 후행 클로저 축약 문법을 통해서 소괄호와 파라미터 이름(completionHandler)을 다 날린다.

downloadData5(completionHandler: { (result: DownloadResult) in
    // 결과 처리 코드
})
downloadData5 { (result: DownloadResult) in
    // 결과 처리 코드
}

2. Swfit에는 타입 추론 기능이 있다. 여기서 result의 타입이 DownloadResult라는 것을 굳이 적어주지 않아도 컴파일러는 알고 있다.
따라서 클로저 파라미터의 타입을 명시적으로 선언하지 않고 생략할 수 있다.

downloadData5 { result in
    // 결과 처리 코드
}

자 이렇게 많이 줄였다!

이 부분을 이해하기 위해서 다시 클로저의 표현식을 살펴보자.

{ (Parameters) -> Return Type in 
// do whatever you want
}

파라미터가 result이고 return type이 Void인 것이다.

이제 in 뒤에서, 즉 Closure Body부분에서 result를 가지고 원하는 처리를 다 할 수 있는 것이다.
만약 return type이 Void가 아니라 String, Int라면
그냥 String, Int 등을 Closure Body 부분에서 리턴해주면 되지만, 꼭 필요한 경우가 아니라면 굳이..? 싶다.
자유로운 로직 구현에 제약처럼 느껴진다.

+ weak self 활용 예시

//  Created by Toughie on 2023/05/29.
//

import SwiftUI

class EscapingViewModel: ObservableObject {
    
    @Published var text: String = "Hi"
    
    func getData() {
        downloadData5 { [weak self] returnedResult in
            self?.text = returnedResult.data
        }
    }
    
}

클로저가 탈출해서 힙 영역에 살아있기 때문에 강한 순환 참조 문제를 고려할 필요가 있다.
RC를 올리지 않기 위해 [weak self]를 사용해서 메모리 누수를 방지한다.


콜백 함수?

간단하게는 함수의 파라미터로 전달되는 클로저를 콜백함수라고 한다.

보통 비동기적인 작업이 완료되거나 특정 이벤트가 발생했을 때 호출되는 함수를 가리킨다.
콜백 함수는 '호출되는 함수'이다.

다른 함수 내에서 특정 조건이 충족되거나 완료되었을 때 호출되는 역할을 한다.

func downloadData5(completionHandler: @escaping DownloadCompletion) {
    DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) {
        let result = DownloadResult(name: "Toughie", age: 20)
        completionHandler(result)
    }
}

1. downloadData5를 호출한다.

2. DispatchQueue.main.asyncAfter(deadline: .now() + 2.0)가 실행되어 비동기적으로 클로저가 예약된다.
(클로저가 탈출 ==  힙 영역에 저장됨)
3. downloadData5 함수는 종료된다.
4. 10초 뒤에 메인 스레드의 이벤트 루프에서 예약된 클로저가 실행된다.
5. 클로저 내부에서 DownloadResult 인스턴스를 생성하고 해당 인스턴스를 completionHandler로 전달해서 호출한다.

-> 즉 completionHandler는 비동기 작업이 끝나고 나서 호출되기 때문에 콜백 함수라고 할 수 있다.

그냥 특정 작업이 끝나고 나서 호출되는 함수를 콜백함수라고 한다 ㅋㅋ

 

클로저는

오래걸리는 작업을 완료하고 나서(주로 비동기, 네트워킹) -> 그 작업을 바탕으로 어떤 작업을 이어서 해야하는 경우
작업이 완료된 후! 다른 작업을 이어서 한다! 라는 순서를 보장해야 하는 경우 사용된다고 볼 수 있다.

앞으로 json파싱이나 네트워킹 코드를 자주 접하다 보면 더 익숙해질 것 같다 :)