[UIKit] iOS 화면 간 데이터 전달방식: Delegate와 Closure

bono·2023년 7월 21일
11

중요기초

목록 보기
1/4

서론

안녕하세요. 보노(해언)입니다.

RxSwiftCombine에 대한 포스팅을 작성하기 앞서 기본이 되는 두 가지 데이터 전달 방식에 대해 포스팅을 하려 합니다.

전송 화면 / 수신 화면 에 따른 코드 처리가 늘 헷갈렸는데 라이브 코딩으로 끝장나는 데이터 전달 설명을 해준 메이슨에게 감사 인사를 전합니다.

요즘 푸바오가 핫하기 때문에🐼 관련된 소소한 비유로 풀어보았습니다. 나름 적절한 상황 묘사를 하고 싶었던 것인데... 다 쓰고보니 간결하게 쓰지 못한 것에 대한 아쉬움이 남습니다.

코드 읽기가 어렵다면 마지막 정리 글만 보셔도 좋을 것 같습니다.

양방향 데이터 전달 상황

사육사VC푸바오VC에게 먹이를 전달하려 합니다.
푸바오VC는 자신이 사라질 때 배부름(Bool) 상태를 사육사VC에게 전달하려 합니다.

사육사VC :
1️⃣ 먹이를 가지고 있음
2️⃣ 푸바오VC에게 먹이를 주고 싶음
데이터 전달 : 사육사 -> 푸바오

푸바오 VC :
1️⃣ 먹이를 손에 쥘 수 있음
2️⃣ 손에 쥔 먹이를 먹을 수도, 먹지 않을 수도 있음
3️⃣ 배 부른지 아닌지 사라지기 전에 사육사에게 알려주고 싶음
데이터 전달 : 푸바오 -> 사육사

(단순 학습용으로 작성하여서... 세세히 읽지 않는 것을 권합니다!)
(필요한 코드엔 이모지를 달아두었습니다)

기본 구성

import UIKit

enum Food {
  case 죽순
  case 고구마
}

// MARK: - 사육사
final class 사육사ViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()
    self.view.backgroundColor = .white
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
      let 푸바오VC = 푸바오ViewController()
      let food: Food = .죽순
      print("1️⃣ 📦 먹이 전송 \(food)")
      푸바오VC.손에쥐어줌(food: food)
      self.present(푸바오VC, animated: true)
    }
  }
  
  func 푸바오밥먹었니(state: Bool) {
    print("4️⃣ 📮 전달받은 배부름 상태 \(state)")
  }
}

// MARK: - 푸바오
final class 푸바오ViewController: UIViewController {
  private var 손에쥐고있는: Food?
  private var 배부름: Bool = false
  
  override func viewDidLoad() {
    super.viewDidLoad()
    self.view.backgroundColor = .black
  }
  
  deinit {
    print("3️⃣ 📦 배부름 상태 \(배부름) 전송") //이 부분이 문제
  }
  
  func 손에쥐어줌(food: Food) {
    print("2️⃣ 📮 먹이 전달 받음 \(food)")
    self.손에쥐고있는 = food
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
      self.식사()
    }
  }
  
  func 식사() {
    guard let food = self.손에쥐고있는 else { return }
    
    switch food {
    case .죽순:
      배부름 = true //죽순이면 먹고
    case .고구마:
      배부름 = false //고구마면 안 먹음
    }
    self.dismiss(animated: true)
  }
}

문제 상황 : 푸바오가 사육사에 접근할 방법이 없음

//푸바오ViewController
  deinit {
  	//해제될 때 사육사VC에게 접근할 방법 필요
  }

Delegate

해당 방법에서 교류하는 두 VC는

  • Delegate 채택 VC : 사육사
  • Delegate 사용 VC : 푸바오

로 나뉜다.

네 가지 단계만 기억하면 된다 (코드상의 숫자는 실제 실행 순서로 단계와 무관)
1️⃣ 접근하고 싶은 타 객체의 메서드를 protocol로 생성

protocol 사육사Delegate {
  func 푸바오밥먹었니(state: Bool)
}

2️⃣ 데이터 전송 객체는(푸바오VC) delegate 변수를 생성하여 원하는 시점에 메서드 실행

final class 푸바오ViewController: UIViewController {
	var delegate: 사육사Delegate?
  ...
  
  deinit {
    print("2️⃣-1 📦 배부름 상태 \(self.배부름) 전송")
    delegate?.푸바오밥먹었니(state: self.배부름)
  }
  
  ...
}

3️⃣ 수신 객체는(사육사VC) protocol을 채택하여 해당 메서드 구현

final class 사육사ViewController: UIViewController, 사육사Delegate {
...
func 푸바오밥먹었니(state: Bool) {
    print("2️⃣-2 📮 전달받은 배부름 상태 \(state)")
  }
...
}

4️⃣ ⭐️ 데이터 수신 객체는(사육사VC) 전송 객체(푸바오VC)가 사용할 delegate의 구현자가 '나' 임을 밝힘 ⭐️

override func viewDidLoad() {
    super.viewDidLoad()
	...
    
    let 푸바오VC = 푸바오ViewController()
    let food: Food = .죽순
    
    print("1️⃣-1 📦 먹이 전송 \(food)")
    푸바오VC.손에쥐어줌(food: food)
    푸바오VC.delegate = self ⭐️ //(푸바오VC가 delegate 메서드를 통해 나를 쓸 거임 = 데이터를 넣어줄 거임)
    self.present(푸바오VC, animated: true)
   
  }

델리게이트 채택 VC와 델리게이트 소유 VC의 구분법

체득할 정도로 자주 사용한 게 아니라면 delegate의 사용은 각 위치에 뭐가 들어가야 하는 지 헷갈리기 쉽다.

천천히 원리를 생각해보자.

함수는 inputoutput으로 구성된다.
또한 함수를 통해 데이터를 전송한다는 건 전달자수신자 메서드 파라미터에 접근하여 데이터를 넣어 준다는 뜻이다.

그렇다면 누가 메서드를 구현하여 가지고 있어야할까?
즉, 누가 데이터를 필요로 하는가?

그렇다. 데이터를 원하는 쪽이 상세 메서드를 구현해야 한다.

발송자는 실행할 delegate 메서드가 어떤식으로 동작하는 지 알 필요가 없다. 짜장면을 주문하는 데 요리법을 알 필요 없는 것과 같다.

그러나 요리사와 주문을 엮어주는 과정은 반드시 필요하다.

생성자.delegate = 수신자

🛑 주의! 순환참조

final class 푸바오ViewController: UIViewController {
  var delegate: 사육사Delegate?
  ...
}

은근슬쩍 넘어갔지만, 이러면 순환 참조의 위험이 있으므로 weak 처리를 해줘야 한다.

final class 푸바오ViewController: UIViewController {
	weak var delegate: 사육사Delegate?
  ...
}

현재 코드상으론 weak 처리를 해주지 않아도 메모리 누수가 발생하지 않는다. 왜냐하면 사육사VC가 푸바오VC를 ViewDidLoad 내에서 생성하므로 강하게 참조하지 않는 덕분이다.

그러나 아래와 같은 코드라면 🚨순환 참조 문제🚨가 발생하게 된다.

final class 사육사ViewController: UIViewController, 사육사Delegate {
  var 푸바오VC: 푸바오ViewController? //🚨문제
  
  override func viewDidLoad() {
    super.viewDidLoad()
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
      
      self.푸바오VC = 푸바오ViewController() //🚨문제
      let food: Food = .죽순
      print("1️⃣ 📦 먹이 전송 \(food)")
      self.푸바오VC?.손에쥐어줌(food: food)
      self.푸바오VC?.delegate = self  //(푸바오VC가 delegate 메서드를 통해 나를 쓸 거임 = 데이터를 넣어줄 거임)
      self.present(self.푸바오VC!, animated: true)
    }
  }
  ...
}
final class 푸바오ViewController: UIViewController {
    var delegate: 사육사Delegate?
}

사육사 -> <- 푸바오

서로가 서로를 가르키므로 순환 참조가 발생한다.

푸바오가 가진 사육사 Delegate사육사ViewController가 연결되면, 푸바오는 Delgate를 통해 사육사의 메서드를 참조하게된다.

그러니 푸바오가 가진 사육사를 향한 참조 (화살표)를 약하게 (weak) 만들지 않으면 푸바오가 화면에서 내려지고도 메모리 해제 되지 않는 문제가 발생한다.

식사 여부 확인 없이 먹이만 전달하고 푸바오VC를 종료하는 코드를 작성해 보자.

화면 제거 시 푸바오VC 사라짐을 출력하고 메모리 해제 시 메모리 해제를 출력하도록 하였다.

// MARK: - 푸바오
final class 푸바오ViewController: UIViewController {
  private var 손에쥐고있는: Food?
  private var 배부름: Bool = false
  var delegate: 사육사Delegate?
  
  override func viewDidLoad() {
    super.viewDidLoad()
    self.view.backgroundColor = .black
  }
  
  deinit {
    print("메모리 해제")
  }
  
  func 손에쥐어줌(food: Food) {
    print("2️⃣ 📮 먹이 전달 받음 \(food)")
    self.손에쥐고있는 = food
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
      self.식사()
    }
  }
  
  func 식사() {
    guard let food = self.손에쥐고있는 else { return }
    
    switch food {
    case .죽순:
      배부름 = true //죽순이면 먹고
    case .고구마:
      배부름 = false //고구마면 안 먹음
    }
    self.dismiss(animated: true)
    print("푸바오VC 사라짐")
  }
}

실행하면 콘솔 창에는 아래와 같이 출력된다.

메모리 해제가 출력되지 않는다.

즉, 화면 상에는 더 이상 푸바오VC가 없음에도 메모리가 해제되고 있지 못하고 누수가 발생하였다.

해결법은 간단하다.

final class 푸바오ViewController: UIViewController {
  weak var delegate: 사육사Delegate?
  ...
}

delegate 변수 선언 시 순환참조가 일어나지 않도록 weak 키워드를 작성하는 것이다.

🛑 weak 키워드 에러 뜨는데?

weak약한참조! 참조! 참조! 를 의미한다.

해당 경고는 사육사Delegate를 채택한 것이 클래스(참조 타입)이 아닌 구조체(값 타입)일 수 있으므로 weak 타입이 적절하지 않다고 말하고 있다 (구조체도 프로토콜을 채택할 수 있으므로)

해당 문제는 해당 delegate가 반드시 클래스만을 채택할 것이라고 특정해주면 해결된다.

protocol 사육사Delegate: AnyObject {
  func 푸바오밥먹었니(state: Bool)
}

에러가 사라졌다.

+) AnyObject와 AnyClass의 차이는 무엇일까?

AnyObject는 클래스 인스턴스를 의미하고 AnyClass는 class라는 타입 자체, 메타타입을 의미한다

Closure

클로저는 헤더가 없는, 이름이 없는 메서드다.
델리게이트클로저나 input으로 데이터를 이동시킨다는 대주제는 같다.

클로저는 일급 객체로서 변수로도, 함수의 인자로도 사용될 수 있다.
클로저를 이용한 데이터 전달은 파라미터로 원하는 데이터를 받았다치고! 작업을 서술하는 데 있다.

1️⃣ : 데이터를 수신하는 VC가 데이터를 전송하는 VC의 클로저정의한다.
2️⃣ : 데이터를 전송하는 VC클로저를 속성으로 가지고, 실행한다.

delegate와 클로저 방식의 가장 큰 차이점은 클로저는 수신VC가 전송VC의 저장속성인 클로저에 접근하여, 상세 내용을 직접 주입한다는 점에 있다.

delegate는 함수의 구현부와 사용 헤더를 Delegate를 통해 연결해 주는 느낌이었다면, 클로저는 상세 구현을 냅다 꽂아주는 느낌이다.

코드를 보면 이해가 빠를 것 같다. 코드를 보자.

// MARK: - 사육사
final class 사육사ViewController: UIViewController {
  override func viewDidLoad() {
    super.viewDidLoad()
    self.view.backgroundColor = .white
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
      let 푸바오VC = 푸바오ViewController()
      let food: Food = .죽순
      print("1️⃣ 📦 먹이 전송 \(food)")
      푸바오VC.손에쥐어줌(food: food)
      
      푸바오VC.밥먹었는지안먹었는지 = { state in
        print("4️⃣ 📮 전달받은 배부름 상태 \(state)")
      }
      self.present(푸바오VC, animated: true)
    }
  }
}

사육사VC 내부 코드이다.

핵심 코드는 아래와 같다.

푸바오VC.밥먹었는지안먹었는지 = { state in
    print("4️⃣ 📮 전달받은 배부름 상태 \(state)")
}

푸바오VC에 접근하여, 내부에 클로저인 것으로 추정되는 (넣어주는 값이 {} 꼴이므로) 밥먹었는지안먹었는지 속성에 세부 구현을 넣어주고 있다.

앞선 delegate 방식이 그러했듯이 데이터를 전달받는 쪽이 함수(클로저)를 구현하고 있다.

그러니 푸바오VC 내부 코드를 보기전에도 푸바오VC에서 해당 클로저를 원하는 타이밍에 실행하겠구나 추측할 수 있다.

// MARK: - 푸바오
final class 푸바오ViewController: UIViewController {
  private var 손에쥐고있는: Food?
  private var 배부름: Bool = false
  var 밥먹었는지안먹었는지: ((Bool) -> Void)? //🐼
  
  override func viewDidLoad() {
    super.viewDidLoad()
    self.view.backgroundColor = .black
  }
  
  deinit {
    print("3️⃣ 📦 배부름 상태 \(배부름) 전송")
    밥먹었는지안먹었는지?(배부름) //🐼
  }
  
  func 손에쥐어줌(food: Food) {
    print("2️⃣ 📮 먹이 전달 받음 \(food)")
    self.손에쥐고있는 = food
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
      self.식사()
    }
  }
  
  func 식사() {
    guard let food = self.손에쥐고있는 else { return }
    
    switch food {
    case .죽순:
      배부름 = true //죽순이면 먹고
    case .고구마:
      배부름 = false //고구마면 안 먹음
    }
    self.dismiss(animated: true)
  }
}

핵심적인 코드는 아래와 같다.

final class 푸바오ViewController: UIViewController {
var 밥먹었는지안먹었는지: ((Bool) -> Void)? //🐼
...
deinit { //원하는 타이밍 
    print("3️⃣ 📦 배부름 상태 \(배부름) 전송")
    밥먹었는지안먹었는지?(배부름) //🐼
  }
}

값을 전달하는 푸바오는 밥먹었는지안먹었는지 클로저에 자신이 전달하고자 하는 input 값인 자신의 배부름 상태를 넣어 실행한다.

그리고 해당 클로저 실행으로 인해 input 값을 받게 되는 곳이 바로 해당 클로저의 내용을 대입해준 사육사VC가 되는 것이다.

🛑 주의! 캡쳐 현상

클로저 사용시 캡쳐현상이 발생할 수 있다.

  푸바오VC.밥먹었는지안먹었는지 = { state in
    print("4️⃣ 📮 전달받은 배부름 상태 \(state)")
  }

그러나 현재 코드는 자기 자신을 참조할 일이 없어 캡처 현상을 일으키지 않는다.

만약 아래와 같이 자신을 참조하는 코드가 존재한다면 weak 처리를 해주어 캡처 현상을 피해야 한다.

//사육사VC
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
      푸바오VC.밥먹었는지안먹었는지 = { [weak self] state in
        guard let self = self else { return }
        self.test = "weak을 써야 VC 화면에서 내려갈 때 test가 캡처되는 일을 막을 수 있다"
      }
    }

참고) 클로저 사용 시 메모리 누수

아래 코드도 메모리 누수의 위험이 있다.

// MARK: - 푸바오
final class 푸바오ViewController: UIViewController {
  ...
  deinit {
    print("메모리 해제")
  }

  func 식사() {
    ...
    self.dismiss(animated: true)
    
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
      print("3초 뒤")
      print("3️⃣ 📦 배부름 상태 \(self.배부름) 전송")
      self.밥먹었는지안먹었는지?(self.배부름) //🐼
    }
  }
}

화면이 사라졌음에도 메모리가 해제되지 않는 것은 자기 자신을 참조하는 클로저 탓이다. 그러니 class 내 클로저 사용 시에는 반드시 weak 키워드를 습관화 해야 한다.

개선 코드

DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
      guard let self = self else { return }
      print("3초 뒤")
      print("3️⃣ 📦 배부름 상태 \(self.배부름) 전송")
      self.밥먹었는지안먹었는지?(self.배부름) //🐼
    }

실행 결과

화면이 사라짐에 따라 메모리도 해제되었음을 확인할 수 있다.


공통점

발행자, 수신자 간의 1:1 매칭

아무래도 전송자(발행자), 수신자와 같은 표현은 Rx를 공부하며 버릇이 된 것 같다.
그래도 VC의 관계에 대해 직관적으로 이해하기 편리한 이름이라 사용해 보자면,
DelegateClosure은 데이터 전송 시 모두 아래와 같은 관계를 가진다.

전송자 : 수신자 = 1 : 1

Delegate 먼저 생각해 보자.

우선 Delegate는 protocol이다.

사실 이전의 나는 프로토콜이 재사용성을 확보하는 추상화 요소로 사용되기에 재사용! 자유롭게! 라는 단어의 어감에서 오는 이미지로 수신자와 1:N 관계를 맺고 있다고 생각했다.

하지만 이는 오해였다.

프로토콜이 가진 추상화는 규격화된 틀을 사용하기에 교체가 편리하다는 거지, 연결 자체를 여러개 할 수 있다는 말이 아니다.

콘센트처럼 말이다. 규격화된 콘센트 양식은 다양한 전자기기가 같은 충전포트에 꽂힐 수 있도록하지만, 한 개의 포트에 꽂히는 것은 결국 하나의 전자기기다.

Closure 도 마찬가지로 1:1 관계이다.

앞선 코드에서, closure는 수신자에 의해 상세 클로저를 주입 받았다.

만일 다른 객체가 접근하여 해당 변수에 다른 클로저를 주입 한다면 내용이 교체된다.

코드 작성 시 공통점

(일반적으로)

  • 메서드(클로저)의 상세 내용을 서술하는 것은 수신화면 (수신자)
  • 메서드(클로저)를 실행하는 것은 전달할 데이터를 가진 전송화면 (발행자)

차이점

  • 델리게이트 : 객체간의 규격화된 소통
  • 클로저 : 코드 블럭을 전달하기 위함

마치며,

NotificationCenter 은 다룰까 하다가 다루지 않았다.
기본적인 데이터 전송 방식 중 NotificationCenter가 대표적인 1:N 매칭 방식이라 앞선 두 방식과 비교하면 좋았겠으나, 익숙하게 사용한 경험이 없기도 하고 싱글턴과 같은 방식으로 동작함을 알게되어 앞으로도 썩 사용하지 않을 것 같았다.

차차 포스팅하고자 하는 RxSwiftCombine은 발행자와 수신자간의 관계가 1:1도, 1:N도 가능하다. 더 나아가 이벤트 발생과 수신에 대해 세밀한 분류를 가진다. 아직은 아득하지만 다룰 날이 온다면 좋겠다.

포스팅이 길어짐에 따라 세밀한 검토를 하지 못하였습니다. 혹시 문제가 있다면 언제든 댓글로 알려주세요!
다들 즐거운 개발 되세요 🐼

profile
iOS 개발자 보노

3개의 댓글

comment-user-thumbnail
2023년 7월 23일

푸바오에 비유한 설명 너무 좋네여 잘 읽었습니당^.^~~

답글 달기
comment-user-thumbnail
2023년 7월 25일

어려운 내용도 좋은 비유로 설명해주셔서 감사합니다~ 잘 읽고 갑니다!

답글 달기
comment-user-thumbnail
2023년 8월 30일

영광...

답글 달기