Swift 메모리 관리 – 클로저 편

스위프트의 캡쳐 목록은 클로저 코드 내부에 폐쇄된 공간의 매개 변수 목록 앞에 표시되며, 메모리 참조환경에서 강한참조(strong), 약한참조(weak), 미소유(unowned) 참조 메모리값을 캡쳐합니다.

참조 설정과 그리고 클로저 내부의 캡처 설정을 이해하지 못할 경우, 메모리 관련 오류(데드락, 잘못된 메모리 참조, 누수)가 발생하게 됩니다. 이번 지난 포스팅에 이어, 이번 포스팅 또한 메모리 관련 내용을 가지고 작성하였습니다.

안내: 만일 메모리 참조 옵션에 대한 각 특징을 모를 경우, Swift 메모리 관리 – 클래스 편을 먼저 읽고 보시는걸 추천드립니다.


시작하기 전

먼저 예제를 만들어봅시다. 첫번째로는 간단한 클래스를 만들어 보겠습니다.

class Singer {
  func playSong() {
    print("Get Lucky")
  }
}

둘째, Singer 인스턴스를 생성하고, playSong() 메서드를 사용할 수 있는 클로저를 만들도, 다른 곳에서 사용할 클로저를 반환하는 함수를 또한 만들어보겠습니다.

func sing() -> () -> Void {
  let daftPunk = Singer()

  let singing = {
    daftPunk.playSong()
    return
  }

  return singin
}

마지막으로, sing() 메서드 내부의 기능인 playSong()를 원하는 곳에서 호출할 수 있도록 해봅시다.

let singFunction = sing()
singFunction()

해당 코드를 실행하면 Get Lucky가 출력됩니다.


강력한 캡처 (Strong Capture)

여러분의 코드에 메모리 참조 옵션을 입력하지 않을 경우, 기본적으로 스위프트는 강력한 캡처를 기본값으로 사용합니다. 이는 함수 내부에서 사용될 수 외부 값을 캡처하여 메모리에서 해제가 되지 않도록 해야할 경우 사용합니다.

위에서 만들었던 Singer 클래스유형에 다음과 같이 deinit 메서드를 추가해 봅시다.

class Singer {
  func playSong() {
    print("Get Lucky")
  }

  deinit{
    print("Singer class is deallocated")
  }
}

그리고 다시 실행하고 콘솔 로그를 살펴보죠. 여전히 Get Lucky 만 출력되고 있습니다. 왜 그럴까요?

내부의 Singer 클래스 유형으로 사용된 변수 daftPunk는 sing() 함수 내부에서 생성하였습니다. 정상적으로 함수의 기능이 종료되는 시점에, singing 클로저에서 강력한 캡처를 사용하여, 해당 변수를 참조하였습니다. 이는 해당 변수의 메모리 카운트가 증가되어, sing() 메서드 내부 기능이 정상적으로 끝났지만, 메모리 참조 카운트가 0이 되질 않아 정상적으로 메모리 해제가 안되는 문제점을 발견했습니다.


약한 캡처 (Weak capture)

스위프트의 클로저는 캡처부분에 메모리 참조 옵션을 사용하여, 캡쳐 방법을 선택할 수 있습니다.

캡처 방법을 약한 캡처를 지정한 경우, 메모리 카운트가 증가가 되지않고 참조할 수 있습니다. 강력한 캡쳐의 대안으로 사용되는 약한 캡처는 다음 두가지 특징을 갖게 됩니다.

  1. 약한 유형으로 참조하게되는 캡처는, 메모리 해제 된 유형을 참조할 경우 nil이 됩니다.
  2. 1의 결과로, 약하게 캡처된 값은 언제나 선택사항(Optional type)이 됩니다. 이는 외부의 유형이 실제로 존재하지 않을 수도 있을 때, 다른 대안으로 로직을 구성할 수 있기 때문입니다.

약한 캡처를 사용하기 위해서는 위의 예제를 수정해봅시다.

func sing() -> () -> Void {
  let daftPunk = Singer()

  let singing = { [weak daftPunk] in
    daftPunk?.playSong()
    return
  }

  return singin
}

우리는 변수 singing에서 약한 캡처를 통해서 변수 daftPunk를 매개변수를 받았습니다. 자 그럼 코드를 실행 해봅시다.

  • 로그 출력: Singer class is deallocated

로그 출력에는 Get Lucky가 출력되질 않고 있네요. 왜 그럴까요?

그 이유는, singFunc() 메서드가 호출되기전에 변수 daftPunk는 이미 해제가 되었습니다. 자세히 말하자면, 변수 singFunc가 생성되는 시점에 sing() 클로저 메서드는 이미 정상적으로 수행을 끝냈습니다. 이 시점에 변수 datfPunk는 이미 메모리에서 해제가 되었습니다. 그 이후에 singFunc() 함수를 호출 했을 때, 캡처 방법은 약한 캡처로 설정하였기 때문에 메모리 참조에 아무런 영향을 주지 않았습니다, 그로 인해 매개변수 daftPunk는 메모리에서 해제가 되어있어, 매개변수로 nil 값으로 클로저 내부에 전달이되어 로그가 출력이 되지 않았던 것입니다.

메모리에서 해제된것을 확인하기 위해 해당 코드를 선택 유형을 강제유형(force unwrap)으로 변경해 봅시다.

func sing() -> () -> Void {
  let daftPunk = Singer()

  let singing = { [weak daftPunk] in
    daftPunk!.playSong()
    return
  }

  return singin
}

클로저 내부에서 무효화(nil)을 참조하였으므로, 잘못된 메모리 참조로 충돌이 발생합니다. 따라서 우리가 예상한대로 변수 daftPunk는 메모리에서 해제가 되었네요


미소유 캡처 (Unowned capture)

약한 캡처와 마찬가지로 메모리 참조 카운트에 영향을 주지 않으며, 해당 캡처 방법은 반드시 메모리에서 해제가 되지 않는 유형이 들어온다는 가정을 사용한 것이기 때문에 선택 유형이 아닙니다.

위의 코드를 약한 캡처를 미소유 캡처로 변경해 봅니다.

func sing() -> () -> Void {
  let daftPunk = Singer()

  let singing = { [unowned daftPunk] in
    daftPunk.playSong()
    return
  }

  return singin
}

하지만, 해당 코드는 선택 유형의 강제해제와 동일하게 충돌하게 됩니다. 그 이유는 메모리에서 해당 daftPunk의 강력한 참조 캡처와 달리 해당 메모리의 안전성을 보장해 주진 않습니다. 이 또한 약한 참조와 동일하게 sing() 메서드가 실행하고 난뒤에는 변수 daftPunk는 메모리에서 헤제가 되었기 때문이죠.

따라서, 미소유 캡처를 사용할 경우, 정말로 신중하게 사용해야 합니다.


미소유 캡쳐 vs 약한 캡쳐

이 두개 앞서 설명한 것과 같이 외부의 값의 안전성을 보장하지 않도록 메모리 레퍼런스 카운트를 증가 시키지 않습니다. 다만 두 옵션의 차이는 단지 이것 뿐입니다. 선택적 유형(Optional Type)인가? 비선택적 유형(Non Optional Type)인가 차이입니다. 물론 미소유 캡쳐를 통해서 guard let 또는 if let 과 같이 선택적 유형에서 사용하는 nil 검증 코드가 없기 때문에 미소유 캡쳐가 좋을 수 있어보이지만, 저는 왠만하면 미소유 캡쳐보다는 약한 캡쳐 사용을 권유 드리고 싶습니다.


일반적인 문제

클로저의 캡처 타입으로 인해 다음 4가지 문제 중 한개 이상의 문제가 발생할 수 있습니다.

  1. 클로저가 매개 변수를 수락 할 때 캡처 목록을 어디에 사용해야할지 잘 모르겠습니다.
  2. 참조하는 유형을 강력한 참조 사이클을 만들어 메모리에서 해제가 되지 않게 됩니다.
  3. 특히 실수로 여러 개의 캡처를 사용하는 경우 강력한 참조를 사용합니다.
  4. 클로저 내부에서 참조 유형의 복사본을 만들고 캡처 된 데이터를 공유합니다.

몇 가지 코드 예제를 사용하여 각각 살펴보고 어떤 일이 발생하는지 알아봅시다.


매개 변수와 목록 캡처를 함께 사용

해당 케이스는 캡처 목록을 처음 알고 사용할 때 흔히 볼 수 있는 문제입니다. 다행히도 스위프트 IDE에서는 사전에 해당 문제를 먼저 포착하여, 개발자에게 알려줍니다.

캡처 목록 및 폐쇄적 매개변수를 사용하는 경우에 다음과 같은 규칙으로 코딩하시면 됩니다.

[let or var] 변수명 = { [(캡처 유형) (매개변수)] 폐쇄적 매개변수 in
  폐쇄 로직
}

let writeToLog = { [weak self] user, message in
  self?.addToLog("\(user) triggered event: \(message)")
}

강력한 참조주기

예를들어 다음 클래스 유형이 있습니다.

class House {     
  var ownerDetails: (() -> Void)?
 
  func printDetails() {         
    print("This is a great house.")     
  }
 
   deinit {
    print("I'm being demolished!")     
   }
}

House 클래스 유형은 하나의 속성인 클로저, 하나의 메서드 및 deinitializer 를 가진 클래스입니다. 그리고 메모리에서 해제가 될 때, 메시지를 출력합니다.

이제 다른 클래스를 하나더 만들어보죠

class Owner {
  var houseDetails: (() -> Void)?

  func printDetails() {         
    print("I own a house")     
  }
 
   deinit {
    print("I'm dying!")     
   }
}

자 이제 두개의 클래스 유형을 사용하기 위해서 do 블록을 사용하여, 생성해봅니다.

print("Create a house and an owner")
 do {
   let house = House()
   let owner = Owner()
 }
 print("Done")

로그를 살펴보죠

  • Create a house and an owner
  • I’m dying!
  • I’m being demolished!
  • Done

do 블록 내부의 수행을 마침과 동시에 두 클래스 유형은 해제가 되었습니다. 두 클래스 유형은 메모리 순환 싸이클은 정상적입니다.

이제 강력한 참조주기를 만들어 봅시다.

print("Create a house and an owner")
 do {
   let house = House()
   let owner = Owner()
   house.ownerDetails = owner.printDetails
   owner.houseDetails = house.printDetails
 }
 print("Done")

로그를 살펴봅시다.

  • Create a house and an owner
  • Done

이전 포스팅에서 다루었던 서로 간의 강력한 참조로 인하여, 두 클래스 유형 모두 메모리 참조 방식을 강력한 참조 옵션을 사용하였기 때문에 메모리에서 헤제가 되질 않았습니다. 실제 코드에서 이러한 문제가 발생되었을 경우, 메모리 누수로 두 유형의 클래스를 해제할 수 없으므로 시스템 성능이 저하되고, 응용 프로그램이 예기치 못한 종료가 발생할 수 있습니다.

이 문제를 해결하려면 다음과 같이 새로운 클로저를 만들어 약한 캡처를 사용해야합니다.

print("Create a house and an owner")
 do {
   let house = House()
   let owner = Owner()
   house.ownerDetails = { [weak owner] in owner?.printDetails() }
   owner.houseDetails = { [weak house] in house?.printDetails() }
 }
 print("Done")

로그를 살펴보도록 하죠

  • Create a house and an owner
  • I’m dying!
  • I’m being demolished!
  • Done

우리가 원하는대로 do 블록이 완료되는 시점에 두 클래스 유형은 모두 메모리에서 해제가 되었습니다.

두 가지 모두 약한 캡처를 사용 할 필요는 없습니다. 중요한 것은 스위프트가 필요할 때 둘 다 삭제할 수 있기 때문에 적어도 하나이상은 사용해야합니다.

이제는 실제 프로젝트 코드에서 그렇게 명확한 강력한 참조주기를 찾는 것은 드뭅니다. 하지만 이는 메모리 해제가 되질 않는 문제를 완전히 피하기 위해 약한 캡처를 사용하는 것은 매우 중요합니다.

우발적인 강력한 참조

스위프트의 캡쳐는 의도하지 않는 문제를 유발할 수 있는 강력한 캡쳐가 기본 설정입니다. 이 또한 개개인에 따라 직관적이거나 비직관적이라고 판단할 수 있는 문제에서 비롯됩니다.

위에서 작성한 sing 메서드를 변경해봅시다.

func sing() -> () -> Void {
  let taylor = Singer()
  let adele = Singer()

  let singing = { [unowned taylor, daftPunk] in
      taylor.playSong()
      adele.playSong()
      return
  }

  return singing
}

클로저에 의해 캡쳐되는 두 값의 매개변수를 참조하게되며, 두 값은 클로저 내부에서 같은 방식으로 사용됩니다. 그러나, 매개변수인 taylor는 미소유 캡쳐를 사용하고, daftPunk 는 강력한 캡쳐를 사용합니다. 만약 두 매개변수 모두 미소유 캡쳐로 사용하기 위해서는 다음과 같이 코드를 변경해야 합니다.

let singing = { [unowned taylor, daftPunk] in

let singing = { [unowned taylor, unowned daftPunk] in

스위프트로 개발시, 의도하지 않았던 참조 오류가 발생하기 쉬운 케이스입니다.

스위프트는 일부 실수로 인한 캡쳐에 대한 실행전 오류를 내보내지 않습니다. 그래서 대부분의 개발자들은 self 라는 매개변수 하나로만 사용합니다.


클로저 사본

캡처 된 데이터가 복사본과 원본간의 공유가 되므로 클로저 자체가 복사되는 방식입니다.

예를 들어, 다음 numberOfLinesLogged 외부에서 생성 된 정수 유형을 캡쳐하여 호출 할 때마다 값을 증가시키고 인쇄 할 수 있는 간단한 클로저를 만들어 봅시다.

var numberOfLinesLogged = 0
 
let logger1 = {     
  numberOfLinesLogged += 1
  print("Lines logged: \(numberOfLinesLogged)") 
}
 
logger1()

로그를 살펴봅시다.

  • Lines logged: 1

마지막에 우리가 logger1 메서드를 사용하여 해당 로그를 출력하였습니다.

이제 그 클로저의 복사본을 가져오면 그 복사본은 원본과 동일한 캡쳐 값을 공유합니다. 그래서 원본 또는 복사본을 호출해도 로그의 라인수가 증가되는 것을 볼 수 있습니다.

let logger2 = logger1 
logger2()
logger1()
logger2()

로그를 살펴봅시다.

  • Lines logged: 1
  • Lines logged: 2
  • Lines logged: 3
  • Lines logged: 4

결국 클로저는 클래스 유형으로 볼 수 있습니다. 이는 값타입이 아닌, 레퍼런스 타입이기 때문에 얇은 복사방식을 사용합니다.


해당 캡쳐 유형은 각각 언제쓰면 좋을까?

우리는 위의 글을 통해서 캡쳐 유형이 각각 어떻게 작동되는지 알아보았습니다. 그렇다면 각각의 유형은 어떤 경우에 어떻게 사용할지에 대해 요약해보겠습니다.

  1. 클로저가 호출 될 가능성이있는 동안 캡처 된 값이 절대로 메모리에서 해제가 되지 않는다는 걸 알고 있다면 미소유 캡쳐(unowned)를 사용하세요. 이것은 guard let 또는 if let 코드로 선택적 유형 코드 판별을 하지 않아도 되어 코드를 줄일 수 있습니다.
  2. 두 클래스 유형 서로가 강력한 참조 주기를 사용하는 상황인 경우, 적어도 하나 이상 약한 캡쳐를 사용해야합니다. 이것은 일반적으로 둘 중 하나는 먼저 메모리에서 해제가 되어야하는 상황이기 때문입니다.
  3. 강력한 참조 주기가 없으면 강력한 캡쳐를 사용할 수 있습니다. 예를 들어, self 내부에서 애니메이션을 실행해도 클로저 내부에 유지되지 않으므로 강력한 캡쳐를 사용할 수 있습니다.

정말 헷갈리거나 잘모를 경우, 약한 캡쳐 주기를 먼저 사용하여 변경하세요.


해당 글은 Pro Swift 의 글을 번역한 내용입니다. 감사합니다.

0 Shares:
You May Also Like
Read More

클래스 vs 구조체

클래스와 구조체의 차이점은 무엇이 있을까요? Swift에서는 두 타입간에 매우 비슷합니다. 자 그럼 두 타입을 비교분석해보도록 합시다.
Read More

Swift 메모리 관리 – 클래스편

해당 포스트에서는 Apple의 메모리 관리에 대해 설명합니다. 대부분 ARC를 사용하여 자동으로 메모리를 처리한다고 하더라도 여전히 몇 가지 함정이 있습니다. 객체간의 설명하는 올바른 참조 유형을 선택하면 메모리 누수를 막을 수 있습니다.