2025/07/21

16. Swift 클로저 심층 분석: 캡처 리스트와 순환 참조 문제 해결

안녕하세요! 지난 블로그에서는 Swift 코드를 유연하고 재사용 가능하게 만드는 제네릭에 대해 알아보았습니다. 오늘은 Swift의 함수형 프로그래밍 요소를 대표하며, 강력하지만 동시에 조심해서 다뤄야 하는 도구인 클로저(Closures)에 대해 심층적으로 분석해 보겠습니다. 특히, 클로저가 외부 값을 '캡처'하는 방식과 이로 인해 발생할 수 있는 순환 참조(Retain Cycle) 문제를 해결하는 방법에 집중하겠습니다. 🕵️‍♀️

클로저는 코드 블록을 변수처럼 전달하고 저장할 수 있게 해주는 Swift의 핵심 기능입니다. 비동기 작업, 애니메이션, UI 이벤트 처리 등 다양한 곳에서 클로저를 사용하지만, 그 내부 동작, 특히 외부 변수를 '캡처'하는 메커니즘을 제대로 이해하지 못하면 메모리 누수로 이어지는 순환 참조라는 함정에 빠질 수 있습니다.

1. 클로저(Closures)란 무엇인가요? 다시 한번 복습!

클로저는 코드 블록과 그 코드 블록이 정의된 주변 환경(Context)으로부터 캡처한 상수 또는 변수를 묶은 독립적인 기능 블록입니다. Swift에서 클로저는 다음과 같은 형태로 사용됩니다:

  • 이름 있는 함수: 전역 함수나 중첩 함수
  • 이름 없는 클로저 표현: 우리가 흔히 클로저라고 부르는, 독립적인 코드 블록

클로저의 가장 중요한 특징은 자신이 정의된 스코프(Lexical Scope) 내의 상수와 변수를 캡처(Capture)하여, 해당 상수나 변수가 원래 스코프를 벗어나더라도 클로저 내부에서 계속 접근하고 수정할 수 있다는 점입니다.


func makeIncrementer(forIncrement amount: Int) -> () -> Int {
    var runningTotal = 0
    func incrementer() -> Int { // 중첩 함수 (클로저의 한 형태)
        runningTotal += amount // runningTotal과 amount를 캡처
        return runningTotal
    }
    return incrementer
}

let incrementByTen = makeIncrementer(forIncrement: 10)
print(incrementByTen()) // 출력: 10
print(incrementByTen()) // 출력: 20
// runningTotal은 makeIncrementer 함수 스코프를 벗어났지만, incrementByTen 클로저에 의해 캡처되어 유지됨

2. 클로저의 캡처(Capture) 메커니즘

클로저가 주변 환경의 값을 캡처할 때, Swift는 기본적으로 해당 값을 강력 참조(Strong Reference)합니다. 즉, 캡처된 변수나 상수의 참조 횟수(Reference Count)를 1 증가시킵니다. 이 때문에 순환 참조 문제가 발생할 수 있습니다.

문제 상황의 예시:

클래스 A가 클로저 C를 프로퍼티로 가지고 있고, 이 클로저 C가 다시 클래스 A의 인스턴스(즉, self)를 캡처하는 경우입니다.

  1. A 인스턴스: 클로저 C를 강력 참조
  2. 클로저 C: A 인스턴스(self)를 강력 참조 (캡처로 인해)

이 경우 서로가 서로를 강력 참조하기 때문에, 어느 한쪽도 참조 횟수가 0이 되지 않아 메모리에서 해제되지 않습니다. 이것이 바로 순환 참조(Retain Cycle)입니다.


class HTMLElement {
    let name: String
    let text: String?

    // 클로저를 프로퍼티로 가짐
    lazy var asHTML: () -> String = {
        // 클로저 내에서 self (HTMLElement 인스턴스)를 참조
        // 이 시점에서 self가 강력 참조되어 순환 참조 발생 가능성
        if let text = self.text {
            return "<\(self.name)>\(text)</\(self.name)>"
        } else {
            return "<\(self.name) />"
        }
    }

    init(name: String, text: String? = nil) {
        self.name = name
        self.text = text
        print("\(name) 이(가 초기화되었습니다.")
    }

    deinit {
        print("\(name) 이(가 해제되었습니다.")
    }
}

var paragraph: HTMLElement? = HTMLElement(name: "p", text: "Hello, world")
print(paragraph!.asHTML()) // 클로저 실행
// 출력: <p>Hello, world</p>

paragraph = nil // 인스턴스에 대한 외부 참조를 nil로 만듦
// 예상: deinit 메시지가 출력되어야 하지만, 출력되지 않음 -> 순환 참조 발생!

위 코드에서는 HTMLElement 인스턴스가 asHTML 클로저를 참조하고, asHTML 클로저가 self(즉, HTMLElement 인스턴스)를 참조함으로써 순환 참조가 발생하여 deinit이 호출되지 않습니다.

3. 순환 참조 문제 해결: 캡처 리스트 (Capture List)

Swift는 클로저의 캡처 동작을 명시적으로 제어할 수 있도록 캡처 리스트(Capture List)제공합니다. 캡처 리스트를 사용하여 클로저가 특정 값을 강력 참조할지, 아니면 약하게(weak) 또는 미소유(unowned) 참조할지 지정할 수 있습니다.

캡처 리스트는 클로저의 여는 중괄호({) 바로 뒤에 꺾쇠 괄호([]) 안에 작성합니다.


lazy var someClosure: () -> String = { [weak self, unowned anotherVariable] in
    // 클로저 본문
}

3.1. weak (약한 참조): 옵셔널로 캡처

weak 키워드는 캡처된 인스턴스를 약하게 참조(Weak Reference)합니다. 약한 참조는 참조 횟수를 증가시키지 않으며, 참조하는 인스턴스가 메모리에서 해제되면 자동으로 nil로 설정됩니다. 따라서 weak로 캡처된 변수는 항상 옵셔널 타입이 됩니다.

weak self는 순환 참조를 끊는 가장 일반적이고 안전한 방법입니다.


class HTMLElement_Fixed {
    let name: String
    let text: String?

    lazy var asHTML: () -> String = { [weak self] in // self를 약하게 캡처
        guard let self = self else { // self가 nil일 경우를 대비하여 언래핑
            return "HTML element has been deallocated."
        }
        if let text = self.text {
            return "<\(self.name)>\(text)</\(self.name)>"
        } else {
            return "<\(self.name) />"
        }
    }

    init(name: String, text: String? = nil) {
        self.name = name
        self.text = text
        print("\(name) 이(가 초기화되었습니다.")
    }

    deinit {
        print("\(name) 이(가 해제되었습니다.") // 이제 정상적으로 출력될 것임
    }
}

var paragraphFixed: HTMLElement_Fixed? = HTMLElement_Fixed(name: "p", text: "Hello, world (Fixed)")
print(paragraphFixed!.asHTML())
// 출력: <p>Hello, world (Fixed)</p>

paragraphFixed = nil // 인스턴스에 대한 외부 참조를 nil로 만듦
// 출력: p 이(가 해제되었습니다. -> 순환 참조 해결!

[weak self]를 사용하여 클로저가 self를 약하게 참조하도록 했기 때문에, paragraphFixed가 nil이 되면 HTMLElement_Fixed 인스턴스의 참조 횟수가 0이 되어 정상적으로 deinit이 호출됩니다.

3.2. unowned (미소유 참조): 비-옵셔널로 캡처 (항상 존재할 것이라는 확신)

unowned 키워드는 캡처된 인스턴스를 미소유 참조(Unowned Reference)합니다. 약한 참조와 마찬가지로 참조 횟수를 증가시키지 않지만, unowned 참조는 인스턴스가 메모리에서 해제되더라도 자동으로 nil로 설정되지 않습니다. 따라서 unowned로 캡처된 변수는 항상 비-옵셔널 타입이 됩니다.

unowned는 참조하는 인스턴스가 자신보다 수명이 같거나 길다는 것이 확실할 때 사용합니다. 만약 unowned 참조가 가리키는 인스턴스가 미리 해제되면, 해당 참조에 접근 시 런타임 오류(크래시)가 발생합니다.


class Customer {
    let name: String
    var card: CreditCard?

    init(name: String) {
        self.name = name
        print("고객 \(name) 초기화됨")
    }
    deinit { print("고객 \(name) 해제됨") }
}

class CreditCard {
    let cardNumber: Int
    // unowned: CreditCard는 항상 Customer에 의해 소유되며, Customer보다 먼저 해제되지 않음
    unowned let customer: Customer

    init(cardNumber: Int, customer: Customer) {
        self.cardNumber = cardNumber
        self.customer = customer
        print("신용카드 \(cardNumber) 초기화됨")
    }
    deinit { print("신용카드 \(cardNumber) 해제됨") }
}

var john: Customer? = Customer(name: "John Appleseed") // John 초기화
john!.card = CreditCard(cardNumber: 1234_5678_9012_3456, customer: john!) // 카드 초기화, customer를 unowned로 참조

john = nil // John 인스턴스에 대한 외부 참조 제거
// 예상: "고객 John Appleseed 해제됨", "신용카드 1234567890123456 해제됨" 순서로 출력
// 출력:
// 고객 John Appleseed 초기화됨
// 신용카드 1234567890123456 초기화됨
// 고객 John Appleseed 해제됨
// 신용카드 1234567890123456 해제됨

위 예시에서 CreditCard는 Customer에 의해 소유되고(프로퍼티로), CreditCard 인스턴스는 Customer 인스턴스보다 수명이 짧을 수 없습니다. 따라서 CreditCard의 customer 프로퍼티를 unowned로 설정함으로써 순환 참조를 안전하게 끊을 수 있습니다.

4. weak vs unowned 선택 가이드

  • weak: 참조하는 인스턴스가 언제든지 nil이 될 수 있는 경우에 사용합니다. (예: 델리게이트 패턴, 비동기 콜백에서 self 참조) 옵셔널 타입이므로 사용 전에 항상 nil 체크(guard let self = self 또는 옵셔널 체이닝)가 필요합니다.
  • unowned: 참조하는 인스턴스가 자신보다 수명이 같거나 길다는 것이 확실할 때 사용합니다. nil이 될 수 없다고 확신하기 때문에 비-옵셔널 타입으로 선언하며, nil 체크가 필요 없습니다. 만약 참조하는 인스턴스가 먼저 해제되면 런타임 오류가 발생합니다.

일반적으로 클로저 내에서 self를 캡처할 때는 weak self를 사용하는 것이 더 안전합니다. unowned self는 특정 상황에서 성능상의 이점이 있을 수 있지만, 오용 시 앱 크래시로 이어질 수 있으므로 신중하게 판단해야 합니다.

정리하며

오늘은 Swift 클로저의 핵심 기능인 캡처 메커니즘과 이로 인해 발생할 수 있는 순환 참조 문제, 그리고 이를 해결하는 캡처 리스트(weak, unowned)에 대해 심층적으로 알아보았습니다.

  • 클로저: 코드 블록과 주변 환경의 캡처된 값을 묶은 기능 단위입니다.
  • 캡처: 클로저가 외부 변수를 참조할 때, 기본적으로 강력 참조하여 메모리에 유지합니다.
  • 순환 참조: 두 인스턴스(또는 인스턴스와 클로저)가 서로를 강력 참조하여 메모리에서 해제되지 않는 문제입니다.
  • 캡처 리스트: [weak self] 또는 [unowned self]와 같이 클로저의 캡처 방식을 명시적으로 제어하여 순환 참조를 해결합니다.
    • weak: 약한 참조, 옵셔널, 참조 대상이 nil이 될 수 있을 때 사용.
    • unowned: 미소유 참조, 비-옵셔널, 참조 대상이 항상 존재할 때 사용.

클로저와 순환 참조는 Swift 개발자가 반드시 이해하고 능숙하게 다룰 줄 알아야 하는 개념입니다. 메모리 누수는 앱 성능에 치명적이므로, 클로저 사용 시 항상 캡처 리스트를 고려하는 습관을 들이는 것이 중요합니다.

궁금한 점이 있다면 언제든지 댓글로 남겨주세요. 감사합니다.

0 comments:

댓글 쓰기