관리 메뉴

사과하는 제라스

[Swift 지식] iOS에서 UI 작업을 Main Thread에서 해야하는 이유 본문

제라스의 Swift 공부/Swift 지식

[Swift 지식] iOS에서 UI 작업을 Main Thread에서 해야하는 이유

Xerath(제라스) 2024. 7. 19. 23:40

목차

    728x90
    반응형

    서론

    안녕하세요! 개발자 Xerath입니다!👍🏻🤖👍🏻

     

    제목... 정말 많이 들어보셨죠...?ㅋㅋㅋㅋㅋㅋㅋ

    귀에서 피가 날 수준...

    우리는 iOS 개발을 하다보면 어떻게 해서든 UI작업들은 Main Thread로 보내서 작업을 진행해야 합니다.

    다들 막 이게 필수지식이라고 하는데...

     

    근데 이유가 분명 명료하게 있음에도...

     

    명확하게 확립이 안 되었기에...

     

    이렇게 그 이유를 정리해두고자 합니다!!🚀🚀

    그럼 간단한 지식 창고 시작하겠습니다~~

     

    Race Condition에서 시작해보자.

    Race Condition은 앞선 포스팅에서도 다뤘는데요!

    정의부터 봅시다~!!

    Race Condition : 여러 쓰레드가 하나의 자원에 동시에 접근해서 자원이 변질되어 원하는 결과가 나오지 않는 현상.

     

    간단합니다!

    예를 들어 [1,2,3]이란 배열이 있을 때,

    가장 뒤에 있는 값을 출력후 제거한다고 생각해봅시다.

     

    이때, A,B,C 쓰레드에서 동시에 이 배열에 접근해서 '가장 뒤에 있는 값을 출력후 제거'하는 동작을 실행하면,

    셋 다 3이란 값을 쓸 겁니다.

    왜냐? 셋이 동시에 배열에 접근했기에 다들 3이 마지막 값이라고 알고 있으니까요!!

     

    이런 상황은 예상과는 다르잖아요?

    우리가 보통 예상하는 건 각 쓰레드가 1,2,3을 순서에 관계없이 하나 출력하고 삭제하는 거겠죠?

     

    근데 그게 아니었던 거죠...!(왜?? 동시 접근해서!)

    이런 걸 Race condition이라고 합니다.

    Thread-Safe가 뭘까?

     

    사실 개발을 하다보면 이게 Thread Safe하냐 아니냐는 말을 자주 하게 되는데,

    과연 이게 무엇인지 알아볼게요~!!

     

    우선 정의부터 봅시다!

    Thread-Safe : 멀티 스레드 프로그래밍에서 일반적으로 어떤 함수나 변수, 혹은 객체가 여러 스레드로부터 동시에 접근이 이루어져도 프로그램의 실행에 문제가 없음.

     

    즉, 우리가 동시성 프로그래밍을 하다보면 멀티 스레드 프로그래밍을 구현하게 되잖아요??

     

    근데 이때 발생할 수 있는 앞서 설명한 문제. Race Condition이 발생할 수 있습니다.

     

    왜??

     

    멀티 쓰레드 프로그래밍

    = 쓰레드가 여러개란 뜻

    = 어떤 자원에 각 쓰레드가 동시에 접근할 가능성이 존재하긴 하다는 뜻...!

     

    그래서 우리는 여러 쓰레드가 하나의 자원에 동시에 접근하는 것을 막아야 합니다.

    근데, Swift는 대부분의 타입들이 '여러 쓰레드에서 동시 접근이 가능한' Thread- Unsafe입니다...!

     

    그렇기에 우리는 이걸 막아주고자 이전 글에서처럼 DispatchSemaphore, Custom한 Serial Queue을 통해 구현할 수 있습니다.

     

    혹은 아래와 같이 NSLock을 통해 lock을 했다가 unlock하는 방식으로도 가능하고,

    var nums = [43,23,32,31,12,234,554]
    
    DispatchQueue.global().async {
      for _ in 1...10 {
        lock.lock() // lock해서 막고,
        let num = nums.removeFirst()
        lock.unlock() // unlock으로 풀어준다.
        print("\(num)")
      }
    }

     

     

    아래와 같이 concurrent한 DispatchQueue의 flag로 .barrier를 두는 방식으로도 가능합니다.

    이렇게 하면 분명 concurrent한 Queue임에도 serial하게 해당 클로져가 순차적으로 실행됩니다.

    var barrierQueue = DispatchQueue(
        label: "barrierQueue",
        attributes: .concurrent
    )
    
    barrierQueue.async(flags: .barrier) {
      for _ in 1...5 {
        let num = nums.removeFirst()
        print("\(num)")
      }
    }
    barrierQueue.async(flags: .barrier) {
      for _ in 1...3 {
        let num = nums.removeFirst()
        print("\(num)")
      }
    }

     

     

    이런 방식들을 쓰면 우리는 해당 객체에 대해서 접근이 동시에 온 애들이든, 따로 온 애들이든 모두 순서가 생기므로 Thread-Safe하게 됩니다.

     

    그래서 UI 작업은 왜 Main Thread에서 하는데...?

    자꾸 딴소리를 했죠 ㅋㅋㅋㅋㅋㅋㅋ

    자, 이제 그 이유가 나올 때가 되었네요!!

     

    만약 메인 쓰레드에서 그려지지 않는다고 가정해볼게요.

    그러면 각 작업들은 서로 다른 쓰레드에서 진행될 거고,

    그것들이 끝나는 것에 따라 제각각 UI가 반영이 되겠죠?

     

    이렇게 되면 그림을 계속 다시 그리고, 다시 그리고, ...이렇게 되겠죠?

    각 Thread마다 RunLoop를 가지고 있다보니 모아서 한번에 반영을 할 수가 없는 상황이 펼쳐집니다...!!

     

    또한, 이건 Race Condition이 발생하게 만드는 구조입니다.

    예를 들어, 100*100 크기인 사각형이 화면에 띄워져 있다고 생각해봅시다!!

     

    저는 이걸 50*50으로 줄였다가 30*30으로 또 줄이려고 해요.

    근데 이 과정이 순서대로 실행이 되어야 하는데 서로 다른 쓰레드에서 RunLoop를 돌다보니 30*30이 되었다가 50*50이 될 수도 있는 거죠...!!

     

    으어어엇..! 구현하려던 순서가 아니니 이상하겠죠?

     

    그리고 UIKit의 모든 요소들(View, frame, height, weight...등등 정말 모든 변수, 함수, 객체)은 Thread-Nonsafe합니다.

    즉, 동시에 접근가능하다는 것이죠...!

    이 모든 것들을 우리가 매번 Thread-Safe하게 구현한다는 것 자체가

    사실 엄청난 공수가 들기도 하고, 오버헤드도 엄청나죠...(시간이 들겠지...에에에)

     

    오래 걸리면 뭐가 문제냐...?

    우리는 UI, 즉, 유저가 접촉하는 부분에서 이런 버벅임, 화면의 멈춤, 터치가 안 먹는 상황 등이 잠시나마 펼쳐지겠죠?

    심하면 몇 초, 몇 분씩 그럴거구요!

     

    그.래.서!

    Apple에서는 Main Thread에서 UI작업을 처리하도록 하라고 합니다.

    그리고 Apple은 이 Main Thread에서의 Main RunLoop 작업을 디스플레이와 동기화시켜둠으로써 화면에 작업 결과가 나타나도록 만들어두었죠.

     

    반면, 다른 Global Thread들에서는 기본적으로 RunLoop를 들고있지 않아요!

    만~~~~약에 생성해주더라도, 화면 업데이트와 동기화되진 않습니다.

    즉, Apple은

     

    "야! 너네 무조건 Main Thread에서만 화면 업데이트해. 뒷작업은 Global Thread에서 해도 상관없는데 그 결과를 Main Thread로 전달해주고 화면을 업데이트하는 건 Main Thread의 Main RunLoop에서 해!!"

     

    요렇게 한 거죠.

     

    예시코드는 다음과 같습니다.

    DispatchQueue.global().async {
        // 백그라운드(Global Thread)에서 긴 작업 수행
        let result = performLongTask()
    
        // Main Thread에서 UI 업데이트
        DispatchQueue.main.async {
            self.updateUI(with: result)
        }
    }

     

    이렇게 함으로써 UIKit / SwiftUI가 내부 요소들이 Thread Safe하지 않음에도 안정적으로 UI를 업데이트해줄 수 있습니다.

    그럼 Main Thread는 뭐가 다를까요?

    일단 Main Thread는 Serial Queue이다보니 모든걸 순서대로 실행할 거고

    모든 Thread의 중추가 되는 역할을 하는데다가 Main RunLoop를 갖고 있고,

    얘는 앱이 실행되면 자동으로 UIApplication가 호출하고 앱이 죽을 때까지 일정 주기를 갖고 계속 도는 특징이 있습니다.

    이건 다른 Global Thread에선 RunLoop를 자동으로 ㄱ

     

    이때, 주기에 따라서 유저로부터 이벤트를 입력받고 그걸 UI에 그리는 작업을 진행합니다.

    이걸 우리는 View Drawing Cycle이라고 부릅니다.

     

    그럼 한번 View Drawing Cycle도 자세히 뜯어볼게요!

    View Drawing Cycle

    먼저, 이건 여기서는 가볍게 설명하고 딱 이것만 다루는 포스팅으로 제대로 다뤄볼게요!! 

    (너무 중요한 것...같은...기분이...드는...그런 개념이라서요!)

     

    어떤 이벤트를 입력받고 그 결과를 UI에 반영하는 과정 View Drawing Cycle이라고 합니다.

    View Drawing Cycle에서는 크게 3가지 작업을 거칩니다.

     

    먼저 코드부터 보면

    import SwiftUI
    import Combine
    
    class ViewModel: ObservableObject {
        @Published var text = "Initial Text"
        private var cancellable: AnyCancellable?
    
        func updateText() {
            cancellable = Just(performLongRunningTask())
                .receive(on: DispatchQueue.main) // 2. UI 업데이트
                .sink { [weak self] result in
                    self?.text = result
                }
        }
    
        func performLongRunningTask() -> String { // 1. 이벤트 처리
            return "Updated Text"
        }
    }
    
    struct ContentView: View {
        @ObservedObject var viewModel = ViewModel()
    
        var body: some View {
            VStack {
                Text(viewModel.text)
                    .padding()
                
                Button(action: {
                    viewModel.updateText()
                }) {
                    Text("Update Text")
                }
            }
        }
    }
    
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
        }
    }

     

    어라라? 코드에 3번 설명이 없네???

     

    거짓말 아닙니다!!!!!!!!!!

    차차 설명 드릴게요 ㅋㅋㅋㅋㅋㅋㅋ

    일단은 렛츠고!!

    1. 이벤트 처리

    이벤트 처리 과정에선 주로 유저로부터의 터치 이벤트, 타이머 설정, 네트워크 이벤트 등의 여러 이벤트를 처리합니다.

    예를 들면, 유저가 어떤 버튼을 클릭하면 그 터치 버튼의 이벤트에 대한 코드를 실행합니다.

    2. UI 업데이트

    UI 업데이트 과정에선 주로 버튼 클릭으로 얻어낸 결과를 UI요소에 반영하는 작업을 합니다.

    예를 들면, 어떤 버튼 터치 이벤트의 처리 결과값을 View의 text값에 반영하는 작업입니다.

    3. 화면 반영

    이건 실제 코드에 명시적으로 드러나지 않는 부분입니다.

    UIKit이나 SwiftUI가 내부적으로 처리하는 부분입니다!

     

    우리가 종종 '이건 SwiftUI에서 알아서 처리해줘!'라고 하잖아요?

    그런 것들이 이 부분을 말하는 겁니다 ㅎㅎㅎ

     

    '2. UI 업데이트' 과정을 통해 어떤 요소가 바뀌게 되겠죠?

    그럼 그 변경 내역을 이 단계에서 Core Animation 트랜잭션으로 커밋 즉, 기록합니다.

     

    그 동작 순서를 보자면...

     

    트랜잭션 시작

    트랜잭션 : 애니메이션 및 레이아웃 변경을 그룹화하여 일관되게 처리하는 단위.

     

    트랜잭션은 UI요소가 업데이트되면은 바로 시작됩니다.

     

    변경 사항 수집

     

    UI 요소들이 변경된 사항들을 수집하여 트랜잭션에 추가합니다.

    이때, 변경된 frame, size, opacity 등등의 것들이 반영됩니다!

     

    트랜잭션 커밋

     

    이때 모아진 내용들이 다음 RunLoop Cycle에서 커밋됩니다.

    즉, Core Animation이 이 변경 내용들을 GPU에 전달하는 과정인 거죠!

     

    렌더링

     

    이렇게 변경 내용들을 접수받은 GPU는 열심히 뚝딱뚝딱 화면에 반영합니다.

    이 속도는 1초에 60번 정도라고 하는데...!

    기기마다 달라요!

    우리가 흔히 아는 주사율! 그게 딱 이겁니다.

     

    Pro 급들은 120Hz를 지원하기에 스크롤을 할 때 엄청 부드럽지만...!

    Air나 일반 아이폰들은 60-75Hz라서 살짝 버벅이는 감이 없잖아 있죠?? ㅎㅎㅎ

     

    무튼무튼!! 이렇게 함으로써 '3. 화면 반영' 이 완료되는 거죠! 👍🏻😃👍🏻

     

    마무리

    'iOS에서 UI작업을 Main Thread에서 해야하는 이유' 라는 제목을 써두었는데...

    '그래서 뭐 어쩌라는 거에요??'

     

    ㅎㅎㅎ 너무 둘러둘러 얘기한 것 같아서...!!

    간단히 그 이유를 나열해보자면...!

     

    1. Main Thread에서 serial한 Queue를 통해 순서가 보장되고, 반드시 모든 작업들이 수행될 수 있도록 보장해줌으로써 Thread-Safe한 UI 변경사항을 빠르게 반영하기 위함.

    2. 외부요인으로 Main RunLoop가 멈출 일이 없기에 문제 발생을 종식시킴.

     

    이렇게 볼 수 있습니다.

     

    사실 두가지 측면이 있긴 하죠.

    하나는, Apple이 만들어둔 이 시스템에서 왜 UI 작업을 Main Thread에서 실행해야 하는지,

    다른 하나는, Apple이 왜 이 시스템을 이렇게 만들어둔 건지.

     

    이 2가지 측면을 생각해보면서 이해해본다면 충분히 납득가고 설득력있는 이해가 가능할 것 같아유~~~~~

     

    그럼 다음엔 View Drawing Cycle에 대해서 관련된 함수들(Layout, Display, Constraints 관련 함수들)을 가지고 포스팅을 해보겠슴다!!

    참고

    https://medium.com/@duwei199714/ios-why-the-ui-need-to-be-updated-on-main-thread-fd0fef070e7f

     

    iOS: Why the UI need to be updated on Main Thread

    Do you ever think about why UI really MUST to be updated on main thread? What will happened if we turn UIKit into thread-safe design?

    medium.com

    https://stackoverflow.com/questions/53757664/why-do-we-need-to-call-the-main-thread-for-ui-updates

     

    Why do we need to call the Main Thread for UI updates?

    I know that we have to call the main thread when we update the UI. But I can't explain my teammates why we have to do it and why Swift doesn't do it automatically. They used to call self.present()

    stackoverflow.com

     

    https://siwon-code.tistory.com/34

     

    [iOS] Race Condition과 Thread Safe

    비동기적으로 기능을 구현하다 보면, 순차적으로 코드가 실행되는 것이 아니다 보니 주의해야 할 점이 생긴다. 그중 Race Condition과 해결책인 Thread Safe에 대해 알아보고자 한다. Race Condition Race Cond

    siwon-code.tistory.com

    https://gyuios.tistory.com/242

     

    iOS) thread-safe 와 atomic

    thread-safe : 멀티스레드 프로그래밍에서 자원에 스레드가 동시에 접근해도 문제가 생기지 않는 것을 말한다. 즉, 여러 곳에서 접근하더라도 올바른 결과를 얻게된다. atomic : 멀티스레드 프로그래

    gyuios.tistory.com

    https://jife98.tistory.com/m/62

     

    [iOS] 왜 메인스레드에서만 UI 업데이트를 할까?

    서론iOS개발을 하다보면 UI 업데이트 관련 로직은 main thread에서 작업을 해야한다는것을 알것이다. 비동기 작업을 하면서 UI업데이트 시, 당연스럽게 메인스레드에 작업을 지정하며 넘어갔다. 문

    jife98.tistory.com

     

    https://babbab2.tistory.com/68

     

    iOS) 런 루프(RunLoop) 이해하기

    안녕하세요 :) 소들입니다 오늘은 RunLoop라는 것에대해 공부를 해볼 건데여 음... 내용이 좀 어려울 수도 있어여!! 저도 오랜만에 다뤄서 완전히 이해하고 쓰는 내용이 아니라... (한 1년 전에 공부

    babbab2.tistory.com

     

     

    https://velog.io/@mmim/iOS-View-Drawing-Cycle

     

    [iOS] View Drawing Cycle

    view가 로드되거나 변경이 있을때, 화면에 그려지는 cycleView에는 시각적으로 표현되는 컨텐츠들이 많다.만약 변화가 있을 때마다 매번 View의 모든 컨텐츠를 다시 그리는 것은 비효율적일 것이다.

    velog.io

     

    https://jeonyeohun.tistory.com/336

     

    [iOS] iOS는 화면을 어떻게 그릴까? RunLoop 부터 Constraint, Layout, Display 까지

    런루프부터 화면에 뷰가 그려지기까지. 스터디를 하면서 기술발표가 있었는데요, 준비한 내용을 정리해두겠습니다! 거의 피피티에 모든걸 다 담아서 글은 많이 없을 것 같아요ㅎㅎ RunLoop(런루

    jeonyeohun.tistory.com

     

    https://green1229.tistory.com/67

     

    View Drawing Cycle

    안녕하세요. 그린입니다! 이번 포스팅에서는 iOS에서의 뷰 드로잉 사이클에 대해 알아보겠습니다. Drawing Cycle? : 뷰가 로드 되거나 변경이 있을때 화면에 시각적으로 표현되어 그려지는 일종의 사

    green1229.tistory.com

    https://yagom.net/courses/%eb%8f%99%ec%8b%9c%ec%84%b1-%ed%94%84%eb%a1%9c%ea%b7%b8%eb%9e%98%eb%b0%8d-concurrency-programming/lessons/gcd-%ec%8b%ac%ed%99%94/topic/ui-%ec%9e%91%ec%97%85%ec%9d%80-%ec%99%9c-main-thread%ec%97%90%ec%84%9c-%ed%95%b4%ec%95%bc%ed%95%a0%ea%b9%8c%ec%9a%94/

     

    동시성 프로그래밍 (Concurrency Programming) - 야곰닷넷

    동시성 프로그래밍 동시성 프로그래밍(Concurrency Programming) 코스에 오신 것을 환영합니다! 이번 코스는 동시성 프로그래밍에 대한 이해를 […]

    yagom.net

     

     


    아직 꼬꼬마 개발자입니다.

    더 나은 설명이나 코드가 있다면 언제든 환영입니다.

    적극적인 조언과 피드백 부탁드립니다!

     

    그럼 오늘도 개발 가득한 하루되세요!

    - Xerath -

    🤖🤖🤖🤖🤖🤖🤖

     

    728x90
    반응형