[Swift] GC가 없다고..? 메모리 관리의 세계

SwiftARC
avatar
2025.03.20
·
19 min read

Python을 주로 사용하다가 Swift를 공부하면서, 메모리 관리의 신세계를 경험하게 됐다. Python에서는 GC 덕분에 메모리 관리를 걱정할 일이 거의 없었는데, Swift에서는 ARC라는 메모리 관리 방식이 사용되고(물론 Python도 Reference Counting은 사용하지만..), 이게 생각보다 신경 쓸 게 많았다.

이 글에서는 Swift의 메모리 관리에 대해 정리하고자 한다. (그런데 이제 Python과의 비교를 곁들인..)


Automatic Reference Counting (ARC)

Swift에서는 ARC를 사용해서 메모리를 관리한다. Python의 참조 카운팅과 비슷하지만 중요한 차이점이 있다. Python은 GC를 함께 사용한다는 점이다.

Swift와 Python 모두 객체가 참조될 때마다 카운트가 증가하고, 참조가 해제되면 카운트가 감소한다. 이 방식에는 객체들이 서로를 참조하는 순환 참조라는 문제가 존재하는데, Python에서는 GC가 순환 참조를 찾아서 메모리를 해제시켜준다. 반면, Swift는.. weak, unowned 키워드를 사용해서 수동으로 해결해야 한다.

Python

Swift

메모리 관리 방식

참조 카운팅 + 가비지 컬렉션

참조 카운팅

순환 참조 문제

가비지 컬렉터로 해결

waek 또는 unowned 키워드 사용

성능 영향

주기적으로 GC가 실행되어 오버헤드 발생

컴파일 타임 최적화로 성능 손실이 적음

명시적 관리

가능 (gc.collect())

불가능 (ARC가 자동으로 관리)

순환 참조 문제

Swift에서는 순환 참조 문제를 방지하고 메모리 관리를 더욱 효율적으로 하기 위해 약한 참조(Weak Reference)와 강한 참조(Strong Referenec)라는 개념을 도입했다.

그렇다면 이 약한 참조와 강한 참조라는 것은 무엇이고, 왜 필요할까?

우선 순환 참조 문제를 조금 더 자세히 들여다 보자. 객체가 참조될 때 카운트가 증가하고, 해제되면 감소하여 결과적으로 카운트가 0이 되면 메모리에서 해제된다. 하지만, 두 객체가 서로를 강하게 참조하게 될 경우 참조 카운트가 절대 0이 되지 않는 문제가 발생한다.

class A {
    var b: B?
}

class B {
    var a: A?
}

var objectA: A? = A()  // objectA 변수가 A 인스턴스를 참조하므로 참조 카운트 증가 (1)
var objectB: B? = B()  // objectB 변수가 B 인스턴스를 참조하므로 참조 카운트 증가 (1)

objectA?.b = objectB  // objectA의 프로퍼티 b가 objectB를 참조하므로 참조 카운트 증가 (2)
objectB?.a = objectA  // objectB의 프로퍼티 a가 objectA를 참조하므로 참조 카운트 증가 (2)

objectA = nil  // objectA 변수의 참조 해제 (1)
objectB = nil  // objectB 변수의 참조 해제 (1)

위 코드를 살펴보면,

  1. 인스턴스 생성 시의 참조 카운트 증가:

    • var objectA: A? = A() → A 인스턴스의 참조 카운트 = 1

    • var objectB: B? = B() → B 인스턴스의 참조 카운트 = 1

  2. 프로퍼티 할당으로 인한 추가 참조 카운트 증가:

    • objectA?.b = objectB → B 인스턴스의 참조 카운트가 1 증가하여 2가 됨.

    • objectB?.a = objectA → A 인스턴스의 참조 카운트가 1 증가하여 2가 됨.

  3. 인스턴스 해제 시도의 문제점:

    • objectA = nil → A 인스턴스의 참조 카운트가 2 → 1로 감소.

    • objectB = nil → B 인스턴스의 참조 카운트가 2 → 1로 감소.

결과적으로, 참조 카운트가 1로 남아 있기 때문에 ARC는 메모리에서 해제하지 못하게 된다. 따라서, 메모리 누수가 발생하게 된다.

그래서 강한 참조가 뭐고, 약한 참조가 뭔데?

강한 참조

  • Swift에서 기본적으로 사용하는 참조 방식

  • 참조 카운트가 증가하는 참조 방식

약한 참조

  • 참조 카운트가 증가하지 않는 참조 방식

  • 참조 대상이 메모리에서 해제되면 자동으로 nil로 설정됨

  • weak 키워드로 사용

  • 주로 delegate 패턴에 사용

미소유 참조

  • 참조 카운트가 증가하지 않는 참조 방식

  • 참조 대상이 메모리에서 해제된 후에도 nil로 설정되지 않음

  • 참조 대상이 메모리에서 해제된 상태에서 접근하면 에러 발생

  • unowned 키워드로 사용한다.

  • 영구적으로 참조가 유지될 때 사용

이제 강한 참조와 약한 참조에 대해 알았으니, 위에서 사용한 예제 코드로 다시 돌아가서 순환 참조 문제를 해결해보자.

class A {
    weak var b: B?
}

class B {
    weak var a: A?
}

var objectA: A? = A()  // objectA 변수가 A 인스턴스를 참조하므로 참조 카운트 증가 (1)
var objectB: B? = B()  // objectB 변수가 B 인스턴스를 참조하므로 참조 카운트 증가 (1)

objectA?.b = objectB  // 약한 참조로 참조 카운트가 증가하지 않음 (1)
objectB?.a = objectA  // 약한 참조로 참조 카운트가 증가하지 않음 (1)

objectA = nil  // objectA 변수의 참조 해제 (0)
objectB = nil  // objectB 변수의 참조 해제 (0)

weak 키워드를 사용해서 프로퍼티를 약한 참조로 설정했으므로,

  1. 인스턴스 생성 시의 참조 카운트 증가:

    • var objectA: A? = A() → A 인스턴스의 참조 카운트 = 1

    • var objectB: B? = B() → B 인스턴스의 참조 카운트 = 1

  2. 약한 참조로 프로퍼티 할당 시 참조 카운트 증가 없음:

    • objectA?.b = objectB → B 인스턴스의 참조 카운트 = 1

    • objectB?.a = objectA → A 인스턴스의 참조 카운트 = 1

  3. 인스턴스 해제:

    • objectA = nil → A 인스턴스의 참조 카운트가 1 → 0으로 감소.

    • objectB = nil → B 인스턴스의 참조 카운트가 1 → 0으로 감소.

이제 메모리 누수가 발생하지 않으니, 잘 해결된 걸까?

class A {
    var b: B?
}

class B {
    weak var a: A?
}

var objectA: A? = A()  // objectA 변수가 A 인스턴스를 참조하므로 참조 카운트 증가 (1)
var objectB: B? = B()  // objectB 변수가 B 인스턴스를 참조하므로 참조 카운트 증가 (1)

objectA?.b = objectB  // objectA의 프로퍼티 b가 objectB를 참조하므로 참조 카운트 증가 (2)
objectB?.a = objectA  // 약한 참조로 참조 카운트가 증가하지 않음 (1)

objectA = nil  // objectA 변수의 참조 해제 (0)
objectB = nil  // objectB 변수의 참조 해제 (0)

이번에는 하나의 프로퍼티만 weak으로 선언했다.

  1. 인스턴스 생성 시의 참조 카운트 증가:

    • var objectA: A? = A() → A 인스턴스의 참조 카운트 = 1

    • var objectB: B? = B() → B 인스턴스의 참조 카운트 = 1

  2. A 인스턴스의 프로퍼티 할당으로 인한 추가 참조 카운트 증가:

    • objectA?.b = objectB → B 인스턴스의 참조 카운트가 1 증가하여 2가 됨.

  3. B 인스턴스의 약한 참조로 프로퍼티 할당 시 참조 카운트 증가 없음

    • objectB?.a = objectA → A 인스턴스의 참조 카운트 = 1

  4. A 인스턴스 해제:

    • objectA = nil

      • A 인스턴스의 참조 카운트가 1 → 0으로 감소.

      • B 인스턴스의 프로퍼티 a가 weak로 선언되었으므로, A 인스턴스가 메모리에서 해제되면서 nil로 설정됨.

      • A 인스턴스가 메모리에서 해제되면서 A 인스턴스가 가지고 있던 모든 참조가 함께 사라짐.

      • 즉, objectA.b = objectB로 증가했던 B 인스턴스의 참조 카운트가 2 → 1로 감소.

  5. B 인스턴스 해제:

    • objectB = nil → B 인스턴스의 참조 카운트가 1 → 0으로 감소.

우리가 weak 키워드로 프로퍼티를 약한 참조로 설정하는 이유는 참조 카운트가 증가하는 것을 방지하여 순환 참조를 끊기 위함이다. 두 프로퍼티 모두 weak로 설정하든, 하나의 프로퍼티만 weak로 설정하든 순환 참조는 끊어졌으니 목적을 달성한 게 아닐까?

만약 두 프로퍼티 모두를 weak로 설정한다면, objectAobjectB가 서로의 인스턴스를 참조하고 있는 경우에도 참조 카운트가 증가하지 않는다. 즉, 인스턴스가 언제든지 nil이 될 수 있다. 이렇게 할 경우 불필요하게 약한 참조를 사용해서, 참조 카운트 증가를 막고자 하는 목적을 넘어 불안정한 참조 상태가 될 수 있다.

무슨 말인지 잘 이해가 안된다면 A 객체를 회사, B 객체를 직원으로 생각해보자.

  • 회사는 직원을 고용한다. (강한 참조)

  • 회사가 망하면 (메모리에서 해제되면) 직원들도 자동으로 퇴사 (참조 카운트가 감소)해야 한다.

  • 직원은 회사에 취직한다. (약한 참조)

  • 직원이 퇴사한다고 해서 (메모리에서 해제된다고 해도) 회사가 사라지지는 (참조 카운트는 감소되지) 않는다.

위의 예시처럼 일반적으로, 부모-자식 관계에서 부모는 자식을 강하게 참조하고 자식은 부모를 약하게 참조하는 구조가 안전하다.

클로저에서의 강한 참조 사이클

클로저는 클래스 인스턴스를 캡처할 때 강한 참조를 생성할 수 있다. 특히 UIViewController 같은 클래스 내부에서 클로저를 사용하면서 self를 캡쳐하면 문제가 발생할 수 있다.

그렇다면 왜 문제가 발생할까?

클로저는 기본적으로 self를 강하게 캡처한다. 즉, 클로저가 실행될 때 self가 메모리에서 해제되지 못하게 막는다. 이게 문제가 되는 이유는 클로저를 ViewController나 다른 클래스 내부에서 사용하면서 메모리 해제를 방해하기 때문이다.

class ViewController: UIViewController {
    var name = "Alice"

    func fetchData() {
        DispatchQueue.global().async {
            print("Hello, \(self.name)")  // self를 강하게 참조
        }
    }
}

위 코드에서 fetchData() 메서드가 비동기로 실행될 때 self를 강하게 참조한다. 만약 ViewController 인스턴스가 메모리에서 해제되어야 하는 상황이어도, 클로저가 강하게 잡고 있기 때문에 해제되지 않을 수 있다.

func fetchData() {
    DispatchQueue.global().async { [weak self] in
        guard let self = self else { return }
        print("Hello, \(self.name)")  // 안전하게 self 사용
    }
}

따라서 weak 또는 unowned 키워드를 사용해서 약한 참조로 설정해야 한다. 보통 안전하게 weak 키워드를 사용한다.

글로벌 변수 및 Singleton (전역 인스턴스)

ARC는 전역 변수나 Singleton 객체 같은 전역 인스턴스 메모리를 자동으로 해제하지 않는다. 따라서 사용이 끝났다고 해도 메모리에 계속 남아 있게 된다.

class Logger {
    static let shared = Logger()  // 전역 인스턴스 (Singleton)
    var logs: [String] = []
    
    private init() {}
    
    func log(message: String) {
        logs.append(message)
    }
}

class ViewController {
    var logger = Logger.shared  // Singleton 객체를 강하게 참조
    var text = "Hello, World!"
    
    func doSomething() {
        logger.log(message: text)  // Logger가 text를 강하게 참조
    }
}

위 예제에서 문제점은 크게 세 가지다.

  1. Logger.shared는 전역 인스턴스로 메모리에서 자동으로 해제되지 않음.

  2. ViewControllerLogger.shared를 참조하고 있고, 그 안의 logs 배열이 계속해서 데이터를 추가하면서 메모리가 계속 사용됨.

  3. ViewController가 메모리에서 해제되어도 Logger.shared가 메모리에 남아 있기 때문에 데이터가 계속 쌓일 수 있음.

class Logger {
    static let shared = Logger()
    var logs: [String] = []
    
    private init() {}
    
    func log(message: String) {
        logs.append(message)
    }
    
    func clearLogs() {  // 사용이 끝난 후 메모리를 해제하는 메서드 추가
        logs.removeAll()
    }
}

class ViewController {
    var logger = Logger.shared
    var text = "Hello, World!"
    
    func doSomething() {
        logger.log(message: text)
    }
    
    deinit {
        logger.clearLogs()  // 뷰 컨트롤러가 해제될 때 로그도 정리
    }
}

가장 중요한 것은 전역 인스턴스의 사용을 줄이는 것이다. 필요한 경우, 싱글톤이 self를 강하게 참조하지 않도록 주의해야 한다. 또한 객체가 참조를 유지할 필요가 없을 경우, 인스턴스를 제거하거나 nil로 설정하는 방법도 고려할 수 있다.

비동기 작업 (Async Operation)

비동기 작업은 DispatchQueue, URLSession, Timer 등을 사용할 때 발생할 수 있다. 비동기 작업의 클로저가 클래스 인스턴스를 강하게 캡처하는 경우, 메모리에서 해제되지 않는 문제가 발생할 수 있다.

class DataFetcher {
    var data: String = ""
    
    func fetchData() {
        URLSession.shared.dataTask(with: URL(string: "https://example.com")!) { data, response, error in
            if let data = data {
                self.data = String(data: data, encoding: .utf8) ?? ""
            }
        }.resume()
    }
}

URLSession의 클로저 내부에서 self를 사용하고 있어 DataFetcher 인스턴스가 네트워크 요청이 끝날 때까지 메모리에서 해제되지 않는다. 따라서, 네트워크 요청이 오래 걸리거나 오류가 발생하면 메모리 누수가 발생할 수 있다.

class DataFetcher {
    var data: String = ""
    
    func fetchData() {
        URLSession.shared.dataTask(with: URL(string: "https://example.com")!) { [weak self] data, response, error in
            guard let self = self else { return }  // self가 해제되면 코드 실행 중단
            
            if let data = data {
                self.data = String(data: data, encoding: .utf8) ?? ""
            }
        }.resume()
    }
}

weak 키워드를 사용하면 self를 강하게 캡처하지 않기 때문에, 인스턴스가 필요 없으면 메모리에서 해제된다. 즉, URLSession의 요청이 끝나기 전에 DataFetcher가 사라져도 문제가 되지 않는다.







- 컬렉션 아티클