Swift의 Automatic Reference Counting (ARC)와 iOS 앱 메모리 관리

Flamozzi·2023년 3월 3일
0

Swift

목록 보기
2/2
post-thumbnail

개요: Swift에서는 메모리를 어떻게 관리할까?

iOS 앱을 개발할 때, 메모리 관리는 매우 중요한 고려 사항 중 하나이다. 메모리 누수는 앱 성능에 영향을 미치며, 중대한 문제를 야기할 수 있다. 이를 방지하기 위해, Swift에서는 앱의 메모리 사용을 관리하기 위해 ARC(Automatic Reference Counting)을 사용한다. Swift를 포함하여 다른 언어들 또한 각자 메모리 관리 모델을 가지고 있다. 가령 Java 혹은 Python의 경우 GC(Garbage Collection)을 메모리 관리 모델로써 사용한다. 본 문서에서는 ARC와 다른 언어의 GC과의 차이점을 비교하고, ARC가 iOS 앱의 메모리 관리를 어떻게 효율적으로 수행하는지에 대해서 알아보고자 한다.

Automatic Reference Counting (ARC)와 Garbage Collection

Garbage Collection은 런타임에서 메모리를 관리하는 방식이다. Garbage Collection은 더 이상 필요하지 않은 객체를 추적하여 메모리를 해제한다. 이는 프로그래머가 관리에 대한 책임을 덜어주지만, 런타임에 추가적인 오버헤드가 발생하고, 메모리 누수와 같은 문제가 발생할 수 있다.

Swift의 ARC는 컴파일 시간에 코드에서 참조하는 객체의 수를 추적하고, 해당 객체의 참조가 더 이상 필요하지 않은 경우 즉시 메모리를 해제한다. 이러한 방식으로, ARC는 런타임 오버헤드를 피하면서도 메모리 누수를 방지할 수 있다.

ARC의 작동 방식

ARC는 런타임에 메모리 사용을 추적하며, 객체에 대한 참조 수를 계산한다. 객체는 생성되면 참조 수가 1 증가하고, 참조가 끝나면 참조 수가 1 감소한다. 참조 수가 0이 되면, 해당 객체는 더 이상 사용되지 않으므로 메모리에서 해제된다.

ARC는 다음과 같은 규칙을 따른다.

  1. 객체가 생성될 때, 해당 객체의 참조 수는 1이다.
  2. 객체를 다른 변수나 상수에 할당하면, 해당 객체의 참조 수가 1 증가한다.
  3. 참조 변수가 nil로 설정되면, 해당 객체의 참조 수가 1 감소한다.
  4. 객체를 참조하는 컨테이너 객체가 해제되면, 해당 객체의 참조 수가 1 감소한다.

ARC와 강한 참조 순환 문제

객체에 대한 참조는 강한 참조(Strong Reference)약한 참조(Weak Reference), 비소유 참조(Unowned Reference) 세 가지 유형이 있다.

강한 참조 순환 문제(Strong Reference Cycle)는 두 개 이상의 객체가 서로를 강한 참조하는 상황을 의미한다. 이러한 상황이 발생하면, 두 객체 모두 참조 수가 0이 되지 않아 메모리 누수가 발생한다.

Swift는 이러한 문제를 해결하기 위해, 약한 참조와 비소유 참조를 제공한다. 약한 참조와 비소유 참조를 사용하면 해당 객체를 참조하는 다른 객체의 강한 참조 카운트를 감소시키지 않아서, 참조 대상이 메모리에서 해제되더라도 약한 참조와 비소유 참조를 통해 참조하고 있는 객체에는 nil 값이 할당된다. 이를 이용하면, 약한 참조와 비소유 참조를 사용하여 참조하고 있는 객체가 메모리에서 해제되는 시점을 감지할 수 있으며, 이러한 방식으로 참조하고 있는 객체의 해제 여부를 확인하고, 필요한 경우 객체를 적절히 처리할 수 있다.
만약 객체 A가 객체 B를 참조하고 있고, 객체 B가 객체 A를 참고하고 있다면, ARC는 이러한 상황을 인식하여 메모리 누수를 방지하기 위해 이를 강한 참조 순환 문제로 판단하고, 약한 참조나 비소유 참조를 사용하여 해결하는 방식인 것이다.

ARC의 장단점

ARC의 장점은 다음과 같다.

  1. 런타임 오버헤드가 적다.
  2. 메모리 누수를 방지할 수 있다.
  3. 개발자가 직접 메모리 관리를 수행할 필요가 없다.

그러나, ARC는 다음과 같은 단점도 가지고 있다.

  1. 강한 참조 순환 문제를 해결하기 위해 약한 참조나 비소유 참조를 사용해야 한다.
  2. ARC는 다중 스레드 환경에서 안전하지 않을 수 있다.

단점의 각 요소가 단점으로 작용하는 이유를 조금만 더 자세하게 적어보고자 한다.

ARC에서 약한 참조나 비소유 참조를 사용하는 것이 단점이 되는 경우는 두 가지이다.

첫째, 약한 참조나 비소유 참조는 참조 대상이 해제되어도 해당 객체의 참조 카운트를 감소시키지 않는다. 따라서, 객체를 참조하고 있는 다른 객체가 여전히 해당 객체에 대한 강한 참조를 유지하고 있다면, 해당 객체는 메모리에서 해제되지 않고 계속 남아 있게 된다. 이러한 현상을 "weak 또는 unowned 참조의 dangling" 이라고 한다. 이러한 문제를 방지하기 위해서는 객체가 해제되었는지 확인하고, 객체가 해제된 후에 약한 참조나 비소유 참조를 사용하면 안 된다.

둘째, 다중 스레드 환경에서 약한 참조나 비소유 참조를 사용하면, 참조하려는 객체가 이미 메모리에서 해제되어 버린 상태라면, 액세스 오류(access violation)가 발생할 수 있다. 이는 다중 스레드 환경에서 안전하지 않을 수 있으며 주의가 필요하다.

조금 더 부연 설명을 하자면, 약한 참조가 참조하는 객체가 메모리에서 해제(deallocated)될 때 자동으로 nil로 설정된다. 따라서 앱이 약한 참조를 사용하여 해제된 객체에 접근하려고 할 때 nil을 반환하여, 앱 충돌을 일으키지 않는다. 하지만 약한 참조를 잘못 사용하는 경우, 객체가 아직 메모리에 유지되고 있지만 이미 해제되었다고 착각하여 앱 충동을 일으키는 경우가 있다. 이러한 상황을 방지하기 위해서는 옵셔널 바인딩(optional binding) 또는 옵셔널 체이닝(optional chaining)을 사용하여 약한 참조를 안전하게 사용하여야 한다.

따라서, ARC에서 약한 참조나 비소유 참조를 사용할 때는, 해당 객체에 대한 다른 객체들의 강한 참조 여부를 확인하고, 다중 스레드 환경에서의 안정성을 고려해야 함을 알 수 있다.

이어서, ARC는 다중 스레드 환경에서 안전하지 않은 이유를 조금 더 자세하게 다루어 보자면 다음과 같다.

ARC가 다중 스레드 환경에서 안전하지 않을 수 있다는 이유는, ARC는 객체의 참조 카운트를 증가시키거나 감소시키는 작업이 복잡한 연산을 필요로 하기 때문이다. 만약 다른 스레드에서 동시에 객체의 참조 카운트를 변경하는 작업을 수행하면, 이러한 연산이 충돌하여 예상치 못한 결과가 발생할 수 있기 때문이다.

따라서, ARC는 단일 스레드 환경에서만 안전하게 동작한다는 것을 알고 있어야 한다. 만약 다중 스레드 환경에서 객체를 공유하는 경우, 해당 객체에 대한 참조 카운트를 조작하는 작업은 동기화 메커니즘을 사용하여 스레드 간 충돌을 방지해야 한다. 이를 통해 객체를 안전하게 참조하고 메모리 누수와 같은 문제를 예방할 수 있다.

참고: 동기화 메커니즘은 다음과 같은 방법들이 있다.
1. 뮤텍스(Mutex): 뮤텍스는 임계 구역(critical section)에 대한 동시 엑세스를 막는 가장 기본적인 동기화 메커니즘이다. 임계 구역에 진입할 때 뮤텍스를 획득하고, 나올 때 뮤텍스를 해제하는 방식으로 동기화를 수행한다.
2. 세마포어(Semaphore): 세마포어는 공유 자원의 동시 엑세스를 제어하기 위한 동기화 메커니즘이다. 세마포어는 일정한 수의 스레드만이 임계 구역에 진입할 수 있도록 허용하는 방식으로 동기화를 수행한다.
3. 스핀락(Spinlock) : 스핀락은 뮤텍스와 비슷한 기능을 수행하지만, 뮤텍스와 달리 대기 중인 스레드를 블록하지 않고 계속해서 CPU를 점유하며 대기하는 방식으로 동기화를 수행한다.

Swift에서 ARC를 사용한 앱 메모리 관리 예제

class Person {
    var name: String
    var apartment: Apartment?
    
    init(name: String) {
        self.name = name
        print("\(name) is being initialized")
    }
    
    deinit {
        print("\(name) is being deinitialized")
    }
}

class Apartment {
    var unit: String
    var tenant: Person?
    
    init(unit: String) {
        self.unit = unit
        print("Apartment \(unit) is being initialized")
    }
    
    deinit {
        print("Apartment \(unit) is being deinitialized")
    }
}

var john: Person?
var unit4A: Apartment?

john = Person(name: "John Appleseed")
unit4A = Apartment(unit: "4A")

john!.apartment = unit4A
john?.apartment = nil
unit4A!.tenant = john

john = nil
unit4A = nil

// Output:
// John Appleseed is being initialized
// Apartment 4A is being initialized
// John Appleseed is being deinitialized
// Apartment 4A is being deinitialized

위 코드에서는 'Person' 클래스와 'Apartment' 클래스를 정의하고, 'john' 인스턴스와 'unit4A' 인스턴스를 생성한다. 'john' 인스턴스는 'apartment' 프로퍼티를 가지며, 'unit4A' 인스턴스는 'tenant' 프로퍼티를 가진다.

'john' 인스턴스가 'apartment' 프로퍼티를 참조하고, 'unit4A' 인스턴스가 'tenant' 프로퍼티를 참조하므로, 두 객체는 강한 참조(Strong Reference)를 갖는다.

하지만, 'john''unit4A'를 nil로 설정한 후에는 두 객체가 더 이상 참조되지 않으므로, ARC는 이를 인식하고 두 객체를 메모리에서 해제한다. 이러한 과정에서 'deinit' 메서드가 호출되어 객체의 메모리 해제 과정이 출력된다.

이 예제에서는 개발자가 메모리 관리를 수행할 필요가 없으며, ARC가 자동으로 메모리를 관리하는 것을 확인할 수 있다.

결론

Swift의 ARC는 Garbage Collection과 비교하여 런타임 오버헤드가 적고 메모리 누수를 방지할 수 있다. 또한, 개발자가 메모리 관리를 수행할 필요가 없기 때문에 개발 생산성을 높일 수 있다. 그러나, 강한 참조 순환 문제와 다중 스레드 환경에서의 문제에 대해 유의해야 한다.


References

profile
개발도 하고, 글도 적고

0개의 댓글