🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: 5편
5편에서 함수를 변수에 담을 수 있다는 것을 배웠습니다. 클로저(closure)는 그것의 확장입니다. 이름을 붙이지 않고, 그 자리에서 바로 정의해서 넘길 수 있는 함수 블록입니다.
Swift에서 map, filter, sort 같은 메서드를 쓸 때, 비동기 작업의 완료 콜백을 넘길 때, 버튼의 동작을 정의할 때 — 모두 클로저를 씁니다. 클로저를 이해하지 않으면 Swift 코드의 절반을 읽을 수 없습니다.
클로저 기본 문법
클로저는 { } 안에 파라미터, 반환 타입, 본문을 모두 씁니다.
{ (파라미터) -> 반환타입 in
본문
}
5편에서 함수로 만들었던 덧셈을 클로저로 쓰면 이렇습니다.
// 함수 방식
func add(_ a: Int, _ b: Int) -> Int { a + b }
// 클로저 방식
let add = { (a: Int, b: Int) -> Int in
return a + b
}
add(3, 4) // 7
타입은 컨텍스트에서 추론될 때 생략할 수 있고, 표현식이 하나면 return도 생략됩니다.
let add: (Int, Int) -> Int = { a, b in a + b }
후행 클로저와 축약 문법
클로저를 함수의 마지막 인자로 넘길 때는 괄호 밖으로 꺼내 쓸 수 있습니다. 이것을 후행 클로저(trailing closure)라고 합니다.
let numbers = [3, 1, 4, 1, 5, 9]
// 일반 방식
let sorted = numbers.sorted(by: { (a: Int, b: Int) -> Bool in
return a < b
})
// 후행 클로저
let sorted = numbers.sorted { (a: Int, b: Int) -> Bool in
return a < b
}
// 타입 추론으로 생략
let sorted = numbers.sorted { a, b in a < b }
// $0, $1 축약 인자
let sorted = numbers.sorted { $0 < $1 }
// 연산자 함수로 더 짧게
let sorted = numbers.sorted(by: <)
모두 완전히 동일한 코드입니다. 상황에 따라 적절한 수준의 축약을 씁니다. 너무 줄이면 읽기 어려워지므로, 한 줄에 다 들어오고 의미가 명확할 때만 $0, $1 축약을 씁니다.
3편에서 봤던 map, filter도 클로저를 받는 함수입니다.
let numbers = [1, 2, 3, 4, 5]
let doubled = numbers.map { $0 * 2 } // [2, 4, 6, 8, 10]
let evens = numbers.filter { $0 % 2 == 0 } // [2, 4]
let total = numbers.reduce(0) { $0 + $1 } // 15
값 캡처
클로저는 자신이 정의된 스코프의 변수를 캡처(capture)합니다. 함수가 끝난 뒤에도 그 변수를 계속 참조할 수 있습니다.
func makeCounter() -> () -> Int {
var count = 0
let increment = {
count += 1
return count
}
return increment
}
let counter = makeCounter()
print(counter()) // 1
print(counter()) // 2
print(counter()) // 3
makeCounter()가 반환되고 나서도 클로저 increment는 count를 살아있게 유지합니다. 클로저가 count를 캡처했기 때문입니다. Python의 클로저나 JavaScript의 클로저와 동일한 개념입니다.
@escaping — 클로저가 함수보다 오래 사는 경우
클로저를 함수 인자로 받을 때, 그 클로저가 함수 실행이 끝난 뒤에도 살아남아야 한다면 @escaping을 표시합니다.
var completionHandlers: [() -> Void] = []
func register(handler: @escaping () -> Void) {
completionHandlers.append(handler) // 함수 종료 후에도 handler가 살아남음
}
가장 흔한 사례는 비동기 작업의 완료 콜백입니다.
func fetchData(completion: @escaping (String) -> Void) {
DispatchQueue.global().async {
// 백그라운드에서 작업
let result = "데이터"
DispatchQueue.main.async {
completion(result) // 함수 반환 이후에 호출됨
}
}
}
@escaping이 없는 클로저(non-escaping)는 함수 내에서만 실행되고 사라집니다. Swift가 기본을 non-escaping으로 둔 이유는 성능 최적화와 메모리 안전성 때문입니다. 탈출하지 않는다는 보장이 있으면 컴파일러가 더 적극적으로 최적화할 수 있습니다.
캡처 리스트와 [weak self]
클로저가 클래스 인스턴스를 캡처하면 강한 참조(strong reference)가 생깁니다. 그 인스턴스가 클로저를 소유하고, 클로저가 다시 그 인스턴스를 참조하면 순환 참조(retain cycle)가 생겨 메모리 누수가 발생합니다.
class NetworkManager {
var onComplete: (() -> Void)?
func start() {
onComplete = {
print(self.description) // self를 강하게 캡처
// NetworkManager → onComplete → 클로저 → NetworkManager 순환
}
}
}
이를 막기 위해 캡처 리스트로 약한 참조를 씁니다.
class NetworkManager {
var onComplete: (() -> Void)?
func start() {
onComplete = { [weak self] in
guard let self = self else { return }
print(self.description)
}
}
}
[weak self]는 "self를 약하게 캡처한다"는 선언입니다. 약한 참조는 인스턴스가 해제되면 자동으로 nil이 됩니다. 그래서 guard let self = self로 먼저 nil 여부를 확인합니다.
언제 [weak self]가 필요한가:
- 클로저가
@escaping이고 - 클로저가
self를 참조하고 self가 그 클로저를 (직접 또는 간접으로) 소유하고 있을 때
세 조건이 모두 해당되면 순환 참조 가능성이 있으니 [weak self]를 씁니다. 메모리 관리는 12편에서 ARC와 함께 더 자세히 다룹니다.
다른 언어와 비교
| Python | JavaScript | Swift | |
|---|---|---|---|
| 익명 함수 | lambda x: x*2 |
x => x*2 |
{ x in x*2 } 또는 { $0*2 } |
| 값 캡처 | 있음 | 있음 | 있음 |
| 캡처 방식 제어 | 없음 | 없음 | 캡처 리스트 [weak self] |
| 탈출 여부 명시 | 없음 | 없음 | @escaping |
| 후행 클로저 문법 | 없음 | 없음 | 있음 |
핵심 요약
- 클로저는 이름 없는 함수 블록이다.
{ 파라미터 in 본문 }형태로 쓴다. - 함수의 마지막 인자가 클로저면 괄호 밖으로 꺼내는 후행 클로저 문법을 쓸 수 있다.
$0,$1은 클로저의 첫 번째, 두 번째 인자를 가리키는 축약 표현이다.- 클로저는 자신이 정의된 스코프의 변수를 캡처한다. 함수가 끝난 뒤에도 그 변수를 유지한다.
@escaping은 클로저가 함수 반환 이후에도 살아남는다는 표시다. 비동기 콜백에 많이 쓰인다.- 순환 참조를 막으려면
[weak self]캡처 리스트를 쓴다.
다음 편은 7편 — 옵셔널: nil을 타입 시스템으로 다루기입니다. Swift에서 가장 낯선 개념이지만, 이것을 이해하면 Swift가 왜 "안전한 언어"인지 실감할 수 있습니다.
🤖 Generated with Claude Code