iOS 앱을 개발하면서 얻은 지식과 깨달음, 그 과정에서 겪은 시행착오를 공유합니다.
Contents
1. 백그라운드 스레드와 메인 스레드 간 전환
2. 의존성 주입(Dependency Injection)
- 유연성
- 테스트 용이성
- 단일 책임 원칙
- 확장성
- 재사용성
3. SwiftUI View에서 ViewModel 사용
아이폰 캘린더(달력) 앱 스케줄러에서는 사용자가 좌우 스와이프로 '월'을 이동할 수 있다. 달력에 표시해야 하는 데이터가 적을 때는 크게 눈에 띄지 않지만, 데이터가 많아지면 움직임이 월을 이동하면서 살짝 끊기는 현상이 생긴다. 이 문제의 원인 중 하나를 월 이동 시 데이터 조회(Data Fetching) 같은 시간이 오래 걸리는 작업을 메인 스레드(Main Thread)에서 수행하고 있기 때문으로 추측하고 있다.
최근 '잘못 작성한 Swift 동시성(Concurrency) 코드로 인해 생긴 경쟁 상태(Race Condition) 버그'를 해결하면서 Swift 동시성 및 멀티 스레딩 환경에 대해 이전보다 이해가 조금이나마 깊어져, 이 문제도 조금 더 올바른 방향으로 연구해 볼 수 있을 것 같았다.
https://ios-tech.tistory.com/1
Swift 동시성 코드 Race Condition 버그 해결 과정 💡✨
iOS 앱을 개발하면서 얻은 지식과 깨달음, 그 과정에서 겪은 시행착오를 공유합니다. iOS 캘린더(달력) 앱 스케줄러에는 '기본 캘린더' 기능이 있다. 애플 캘린더 등 외부 캘린더를 연동한 뒤, 연
ios-tech.tistory.com
1. 백그라운드 스레드와 메인 스레드 간 전환
실제 스케줄러 앱의 코드를 리팩토링(Refactoring)하기 전 큰 그림을 그려 어떤 방향으로 코드를 리팩토링할지 연구해 보기로 하고, MVVM 아키텍처 패턴을 활용해 아래 ViewModel 코드부터 작성했다.
final class DataViewModel: ObservableObject {
/// UI와 Binding할 데이터를 담고 있는 속성(Property, 프로퍼티)
@Published private(set) var data: [String] = []
private let dataFetcher: DataFetchable
/// 💡 의존성을 주입(Dependency Injection)해 dataFetcher 설정
init(dataFetcher: DataFetchable) {
self.dataFetcher = dataFetcher
}
/// View에서 비동기 작업의 세부 사항을 몰라도 되도록 Task로 비동기 작업을 수행
func fetchData() {
Task {
/// 💡 백그라운드 스레드에서 데이터 가져옴
let data = await dataFetcher.fetchData()
let transformedData = dataFetcher.transformData(data: data)
/// 💡 메인 스레드에서 UI 업데이트
await MainActor.run {
self.data = transformedData
}
}
}
}
위 코드에서 내가 문제 해결의 핵심으로 생각한 부분은 fetchData() 함수에서 UI를 업데이트하는 코드인 self.data = transformedData 외에는 백그라운드 스레드(Background Thread)에서 실행되도록 한 부분이다. 백그라운드 스레드(Background Thread)는 네트워크 요청 등 시간이 많이 걸리는 작업을 처리하는 스레드이고, 메인 스레드(Main Thread)는 UI 업데이트 및 사용자 상호작용을 처리하는 스레드다.
시간이 많이 걸리는 작업을 메인 스레드에서 처리하면 앱(Application)이 일시적으로 멈추는 것처럼 보일 수 있다. 실제로 스케줄러 앱에서 발생하고 있는 현상이다. 현재 코드에서는 메인 스레드로 전환해야 하는 부분만 MainActor.run으로 지정한 게 아니라, 아래처럼 @MainActor를 사용해 fetchData 함수 내부의 모든 작업이 메인 스레드에서 작동하는 문제가 있다.
// ❌
@MainActor
func fetchData() {
Task {
let data = await dataFetcher.fetchData()
let transformedData = dataFetcher.transformData(data: data)
self.data = transformedData
}
}
2. 의존성 주입(Dependency Injection)
위 DataViewModel은 초기화 시 DataFetchable 프로토콜(protocol)을 채택하는 dataFetcher 객체(Object)를 의존성으로 주입받고 있다. 이렇게 하면 아래와 같은 5가지 장점들이 생긴다.
1) 유연성 증가
DataViewModel이 특정 구현에 고정되지 않고, 다양한 DataFetchable를 사용할 수 있게 된다.
let networkDataFetcher = NetworkDataFetcher()
let localDataFetcher = LocalDataFetcher()
let networkDataViewModel = DataViewModel(dataFetcher: networkDataFetcher)
let localDataViewModel = DataViewModel(dataFetcher: localDataFetcher)
2) 테스트 용이
테스트를 위한 모의(Mock) 객체를 만들어 쉽게 주입할 수 있다. 이를 통해 테스트 시 네트워크 요청을 하지 않아도 DataViewModel의 동작을 테스트할 수 있다.
protocol DataFetchable {
func fetchData() async -> [String]
func transformData(data: Data) -> [String]
}
// MARK: - Real Data Fetcher
final class RealDataFetcher: DataFetchable {
func fetchData() async -> [String] {
// 실제 데이터를 비동기로 가져오는 코드
Fetch...
}
func transformData(data: [String]) -> [String] {
// 데이터를 변환하는 코드
Transform...
}
}
let realDataFetcher = RealDataFetcher()
let realDataViewModel = DataViewModel(dataFetcher: realDataFetcher)
// MARK: - Mock Data Fetcher for Testing
final class MockDataFetcher: DataFetchable {
func fetchData() async -> [String] {
// 테스트용 mock 데이터를 반환하는 코드
return ["MockData1", "MockData2"]
}
func transformData(data: [String]) -> [String] {
// 데이터를 변환하는 코드
return data.map { $0.uppercased() }
}
}
let mockDataFetcher = MockDataFetcher()
let mockDataViewModel = DataViewModel(dataFetcher: mockDataFetcher)
3. 단일 책임 원칙(SRP, Single Responsibility Principle) 준수
DataViewModel은 UI 관련 데이터와 업데이트를 관리하고, RealDataFetcher는 실제 데이터 조회와 변환을 담당해 책임을 분리했다.
4. 확장성 증가
DataFetchable 프로토콜을 준수하는 새로운 데이터 조회와 변환 객체를 쉽게 추가해 새로운 DataViewModel을 만들 수 있다.
final class NewDataFetcher: DataFetchable {
func fetchData() async -> [String] {
// 새로운 방식으로 데이터를 가져오는 코드
}
func transformData(data: [String]) -> [String] {
// 새로운 방식으로 데이터를 변환하는 코드
}
}
// 기존 DataViewModel 코드는 변경하지 않고 새로운 DataFetcher 사용
let newDataFetcher = NewDataFetcher()
let newDataViewModel = DataViewModel(dataFetcher: newDataFetcher)
5. 재사용성 증가
DataFetchable 프로토콜의 구현체가 DataViewModel과 분리돼 있기 때문에, 구현체를 여러 객체에서 재사용할 수 있게 된다. 이는 코드의 중복을 줄이고 유지보수를 용이하게 만든다.
3. SwiftUI View에서 ViewModel 사용
SwiftUI View에서는 아래 코드처럼 사용한다.
import SwiftUI
struct ContentView: View {
@StateObject private var vm = DataViewModel(dataFetcher: RealDataFetcher())
var body: some View {
VStack {
// UI
}
.onAppear {
vm.fetchData()
}
}
}
마치며
오랫동안 고민하던 문제인데, 이번 연구로 해결의 실마리를 찾은 것 같아서 기쁘다. 하지만 실제 코드에서는 생각하지 못한 여러 문제를 맞닥뜨리게 될 것이고, 더 나은 코드가 되려면 어떻게 해야 하는지 고민이 필요하다. 어서 스케줄러 앱의 실제 코드에 적용해 개선 후 사용자 분들께 더 나은 앱 사용 경험을 드리고 싶다(끝).
덧
이 글 내용과 관련해 iOS 개발자 님들의 의견을 여쭙고자 레딧 r/swift에도 토론 글을 올려보았는데, 댓글로 감사한 조언을 주신 분이 계셨습니다. Swift6의 핵심을 이해하는 데 큰 도움이 되어 관련 레딧 포스팅과 요약 번역을 함께 공유드립니다 🙌
[조언 요약 번역]
- 정적 격리의 중요성: Swift 6에서는 코드 격리를 정적으로 정의할 수 있어, 컴파일러가 이를 검증하고 자동으로 관리할 수 있습니다. 이는 코드 안전성과 효율성을 높이는 데 큰 도움이 됩니다.
- 글에서 제안한 방법의 한계: MainActor.run과 같은 방법은 임시방편에 불과하며, 최선의 방법은 아닙니다. 정적 격리를 사용하여 시스템의 격리 요구 사항을 명확히 표현하는 것이 중요합니다.
- @MainActor 활용: DataViewModel과 같은 뷰 모델에는 @MainActor를 사용하여 메인 스레드에서의 격리를 보장하는 것이 좋습니다. 이는 모든 코드가 메인 스레드에서 안전하게 실행되도록 합니다.
- 비동기 함수 사용 권장: 비동기 작업을 수행할 때는 Task 블록 대신 비동기 함수를 사용하는 것이 좋습니다. 이는 작업 취소를 더 쉽게 할 수 있게 해줍니다.
Improving View Performance with Asynchronous Switching Between Background and Main Threads
https://www.reddit.com/r/swift/comments/1dlt1b7/improving_view_performance_with_asynchronous/
Swift 6 Migration Document
-
-
-
'미리 알림'부터 '기본 캘린더'까지 쉽게 연동.
다양한 위젯.
아이폰, 아이패드, 맥 '달력' 앱 스케줄러:
https://apps.apple.com/kr/app/id6467635137
스케줄러 - 캘린더 위젯 & 미리 알림 & 투두 플래너
일정 관리. 필요한 기능만. 말이 필요 없는 단순함. 그 단순함이 주는 편리함. 일정 관리에만 집중할 수 있도록 도와드립니다. - 이 앱은 사용자의 개인 정보를 수집하지 않습니다. 개인 정보
apps.apple.com
참고 자료
1. Swiftful Thinking Youtube: Swift Concurrency
Swift Concurrency (Intermediate Level)
www.youtube.com
2. Dependency Injection in Swift using latest Swift features
Dependency Injection in Swift using latest Swift features
Dependency Injection using latest Swift features allows you to mock data, and write tests easily without 3rd party dependencies.
www.avanderlee.com
3. Swift Documentation: Concurrency
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/
Documentation
docs.swift.org
4. Apple Developer Documentation: API Collection - Concurrency
https://developer.apple.com/documentation/swift/concurrency
Concurrency | Apple Developer Documentation
Perform asynchronous and parallel operations.
developer.apple.com
5. Apple Developer Documentation: MainActor
https://developer.apple.com/documentation/swift/mainactor
MainActor | Apple Developer Documentation
A singleton actor whose executor is equivalent to the main dispatch queue.
developer.apple.com
6. Apple Developer Documentation: Actor
https://developer.apple.com/documentation/swift/actor
Actor | Apple Developer Documentation
Common protocol to which all actors conform.
developer.apple.com
7. Apple Documentation: Migrating to Swift 6
Documentation
www.swift.org
8. [My Post] Reddit - r/swift
Improving View Performance with Asynchronous Switching Between Background and Main Threads
https://www.reddit.com/r/swift/comments/1dlt1b7/improving_view_performance_with_asynchronous/
From the swift community on Reddit
Explore this post and more from the swift community
www.reddit.com
'Concurrency' 카테고리의 다른 글
Swift 동시성 코드 Race Condition 버그 해결 과정 💡✨ (2) | 2024.06.20 |
---|