ARC(Automatic Reference Counting)를 SIL로 직접 확인해보기

ARC(Automatic Reference Counting)를 SIL로 직접 확인해보기


1. Swift의 ARC, 직접 눈으로 볼 수 있을까요?

Swift 개발을 하다 보면 “ARC는 인스턴스 생성 시 retain, 해제 시 release”라고 배우는 경우가 많아요.
하지만 실제로 컴파일 타임에 어떤 ARC 코드가 삽입되는지 직접 본 적 있으신가요?

오늘은 Swift의 ARC가 컴파일 타임에 어떻게 처리되는지,
그리고 우리가 작성한 코드가 SIL(Swift Intermediate Language)로 변환됐을 때
retain/release가 실제로 어떻게 동작하는지 직접 확인하는 방법을 소개해드릴게요.

ARC가 무엇인지에 관한 설명은 이미 좋은 글들이 많아서 생략합니다 :)


2. SIL 출력하는 방법

Swift 컴파일러(swiftc)는 -emit-sil 옵션을 통해 소스 코드를 SIL로 변환한 중간 결과를 볼 수 있습니다.

swiftc -emit-sil main.swift

3. 예제 코드

class Person { }

func foo() {
    var a: Person? = Person()
    var b: Person? = a

    b = nil
    a = nil
}

4. SIL로 확인하는 ARC의 실제 동작

이 코드를 SIL로 컴파일해보면, ARC가 어떻게 retain/release를 삽입하는지 눈으로 직접 볼 수 있어요.

주요 포인트

4-1. 객체 생성 시점

%0 = alloc_stack [lexical] [var_decl] $Optional<Person>, var, name "a", type $Optional<Person> // users: %6, %24, %23, %16
%1 = metatype $@thick Person.Type               // user: %3
// function_ref Person.__allocating_init()
%2 = function_ref @$s1c6PersonCACycfC : $@convention(method) (@thick Person.Type) -> @owned Person // user: %3
%3 = apply %2(%1) : $@convention(method) (@thick Person.Type) -> @owned Person // user: %4
%4 = enum $Optional<Person>, #Optional.some!enumelt, %3 : $Person // users: %8, %6, %5
store %4 to %0 : $*Optional<Person>             // id: %6

이 시점에 retain 관련 코드가 없는 것을 알 수 있는데 여러 자료를 찾아보았지만 명확한 답은 찾지 못했고, 예상할 수 있는 지점이 있어요.

Swift RefCount 이 문서를 보면 when the physical field is 0 the logical value is 1 라는 부분이 있어요. 이 부분을 통해서 예상해보자면 init 호출 시점에 reference count가 0으로 (physically) 초기화 되어서 logically 1이 되는 것이 아닐까..? 하고 추측하고 있어요.

4-2. 값 복사(b = a) 시점

retain_value %4 : $Optional<Person>             // id: %5
%7 = alloc_stack [lexical] [var_decl] $Optional<Person>, var, name "b", type $Optional<Person> // users: %8, %22, %21, %10
store %4 to %7 : $*Optional<Person>             // id: %8

retain_value를 통해서 retain 코드가 실행되고, %4 (변수 a)의 값이 변수 b로 저장돼요.

4-3. Optional을 nil로 변경(해제)할 때

%9 = enum $Optional<Person>, #Optional.none!enumelt // user: %12
%10 = begin_access [modify] [static] %7 : $*Optional<Person> // users: %12, %11, %14
%11 = load %10 : $*Optional<Person>             // user: %13
store %9 to %10 : $*Optional<Person>            // id: %12
release_value %11 : $Optional<Person>           // id: %13
end_access %10 : $*Optional<Person>             // id: %14
%15 = enum $Optional<Person>, #Optional.none!enumelt // user: %18
%16 = begin_access [modify] [static] %0 : $*Optional<Person> // users: %18, %17, %20
%17 = load %16 : $*Optional<Person>             // user: %19
store %15 to %16 : $*Optional<Person>           // id: %18
release_value %17 : $Optional<Person>           // id: %19
end_access %16 : $*Optional<Person>             // id: %20

release_value가 두 번 호출되는 것을 알 수 있어요.

결론

SIL을 직접 뜯어보면 우리가 흔히 “컴파일 타임에 retain/release가 무조건 들어간다”고 생각했던 부분도 실제로는 그렇지 않다는 걸 확인할 수 있습니다.

객체 생성 시점에는 별도의 retain 호출 없이 Reference Count가 물리적으로 0에서 시작하지만 논리적으로 1로 간주된다는 점이 인상적이었어요.
Swift 컴파일러는 메모리 최적화를 위해 불필요한 retain/release를 생략하거나 지연시키는 최적화(ARC 옵티마이제이션)를 적용하고 있기 때문이죠.

즉, 우리가 작성하는 Swift 코드와 ARC가 동작하는 방식이 항상 1:1로 대응된다고 생각하면 안 되고,
컴파일러가 중간 단계(SIL)에서 어떻게 최적화하는지까지 이해하는 게 중요한 포인트입니다.

ARC가 어떻게 작동하는지 확실히 이해하고 싶다면, SIL을 보는 습관을 들여보는 것도 좋은 방법이에요.