최근 새로 진행 중인 프로젝트를 열심히 진행 중인데, 기존 작업이 모두 SwiftUI로 되어있어서 몇가지 시행착오를 겪고 있다.
지금까지는 UIKit만을 사용하는 협업을 주로 해왔기 때문에, SwiftUI로 만들어진 프로젝트에서 UIKit을 사용하여 협업하는 것이 처음이라 어려운 부분이 많다.
이번에는 프로젝트 작업 진행 도중 마주친 에러를 다뤄보려고 한다.


위 사진은 에러가 발생한 당시의 사진이며 에러 내용은 아래와 같다.
Publishing changes from within view updates is not allowed, this will cause undefined behavior.
그럼 지금부터 이 에러가 무엇이고, 왜 발생하는지, 어떻게 해결하는지 알아보자.
위와 같은 에러는 SiwftUI에서 상태(State)를 관리할 때 발생하는 가장 흔하고 중요한 오류 중 하나라고 한다.
뷰의 레이아웃을 그리는 중이나 업데이트하는 과정 중에, 뷰를 다시 업데이트하도록 만드는 상태 변경이 발생하면 목격할 수 있는 에러인데, 이는 시스템이 무한 루프에 빠지는 것을 방지하기 위해 경고나 치명적인 오류(Fatal Error)를 발생시키는 것이라고 한다.
오류의 발생 원리는 다음과 같다.
@State, @ObservedObjetc, @EnvironmentObject 등)이 변경즉, 이런 오류가 발생하는 것은 SwiftUI가 앱이 무한 루프에 빠지지 않도록 하기 위해 일종의 경고를 보내는 것 같다.
실제 빌드된 앱 내에서는 특별히 문제가 발생하거나 앱이 강제종료되지는 않았는데, 내부적으로도 해당 코드가 무한 루프에 빠지지 않도록 강제로 멈춘다던가 하는 안전장치가 더 마련되어 있는 것으로 보인다.
그런데, 앞서 말했듯 나는 SwiftUI가 아닌 UIKit을 사용하는데, 왜 이런 오류가 발생하는걸까?
지금부터 차근차근 이 원인을 파악해보자.
supplementaryView.moreButtonTapped = { [weak self] in
guard let self else { return }
self.coordinator.push(.dailyMSG)
}
위에서 오류가 발생한 사진을 보면 알 수 있듯이, 오류가 발생한 부분은 클로저 내부에서 Coordinator를 통한 화면 전환을 시도하는 코드이다.
단순히 화면 전환을 요청하는 코드인데, 왜 SwiftUI의 뷰를 업데이트가 되도록 요청되는걸까?
문제점을 정확히 이해하기 위해서는 현재 내가 진행 중인 프로젝트의 구조에 대해 이해할 필요가 있다.
프로젝트의 구조는 MVI + Clean Architecture + Coordinator의 아키텍처 패턴을 사용하고 있고, 메인 언어는 SwiftUI이다. Coordinator를 사용하기 때문에 화면 전환시 Coordinator를 활용하게 되고, 클린 아키텍처의 의존성의 단방향 흐름을 유지하기 위해 DI 패턴을 활용한 의존성 주입을 통해 Coordinator를 하위 레이어에 전달하여 사용하고 있다.
때문에 전체적인 구조는 아래와 같다.
AppCoordinator // @StateObject Coordinator 객체 전달
⎿ MainCoordinator // @EnvironmentObject 를 통해 Coordinator 구독 및 하위 레이어에 전달
⎿ ACoordinator
⎿ BCoordinator
⎿ ExampleCoordinator // 문제가 발생한 부분
⎿ NavigationStackView
⎿ UIViewControllerRepresentable // UIViewController를 SwiftUI에서 사용할 수 있도록 래핑한 객체
⎿ CostomViewController // Coordinator를 객체로서 소유
⎿ ChildViewController // init을 통해 Coordinator 초기화
⎿ 클로저를 통해 Coordinator push 실행 // Error 발생
구조가 복잡해 보일 수 있는데, 중요한 것은 어떤 흐름을 통해 Coordinator가 전달되고 있는가 이다.
에러가 발생한 supplementaryView는 UICollectionView의 HeaderView로, 뷰 업데이트 사이클의 일부로 간주된다. 이 시점에서 실행되는 클로저 내부에서 SwiftUI의 환경 객체(@EnvironmentObject로 받은 Coordinator)를 통해 상태를 변경하려고 했기 때문에 에러가 발생한 것이다.
UICollectionView의 supplementaryViewProvider 클로저는 UIKit의 레이아웃 패스(UI 업데이트)가 진행되는 도중에 호출됨.self.coordinator.push(.dailyMSG)를 호출coordinator는 @EnvironmentObject로 받은 SwiftUI의 상태(ObservableObject)이며, push 메서드는 이 상태 객체의 @Published 속성을 변경함. 즉, UIKit에서 SwiftUI의 상태 변수(Coordinator)를 뷰가 업데이트 되는 중에 변경시키려고 했기 때문에 SwiftUI에서 다시 뷰를 업데이트 하려고 했고, 이 때문에 충돌이 발생하여 오류가 발생했다는 의미이다.
SwiftUI에 대해 잘 모르다보니 막 사용하다가 발생한 문제인 것 같다...
문제의 원인을 알았으니, 이제 해결을 해야할 차례인데 어떻게 해결할 수 있을까?
가장 좋은 방법은 UIKit의 구조를 바꿔서 직접적으로 Coordinator 객체를 전달하고 소유하지 않고, Delegate 패턴 등을 활용하여 간접적으로 사용하는 것이 안전한 방법이다.
하지만, 요새 너무 바빠서 구조를 바꾸기엔 시간이 오래 걸리기 때문에 간단하게 문제를 해결해 보려고한다.
다시 한번 문제를 정의하자면 뷰가 업데이트 되는 도중 업데이트를 요청한 것이다.
그럼 문제를 해결하는 방법도 간단하다. 뷰가 업데이트 완료된 후 클로저가 실행(지연 실행 defer)되도록 명시해두면 되는 것이다.
일반적인 상황이라면 DispatchQueue.main.async { ... } 정도만 사용해도 문제가 해결될 수 있지만, 지금은 SwiftUI와 UIKit의 생명주기가 복잡하고 깊게 얽혀있어 잘 해결이 되지 않았다.
이럴 때는 Swift Concurrency의 Task 구문을 사용하여 @MainActor 환경에서 비동기 작업을 수행하면, SwiftUI의 뷰 계층 갱신이 완료될 때까지 상태 변경을 훨씬 더 확실하게 지연시킬 수 있다.
// 수정된 코드
supplementaryView.moreButtonTapped = { [weak self] in
guard let self else { return }
Task { @MainActor in
self.coordinator.push(.dailyMSG)
}
}
Task { @MainActor { ... } } 구문은 액터 모델(ActorModel)을 사용하기 때문에 메인 스레드에서 코드를 실행해야 함을 명시한다.
SwiftUI의 모든 뷰 업데이트와 상태 변경은 @MainActor에서 처리되는데, SwiftUI가 뷰 업데이트를 시작할 때부터 업데이트를 완료할 때까지 독점적으로 이를 관리하는 액터이다.
때문에 @MainActor를 사용하면, 현재 @MainActor의 모든 보류 중인 작업, 즉, 현재 진행 중인 SwiftUI의 뷰 렌더링 및 상태 전파가 완료된 후에 클로저가 실행되도록 보장할 수 있는 것이다.
코드 흐름을 요약하자면 아래와 같다.
@MainActor에 SwiftUI의 뷰 렌더링 및 상태 업데이트 작업이 보류됨(혹은 진행 중)Task를 통해 @MainActor에 새로운 비동기 작업(Coordinator push)을 추가@MainActor가 보류 중인 SwiftUI 작업을 모두 완료한 후 새로운 작업(Coordinator push)을 진행Task { @MainActor { ... } } 구문은 SwiftUI 친화적인 안전장치를 제공하기 때문에, DispatchQueue.main.async보다 충돌 문제를 해결하는 데 훨씬 더 효과적이고 신뢰할 수 있는 것이다.
SwiftUI를 공부한 것이 벌써 1년은 지난 일이라 @EnvironmentObject라던지, 상태 변화와 뷰 업데이트의 관계 등을 잊고 있었다.
그래서 이번 오류가 발생했을 때, 곧바로 이해하지 못하였고 자세하게 찾아보게 되었다.
결론적으로는 내가 무지했기 때문에 발생한 문제가 맞았고, SwiftUI와 UIKit을 함께 사용할 때의 주의점에 대해 깊이 깨닫게 되는 계기가 되었다.
이번에는 간단히 고쳤지만, 다음에는 그렇지 않을 수도 있기 때문에 다음부터는 설계부터 구조를 더 탄탄히 짜고 시작해야겠다...
물론 이번 프로젝트에서도 구조를 개선할 것이다.