Swift/Objective-C ARC와의 통합
Swift/Objective-C ARC와의 통합
Kotlin과 Objective-C는 서로 다른 메모리 관리 전략을 사용해요. Kotlin에는 추적 기반 가비지 컬렉터(tracing garbage collector)가 있는 반면, Objective-C는 자동 참조 카운팅(ARC, Automatic Reference Counting)에 의존해요.
이 두 전략 사이의 통합은 보통 매끄럽게 이루어지며 일반적으로 추가 작업이 필요하지 않아요. 하지만 염두에 둬야 할 몇 가지 특이점이 있어요.
본문
스레드
해제자(Deinitializer)
Swift/Objective-C 객체와 그 객체가 참조하는 객체들의 해제는, 이 객체들이 메인 스레드에서 Kotlin에 전달된 경우 메인 스레드에서 호출돼요. 예:
// Kotlin
class KotlinExample {
fun action(arg: Any) {
println(arg)
}
}
// Swift
class SwiftExample {
init() {
print("init on \(Thread.current)")
}
deinit {
print("deinit on \(Thread.current)")
}
}
func test() {
KotlinExample().action(arg: SwiftExample())
}
결과 출력:
init on <_NSMainThread: 0x600003bc0000>{number = 1, name = main}
shared.SwiftExample
deinit on <_NSMainThread: 0x600003bc0000>{number = 1, name = main}
Swift/Objective-C 객체의 해제는 다음 경우에 메인 스레드 대신 전용 GC 스레드에서 호출돼요:
- Swift/Objective-C 객체가 메인이 아닌 스레드에서 Kotlin에 전달된 경우
- 메인 디스패치 큐가 처리되지 않는 경우
해제를 전용 GC 스레드에서 명시적으로 호출하고 싶다면 gradle.properties에 kotlin.native.binary.objcDisposeOnMain=false를 설정하세요. 이 옵션은 Swift/Objective-C 객체가 메인 스레드에서 Kotlin에 전달되었더라도 전용 GC 스레드에서 해제가 일어나게 해요.
전용 GC 스레드는 Objective-C 런타임을 따르므로, 런 루프를 갖고 있고 autorelease 풀을 비워내요.
완료 핸들러(Completion handlers)
Swift에서 Kotlin suspending 함수를 호출할 때 완료 핸들러가 메인이 아닌 스레드에서 호출될 수 있어요. 예:
// Kotlin
// coroutineScope, launch, and delay are from kotlinx.coroutines
suspend fun asyncFunctionExample() = coroutineScope {
launch {
delay(1000L)
println("World!")
}
println("Hello")
}
// Swift
func test() {
print("Running test on \(Thread.current)")
PlatformKt.asyncFunctionExample(completionHandler: { _ in
print("Running completion handler on \(Thread.current)")
})
}
결과 출력:
Running test on <_NSMainThread: 0x600001b100c0>{number = 1, name = main}
Hello
World!
Running completion handler on <NSThread: 0x600001b45bc0>{number = 7, name = (null)}
가비지 컬렉션과 수명 주기
객체 회수(Object reclamation)
객체는 가비지 컬렉션 중에만 회수돼요. 이는 interop 경계를 넘어 Kotlin/Native로 들어오는 Swift/Objective-C 객체에 적용돼요. 예:
// Kotlin
class KotlinExample {
fun action(arg: Any) {
println(arg)
}
}
// Swift
class SwiftExample {
deinit {
print("SwiftExample deinit")
}
}
func test() {
swiftTest()
kotlinTest()
}
func swiftTest() {
print(SwiftExample())
print("swiftTestFinished")
}
func kotlinTest() {
KotlinExample().action(arg: SwiftExample())
print("kotlinTest finished")
}
결과 출력:
shared.SwiftExample
SwiftExample deinit
swiftTestFinished
shared.SwiftExample
kotlinTest finished
SwiftExample deinit
Objective-C 객체 수명 주기
Objective-C 객체가 필요한 것보다 더 오래 살아남는 경우가 있는데, 때로는 성능 문제를 일으킬 수 있어요. 예를 들어 오래 실행되는 루프가 반복마다 Swift/Objective-C interop 경계를 넘는 임시 객체를 여러 개 생성하는 경우가 그렇죠.
GC 로그에는 루트 세트의 안정적인 참조(stable refs) 수가 있는데, 이 숫자가 계속 늘어난다면 Swift/Objective-C 객체가 해제돼야 할 때 해제되지 않고 있다는 뜻일 수 있어요. 이 경우 interop 호출을 하는 루프 본문 주변에 autoreleasepool 블록을 사용해 보세요:
// Kotlin
fun growingMemoryUsage() {
repeat(Int.MAX_VALUE) {
NSLog("$it\n")
}
}
fun steadyMemoryUsage() {
repeat(Int.MAX_VALUE) {
autoreleasepool {
NSLog("$it\n")
}
}
}
Swift와 Kotlin 객체 체인의 가비지 컬렉션
다음 예를 살펴볼까요:
// Kotlin
interface Storage {
fun store(arg: Any)
}
class KotlinStorage(var field: Any? = null) : Storage {
override fun store(arg: Any) {
field = arg
}
}
class KotlinExample {
fun action(firstSwiftStorage: Storage, secondSwiftStorage: Storage) {
// Here, we create the following chain:
// firstKotlinStorage -> firstSwiftStorage -> secondKotlinStorage -> secondSwiftStorage.
val firstKotlinStorage = KotlinStorage()
firstKotlinStorage.store(firstSwiftStorage)
val secondKotlinStorage = KotlinStorage()
firstSwiftStorage.store(secondKotlinStorage)
secondKotlinStorage.store(secondSwiftStorage)
}
}
// Swift
class SwiftStorage : Storage {
let name: String
var field: Any? = nil
init(_ name: String) {
self.name = name
}
func store(arg: Any) {
field = arg
}
deinit {
print("deinit SwiftStorage \(name)")
}
}
func test() {
KotlinExample().action(
firstSwiftStorage: SwiftStorage("first"),
secondSwiftStorage: SwiftStorage("second")
)
}
로그에 "deinit SwiftStorage first"와 "deinit SwiftStorage second" 메시지가 나타나는 사이에는 시간 간격이 있어요. 그 이유는 firstKotlinStorage와 secondKotlinStorage가 서로 다른 GC 사이클에서 수집되기 때문이에요. 사건의 흐름은 다음과 같아요:
KotlinExample.action이 끝나요.firstKotlinStorage는 아무것도 참조하지 않으므로 "죽은" 것으로 간주되는 반면,secondKotlinStorage는firstSwiftStorage가 참조하므로 죽지 않아요.- 첫 번째 GC 사이클이 시작되고
firstKotlinStorage가 수집돼요. firstSwiftStorage에 대한 참조가 없으므로 그것도 "죽은" 상태가 되고deinit이 호출돼요.- 두 번째 GC 사이클이 시작돼요.
firstSwiftStorage가 더 이상 참조하지 않으므로secondKotlinStorage가 수집돼요. - 마지막으로
secondSwiftStorage가 회수돼요.
이 네 객체를 수집하려면 두 번의 GC 사이클이 필요해요. Swift와 Objective-C 객체의 해제가 GC 사이클 이후에 일어나기 때문이에요. 이 제약은 deinit이 임의의 코드를 호출할 수 있고, 그 코드에는 GC 일시 중지 동안 실행할 수 없는 Kotlin 코드도 포함될 수 있기 때문에 생겨요.
보유 사이클(Retain cycles)
보유 사이클에서는 여러 객체가 강한 참조로 순환적으로 서로를 참조해요:
A → B → C → A
Kotlin의 추적 GC와 Objective-C의 ARC는 보유 사이클을 다르게 처리해요. 객체가 도달 불가능해지면 Kotlin의 GC는 이러한 사이클을 제대로 회수할 수 있지만 Objective-C의 ARC는 그럴 수 없어요. 따라서 Kotlin 객체의 보유 사이클은 회수될 수 있지만, Swift/Objective-C 객체의 보유 사이클은 회수될 수 없어요.
보유 사이클에 Objective-C 객체와 Kotlin 객체가 모두 포함된 경우를 생각해 볼까요:
Kotlin.A ↔ ObjC.B
이 경우 Kotlin의 메모리 관리 모델과 Objective-C의 메모리 관리 모델이 결합되는데, 이 둘은 함께 보유 사이클을 처리(회수)할 수 없어요. 즉, Objective-C 객체가 하나라도 있으면 객체 그래프 전체의 보유 사이클을 회수할 수 없고, Kotlin 쪽에서 사이클을 깨는 것도 불가능해요.
아쉽게도 Kotlin/Native 코드에서 보유 사이클을 자동으로 감지해 주는 특별한 도구는 현재 없어요. 보유 사이클을 피하려면 약한(weak) 또는 unowned 참조를 사용하세요.
백그라운드 상태 및 App Extensions 지원
현재 메모리 관리자는 기본적으로 애플리케이션 상태를 추적하지 않으며 App Extensions와도 기본적으로 통합되지 않아요.
즉, 메모리 관리자가 그에 맞춰 GC 동작을 조정하지 않는데, 어떤 경우에는 이로 인해 문제가 생길 수 있어요. 이 동작을 바꾸려면 gradle.properties에 다음 Experimental 바이너리 옵션을 추가하세요:
kotlin.native.binary.appStateTracking=enabled
이 옵션은 애플리케이션이 백그라운드에 있을 때 타이머 기반 가비지 컬렉터 호출을 끄므로, 메모리 사용량이 너무 높아졌을 때만 GC가 호출돼요.
다음 단계
Swift/Objective-C 상호 운용에 대해 더 알아보세요.
더 알아보기
- Kotlin/Native 메모리 관리
- 새 메모리 관리자로 마이그레이션하기