개요
기본적으로 거의 모든 컴퓨터는 2가지에 데이터를 보관한다.
하나는 디스크, 다른 하나는 메모리(RAM)로 힙 이라고도 한다.
우리가 보는 메모리 관리는 사실상 힙 메모리 관리라고 볼 수 있다.
디스크에 있는 데이터를 바로 가져다 쓰기에는 데이터를 읽는 속도가 굉장히 느리기 때문에, 당장 사용할 프로그램과 데이터들을 메모리에 미리 불러 놓은 뒤에 메모리의 빠른 읽기 속도를 활용하여 프로그램을 실행시킨다.
이는 아이폰에서도 똑같이 동작을 하며, 앱이 실행되는 동안 사용되는 클래스 인스턴스들은 모두 이 메모리 안에 저장이 된다.
메모리 관리라고 하면 엄청나게 거창한 무언가를 하는 것 처럼 느껴지지만 사실 프로그램이 요구로 하는 곳에 메모리 자원을 주고, 프로그램이 더 이상 사용하지 않는 메모리를 회수하는 것이 메모리 관리다.
Objectivce-C 에서는 Int
, CGRect
와 같은 것들이 모두 객체로 표현이 되었기에 모든 변수들이 힙 메모리에 올라갔었다.
하지만 Swift에서는 참조 타입들은 힙 메모리에 올라가지만, 값 타입들은 힙 메모리에 올라가지 않는다.
그런데 여기서 이런 의문이 들 수 있다.
“다른 앱들이 실행되든 말든 상관없이 지금 내가 만든 앱이 메모리 다 끌어다 써서 빠르게 동작하면 되는 거 아닌가?
다른 앱들 신경 안쓰고 아이폰 메모리 다 먹는다는 생각으로 써도 될 거 같은데?”
꽤나 이기적인 생각이지만, 아이폰 메모리를 다 쓰지 않는 선에서는 그럴듯한 생각이기도 하다.
하지만 메모리를 많이 사용하게 되면 전체적인 속도가 느려지면서 앱이 충돌날 가능성이 높아지기 때문에 이런 이기적인 방식이 통할 수 없다. ▼
앱이 충돌나서 비정상적으로 종료되는 것은 굉장히 치명적인 문제이고, 일어나서는 안되는 현상이기에 메모리 관리는 굉장히 중요한 부분이고, 이에 대해 민감하게 대응해야한다.
ARC(Automatic Reference Counting)
Swift는 ARC를 사용하여 앱의 메모리 사용량을 추적한다.
ARC는 스위프트에서 자동적으로 실행되기에, 우리가 메모리 관리에 대해서 생각할 필요는 없다.
ARC는 이름에 들어간 Automatic 대로 자동적으로 더 이상 필요하지 않은 클래스 인스턴스의 메모리를 회수한다.
“그럼 우리는 ARC에 대해 왜 알아야하는거죠?”
그런데 가끔 ARC가 코드와 코드에서 필요로 하는 메모리의 관계에 대한 정보를 필요로 할 때가 있다.
자동적으로 메모리를 관리해준다고 해도, ARC가 개발자의 의도를 읽지 못하여 메모리를 제대로 회수하지 못하는 경우가 생길 수 있기 때문이다. ▼
지금 당장 메모리 회수가 필요한 상황이라고 했을 때, ARC가 어떻게 동작하는 지 알고 있다면 ARC가 그렇게 동작하게끔 유도하는 것도 가능할 수 있다.
ARC가 동작하는 방식
새로운 클래스 인스턴스를 생성할 때 마다, ARC는 해당 인스턴스에 대한 정보를 저장하는 메모리 청크를 할당한다.
이 메모리는 인스턴스의 타입에 대한 정보와 해당 인스턴스와 관련된 모든 속성의 값이 포함된다.
추가적으로 인스턴스를 더 이상 필요로 하지 않을 경우, ARC는 해당 인스턴스가 사용했던 메모리를 다른 용도로 사용될 수 있게 회수한다.
이 행위를 통해 해당 인스턴스가 더 이상 메모리에서 공간을 차지하지 않도록 보장한다.
그러나 만약 ARC가 사용중인 인스턴스의 메모리를 할당 해제 해 버린다면, 그 인스턴스의 속성들에 접근하거나 메소드를 사용하는 것이 불가능해진다.
그렇기에 만약에 사용중인 인스턴스의 메모리가 할당 해제 돼 버린다면 앱은 충돌이 날 수 있다.
아직 필요로 하는 인스턴스가 사라지지 않도록 하기위해서 ARC는 각 클래스 인스턴스를 참조하는 속성, 상수, 그리고 변수들의 개수를 추적한다.
ARC는 활성화된 참조가 하나라도 있다면, 할당 해제를 수행하지 않는다.
이걸 가능하게 하기 위해서 클래스 인스턴스를 속성, 상수 또는 변수에 할당할 때 마다 해당 속성, 상수 또는 변수는 해당 인스턴스에 strong reference를 생성한다.
강한 참조라고 불리는 이유는 그 인스턴스를 확실하게 유지시키면서, strong reference가 존재하는 한 인스턴스가 할당 해제되는 것을 허용하지 않기 때문이다.
ARC의 실제 동작 예제
코드 예제를 통해 위의 동작 방식 흐름을 따라가보자.
클래스와 옵셔널 변수 선언
아래와 같이 Gorani
클래스가 있다고 해보자. ▼
class Gorani {
let name: String
init(name: String) {
self.name = name
print("\(name) is being initialized")
}
deinit {
print("\(name) is being deinitialized")
}
}
그리고 옵셔널 고라니 클래스 타입의 세 변수를 만들었다. ▼
var reference1: Gorani?
var reference2: Gorani?
var reference3: Gorani?
세 변수는 옵셔널이고 아직 초기화를 하지 않았기에 nil
값이 들어가 있을 것이다. ▼
Gorani
인스턴스 생성과 strong reference 생성
이 세 변수 중 reference1
에 Gorani
인스턴스를 만들었다고 해보자. ▼
reference1 = Gorani(name: "noguen")
이렇게 되면 새로운 Gorani
인스턴스가 reference1
에 참조가 되고, reference1
으로부터 새 Gorani
인스턴스에 strong reference가 생기게 된다.
reference1
와 Gorani
인스턴스 사이에 강한 참조가 생겼기에 ARC는 새롭게 생성된 Gorani
인스턴스가 아직 필요가 있다고 판단하여 할당 해제를 수행하지 않는다. ▼
추가적으로 아래의 코드와 같이 reference2
와 reference3
에 reference1
을 할당하게 되면, 두 개의 strong reference가 더 생기게 된다. ▼
reference2 = reference1
reference3 = reference1
참조 해제와 할당 해제
이번에는 참조를 해제했다고 해보자.
reference1
과 reference2
에 nil을 할당하여 참조를 없앴다.
하지만 아직 reference3
는 Gorani
인스턴스를 참조하고 있기에 ARC는 아직 인스턴스가 필요한 인스턴스라고 판단한다. ▼
reference1 = nil
reference2 = nil
그런데 reference3
마저 nil
로 바꾸면, Gorani
인스턴스를 참조하고 있는 것이 더 이상 존재하지 않기에 ARC는 해당 인스턴스가 필요 없다고 판단한다. ▼
클래스 인스턴스 사이의 강한 참조 Cycle
앞에서 봤던 예시를 보면, ARC는 강한 참조의 개수를 추적하여 해당 인스턴스가 필요한 지, 아닌 지를 판별하는 것을 알 수 있다.
하지만 강한 참조의 개수가 0이 되지 않는 경우가 존재한다.
두 클래스 인스턴스가 서로에게 강한 참조를 갖게 되면, 다른 한 쪽이 사라져야 스스로가 사라지는 상황이 만들어진다.
하지만 다른 한쪽은 자신이 사라져야 사라지기에 프로그램을 끄지 않는 한, 일반적인 ARC의 동작으로는 사라질 수 없는 구조가 만들어진다.
이를 Strong Reference Cycle이라고 부른다.
예제
위의 코드에 Forest
클래스를 추가로 붙여보았다. ▼
class Gorani {
let name: String
init(name: String) {
self.name = name
print("\(name) is being initialized")
}
var mountain: Mountain?
deinit {
print("\(name) is being deinitialized")
}
}
class Mountain {
let mountain: String
init(mountain: String) {
self.mountain = mountain
print("\(mountain) is being initialized")
}
var gorani: Gorani?
deinit {
print("Mountain \(mountain) is being deinitialized")
}
}
Gorani
클래스는 고라니가 살 공간인 Mountain
클래스를 속성으로 가지고, Mountain
클래스는 산에 사는 Gorani
를 속성으로 가진다.
고라니가 산에 살지 않을 수 도 있고, 산에 고라니가 살지 않을 수 도 있으니 옵셔널로 표현을 해주었다.▼
그러면 각각 옵셔널로 인스턴스를 만들었다고 해보자.
아까와 같이 각 인스턴스는 초기화가 되지 않았으므로 nil
값을 갖는다. ▼
var noguen : Gorani?
var umyeonMountain : Mountain?
그러면 이제 본격적으로 초기화를 해주고 값을 할당해보자.
noguen
은 umyeonMountain
을 umyeonMountain
은 noguen
을 각자의 속성 값으로 갖게 된다. ▼
noguen = Gorani(name: "noguen")
umyeonMountain = Mountain(mountain: "umyeonMountain")
noguen!.mountain = umyeonMountain
umyeonMountain!.gorani = noguen
그리고 아까와 같이 참조를 해제했다고 해보자.
아까와 같다면 이렇게 참조를 해제했다면, strong reference가 사라지므로 메모리를 회수해야한다. ▼
noguen = nil
umyeonMountain = nil
하지만 메모리는 회수되지 않는다.
noguen
과 umyeonMountain
의 참조는 사라졌지만, noguen.mountain
과 umyeonMountain.gorani
는 아직 서로를 strong reference 하고 있기 때문이다. ▼
문제는 이를 다시 해제하러 갈 수 도 없다는 것이다.
noguen
과 umyeonMountain
이 가리키고 있던 클래스 인스턴스의 주소를 다시 알 방법이 없기에 이 두 인스턴스의 참조를 해제할 수 없다. ▼
둘은 계속해서 서로를 참조하기에 ARC는 필요가 있다고 판단하여 메모리 할당 해제를 하지 않는다. ▼
클래스 인스턴스 사이의 Strong reference Cycle의 해결
Swift는 이 문제에 대해 두 가지 해결방안을 제시한다.
하나는 weak references, 다른 하나는 unowned references 이다.
다른 인스턴스의 수명이 더 짧을 때(다른 인스턴스가 먼저 해제될 수 있는 경우), weak reference를 사용하곤 한다.
아까 들었던 고라니와 산 예시에서 산의 존재가 오래 남아있기에 몇 십년 뒤 산 보다 고라니가 먼저 없어 질 수 있는 경우, 참조 순환을 깨는데 weak reference가 적절하게 사용된다.
반대로, 다른 인스턴스의 수명이 같거나 더 길 때는 unowned reference를 사용하는게 좋다.
아직은 이게 어떤 이야기를 하는 지 잘 모를 것이라 생각되기에 각각의 reference들에 대해서 자세하게 확인해보자.
Weak references
weak reference는 해당 인스턴스를 strong reference로 유지하지 않기에 ARC가 참조된 인스턴스를 해제하는 것을 막지 않는다.
이를 통해 strong reference cylce을 막는다.
weak reference는 속성 또는 변수 선언 앞에 weak 키워드를 작성하여 선언할 수 있다.
Weak reference는 참조하고 있는 인스턴스를 strong reference로 유지하지 않기 때문에, weak reference가 아직 해당 인스턴스를 참조하고 있어도 해당 인스턴스를 해제할 수 있다.
사용중인 인스턴스를 해제할 때는 주소를 그대로 설정해두면 주소 접근 오류가 발생하기에 해당 weak reference를 nil로 설정한다.
당연하게도, nil로 값을 바꿀 수 있어야 하기 때문에 weak reference는 상수가 아닌 옵셔널 타입의 변수로 선언해야한다.
Weak reference 특징 간단 요약
- 인스턴스를 strong reference로 유지하지 않음
- 다른 인스턴스의 수명이 더 짧을 때(다른 인스턴스가 먼저 할당 해제될 수 있는 경우) weak reference를 사용함
- weak reference가 참조 중이었던 인스턴스가 할당 해제되면, ARC는 weak reference를 nil로 바꿈
- weak reference는 할당 해제 여부에 따라 값이 바뀌어야하기에, 상수가 아닌 변수로 선언됨
예제
위의 고라니와 산 예시를 weak reference로 바꾸어 보았다. ▼
class Gorani {
let name: String
init(name: String) {
self.name = name
print("\(name) is being initialized")
}
var mountain: Mountain?
deinit {
print("\(name) is being deinitialized")
}
}
class Mountain {
let mountain: String
init(mountain: String) {
self.mountain = mountain
print("\(mountain) is being initialized")
}
weak var gorani: Gorani?
deinit {
print("Mountain \(mountain) is being deinitialized")
}
}
전의 예시와 같이 서로를 참조하게 선언한 후, ▼
var noguen : Gorani?
var umyeonMountain : Mountain?
noguen = Gorani(name: "noguen")
umyeonMountain = Mountain(mountain: "umyeonMountain")
noguen!.mountain = umyeonMountain
umyeonMountain!.gorani = noguen
noguen
을 nil
로 값을 바꾸면, noguen
인스턴스는 바로 할당 해제가 된다. ▼
noguen = nil
이후에 umyeonMountain
을 nil
로 바꾸면 strong reference cycle 없이 할당 해제가 진행된다. ▼
umyeonMountain = nil
“그렇다면 umyeonMountain
부터 해제가 되면 어떻게 될까?”
이번에는 umyeonMountain
부터 nil
로 값을 바꿔보았다.
umyeonMountain
의 참조가 사라져도 noguen.mountain
에서 강한 참조를 가지고 있기 때문에 umyeonMountain
의 메모리는 회수되지 않고 남아있게 된다.▼
하지만 noguen
의 참조를 해제하게 되면 noguen
의 강한 참조가 사라지면서 noguen
의 메모리가 먼저 회수되고, umyeonMountain
의 메모리도 마저 회수가 된다. ▼
Unowned references
Unowned reference역시 weak reference와 같이 해당 인스턴스를 strong reference로 유지하지 않기에 ARC가 참조된 인스턴스를 해제하는 것을 막지 않는다.
하지만 옵셔널로 선언되는 weak reference와는 다르게 unowned reference는 값이 항상 존재하는 것으로 예상하기에 하기에 옵셔널로 선언하지 않는다.
이것에 대한 이유는 weak reference는 값이 ARC에 의해 nil로 바뀔 수 있지만, unowned reference는 값이 ARC에 의해 nil
로 바뀌는 일이 없기 때문이다.
weak reference의 경우에는 인스턴스 내부에서 참조되고 있던 다른 인스턴스가 할당 해제되면 ARC가 weak reference의 값을 nil
로 바꿔주어 nil
체크를 통해 인스턴스가 있는지 없는지를 알 수 있다.
하지만 unowned reference의 경우에는 인스턴스 내부에서 참조되고 있던 다른 인스턴스가 할당 해제되면 ARC가 unowned reference의 값을 nil
로 바꾸지 않기에 아무 것도 존재하지 않는 주소값을 가리키는 상태가 된다.
그래서 값이 항상 존재하는 것으로 예상하는 것이다.
Unowned reference 특징 간단 요약
- 인스턴스의 참조를 strong reference로 유지하지 않음
- 다른 인스턴스의 수명이 같거나 더 길 때(현재 인스턴스가 먼저 할당 해제될 수 있는 경우)는 unowned reference를 사용하는게 좋음
- 값이 항상 존재하는 것으로 예상하기에, 옵셔널로 선언하지 않고 상수로 선언함
- weak reference와는 다르게 참조하고 있던 인스턴스가 할당 해제되어도 ARC가 unowned reference의 값을
nil
로 바꿔주지 않음
예제
이번에는 고객과 신용카드의 예제를 보자. ▼
class Customer {
let name: String
var card: CreditCard?
init(name: String) {
self.name = name
}
deinit { print("\(name) is being deinitialized") }
}
class CreditCard {
let number: UInt64
unowned let customer: Customer
init(number: UInt64, customer: Customer) {
self.number = number
self.customer = customer
}
deinit { print("Card #\(number) is being deinitialized") }
}
앞에서 봤던 고라니와 산의 예제와는 조금 다른 형태의 관계를 보인다.
고객은 신용카드를 소유할 수 도 있고 소유하지 않을 수 도 있지만, 신용카드는 그 주인이 무조건 존재해야한다.
신용카드의 인스턴스를 만들 때, 고객 인스턴스를 매개변수로 넣게 해두었기에 신용카드는 그 주인이 무조건 존재하게 된다.
앞의 예제와 비슷하게 Customer
인 noguen
을 옵셔널로 만든 후에 초기화를 진행했다.
noguen!.card
에 CreditCard
인스턴스를 초기화하여 새로운 신용카드를 넣어주었다. ▼
var noguen: Customer?
noguen = Customer(name: "noguen")
noguen!.card = CreditCard(number: 1234_5678_9012_3456, customer: noguen!)
그러면 아래와 같은 구조가 된다. ▼
여기서 noguen
의 참조를 끊어버린다면 unowned reference가 strong reference로 유지되지 않기에 Customer
인스턴스는 할당 해제가 진행이 되고,▼
noguen = nil
Customer
인스턴스가 할당 해제 되면서 CreditCard
를 참조하는 strong reference가 사라져서 CreditCard
인스턴스도 같이 할당 해제가 된다. ▼
Unowned Optional references
앞에서 unowned reference는 값이 항상 존재하는 것으로 예상하기에 옵셔널로 선언하지 않는다고 했었다.
그런데 사실 이것은 컴파일러가 문법적으로 틀리다고 하는 것이 아니고, 프로그래밍할 때에 권장하는 내용이기에 옵셔널로 선언할 수 있다.
대신에 옵셔널로 선언하게 되면, 할당 해제가 될 때마다 unowned reference의 값을 nil
로 직접 바꿔줘야한다.
예제
다음과 같이 산과 고라니가 있다고 해보자. ▼
class Mountain {
var name: String
var gorani: [Gorani]
init(name: String) {
self.name = name
self.gorani = []
}
}
class Gorani {
var name: String
unowned var mountain: Mountain
unowned var nearbyGorani: Gorani?
init(name: String, in mountain: Mountain) {
self.name = name
self.mountain = mountain
self.nearbyGorani = nil
}
}
산에는 고라니들이 살고, 고라니들은 옹기종기 모여 있다.
그런데 고라니들이 항상 옆에 붙어있는건 아니기에, nearbyGorani
가 없을 수도 있다.
그렇기에 nearbyGorani
는 옵셔널로, mountain
은 옵셔널이 아닌 일반 변수로 선언되었다.
그러면 이제 인스턴스를 만들어보자.
아래와 같이 umyeonMountain
변수를 만든 후, Mountain
으로 초기화를 해주었다.
그 다음 세 마리의 고라니를 만들었고, 모두 같은 산인 umyeonMountain
을 참조하도록 했다. ▼
let umyeonMountain = Mountain(name: "umyeonMountain")
let goraniNo1 = Gorani(name: "goraniNo1", in: umyeonMountain)
let goraniNo2 = Gorani(name: "goraniNo2", in: umyeonMountain)
let goraniNo3 = Gorani(name: "goraniNo3", in: umyeonMountain)
그리고 고라니 세 마리가 일렬로 있다는 의미로, nearbyGorani
값을 아래와 같이 채워주었고, umyeonMountain
에도 고라니들의 정보를 넣어주었다. ▼
goraniNo1.nearbyGorani = goraniNo2
goraniNo2.nearbyGorani = goraniNo3
umyeonMountain.gorani = [goraniNo1, goraniNo2, goraniNo3]
이를 그림으로 보면 이런 복잡한 형태가 된다.▼
여기서 만약에 goraniNo2가 사라졌다고 해보자.
그러면 goraniNo2를 참조하고 있는 모든 unowned reference의 값을 nil로 변경해주는 작업을 해줘야한다. ▼
Unowned References and Implicitly Unwrapped Optional Properties
앞에서 봤던 예시들은 서로를 참조하는 두 속성이 nil이 될 수 있는 상황의 예시들이었다.
고라니와 산의 예시에서는 두 속성 모두 nil
이 될 수 있고, strong reference가 발생할 수 있는 가능성이 존재하는 예시였다.
이는 weak reference를 통해 해결이 가능했다.
그리고 그 다음 예시인 고객과 신용카드의 예시에서는 한 속성은 nil이 될 수 있지만, 다른 한 속성은 nil이 되면 안되는 상황에서 strong reference가 발생할 수 있는 가능성이 존재하는 예시였다.
이는 unowned reference를 통해 해결이 가능했다.
하지만 위의 두 상황 말고도 다른 상황이 존재한다.
"만약에 두 개의 속성이 한 번 초기화 된 이후에 어느 한 쪽도 nil이 되면 안되는 상황이라면 어떨까?"
이 상황은 Implicitly unwrapped 옵셔널 속성을 통해 해결이 가능하다.
예제
나라와 도시의 클래스가 있다고 해보자.
나라는 도시 인스턴스를 하나 가져야하며, 도시도 나라 인스턴스를 하나 가져야한다.
이 조건에 맞게 코드를 짜기 위해서는 Implicitly unwrapped 옵셔널 속성을 이용하면 되고, 아래의 코드에서는 capitalCity: City!
로 사용이 되었다. ▼
class Country {
let name: String
var capitalCity: City!
init(name: String, capitalName: String) {
self.name = name
self.capitalCity = City(name: capitalName, country: self)
}
}
class City {
let name: String
unowned let country: Country
init(name: String, country: Country) {
self.name = name
self.country = country
}
}
이렇게 Implicitly unwrapped 옵셔널 속성을 이용하면 Country의 self를 초기화 전에 미리 전달하여 초기화 코드에서 self를 사용할 수 있다.
Initialize
이게 무슨 소리인지 약간 이해가 안갈 수 있다.
초기화에 대한 내용은 Swift의 초기화 방식인 Two Phase Initialize를 보면 이해가 쉽다. ▼
그래도 이해하기 쉽게 빠르게 설명을 하자면, Swift는 stored property들이 초기화 되기 전에는 self 키워드를 사용할 수 없다.
위의 코드에서 만약에 Country의 속성인 capitalCity가 옵셔널 변수가 아니라 일반 변수였다면, 아래의 초기화 코드는 동작하지 않는다. ▼
class Country {
let name: String
var capitalCity: City
init(name: String, capitalName: String) {
self.name = name
self.capitalCity = City(name: capitalName, country: self)
}
}
(동작하지 않는 코드)
self.capitalCity를 초기화 시키려고 City를 초기화 시키는데, City를 초기화 하는 시점에는 아직 Country가 다 초기화되지 않았기에 self 키워드를 사용할 수 없는 것이다.
그런데 옵셔널로 변수를 설정해놓으면, 옵셔널 변수의 경우에는 값이 nil로 처음부터 초기화되어있기 때문에 딱히 초기화를 하지 않아도 self 키워드를 사용할 수 있게 된다.
이렇게 하면 Country와 City를 한 줄에 동시에 초기화가 가능해지면서, strong reference cycle에도 걸리지 않게 된다.
마치며
ARC에 대한 이야기가 워낙 방대해서 여기까지 끊고 다음 글에서 이어서 볼 예정이다.
Closure에서의 메모리 누수와 관련된 내용이 아마 개발을 하면서 제일 많이 접하게 될 거 같은데, 해당 내용까지 이 글에서 전부 다루기에는 지금까지 작성한 내용의 2배까지 길어질 거 같아 여기서 끊고 가기로 했다.
이 글의 내용은 대부분이 공식 문서의 내용이고, 공식 문서의 내용이 좀 더 간결해서 더 빠르게 읽을 수 있지만, 아무래도 더 이해하기 쉽게 글을 작성하다보니 글 길이가 길어지는 것 같다.
공식 문서가 아무래도 더 간결하지만 이것저것 함축된 내용들이 많아서 이해용도로는 이렇게 풀어서 쓰는게 좋은거 같다고 생각이 든다.
어떤 글을 참고할 지는 본인 마음이니 사실 더 읽기 편한 쪽을 선택하면 될 거 같다.
필자는 둘 다 참고할 거 같다.
평소에는 정리본을 읽다가 더 정확한 공식 입장이 필요하면 공식 문서를 참고할 거 같다.
위는 여담이었고, 다들 화이팅이다.
'Develop > iOS' 카테고리의 다른 글
[iOS][Swift] 클로져 캡쳐에 대한 이해 (0) | 2024.02.11 |
---|---|
[iOS][Swift] Two Phase Initialization (0) | 2024.02.09 |
[iOS][UI] Auto Layout에 대해 (0) | 2024.01.23 |
[iOS][Swift] MetaType이란 (0) | 2024.01.23 |
[iOS] Delegate 패턴 (0) | 2024.01.23 |