본문 바로가기
IOS App Programming/Swift

ARC란? - 강한참조와 약한참조, [weak self]

by B_Tori 2024. 1. 31.

메모리 공간 구조 알아보기

프로그램을 실행하게 되면 운영체제는 메모리에 프로그램을 위한 공간을 할당함

할당된 메모리 공간은 아래와 같이 코드영역, 데이터 영역, 힙 영역, 스택 영역으로 나누어져 있음

출처: TCP SCHOOL

그림에 써져 있는 설명처럼

  • 코드 영역: 실행한 프로그램의 코드를 기계어로 변환하여 저장하고 있음 (컴파일 타임 시 결정)
  • 데이터 영역: 전역변수, static 변수 저장 (시작과 동시에 할당, 프로그램이 종료되어야 메모리 해제)
  • 힙 영역 : 프로그래머가 동적으로 할당/해제하는 메모리 영역 (런타임 시 결정)
  • 스택 영역: 지역 변수, 매개 변수 등이 저장되고 해당 변수를 사용하는 함수 종료 시 메모리 해제됨 (컴파일 타임 시 결정)

ARC란?

ARC란?

Automatic Reference Counting의 약자를 따 ARC라고 불리며 스위프트 프로그래밍의 메모리 사용을 관리하기 위하여 사용하는 메모리 관리기법으로 해석 그대로 자동으로 참조 관계를 카운팅 하여 메모리를 관리하는 방법이다.

 

ARC는 동적으로 할당되는 힙영역과 관련되어 있다.

힙영역에 대해 조금 더 자세히 알아보면

데이터 크기가 불확실할 때 사용되며 사용자가 직접 메모리 할당을 하여 (malloc, calloc) 다 사용하고 난 뒤에는 메모리 해제가 필요하다.

Swift에서 힙 영역을 할당하는 방법은 참조 타입 값을 사용하는 것이다.

즉 참조 타입 값을 사용하면 힙 영역에 할당된다.

 

그런데 우리는 코드를 작성할 때 메모리 해제를 해준 적이 없는데?

⇒ 여기서 ARC가 힙에 할당된 메모리가 더 이상 사용되지 않는다 판단되면 자동으로 해제해 주기 때문이다

 

ARC는 어떻게 해제할 시점을 판단할까?

ARC는 Automatic Reference Counting의 약자로 영어 뜻처럼 참조 횟수를 카운팅 하여

해당 카운팅이 0이 되면 더 이상 필요 없다 판단되어 메모리에서 해제시킨다.

그렇다면 카운팅을 어떻게 할까?

특정 인스턴스를 참조하는 변수, 상수, 프로퍼티가 생기면 카운팅을 +1 한다

그리고 해당 변수, 상수, 프로퍼티가 nil이 되거나 메모리에서 사라지면 카운팅은 -1 한다.

변수에 인스턴스를 할당할 때 메모리 모습

let a = A(name: “a”, age: 20)처럼 a라는 변수에 A클래스 인스턴스를 할당하게 되면

인스턴스 내용은 힙 영역에 저장되어 스택에는 지역변수 자체가 저장되어 인스턴스의 힙영역 주소값을 스택에서 가지고 있게 된다.

RC : Reference Counting = 참조 카운트 의미

ARC와 같은 RC방식의 장단점은

[장점]

개발자가 참조 해제 시점을 알 수 있으며 컴파일 시점에 참조가 모두 계산되기 때문에 런타임 시 추가 리소스가 발생하지 않는다.

[단점]

순환참조 발생 시 영구적으로 메모리가 해제되지 않을 위험성이 있다.

 

그렇다면 순환참조란 무엇일까?

순환참조는 약한 참조와 강한 참조도 같이 알아봐야 한다.

강한 참조와 약한 참조 그리고 순환참조

강한 참조 : strong

인스턴스의 주소값이 변수에 할당될 때 RC가 증가하면 강한 참조이다.

즉 별다른 선언 없이 사용하게 되면 디폴트값으로 strong으로 할당된다.

근데 이 강한 참조가 순환 참조를 만들어낸다.

순환참조

두 개의 객체가 서로를 참조하고 있는 상황에 발생한다.

즉 A객체에서 B객체를 프로퍼티로 가지고 있고 B객체에서 A객체를 프로퍼티로 가지고 있다고 하자.

예를 들어서 아래와 같은 코드로 작성되었다고 가정하자

class A {
	let name: String
	let bObject: B?
	
	init(name: String) {
		self.name = name
	}
}
class B {
	let name: String
	let aObject: A?

	init(name: String) {
		self.name = name
	}
}
let a: A? = A(name: "a")
let b: B? = B(name: "b")

a?.bObject = b
b?.aObject = a

위와 같은 메모리 구조를 가지게 된다.

이때 a와 b에 nil을 할당하게 되면

a와 b가 참조하고 있던 카운팅이 사라지게 되면서 0x1111과 0x2222의 rc가 1씩 줄어들게 된다.

그러나 서로를 참조하고 있던 탓에 각각의 rc값이 0이 되지 못해 어느 누구 하나 메모리에서 해제되지 못해 0x1111과 0x2222는 계속해서 메모리에 남아있게 된다.

 

이러한 상황이 ARC의 단점에서 말했던 순환참조 발생 시 영구적으로 메모리가 해제되지 않을 위험성이다.

 

아래는 다른 객체의 참조가 있지만 순환 참조가 발생하지 않는 상황이다.

let a = A(name: "a" bObject: B(name: "b")) 와 같은 코드를 작성한 상황이다.

위의 순환 참조의 경우는 서로가 서로를 참조하고 있었지만 이번에는 한쪽에서만 참조하고 있다.

이때 메모리 할당과 해제를 살펴보면 아래와 같은 순서로 이루어진다.

순서대로 따라가 보면

1. a가 nil이 되면서 a가 참조하고 있던 인스턴스의 RC가 -1이 된다.

2. 즉 0x1111의 RC가 0이 된다.

3. 0x1111의 RC가 0이 되면서 ARC에 의해 자동으로 메모리에서 해제되게 된다.

0x1111의 메모리가 해제되면서 0x1111이 가리키고 있던 참조들이 모두 사라지면서 RC를 -1 한다.

4. 0x2222의 RC가 -1 되어 0이 된다. 0이 된 0x2222 또한 ARC에 의해 메모리에서 해제되면서 끝이 난다.

 

 

그렇다면 이러한 순환참조를 해결하려면 어떻게 해야 할까?

weak와 unowned을 사용한다.

약한 참조

weak를 약한 참조라 하며 인스턴스를 참조할 때 RC를 증가시키지 않는다.

또한 참조하는 인스턴스가 메모리에서 해제된 경우 자동으로 nil이 할당된다.

⇒ nil이 할당될 수 있기 때문에 weak로 선언한 변수는 옵셔널 타입이 여야 한다.

예를 들어서 아까와 같은 순환 참조 상황에 a의 bObject를 weak로 선언하면

weak로 선언한 변수는 RC를 증가시키지 않기 때문에 0x2222의 RC는 1이 된다.

아까와 똑같이 a, b 두 변수를 nil로 할당하게 되면

  1. 두 변수가 nil 처리됐으므로 a와 b가 가리키고 있던 인스턴스들의 RC가 1씩 감소한다.
  2. 둘 다 각각 -1씩 처리되어 0x1111의 RC = 1, 0x2222의 RC = 0 이 된다.
  3. 0x2222가 RC가 0이 되면서 ARC에 의해 메모리에서 해제되어 0x2222에서 가리키고 있던 aObject의 RC가 1 줄어든다. 따라서 0x1111의 RC가 마지막으로 0 이 되면서 할당된 모두가 메모리에서 해제된다.

참조하는 인스턴스가 메모리에서 해제된 경우 자동으로 nil이 할당된다는 것은

(그림에는 깜빡하고 나타내지 못했지만)

0x2222가 메모리에서 해제되면서 0x2222를 참조하고 있던 0x1111의 bObject의 값이 nil로 할당된다는 것이다.

 

미소유 참조

그렇다면 unowned는 무엇일까?

약한 참조 weak와 비슷하지만 한 가지 큰 차이점이 있다.

weak의 특징 중 하나인 참조하는 인스턴스가 메모리에서 해제된 경우 자동으로 nil이 할당되는 것과 다르게 참조하고 있는 도중에 해당 인스턴스가 메모리에서 사라질 일 없다고 확신하여 nil이 할당되지 않는다.

 

즉 강제 옵셔널 언랩핑 같은 느낌이다.

참조하는 도중에 인스턴스가 메모리에서 사라지면 nil이 할당되지 않고 주소값을 계속 들고 있는다.

따라서 이를 접근하려고 하면 메모리에 해당 주소값에 데이터가 없으므로 에러를 발생시킨다.

 

예를 들어 아까 weak로 선언한 변수를 unowned로 선언했다고 하자.

b만 nil이 되었다고 하면 b가 가리키고 있는 0x2222의 RC가 1 감소된다.

그러면 최종 RC = 0으로 ARC에 의해 메모리에서 해제되게 된다.

메모리에서 해제되면서 0x2222가 참조하고 있던 RC가 -1 되어 0x1111의 RC = 1이 된다.

0x1111은 아직 RC가 0이 아니므로 메모리에서 해제되지 않은 상황이다.

 

이때 weak의 경우 bObject에서 참조하고 있던 인스턴스(0x2222)가 사라지면서 bObject는 nil이 되지만

unowned의 경우는 0x2222를 그대로 가지고 있게 된다.

따라서 a.bObject와 같은 접근을 하게 되는 경우

weak 변수의 경우 nil을 반환하여 에러가 생기지 않지만

unowned 변수의 경우 없는 메모리 주소인 0x2222를 반환하여 에러가 발생하게 된다.

[weak self] 란?

클로저를 사용하면서 수도 없이 많이 쓰던 [weak self]는 과연 무엇일까?

메모리 관련해서 꼭 써줘야 한다는 이야기를 들어 사용했지만 제대로 알아보지는 않았었다.

ARC에 대해 공부하면서 weak self를 왜 써야 하는지 제대로 알아보았다.

우선 클로저의 경우 값캡처라는 개념이 있다

값 캡처란?
클로저 외부의 값을 클로져 내부에서 사용할 때 내부적으로 저장하고 있는 것을 해당 외부의 값을 캡처한다고 한다.

캡처 리스트

클로저는 값을 캡처할 때 Value Type / Reference Type 관계없이 Reference Capture를 하게 된다.

그렇다면 참조가 아닌 값을 복사해서 캡처하는 방법은 없을까?

⇒ 캡처 리스트를 사용하면 값을 복사하여 캡처할 수 있다.

캡쳐 리스트의 사용 방법은

클로저 시작 부분은 [ ]을 이용하여 캡처할 값을 명시해준다.

let closer = { [num1 num2 ...] in
	// 클로저 내용
}

 

[캡처할 값 나열] in과 같은 형태로 사용되며

이를 통해 값을 복사하여 캡처하게 된다.

즉 클로저 선언 → 중간에 캡처값 변경 → 클로저 실행과 같은 순서일 때

  • 기존 참조 캡처일 경우 참조된 값을 사용하므로 클로저 실행 시 변경된 값을 사용
  • 캡처 리스트를 사용하여 값 복사 캡쳐일 경우 선언 시 복사한 값을 그대로 실행 시 사용, 즉 변경된 값이 반영되지 않음

하지만 애초에 참조 타입의 경우는 캡쳐 리스트를 사용해도 값 복사 X

[weak self] 원리 알아보기

그렇다면 참조 타입은 캡처리스트가 쓸모가 없나?

정답은 X이다.

클로저는 참조 타입 값을 캡처할 때는 strong으로 캡처한다.

이 말은 즉 RC가 증가하게 된다.

예를 들어 다음과 같은 코드가 있다고 하면

class Person {
	let name: String
	let age: Int
	lazy var getIntro: () -> String = {
		return "안녕하세요. 저의 이름은 \\(self.name)이고, \\(self.age)살 입니다."
	}
}

getIntro 변수에서 self 인스턴스를 캡처하게 된다.

따라서 Person인스턴스를 생성하고(RC+1) getIntro를 호출하게 되면 RC가 1 추가적으로 증가하게 되면서 Person 인스턴스를 없애도 메모리 릭이 발생하여 메모리 해제가 되지 않을 수 있다.

 

따라서 이때 사용하는 것이 캡처 리스트에 weak를 붙여 선언해 주는 것이다.

즉 매일 같이 사용했던 [weak self]가 이러한 이유에서 나온 것이다.

[weak self]를 사용하는 이유 클로저 내부에서 외부 자원을 참조할 때에 reference count가 증가하게 되어 증가된 reference count가 감소되지 못해 돌고 도는 순환참조문제를 해결하기 위해 사용한다.

 

그렇다면 weak self 란 self를 weak로 선언해서 캡처리스트를 작성한 것인데,

참조 타입을 리스트에 작성했기 때문에 기존 캡처 리스트와 달리 값을 복사하는 것은 아니지만

weak로 따로 선언해 줌으로써 캡처 시 RC를 증가하지 않게 한다.

 

아까 말했다시피 weak 변수의 경우 nil이 할당될 가능성이 있기 때문에 옵셔널 변수여야 한다고 했다.

따라서 우리가 weak self를 사용할 때

guard let self = self else { return } 

self?.-----

처럼 self를 옵셔널 언랩핑을 해주었던 것이다.

댓글