[번역] iOS: 왜 UI는 메인 쓰레드에서만 업데이트를 해야 할까?
본 글은 iOS: Why the UI need to be updated on Main Thread(Dywanedu 저)를 한국어로 번역하여 옮긴 글입니다.
개발을 하는 동안에, 우리는 종종 UIKit 컴포넌트를 백그라운드 쓰레드(Background Thread)에서 호출해 본 적이 있습니다. 백그라운드 네트워크 콜백1에서 imageView.image = anImage
구문을 실행하거나, UIApplication.sharedApplication
을 백그라운드 쓰레드에서 호출하는 일을 꼽을 수 있습니다. 이러한 일이 발생하면, 런-타임 에러가 발생하고, 우리는 해당 에러를 고치게 됩니다.
한번 생각해봅시다. 왜 UI는 메인 쓰레드에서만 업데이트를 해야 할까요? UI를 백그라운드 쓰레드에서 업데이트하게 된다면 어떤 일이 벌어질까요? 메인 쓰레드를 블록킹(Blocking)하는 걸 막기 위해 UI를 백그라운드 쓰레드에서 업데이트하는 게 더 좋지 않을까요? 본 글은 이 질문을 기초로 하고 있습니다.
왜 UIKit은 쓰레드에 안전하지 않을까요?
우리가 살펴볼 수 있듯이, 대부분 UIKit 컴포넌트는 nonatomic
2으로 묘사됩니다. 이 의미는 이들이 쓰레드에 안전하지 않다(not Thread-Safe)는 걸 의미하지요. 그리고 UIKit은 매우 거대한 프레임워크이기 때문에 모든 프로퍼티를 쓰레드에 안전하게 설계하는 건 사실상 불가능합니다. 쓰레드에 안전한 프레임워크를 설계하는 건 단순히 nonatomic
프로퍼티를 atomic
프로퍼티로 바꾸거나, NSLock
3을 추가하는 일이 아니며, 아래와 같은 많은 문제를 수반하고 있습니다.
뷰의 프로퍼티를 비동기적으로 변경할 수 있다고 가정해봅시다. 이러한 변경은 동일한 시간에 이루어져야 할까요? 아니면 각 쓰레드 자체의 런-루프(Run-Loop)를 따라야 할까요?
만약
UITableView
가 백그라운드 쓰레드에서 셀을 삭제하고, 다른 백그라운드 쓰레드가 해당 셀의 인덱스를 다뤄야 한다면, 크래시로 이어질 겁니다.백그라운드 쓰레드가 뷰를 삭제하고, 해당 쓰레드의 런-루프 사이클이 끝나지 않았고(뷰를 다시 그리지 않았고), 동일한 시간에 사용자가
곧 삭제될 뷰
를 탭한다면, 해당 탭 이벤트에 반응을 해야 할까요? 어느 쓰레드가 반응을 해야 할까요?
깊게 생각해보면, 백그라운드 쓰레드에서 UI를 업데이트하는 건 그리 많은 이점을 주지 않는 걸로 보입니다. 그리고 우리가 해당 문제를 해결하고자 한다면, 우리는 “직렬 큐(Serial Queue)에서 모든 작업을 처리하면, 아무런 문제가 발생하지 않을 거야”라는 결론에 쉽게 도달할 수 있습니다. 이것이 애플의 생각이며, 따라서 UI 업데이트는 메인 쓰레드에서 동기적으로 작동될 필요가 있습니다.
Thread-Safe Class Design에서도 동일하게 적혀있습니다.
UIKit 프레임워크가 쓰레드에 안전하지 않는 건 애플의 의도적인 설계입니다. 쓰레드 안전성을 갖추는 건 성능 측면에서 바라볼 때 그리 많은 이점을 주지 않을 뿐만 아니라 속도또한 느리게 만듭니다. UIKit 프레임워크가 메인 쓰레드에 종속되어 있는 까닭에 동시성 프로그래밍을 쉽게 작성할 수 있습니다. 한 가지 기억해두어야 할 점은 UIKit 프레임워크는 언제나 메인 쓰레드에서 작동된다는 사실입니다.
- 앞서 언급한 문제를 완벽하게 해결하도록 UIKit 프레임워크에 놀라운 마법을 부려 리팩토링을 했다고 가정해봅시다. 우리는 UI를 백그라운드 쓰레드에서 업데이트할 수 있을까요? 아쉽지만, 그렇지 않습니다.
런-루프와 뷰의 드로잉 사이클
우리가 알다시피, UIApplication
은 메인 쓰레드에 메인 런-루프
라 불리우는 런-루프를 실행시킵니다. 이 런-루프는 애플리케이션이 실행하는 동안에 사용자 이벤트를 전달받아 처리합니다. 일련의 이벤트 처리와 절전(Hibernation)을 끊임없이 반복하며, 사용자 이벤트를 가능한 한 빨리 처리할 수 있도록 보장합니다. 화면이 다시 그려질 수 있는 이유가 메인 런-루프
가 동작하는 덕분이기도 합니다.
또한, 모든 뷰는 즉시 변화되지 않습니다. 뷰는 현재 런-루프의 마지막 포인트(Update Cycle)에서 다시 그려집니다. 이는 애플리케이션이 모든 뷰의 변화 사항을 처리할 수 있도록 보장하며, 모든 변경 사항은 동일한 시간에 적용됩니다. 이를 “뷰의 드로잉 사이클(View Drawing Cycle)” 이라 합니다.
우리가 마법을 부린 UIKit 프레임워크가 백그라운드 쓰레드에서 UI를 업데이트한다고 가정해봅시다. 우리가 기기를 회전시키고, 뷰의 레이아웃을 재배치시킬 때 문제가 발생합니다. 왜냐하면 각 쓰레드는 고유한 런-루프를 가지기 때문에, 모든 변화가 동시에 이뤄지지 않을 수 있습니다. 기기를 회전시켜도 일부 뷰의 레이아웃이 제대로 배치되지 않을 수 있겠죠.
이와 더불어, 마법을 부린 UIKit 프레임워크가 메인 쓰레드에서 동작하지 않기 때문에, 메인 런-루프
의 사용자 이벤트가 화면과 제대로 싱크(Sync)되지 않을 수 있습니다.
- 좋습니다. 그렇다면 전체
UIApplication
의 사용자 이벤트 처리 메커니즘을 리팩토링해서 쓰레드 동시성 문제를 해결하면, 우리는 UI를 백그라운드 쓰레드에서 업데이트할 수 있을까요? 아쉽지만, 그렇지 않습니다.
iOS 렌더링 프로세스의 이해
프레임워크 렌더링
UIKit: 모든 종류의 UI 컴포넌트를 포함하고 있고, 사용자 이벤트를 처리함. 다만, 어떠한 렌더링 코드는 포함되어 있지 않음.
Core Animation: 뷰를 그리고(drawing), 표시하고 애니메이팅함.
OpenGL ES: 2D・3D 렌더링 서버를 제공함.
Core Graphics: 2D 렌더링 서버를 제공함.
Graphics Hardware: GPU
iOS에서는 모든 뷰가 UIKit 프레임워크가 아닌 코어 애니메이션 프레임워크(Core Animation Framework) 에 의해 표시되고, 애니메이션됩니다.
코어 애니메이션 파이프라인
코어 애니메이션은 4가지 단계로 나누어진 코어 애니메이션 파이프라인을 거치며 뷰를 렌더링합니다.
커밋 트랜잭션(Commit Tansaction): 뷰를 배치하고, 이미지 디코딩 및 포맷 변환 작업 처리, 뷰 레이어 팩업(Pack Up) 및 렌더 서버로 전송함.
렌더 서버(Render Server): 커밋 트랙잭션으로부터 전달된 패키지를 분석하고, 렌더링 트리로 역직렬화함. 그 다음 뷰 레이어의 속성을 보고 드로잉 규칙을 생성하고, 브이싱크 시그널(VSync Signal)이 오면 OpenGL을 호출하여 화면을 렌더링함.
GPU: 브이싱크 시그널을 기다리고, 렌더링을 위해 OpenGL 렌더링 파이프라인을 사용함. 렌더링이 끝나면 출력은 버퍼에 전달됨.
디스플레이(Display): 버퍼로부터 데이터를 전달받으면 스크린에 표시함.
따라서, 코어 애니메이션 파이프라인에서는 준비 작업을 1/60초 내에 완료하고, 렌더링 서버로 데이터를 전송한 후 1/60초 내에 렌더링을 완료하여 애플리케이션이 멈추지 않도록 합니다.
그러나 만약 우리가 마법을 부린 UIKit을 사용한다면, 많은 백그라운드 쓰레드가 UI를 업데이트하므로, 런-루프의 마지막 포인트에서 화면이 렌더링되어야 할 때 문제가 발생합니다. 각 쓰레드가 서로 다른 렌더 정보를 커밋하므로, 더 많은 커밋 트랜적션을 처리해야 하며, 그 결과로 코어 애니메이션 파이프라인은 지속적으로 GPU에 정보를 커밋하게 됩니다. 그러나 렌더링은 실제로 시스템 리소스를 무척이나 많이 소모하는 작업이며, 이로 인해 발생하는 빈번한 컨텍스트 스위칭(Context Switching)과 무수히 많은 트랜잭션은 GPU가 처리할 수 없을 정도로 성능에 많은 무리를 주게 됩니다. 이로 인해 1/60초 내에 레이어 트리 제출을 할 수 없게 되어 심각한 지연을 초래하게 됩니다.
결론
“우리는 UI를 메인 쓰레드에서 업데이트할 수 없습니다.” 아마도 대부분의 iOS 개발자가 이를 알고 있겠지만, 이것에 대해 왜 그런지 이유를 생각해보신 적이 있으신가요? 더 깊게 파고들면, 많은 지식이 있고 이러한 지식들은 종종 우리에 의헤 간과되곤 합니다. 코딩은 결코 간단한 일이 아닙니다.
참고 자료
콜백 함수(callback function)는 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 말한다. 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있다. 콜백수신 코드는 실행하는 동안에 넘겨받은 콜백 코드를 필요에 따라 호출하고 다른 작업을 실행하는 경우도 있다. 다른 방식으로는 콜백수신 코드는 넘겨받은 콜백 함수를 ‘핸들러’로서 등록하고, 콜백수신 함수의 동작 중 어떠한 반응의 일부로서 나중에 호출할 때 사용할 수도 있다 (비동기 콜백). (출처: 위키백과) ↩
여러 쓰레드가 공유된 변수나 자료구조에 동시에 접근하여 값을 수정하거나 읽을 때, 예상치 못한 결과를 초래할 수 있습니다. 이로 인해 프로그램이 예기치 않은 방식으로 동작하거나 잘못된 결과를 생성할 수 있습니다. ↩
이 키워드는 Objective-C에서 사용되는 키워드 중 하나로 멀티쓰레딩 환경에서의 보호 메커니즘을 사용하지 않음을 의미합니다. 즉, 이 키워드가 붙은 프로퍼티는 별도의 잠금 메커니즘이 없기 때문에 다른 쓰레드로부터의 접근에 대해 보호받지 않습니다. 따라서 atomic과 달리 빠른 성능을 제공하지만, 멀티쓰레드 환경에서 데이터 일관성을 보장하지 않을 수 있습니다. ↩