iOS

클로저에서 순환참조 없애기 트러블 슈팅

미니미니니

안녕하세요 오늘은 순환참조 관련 문제를 풀다가 이해가 안되는 부분이 있어서 파고든 내용을 작성해보도록 하겠습니다.

먼저 문제의 요구사항은 아래와 같았습니다

  • 클래스 C, D를 만들고 서로를 순환참조 하도록 만든다
  • D에서 클로저를 정의하고 클로저 내부에서 C를 참조하여 클로저 기반의 순환참조를 발생하도록 만든다
  • 두가지 순환참조를 모두 없앤다

위 문제를 해결하기위해 아래와 같이 코드를 작성했습니다. (macOS의 CLI 사용)

class C {
  var friend: D?

  init() {
    print("C init")
  }

  deinit {
    print("C deinit")
  }
}

class D {
  //순환참조 해결을 위해 weak로 선언
  weak var friend: C?
  var closure: (() -> Void)?

  init() {
    print("D init")
  }

  deinit {
    print("D deinit")
  }
}

 

/// main.swift
var c: C? = C()
var d: D? = D()

c?.friend = d
d?.friend = c

//순환참조를 해결하기위해 weak c 선언
d?.closure = { [weak c] in
  print("friend: \(c)")
}

d?.closure?()

c = nil
d = nil

 

코드 실행결과 c와 d에서 모두 deinit이 호출되었습니다.

그럼 해결! 인줄 알았지만 [weak c]를 선언하지않고 실행해봤는데 deinit이 호출되었습니다..

 

그래서 CLI가 문제인가? 하고 프로젝트 파일을 생성해서 아래와 같이 실행해보았습니다.

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
      var c: C? = C()
      var d: D? = D()

      c?.friend = d
      d?.friend = c

      d?.closure = {
        print("friend: \(c)")
      }

      d?.closure?()

      c = nil
      d = nil
    }
}

 

그런데 여기서도 [weak self]를 호출하지 않아도 deinit이 호출되었습니다..!

 

그래서 다음으로 시도한 내용은 class안에서 C와 D를 호출하고 클로저를 만들어보았습니다

class E {
  var c: C? = C()
  var d: D? = D()

  func e() {
    c?.friend = d
    d?.friend = c

    d?.closure = {
      print("friend: \(self.c)")
    }

    d?.closure?()
  }

  deinit {
    print("deinit E")
  }
}

var e: E? = E()
e?.e()
e = nil

 

결과는,,, 드디어 deinit이 호출되지 않았습니다 😭

class E {
  var c: C? = C()
  var d: D? = D()

  func e() {
    c?.friend = d
    d?.friend = c

    d?.closure = { [weak self] in
      guard let self else { return }
      print("friend: \(self.c)")
    }

    d?.closure?()
  }

  deinit {
    print("deinit E")
  }
}

var e: E? = E()
e?.e()
e = nil

 

그리고 weak self를 해주고 코드를 돌려보면 deinit이 호출되는 것을 볼 수 있습니다!

 

그럼 여기서 알아볼 수 있는것은 클로저를 어디서 호출하느냐에 따라서 순환참조가 발생할 수도, 하지 않을수도 있다는 것인가? 

이 문제를 이해하기위해 필요한 개념들을 먼저 알아보겠습니다.


코드에서 찾아볼 수 있는 차이점

self가 필요 없는 클로저

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        var c: C? = C() 
        var d: D? = D()
        d?.closure = {
            print("friend: \(c)") // self 없이 c를 직접 언급
        }
    }
}

self가 반드시 필요한 클로저

class E {
    var c: C? = C() 
    var d: D? = D()
    func e() {
        d?.closure = { [weak self] in 
            guard let self else { return }
            print("friend: \(self.c)") // self를 통해 c를 언급
        }
    }
}

이 두 코드의 차이점은 c를 부르는 방식입니다. 이 작은 차이를 이해하기위해  '메모리'의 구조를 이해해봅시다.


스택과 힙

앱에서 실행중인 함수 내부에서 선언된 변수나 동적으로 생성된 객체는 주로 스택과 힙중에서 한 곳에 존재합니다.

스택(Stack)

스택은 함수가 호출될 때 잠깐 만들어지는 임시 작업 공간입니다. 

  • 함수가 일을 시작하면, 그 함수만을 위한 책상 공간이 생깁니다.
  • 함수 안에서 선언된 지역 변수들은 이 책상 위에 놓입니다.
  • 함수가 일을 마치면, 책상 위의 모든 것이 한 번에 깨끗하게 사라집니다.
  • 생명주기가 짧고 예측 가능합니다.

 힙(Heap)

힙은 함수가 끝나도 데이터가 계속 살아남아야 할 때 사용하는 데이터 보관소입니다.

  • 클래스의 인스턴스(객체)처럼 생명주기가 긴 데이터들이 이곳에 자리를 잡습니다.
  • 책상처럼 한번에 치워지지 않습니다. 언제 데이터가 더 이상 필요 없는지 누군가 알려주기 전까지는 계속 그 자리를 차지합니다.
  • 생명주기가 길고 예측이 어렵기 때문에, 관리가 필요합니다. 관리에 실패하면 메모리 누수가 발생합니다.

코드(Code)

  • 작성한 소스 코드가 컴파일된 상태로 저장됩니다.
  • 프로그램 실행 전에 크기가 정해지고, 읽기 전용입니다.

데이터(Data)

  • 전역변수와 정적변수가 저장됩니다.
  • 프로그램이 시작되어 끝날 때 까지 메모리에 유지되는 변수들이 위치합니다.

ARC

스위프트는 힙에 저장되는 메모리를 관리하기위해 ARC를 사용합니다.

  • 누군가 객체를 강하게 참조하면, 이름표의 숫자가 +1 됩니다.
  •  객체를 참조하던 변수가 사라지거나 관계를 끊으면(nil) 숫자가 -1 됩니다.
  • 숫자가 0이 되는 순간 힙에서 객체를 소멸시켜 메모리를 확보합니다. (deinit 호출)

@escaping

클로저는 코드 덩어리처럼 보이지만, 주변의 변수들을 기억(캡처)합니다. 이때, 클로저의 행동을 결정짓는 중요한 특성이 바로 @escaping 입니다.

@escaping이 붙은 클로저는 자신을 만든 함수가 끝난 후에도 살아남아 나중에 실행될 수 있는 클로저를 의미합니다. 클래스의 프로퍼티에 저장되는 클로저가 대표적입니다. 이때 참조 카운트가 +1 된다고 합니다.


이제 이 개념들을 이해한 상태로 코드를 다시 분석해 봅시다.

override func viewDidLoad() {
    var c: C? = C() // c는 viewDidLoad의 '지역 변수' (스택)
    ...
    d?.closure = {
        print("friend: \(c)")
    }
}
  •  변수 c는 viewDidLoad라는 함수의 '스택(좀전에 책상에 비유)' 위에 있습니다. 클로저 역시 같은 책상에서 만들어졌기 때문에, 바로 옆에 있는 c를 직접 볼 수 있습니다. (self 호출이 필요없음)
  • 클로저가 c를 캡처할 때, c가 가리키는 힙 위의 C 객체에 대한 강한 참조를 만듭니다. 이로 인해 C 객체는 viewDidLoad가 끝나도 살아남을 수 있게 됩니다. 하지만 클로저가 C 객체를 참조할 뿐 순환참조는 발생하지 않습니다.
  • self가 필요 없는 이유는, 클로저가 ViewController(self)의 소유물에 접근하는 것이 아니라, 자신의 작업 공간에 있는 지역 변수를 사용하기 때문입니다. self가 사용되지 않았기 때문에 순환참조가 될 위험도 없습니다.
 
class E {
    var c: C? = C() // c는 E 인스턴스의 '소유물' (힙)
    ...
    d?.closure = { [weak self] in // ⬅️ 범행의 핵심
        guard let self else { return }
        print("friend: \(self.c)")
    }
}
  • 변수 c는 E 인스턴스의 '힙' 안에 들어있는 소유물입니다. 이 c에 접근하려면 주인인 self를 통해야만 합니다. 따라서 클로저는 c에 접근하기 위해 self를 캡처해야 합니다.
  • 만약 [weak self]가 없다면
    1. E 인스턴스(self)는 자신의 소유물인 d를 강하게 참조합니다. (self ➡️ d)
    2. d는 자신의 소유물인 closure를 강하게 참조합니다. (d ➡️ closure)
    3. 이때, 클로저가 self를 강하게 캡처하며 self를 되참조합니다. (closure ➡️ self)
  • 결과: 순환참조 발생!
  • self ➡️ d ➡️ closure ➡️ self... 서로가 서로를 놓지 못하는 순환참조가 형성됩니다. ARC는 이들의 참조 카운트를 0으로 만들 수 없고, 메모리에서 해제시키지 못합니다. 

 

c와 self.c의 차이는 단순한 문법이 아니라 변수가 어디에서 생성되는지(스택/힙), 얼마나 오래 사는지(생명주기), 그리고 그를 관리하는 ARC의 규칙이 어떻게 적용되는지에 대한 근본적인 차이였습니다.

  • 지역 변수를 캡처하는 것은 대부분 안전합니다.
  • 하지만 인스턴스 프로퍼티에 접근하기 위해 self를 캡처하는 @escaping 클로저는 순환참조를 발생시킬 수 있습니다.