ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 메모리 릭 방지 weak 가 정답일까 ? - WWDC ARC 2
    swift 2025. 5. 7. 01:12

    지난 글 에선 ARC 의 기초 개념과 메모리 릭, 객체의 수명에 대해서 참조 카운트가 연산되는 과정을 다루었습니다. 

    이번 글 에선 단순히 Observed Lifetime 에 의존하는 안되는 이유에 대해서 알아보고 메모리 릭을 방지하는 방법에 대해서 알아보겠습니다. 

    2025.05.07 - [swift] - 보이는 객체 수명에 속지 말기 ! - WWDC ARC

    Observed Lifetime 에 의존할 시 생길 버그

    class Traveler {
        var name: String
        var account: Account?
    }
    
    class Account {
        weak var traveler: Traveler?
        var points: Int
        func printSummary() {
    	       print("\(traveler!.name) has \(points) points") 
        }
    }
    
    
    func test() {
        let traveler = Traveler(name: "Lily")
        let account = Account(traveler: traveler, points: 1000)
        traveler.account = account
        account.printSummary()
    }
    단계 설명 Traveler RC Account RC
    1 Traveler 객체 생성 1 -
    2 Account 객체 생성, weak로 Traveler 참조 1 1
    3 Traveler.account = account로 강한 참조 연결 1 2
    4 printSummary() 호출 ❗ 이 시점에 문제가 생길 수도 있음 ?

     

    Observed Lifetime 의존

    Swift ARC는 객체의 마지막 사용 이후에는 release 를 통해 해제한다고 했습니다. 위 코드의 흐름을 보면 traveler 객체는 account.traveler = traveler 할당 후 더 이상 직접 사용되지 않기 때문에 컴파일러는 traveler를 그 시점에 해제해도 된다고 판단할 수 있습니다.

     

    그 결과?

    • account.traveler는 weak임 → ARC 카운트를 증가시키지 않음
    • printSummary() 내부에서 traveler! 접근 시점엔 이미 해제되었을 수도 있음
    • 즉, traveler! 가 nil 이라면 강제 언래핑 crash (EXC_BAD_ACCESS) 발생!

     

    이런 버그가 위험한 이유

    • 현재 Swift 컴파일러에서는 우연히 traveler가 살아있어서 문제가 없을 수도 있음
    • 하지만 Xcode 버전이 바뀌거나 ARC 최적화가 강화되면 → 갑자기 앱이 크래시

    즉, 지금 잘 되더라도 “관찰 가능한 수명(Observed Lifetime)” 에 의존하고 있는 것 = 위험

     


    약한 참조(weak)를 사용할 때 발생할 수 있는 버그를 처리하는 방법

    withExtendedLifetime()

    Swift는 이러한 버그를 방지하기 위해 withExtendedLifetime()이라는 유틸리티 함수를 제공합니다.이 함수는 객체의 생명주기를 명시적으로 연장하는 데 사용되며,해당 객체가 어떤 작업이 끝날 때까지 메모리에서 해제되지 않도록 보장해줍니다.

    func test() {
        let traveler = Traveler(name: "Lily")
        let account = Account(traveler: traveler, points: 1000)
        traveler.account = account
        withExtendedLifetime(traveler) {
            account.printSummary()  // 위 메서드가 실행될때까지 객체의 수명을 연장함. 
        }
        
        withExtendedLifetime(traveler) {} // 이와 같이 표현할 수도 있다. 
        
        defer { withExtendedLifetime(traveler) {} } // defer 키워드를 사용해 스코프가 끝날때까지 객체 수명을 보장할수도 있다. 
        
    }

     

    주의할 점

    withExtendedLifetime()는 객체 수명 관련 버그를 쉽게 해결해주는 도구처럼 보일 수 있지만,이 방식은 코드의 정확성을 개발자에게 완전히 위임한다고 합니다.

    다시 말해, weak 참조로 인해 버그가 발생할 수 있는 모든 위치에 일일이 withExtendedLifetime()를 넣어야 하며, 그렇지 않으면 언제든지 버그가 발생할 수 있습니다. 이러한 방식은 결국 코드베이스 전체에 퍼지게 되며,유지보수 비용도 높아지고 실수할 가능성도 커지게 됩니다.

    따라서 Swift에서는 가능한 한 이러한 유틸리티 사용보다는 더 나은 API 구조를 설계하는 방향으로 해결책을 제시합니다. 예를 들어, 약한 참조가 외부에 노출되지 않도록 숨기고,객체 수명을 명확하게 관리할 수 있는 구조를 사용하는 것이 훨씬 견고한 접근 방식입니다.

     

    기법 설명
    withExtendedLifetime {} 객체 수명을 명시적으로 연장
    defer 사용 스코프 끝까지 수명 유지
    API 설계 개선 강한 참조만 허용하도록 인터페이스 리팩터링

    설계 차원 해결: 구조 바꾸기

    • 순환 참조가 필요한 구조 자체를 피하는 것이 가장 좋은 방법
    class Traveler {
        var name: String
        var account: Account?
        func printSummary() {
            if let account = account {
                print("\\(name) has \\(account.points) points")
            }
        }
    }
    
    class Account {
        private weak var traveler: Traveler?
        var points: Int
    }
    
    func test() {
        let traveler = Traveler(name: "Lily")
        let account = Account(traveler: traveler, points: 1000)
        traveler.account = account
        traveler.printSummary()
    }
    

    Observed Lifetime에 의존하지 말고 Guaranteed Lifetime을 설계하라

    1. traveler = Traveler(name: "Lily") 

    2. let account = Account(traveler: traveler, points: 1000)

     

    3. traveler.account = account 

    4. traveler.printSummary() 

    - 여전히 traveler 가 account 를 강한 참조로 가지고 있기 때문에 account가 살아있다. 크래시가 날 일이 없음. 

    • Traveler는 strong reference 객체
    • printSummary()는 Traveler 내부에 존재 ➝ Guaranteed Lifetime 내에서 동작
    • Account의 traveler는 private + weak ➝ 외부 노출 차단, 캡슐화
    • 안전하고 예측 가능한 메모리 흐름 설계

    deinit

    class 가 메모리에서 해제되기 전에 실행되며 side effects 는 외부에서도 관찰할 수 있습니다. 만약 deinit 을 사용해서 외부 프로그램 동작과 side effects 순서를 조정하는 코드를 작성하면 이는 잠재적인 버그를 유발합니다. 이도 객체의 수명이 달라질때 나타나기 때문에 deinit 으로 side effects 를 관리하는 것은 버그를 발생시킬 수 있다는 점을 생각해야합니다!

     

    class Traveler {
      var name: String
      var destination: String?
      deinit {
        print("\\(name) is deinitializing")
      }
    }
    
    func test() {
        let traveler1 = Traveler(name: "Lily")
        let traveler2 = traveler1
        traveler2.destination = "Big Sur"
        print("Done traveling")
    }
    

    다음 코드에서 "Done traveling" , ("\(name) is deinitializing") 이 둘중에 뭐가 더 먼저 출력이 될까요 ? 정답은 확실하지 않습니다.

     

    컴파일러의 최적화에 따라 traveler1 이 해제되는 시점이 언제인지 판단이 다르기 떄문에 순서를 확정 지을 수 없습니다. 이와같이 단순 콘솔을 찍는것은 문제가 없겠지만, deinit 을 활용해 프로그램 로직이 의존된다면 문제가 달라집니다.

     

    좀 더 복잡한 예제를 살펴보겠습니다. 

     

    class Traveler {
        var name: String
        var id: UInt
        var destination: String?
        var travelMetrics: TravelMetrics
        // Update destination and record travelMetrics
        func updateDestination(_ destination: String) {
            self.destination = destination
            travelMetrics.destinations.append(self.destination)
        }
        // Publish computed metrics
        deinit {
            travelMetrics.publish()
        }
    }
    
    class TravelMetrics {
        let id: UInt
        var destinations = [String]()
        var category: String?
        // Finds the most interested travel category based on recorded destinations
        func computeTravelInterest()
        // Publishes id, destinations.count and travel interest category
        func publish()
    }
    
    

    이번에는 Traveler 클래스에 여행 메트릭 시스템을 추가해봅니다. Traveler.destination이 업데이트될 때마다 그 값은 내부의 TravelMetrics 클래스에 기록됩니다. 그리고 Traveler 객체가 해제되는 시점에는,deinit에서 이 메트릭을 publish 로 발행하게 됩니다. 

     

    func test() {
        let traveler = Traveler(name: "Lily", id: 1)
        let metrics = traveler.travelMetrics
        ...
        traveler.updateDestination("Big Sur")
        ...
        traveler.updateDestination("Catalina")
        metrics.computeTravelInterest()
    }
    
    verifyGlobalTravelMetrics()

     

    test() 함수 흐름

    1. Traveler 객체를 생성합니다.
    2. travelMetrics 참조를 Traveler로부터 복사합니다.
    3. destination을 Big Sur로 변경합니다 → 메트릭에 기록
    4. destination을 Catalina로 변경합니다 → 또다시 메트릭에 기록
    5. 마지막으로 관심 카테고리를 계산합니다

    현재는 deinit이 관심사 카테고리 계산 이후에 실행되므로 "Nature"라는 카테고리가 발행됩니다.

    하지만 ARC 최적화가 개입해 Traveler의 마지막 사용 시점인 destination 업데이트 이후에 deinit이 실행되도록 하면, 관심사 카테고리 계산 전에 객체가 해제되면서 nil이 발행되는 버그가 발생합니다.

     

    부작용

    • deinit 안에서 외부 상태 변경(예: 로그 기록, 메트릭 업로드 등)은 위험
    • 컴파일러 최적화에 따라 deinit 시점이 바뀔 수 있어서, 의도치 않은 순서 버그 발생 가능

     

    해결 방법

    이러한 deinit 부작용을 안전하게 처리하기 위한 몇 가지 기법이 있습니다

    1. withExtendedLifetime()

    이 메서드를 이용하면 Traveler 객체의 수명을 명시적으로 연장할 수 있습니다.

    예: 관심사 카테고리를 계산할 때까지는 deinit이 실행되지 않도록 보장.

    하지만 이 방식은 모든 위험 지점을 개발자가 직접 인지하고 처리해야 하므로, 유지 비용이 커지고 실수가 생길 수 있습니다.

     

    2. API 리디자인: Private 처리

    deinit 부작용을 외부에서 의존하지 않도록 하기 위해

    TravelMetrics를 private으로 설정해 외부 접근을 제한합니다.

    이제 메트릭 발행은 deinit에서 내부적으로 처리되며,

    객체가 해제될 때 관심사 카테고리를 계산해서 전역 저장소에 안전하게 발행됩니다.


    3. 가장 원칙적인 해결:

    deinit 에서 side effects 제거

    deinit 안에서 외부 효과를 처리하지 않고, 대신 defer 구문을 사용해 적절한 시점에 안전하게 처리합니다.

    이제 deinit은 단지 검증만 수행하고,모든 중요한 로직은 defer나 명시적 메서드 호출로 이동합니다.

    이렇게 하면 객체 수명에 의존하는 모든 버그 가능성을 제거할 수 있습니다.

     

     

    Xcode 13에서의 추가 사항

    • Swift 컴파일러는 이제 “Optimize Object Lifetimes” 라는 실험적 설정을 지원합니다.
    • 이 설정을 켜면 ARC가 객체를 더 일관되게 조기에 해제할 수 있게 되어, 실제로 객체가 최소 수명만 보장되는 방식으로 작동하게 됩니다.
    • 이 과정에서 숨겨진 객체 수명 버그가 노출될 수 있습니다

    정리하며

    이번 WWDC 세션을 통해 “공유? 그럼 참조타입이니까 그냥 class 써야겠다.” 라고만 생각했던 저의 개발 습관을 되돌아보게 됐습니다.

    그동안은 ARC가 잘 관리해줄 거라 믿고 객체 수명에 대해 깊이 고민해본 적이 없었는데,

    관찰되는 수명(Observed Lifetime)과 실제 보장되는 수명(Guaranteed Lifetime) 사이에 차이가 있다는 걸 알고 나니,

    내가 짠 코드가 언젠가 버그로 돌아올 수도 있겠다는 경각심이 들더라고요.

    Swift의 ARC는 굉장히 똑똑한 시스템이지만,

    컴파일러의 최적화 방식에 따라 예상과 다른 시점에 객체가 해제될 수 있다는 점은 반드시 개발자가 이해하고 있어야 한다는 걸 느꼈어요.

    이제는 무조건 클래스만 쓰기보다는, 더 나은 구조 설계와 객체 수명에 대한 고려가 필요하다는 걸 배운 시간이었습니다.

    다음부터는 Swift의 메모리 관리 흐름도 염두에 두며 더 안전하고 예측 가능한 코드를 짜보려고 합니다. 😊

     

    [참고 문헌] 

    https://developer.apple.com/kr/videos/play/wwdc2021/10216/

Designed by Tistory.