안녕하세요,루피입니다.
오늘은 공식문서를 바탕으로 클로저에 대해 다시 한번 정리하는 시간을 가져보도록 하겠습니다. 바로 시작합니다.
클로저와 함수의 관계
진행하기에 앞서 우선 클로저와 함수의 관계를 짚고 넘어가도록 하겠습니다. Swift의 공식 문서에 따르면, 함수는 사실 이름이 있는 클로저의 한 종류입니다. 즉, 클로저가 더 큰 개념이고 함수는 그 안에 포함된 특별한 형태인 셈이죠.
공통점
- 특정 작업을 수행하는 코드 블록입니다.
- 매개변수를 받고 결과값을 반환할 수 있습니다.
- 주변 컨텍스트의 변수나 상수를 캡처 할 수 있습니다.
차이점
| 함수 | 클로저 | |
| 이름 | 항상 이름이 있음 | 이름이 없는 형태가 일반적 |
| 키워드 | func 키워드로 명시적 선언 | { } 를 사용한 간결한 문법 |
| 활용 | 독립적인 기능 단위로 사용 | 다른 함수의 인자, 변수 할당 등 유연하게 사용 |
클로저란 무엇인가?
클로저는 쉽게 "이름 없는 함수"라고 정의할 수 있습니다. 클로저의 진짜 정체성은 일급 객체라는 특성에 있는데요.
일급 객체란, 코드 내에서 다음과 같이 자유롭게 다룰 수 있는 대상을 의미합니다.
그렇다면 일급 객체의 조건은 어떻게 될까요?? 크게 3가지가 있습니다.
- 변수나 상수에 할당할 수 있어야 합니다.
- 함수의 인자(Argument)로 전달될 수 있어야 합니다.
- 함수의 반환 값(Return Value)이 될 수 있어야 합니다.
조금 더 쉽게 말하자면, 값으로 취급 될 수 있어야한다는 것입니다. 클로저는 이 모든 조건을 만족하기에, 우리는 클로저를 변수처럼 주고받을 수 있게 됩니다.
클로저 문법 축약
클로저의 강력함을 느끼기 가장 좋은 예제는 sorted(by:) 메소드입니다. 이 예제를 통해 클로저의 문법이 얼마나 간결해질 수 있는지 단계별로 살펴보겠습니다.
let names = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]
Level 0: 일반 함수 사용
func backward(_ s1: String, _ s2: String) -> Bool {
return s1 > s2
}
var reversedNames = names.sorted(by: backward)
코드에 대해 간단한 설명을 해보겠습니다. Swift에서 문자열끼리 > 연산자를 사용하면 알파벳 역순으로 비교합니다.
예를 들어, "Chris" > "Alex" 는 true를 반환합니다.
그렇기에 backward 함수는 첫 번째 문자열이 두 번째 문자열보다 알파벳 순서상 뒤에 오면 true를 반환하는 비교 규칙을 정의한 것입니다.
Level 1: 클로저 표현식의 기본 형
backward 함수의 본문을 그대로 { } 안에 넣습니다. in 키워드는 매개변수/반환 타입 정의와 실제 코드를 분리합니다.
reversedNames = names.sorted(by: { (s1: String, s2: String) -> Bool in
return s1 > s2
})
Level 2: 타입 유추
Swift 컴파일러는 sorted(by:)가 (String, String) -> Bool 타입의 클로저를 받는다는 것을 이미 알고 있으므로, 타입을 생략할 수 있습니다.
reversedNames = names.sorted(by: { s1, s2 in return s1 > s2 })
Level 3: 암시적 반환
클로저의 본문이 단 한 줄의 return 문으로만 이루어져 있다면, return 키워드도 생략할 수 있습니다.
reversedNames = names.sorted(by: { s1, s2 in s1 > s2 })
Level 4: 후행 클로저 (Trailing Closure)
함수의 마지막 인자가 클로저일 경우, ()를 밖으로 빼낼 수 있습니다. 가독성이 극적으로 향상됩니다.
reversedNames = names.sorted() { s1, s2 in s1 > s2 }
그리고 빼낸 ()는 생략할 수 있습니다.
reversedNames = names.sorted { s1, s2 in s1 > s2 }
Level 5: 인자 이름 축약 (Shorthand Arguments)
매개변수 이름을 굳이 짓지 않아도, Swift는 순서대로 $0, $1, $2... 와 같은 이름을 제공합니다. 이 경우 in 키워드도 사라집니다.
reversedNames = names.sorted { $0 > $1 }
Level 6 (Final): 연산자 메소드
심지어 > 연산자 자체가 (String, String) -> Bool 타입의 함수 조건을 만족하므로, 연산자만 전달할 수도 있습니다.
reversedNames = names.sorted(by: >)
이처럼 클로저는 상황에 따라 가장 가독성 좋은 형태로 다양하게 변신할 수 있습니다.
값 캡처 (Capture) 와 생명주기
클로저의 가장 중요한 특징은 자신이 정의된 주변 컨텍스트의 변수나 상수를 캡처(Capture)한다는 것입니다.
func makeIncrementer(forIncrement amount: Int) -> () -> Int {
var runningTotal = 0
let incrementer: () -> Int = {
// 이 클로저는 외부의 'runningTotal'과 'amount'를 캡처했습니다.
runningTotal += amount
return runningTotal
}
return incrementer
}
let incrementByTen = makeIncrementer(forIncrement: 10)
// makeIncrementer 함수는 이미 종료되었지만,
// incrementByTen 클로저는 캡처한 runningTotal을 계속 붙들고 있습니다.
print(incrementByTen()) // 출력: 10
print(incrementByTen()) // 출력: 20
incrementer 클로저는 makeIncrementer 함수가 종료된 후에도, 캡처한 runningTotal 변수에 대한 참조(Reference)를 유지합니다. 이 때문에 클로저는 '상태'를 가질 수 있게 됩니다.
값 캡처와 순환 참조 (Retain Cycle)
이 캡처 기능은 메모리 누수(Memory Leak)의 주범인 순환 참조를 일으킬 수 있습니다.
클래스 인스턴스가 클로저를 강한 참조로 가지고 있고, 그 클로저가 다시 self를 강한 참조로 캡처하는 상황입니다.
class User {
let name: String
// 1. User 인스턴스가 클로저를 강한 참조로 소유
lazy var greeting: () -> String = {
// 2. 클로저가 self(User 인스턴스)를 강한 참조로 캡처
return "Hello, I'm \(self.name)."
}
init(name: String) { self.name = name }
deinit { print("\(name) is being deinitialized") }
}
var user: User? = User(name: "Kim")
user?.greeting()
// user -> greeting 클로저 -> self(user) -> ...
// 서로가 서로를 붙잡고 놓아주지 않아 메모리에서 해제되지 않음!
user = nil // deinit이 호출되지 않음!
이 문제를 해결하기 위해 캡처 리스트를 사용합니다.
- [weak self]: self를 약한 참조로 캡처합니다. self가 먼저 메모리에서 해제될 수 있으므로, 클로저 내에서 self는 옵셔널 타입(self?)이 됩니다. 가장 안전하고 일반적인 해결책입니다.
- [unowned self]: self를 미소유 참조로 캡처합니다. 클로저의 생명주기 동안 self가 항상 존재한다고 100% 확신할 때 사용합니다. 만약 self가 해제된 후 접근하면 런타임 에러(크래시)가 발생합니다.
// 해결책: 캡처 리스트 추가
lazy var greeting: () -> String = { [weak self] in
guard let self = self else { return "User is gone." }
return "Hello, I'm \(self.name)."
}
4. Escaping vs Non-Escaping: 컴파일러의 최적화
- Non-Escaping (기본값): 클로저가 함수 안에서 실행되고, 함수가 반환되기 전에 생명이 끝나는 경우입니다.
- @escaping: 클로저가 함수 밖의 변수에 저장되거나, 함수가 반환된 후에 실행되는 경우입니다. (e.g. 비동기 처리 콜백)
var completionHandlers: [() -> Void] = []
// 클로저를 함수 밖의 배열에 저장하므로, 함수를 '탈출'해야 함.
func someFunctionWithEscapingClosure(completionHandler: @escaping () -> Void) {
completionHandlers.append(completionHandler)
}
왜 이 둘을 구분할까요?
정답은 컴파일러 최적화 때문입니다.
- Non-Escaping 클로저: 컴파일러는 이 클로저의 생명주기를 명확히 예측할 수 있습니다. 따라서 ARC(메모리 관리)와 관련된 복잡한 오버헤드를 줄이고, 심지어 클로저를 더 빠른 스택(Stack) 메모리에 할당하는 등의 공격적인 최적화가 가능합니다.
- @escaping 클로저: 컴파일러는 이 클로저가 언제, 어디서 실행될지 모르므로 안전하게 힙(Heap) 메모리에 할당하고 신중하게 ARC로 관리해야 합니다.
@escaping 클로저 내부에서 self를 사용하면 명시적으로 self.를 붙여야 하는 이유도 여기에 있습니다. 이는 개발자에게 "이 클로저는 오래 살아남을 수 있으니 순환 참조의 가능성을 인지하세요!"라는 경고 신호입니다.
연습 문제
- Swift에서 클로저는 항상 이름을 가지는 함수로 정의되어야만 한다.
- Swift에서 전역 함수, 중첩 함수, 그리고 클로저 표현식은 모두 클로저의 한 형태로 분류된다.
- 클로저는 자신을 정의한 원래 범위(scope)가 더 이상 존재하지 않더라도, 이전에 캡처한 상수나 변수의 값을 계속 참조하거나 수정할 수 있다.
- Swift는 함수나 메서드에 클로저를 인라인(inline)으로 전달할 때, 클로저의 파라미터 타입과 반환 타입을 유추할 수 없으므로 개발자가 항상 명시적으로 작성해야 한다.
- 단일 표현식(Single-expression)으로 구성된 클로저는 반환값이 명확한 경우에도 return 키워드를 반드시 명시해야 한다.
- 클로저 표현식에서 $0, $1과 같은 짧은 인수 이름(Shorthand Argument Names)을 사용하면, 클로저의 인수 리스트와 in 키워드를 모두 생략할 수 있다.
- Swift에서 함수와 클로저는 값 타입(Value Type)이므로, 동일한 클로저를 여러 개의 상수나 변수에 할당하더라도 각각의 변수는 독립적인 클로저 복사본을 가지게 된다.
- 후행 클로저(Trailing Closures)는 함수의 마지막 인수로 클로저 표현식을 전달할 때 사용되며, 함수 호출의 소괄호 안에 클로저 본문을 작성하는 것이 일반적인 구문이다.
- 함수나 메서드에 단 하나의 클로저 인수가 있고, 해당 클로저가 후행 클로저로 사용될 경우, 함수를 호출할 때 함수 이름 뒤의 소괄호 ()를 생략할 수 있다.
- 탈출 클로저(Escaping Closures)는 @escaping 속성을 사용하여 선언하며, 이는 클로저가 자신을 호출한 함수가 반환된 후에도 실행될 수 있음을 의미한다.
- 탈출 클로저 내부에서 클래스 인스턴스인 self를 캡처할 때, 강한 참조 사이클(strong reference cycle)을 방지하기 위해 self를 명시적으로 작성하거나 캡처 리스트(capture list)에 포함하는 것이 권장된다.
- 구조체(struct) 또는 열거형(enum) 인스턴스의 이스케이프 클로저(escaping closure)는 해당 인스턴스에 대한 변경 가능한 참조(mutable reference)를 캡처할 수 있다.
- 자동 클로저(Autoclosures)는 함수에 인수로 전달되는 표현식을 래핑하기 위해 자동으로 생성되는 클로저이며, 이 클로저는 호출될 때까지 래핑된 코드의 실행을 지연시킨다.
- @autoclosure 속성과 @escaping 속성은 Swift에서 동시에 사용할 수 없다.
- makeIncrementer 함수 내의 incrementer 클로저는 runningTotal 변수를 캡처한다. 이 runningTotal 변수 makeIncrementer 함수의 호출이 종료되면 메모리에서 사라지므로, incrementer 클로저를 여러 번 호출해도 runningTotal의 값은 누적되어 유지되지 않는다.
Ref
https://bbiguduk.gitbook.io/swift/language-guide-1/closures
클로저 (Closures) | Swift
명명된 함수 생성없이 실행되는 코드 그룹입니다. 클로저 (Closures) 는 코드에서 주변에 전달과 사용할 수 있는 자체 포함된 기능 블럭입니다. Swift의 클로저는 다른 프로그래밍 언어에서 클로저,
bbiguduk.gitbook.io
오늘도 화이팅입니다!
'iOS > Swift' 카테고리의 다른 글
| [Swift] RunLoop 와 GCD (3) | 2025.07.28 |
|---|---|
| [Swift] iOS는 메모리를 어떻게 관리 할까? (0) | 2025.07.21 |
| [ Swift ] Identifiable (1) | 2025.07.12 |
| [Swift] 고차함수 (3) | 2025.07.07 |
| [Swift] @autoclosure (0) | 2025.06.11 |