본문 바로가기

Swift/Deep dive!!

[Swift] 클로저 @escaping과 @non-escaping의 생명주기, 개념과 차이 탐구

728x90

[Swift] ver 5.7 에서 @escaping, @non-escaping closure의 차이를 탐구하는 글 입니다.


"형 @escaping과 @non-escaping의 정확한 차이를 모르겠어"
... : 나도 모르겠는데 ??!?!

사진1

함수의 생명주기

@Escaping과 @non-escaping 클로저의 차이를 설명하기 전에 함수의 생명주기를 간단하게 설명하려고 합니다. 함수는 계속해서 호출 할 탠데 호출 될 때마다 메모리에 계속 함수들의 정보(매개변수, 함수 안 변수들 등)가 쌓일까요?? X. 함수가 호출을 하는 시점에 스택 영역에 할당 되었다가 함수가 끝나는 시점에 스택 영역에서 해제가 됩니다. 즉 호출을 하면 return 되는 시점에 메모리에서 해제 됩니다.

클로저 정의

여러번 사용되는 코드는 함수로 묶어서 함수명만 호출하면 되게 편리합니다. "단 한번만 쓸건데,, 좋은 방법이 없을까" 이 경우 클로저를 사용하면 됩니다. 고민 끝에 결정하는 함수명, 함수를 구현하는 것보다 간결하면서도 함수의 역할과 블럭 내의 값을 캡쳐하는 기능을 가진 것이 바로 클로저입니다. 이때 캡처는 Value타입이어도 Reference Capture를 활용합니다. 그래서 클로저 내부에 캡처된 특정 변수 값이 외부에서 바뀌면 변경된 값을 참조하게 된다는 특징이 있습니다. 참조이기 때문에 클로저 안에서 캡쳐된 값을 바꾸면 외부 값도 변경됩니다. Capture Lists를 이용하면 value타입의 캡처도 가능합니다.

@Escaping 이란?

함수 매개변수의 인자값으로 클로저가 전달 되었을 때 함수가 return후 종료된 뒤에 클로저 타입의 인자가 호출 될 있음을 의미합니다. 위에서 말했듯이 함수의 생명주기는 "함수명()"에 의해 함수가 호출 되었을 때 매개변수로 받은 인자값, 함수 내 변수, 상수 등이 메모리에 저장 되어 있다가 함수가 return시에 비로소 메모리에서 해제가 됩니다. 함수의 매개변수 타입이 클로저 타입이어도 마찬가지로 함수가 종료되면 소멸됩니다. 그러나 함수 호출시에 전달 받은 클로저 인자값을 함수가 종료되어도 실행할 수 있도록 하는게 @Escaping 키워드입니다.즉 메서드 실행이 완료되어 소멸된 이후에 호출되는 클로저입니다.

@Escaping 키워드가 붙은 클로저 타입의 매개변수는 non-escaping의 성격도 가지고 있습니다. 즉 함수가 끝나야만 실행되는게 아니라 함수가 return된 이후에 실행 될 수 있다는 뜻이고, 함수가 진행중인 동안에 non-escaping처럼 매개변수로 받은 인자값 처럼 사용할 수 있다는 뜻입니다.

@Escaping 생명주기

Escaping 상태가 되었을 때 스택, 힙 영역에서 존재하는지, 메모리 에서 존재하는지 정확하게 알고 싶었는데 아쉽게 찾지 못했습니다. 그러나 중요한 것은 클로저가 실행될 때 까지 캡처된 값들이 남아 있어야 한다는 것입니다. Escaping 클로저를 매개변수로 선언하고 함수의 인자값으로 받을 경우에 함수가 return후 스택에서 해제되어도 @Escaping 키워드가 선언된 클로저는 캡처된 값이 존재합니다. 함수가 종료된 후에 바로 실행되는 경우도 있고 async, DispatchQueue등 비동기적인 방식에 호출될 경우에는 escape된 클로저가 언제 실행될 지는 모릅니다.

  • 궁금했던 것 : 함수는 한 번 호출된 후에 메모리에서 해제 되는데, escape된 클로저는 호출 될 경우에 호출 된 후 메모리에서 바로 해제될까?

이 궁금점에 대해서 처음에는 "yes"라고 생각했습니다. 하지만 클로저는 String 캡처를 하기 때문에 순환 참조가 발생될 수 있습니다. Capture Lists를 선언 할 때 weak와 같은 키워드를 사용해야 강력 순환 참조 문제를 해결 할 수 있습니다. escape된 클로저가 실행된 후에 강력한 순환참조가 발생된다는 것은 결국 캡처된 값이 남아있다는 뜻이기에 해제되지 않을 수도 있겠다는 추측의 결론을 내렸습니다.(제 생각, 정답을 아시는 분 댓글로 알려주시면 정말 감사합니다.)

non-escaping 이란?

위의 경우와 반대로 함수의 매개변수 타입이 클로저인데 함수의 호출시에 인자값으로 클로저를 전달받고 함수가 종료된 후에는 매개변수로 전달받은 클로저 인자값도 메모리에서 해제되는게 non - escaping 클로저입니다. (매개변수로 인자값을 받으면 함수 블럭 안에서 사용되면 사용되고 사용 안 하면 실행 안 하는 일반 매개변수처럼 생각하면 편합니다.) 함수가 return하기 전에는 인자값으로 전달받은 클로저는 클로저 내부 블럭의 모든 객체를 캡쳐 하지만 호출된 함수가 return 될 경우에 캡처된 모든 변수, 객체가 해제되기 때문에 함수의 종료 시에는 더 이상 인자값으로 받은 클로저를 실행 할 수 없습니다.

non-escaping 생명주기

위 사진1의 두 함수를 예로 들자면, non-escaping은 nonEscapingTest함수가 호출된 후 매개변수로 전달받은 클로저
{ print("Closured called" } 는 함수의 호출 시에 인자값으로 전달된 후 함수가 실행됨에 따라 Closure()가 호출된 후에 함수가 종료시에 클로저도 메모리에 존재하지 않습니다. 이렇게 함수가 종료될 시에 클로저의 캡쳐된 객체들도 메모리에서 남아있지 않기 때문에 self키워드를 사용해도 강력한 순환 참조 등의 문제가 발생되지 않습니다.

사진1의 상황과 @Escaping은 언제 사용되는가?

사진1의 두 함수는 호출 결과가 동일합니다. @Escaping 키워드가 붙었다고 무조건 함수가 끝난 후에 실행 된다는 뜻이 아닙니다. @Escaping도 함수 내에서 non-escaping 인자값처럼 사용될 수 있습니다. 저 코드의 논리적인 문제점은 escapingTest(closure:)함수에 있습니다. 왜냐하면 @Escaping 키워드를 선언했지만 해당 기능을 전혀, 아예 활용하지 않기 때문입니다. @Escaping을 붙여도 escape한 상황이 존재하지 않기 때문에 무용지물 이라고 할 수 있습니다. 또한 @Escaping 키워드가 붙을 경우 Swift가 계속해서 신경써야 하기 때문에 모든 클로저에 non-escaping의 성격을 포함하는 @Escaping을 붙이는 경우는 좋지 않습니다. 반면 non-escaping은 함수의 해제와 동시에 클로저의 캡처된 값들도 해제되기 때문에 Swift의 수고를 덜어줍니다.

그렇다면 언제 선언해야 하는가?

그 예로 서버에서 값을 반환받는 API response, request 경우, 비동기, 외부에 클로저 저장(바인딩의 경우)가 있습니다.

func fetchUser(url: URL, completion:@escaping (Result<(Data,URLResponse),Error>) -> Void) {
    
    //1. dataTask함수 실행
    let task = URLSession.shared.dataTask(with: url) { data, response, error in
        if let data = data, let response = response {
            completion(.success((data,response)))
        }
        if let error = error {
            completion(.failure(error))
        }
    }
    //2. dataTask함수 실행 끝
    
    task.resume()
}



여기서 주목해야 할 부분은 fetchUser의 completion매개변수 클로저입니다.

"task에 값을 넣기 전에 dataTask함수를 실행하는 게 사진1 과 똑같은데 뭐가 @Escaping 상황이라는 거야?"

그 이전에 URLSession의 dataTask함수의 두번째 매개변수인 completionHandler 또한 @escaping 클로저 매개변수입니다. completionHandler는 request 요청이 완료 되었을 경우에 비로소 completionHandler 클로저가 실행이 됩니다. " 그럼 task에 URLSessionDataTask 반환하기 전에 이미 클로저 실행하고 task에 값을 치환 하는게 아닌가??" 아닙니다! URLSessionDataTask는 또한 중요한 특징이 있습니다. URLSession또한 포함이 되는데 resume() 메소드를 호출해야만 task가 실행이 됩니다. 즉 resume()를 호출해야만 비로소 url을 통해서 요청 데이터를 서버로부터 받아오는 작업을 합니다. 그 이전까지는 suspend() 상태로 존재합니다.(== completion()함수 사용x) task 실행 시점은 위 코드에서 주석 1,2 표시 이후 task.resume()에 비로소 dataTask(with:completionHandler:)가 작업을 시작합니다. 그 후 데이터가 로드된 후에 completionHandler를 반환합니다. 근데 completionHandler는 asynchronous한 절차를 거친 이후에 반환됩니다.


completionHandler의 경우 @escaping이기 때문에 completion도 @escaping을 사용해야 합니다. compeltion 매개변수를 escape 해야 함수 밖에서 사용될 수 있기 때문입니다.

이 경우에도 마찬가지입니다. bind 프로퍼티에 completion의 클로저를 저장하기 때문에 getText()함수가 종료된 후에도 저장된 클로저가 사용될 수 있는데 non-escaping클로저는 getText()함수의 호출 이후 소멸되기 때문에 escape의 가능성이 있습니다. 따라서 @escaping 키워드를 사용해야 합니다.

728x90