코어데이터를 연동하면서 고민할 점

JIN·2023년 2월 17일
0
post-thumbnail

UI작업은 main thread에서 담당해야만 한다.

  1. UIKit의 대부분의 요소는 thread-safe 하지 않다.(원자성이 보장되지 않는다) 모든 요소를 thread-safe하게 설계하기엔 UIKit이 너무 방대한 프레임워크이기 때문이다. 이를 억지로 보장하는 것은 오히려 성능상 좋지 않다.
    예를 들어, UITableViewCell의 셀이 백그라운드 스레드에서 제거 되었는데 다른 백그라운드 스레드가 해당 셀이 접근하려고 하면 크래시가 발생한다. 또한 백그라운드 스레드의 Runloop에 특정 뷰에 대한 제거가 진행중이에 있는데, 사용자가 해당 뷰를 탭하면 응답을 처리하기도 애매하다

  2. View Drawing Cycle을 생각해보면 하나의 Runloop에서 이를 확인하고 관리하게 된다. 하지만 만약 각 스레드의 Runloop에서 View의 life cycle을 관리하게 되면 화면의 UI가 변경되었을때 모든 변경사항을 화면에 존재하는 여러 View들이 확인할 수 없게 된다. 따라서 View가 깨진다.

  3. ios는 그림을 표현할 때 특정한 렌더링 프로세스(코어애니메이션 -> 렌더서버-> GPU-> 표시) 를 거치는데 여러 스레드에서 각 뷰의 변경사항을 GPU로 보내면 각 정보를 해석하기 위해 많은 오버헤드가 발생할 수 있다.

Runloop와 View Drawing Cycle

UIApplication은 main thread에서 Main Run Loop라고 불리는 런루프를 생성한다. 앱 내에서 발생하는 대부분의 이벤트를 관장한다. 이 Main Run Loop를 통해 스크린의 내용이 refresh 될 수 있다.
view의 변경은 즉시 일어나지 않는다. 이번 RunLoop의 마지막에 redraw하여 view가 변하게 되는데 이러한 변경을 View Drawing Cycle이라고 부른다.
이러한 Run Loop는 thread마다 가지고 있기 때문에 만약 background thread에서 view의 변경이 가능하다면 view가 동시에 변해야 하는 상황(화면의 회전)에서 view들이 동시에 변하지 않는 문제가 발생할 것이다.

  • UIKit : 모든 종류의 컴포넌트들을 가지고 있으며 유저 이벤트를 핸들한다. 하지만 렌더링 관련 코드는 들고있지 않다
  • Core Animation: draw의 책임을 지고 있다. 모든 view를 display하고 animate한다.
  • OpenGL ES: 2D, 3D 렌더링을 진행한다.
  • Graphics Hardware: GPU가 있는 영역이다.

렌더링 진행 방식

  1. Commit Transaction
    • view를 레이아웃하고 이미지를 디코딩하여 Render Server에 이를 전달한다.
  2. Renser Server
    • Commit Transaction으로 부터 받은 package를 분석하고 deserialize하여 rendering tree에 보낸다. 이후 drawing instruction을 생성하고 VSync Signal을 기다렸다가 OpenGL을 호출한다.
  3. GPU
    • VSync Signal이 떨어지면 OpenGL을 사용하여 렌더링을 시작한다. 랜더링이 끝난 후에는 버퍼로 내용을 전달한다.
  4. Display
  • 버퍼로부터 데이터를 받아서 화면에 띄워준다 .
    위의 파이프라인 과정이 1초당 60번(60hz) 이루어지게 된다. 만약 백그라운드 스레드를 활용해서 view를 변경한다면 여러 스레드 위에서 위의 파이프 라인을 시작하는 trigger를 당기게 된다. 파이프라인은 굉장히 비싼 작업이기 때문에 빈번한 context switching은 막는 것이 좋다

Dispatch Queue(GCD) 사용시 주의해야 할 점

1. 반드시 메인 큐에서 처리해야 하는 작업 (UI관련 작업)

ios에서 업데이트 사이클(이벤트 결과를 화면에 출력) 작업을 수행할 수 있는 스레드는 오직 1번쓰레드 (main thread) 뿐입니다.
때문에 다른 스레드에서 작업을 수행하던 중 UI 관련 작업이 시작되면 , 해당 작업을 메인 큐를 통해 1번 쓰레드로 보내야 한다.
즉 UI관련 작업은 1번 쓰레드에서만 할 수 있으며 다른쓰레드에서 UI 작업을 만나면 1번 쓰레드로 보내야 한다.

DispatchQueue.global().async {
// 다양한 작업(UI 제외)
  DispatchQueue.main.async {
  //UI 작업
  }
}

//main.sync가 안되는 이유 : 경합 조건때문에 데드락 발생 가능성 있기 때문

UI 관련 작업을 다른 스레드에서 작업(UI 작업 작동 X)

UI 관련 작업을 1번 쓰레드(main thread)에서 작업(UI 작업 작동O)

2. 컴플리션 핸들러의 존재 이유 - 올바른 콜백 함수의 사용

ios에서 비동기(Async) 처리 방식은 "해당 작업을 기다리지 않고 다음 작업을 진행하는 방식"
이러한 방식은 작업을 분산처리하여 성능을 높인다는 장점을 가지고 있지만 특정 작업의 함수 결과물을 의존/ 사용하는 다른 작업이 존재할 경우 에러가 발생할 수 있다.

  • 서로 다른 스레드에서 task1과 task2가 동시에 진행하려면 에러가 발생
    (task2의 작업 진행은 task1의 결과물에 의존하기때문)
  • 이처럼 특정 함수의 작업 결과를 의존하는 다른 함수가 존재할때는 특정 함수의 작업 결과를 반환(return)하는 방식으로 설계하면 안된다.
  • 에러(nil) 없는 작동을 위해 작업 결과를 반환(return) 하지 않는 형태 (Void) + 클로저를 호출할 수 있는 형태로 설계해야한다.
    즉 비동기 작업의 끝나는 시점을 파악하여 해당 작업 결과를 클로저로 전달하는 형태로 설계해야 한다.

    비동기적인 작업을 해야 하는 함수를 설계할 때 return을 통해서 데이터를 전달하려면 항상 nil이 반환됩니다.
var resultName: String?

func myName(name: String) -> String?{  // n번 쓰레드에서 작업
    DispatchQueue.global().async {
        sleep(2)
        resultName = name
    }
    return resultName
}

print(myName(name: "김철수")) // print() 함수는 n번 쓰레드의 함수 결과를 사용
/* 출력 결과
 nil  // myName() 함수의 작업이 끝나기 전에 print() 함수의 작업이 진행하기 때문에 nil(에러) 출력
 */

비동기적인 작업을 해야 하는 함수는 항상 클로저를 호출할 수 있도록 함수를 설계해야 합니다.

var resultName: String?

func myName(name: String, completionHandler: @escaping (String?) -> Void){  // n번 쓰레드에서 작업
   DispatchQueue.global().async {
       sleep(2)
       resultName = name
       completionHandler(resultName)
   }
}

myName(name: "김철수") { XXX in
   print(XXX)
}

/* 출력 결과
Optional("김철수")
*/

3. weak, strong 캡처의 주의

객체 내에서 비동기 코드를 사용할 때는 약한/ 강한 참조를 생각하면서 코드를 작성해야 한다.

class Man{
    var name: String
    var run: (()->Void)?
  
    init(name: String){
        self.name = name
    }
    func runClosure(){
        DispatchQueue.global().async {
            self.run = {
                print("\(self.name)이 달리고 있습니다.")
            }
        }
    }
  
    deinit{
        print("\(self.name) 메모리에서 제거되었습니다.")
    }
}

func doSomething(){
    var kim: Man? = Man(name: "김철수")  // kim 인스턴스 생성 (kim RC 1증가)
    kim?.runClosure()  // 클로저(run)가 메모리의 Heap 영역에 생성
}

doSomething()  // 아무 출력 없음

대부분의 경우 캡처리스트 안에서 weak self로 선언하여 약한 참조 하게끔 하는 것을 권장한다.

class Man{
    var name: String
    var run: (()->Void)?
  
    init(name: String){
        self.name = name
    }
      
    func runClosure(){
        DispatchQueue.global().async {
            self.run = { [weak self] in
                print("\(self?.name)이 달리고 있습니다.")
            }
        }
    }
  
    deinit{
        print("\(self.name) 메모리에서 제거되었습니다.")
    }
}

func doSomething(){
    let kim: Man? = Man(name: "김철수")  // kim 인스턴스 생성 (kim RC 1증가)
    kim?.runClosure()  // 클로저(run)가 메모리의 Heap 영역에 생성
}

doSomething()  // 김철수 메모리에서 제거되었습니다.

4. 동기 (sync) 함수를 비동기(Async) 적으로 동작하는 함수로 변형

작업 시간이 긴 함수들을 동기 함수로 만들면 1번쓰레드에 과부하가 걸립니다. 이러한 이유로 작업시간이 긴 함수를 내부에 비동기적 처리를 하여 비동기로 동작하는 함수로 변형해야 합니다.

func myTask(programmingLanguage: String) -> String{
    print("수학 숙제 시작")
    sleep(2)
    print("수학 숙제 종료")
   
    print("\(programmingLanguage) 코딩 공부 시작")
    sleep(2)
    print("코딩 공부 종료")
   
    print("영어 숙제 시작")
    sleep(2)
    print("영어 숙제 종료")
   
    return "공부 종료"
}

myTask(programmingLanguage: "Swift")

동기 함수를 비동기적으로 동작하는 함수로 변형하는 함수를 생성하여 만들면 버벅임과 같은 현상을 예방할 수 있습니다.

func myTask(programmingLanguage: String) -> String{
    print("수학 숙제 시작")
    sleep(2)
    print("수학 숙제 종료")
   
    print("\(programmingLanguage) 코딩 공부 시작")
    sleep(2)
    print("코딩 공부 종료")
   
    print("영어 숙제 시작")
    sleep(2)
    print("영어 숙제 종료")
   
    return "공부 종료"
}

func asyncMyTask(programmingLanguage: String, completionHandler: @escaping (String) -> Void){
    DispatchQueue.global().async {
        let function = myTask(programmingLanguage: programmingLanguage)
        completionHandler(function)
    }
}

asyncMyTask(programmingLanguage: "Swift") { XXX in
    print(XXX)
}
profile
배우고 적용하고 개선하기

0개의 댓글