
iOS 앱을 개발하면서 얻은 지식과 깨달음, 그 과정에서 겪은 시행착오를 공유합니다.
iOS 캘린더(달력) 앱 스케줄러에는 '기본 캘린더' 기능이 있다. 애플 캘린더 등 외부 캘린더를 연동한 뒤, 연동한 캘린더에 일정 등록 시 가장 자주 쓰는 캘린더가 선택돼 있도록 하는 설정 기능이다. 이 기능은 연동 캘린더에 일정을 등록할 때 나타나는 아래 편집기의 '캘린더 선택' 부분에 사용자가 설정한 '기본 캘린더'가 선택돼 있도록 한다.

💡 경쟁 상태(Race Condition)로 발생한 문제 해결 과정
그런데 '가끔' 다른 캘린더가 선택돼 있을 때가 있었다. '가끔'이라는 표현이 중요하다. 항상 그런 게 아니었다. 대부분의 경우 정상적으로 동작했다. 어떻게 디버깅해야 할 지 감이 안 잡혔다. 가끔일지라도 이 기능이 정상적으로 동작하지 않으면, 사용자 분들께서 무의식 중에 잘못 선택돼 있는 연동 캘린더에 일정을 등록하게 돼 나중에 불필요한 수정을 하셔야 하는 불편을 겪게 된다.
그러다 최근 해결의 실마리를 찾았다. Xcode의 새로운 기능이 궁금해 Xcode 16 Beta를 설치한 뒤 보게 된 아래 경고 메시지 덕분이었다. 이전 Xcode 버전에서는 나타나지 않았던 경고로, Swift6에서 동시성 모델을 더욱 안전하게 만들기 위해 Actor 격리(Actor Isolation) 규칙이 더욱 엄격해졌기 때문에 나타나는 메시지였다.

Actor-isolated property 'eventStore' can not be referenced from the main actor; this is an error in the Swift 6 language mode.
[번역] Main Actor 맥락에서 Actor로 격리된 'eventStore' 속성에 접근할 수 없습니다. 이는 Swift 6 언어 모드에서 오류로 나타납니다.
Swift의 Actor는 그 내부의 함수와 속성이 동시 접근으로부터 보호되는 동시성 모델이다. Actor는 실행할 작업들을 직렬화해, 각 작업이 순차적으로 실행되도록 보장한다. 이는 경쟁 상태(Race Condition)나 데이터 경합(Data Race) 같은 동시성 문제를 막는다.
경쟁 상태(Race Condition)는 다중 스레드 프로그래밍에서 중요한 개념으로, 시스템의 동작이 실행 순서에 따라 달라질 수 있는 상태다. 다시 말해, 경쟁 상태(Race Condition)는 두 개 이상의 작업이 병렬로 실행될 때 발생할 수 있으며, 실행 순서나 타이밍에 따라 결과가 달라질 수 있다. 경쟁 상태(Race Condition)에 놓이게 되면 프로그램이 의도하지 않은 동작을 하거나 예기치 않은 결과를 초래할 수 있다.
데이터 경합(Data Race)은 두 개 이상의 스레드가 동시에 동일에 메모리 위치에 접근하여, 적어도 하나가 쓰기 연산을 수행할 때 발생한다. 이는 메모리 일관성을 깨뜨리며, 프로그램의 예측 불가능한 동작을 유발할 수 있다. Swift의 Actor는 이러한 문제를 방지하기 위해 상태를 안전하게 격리한다.
내 코드에서는 Actor 내부에 격리된 ekRepository의 eventStore 속성(property, 프로퍼티)을 Main Actor 맥락에서 접근하려고 했으며, Swift 6에서는 컴파일 오류로 나타난다. 문제가 발생한 부분의 코드만 요약하면 아래와 같다.
import EventKit
actor EKRepository {
let eventStore: EKEventStore
init() {
self.eventStore = EKEventStore()
}
}
@MainActor
final class EKInteractor: ObservableObject {
private let ekRepository: EKRepository
var eventStore: EKEventStore {
/// 🔥 Actor-isolated property 'eventStore' can not be referenced from the main actor;
/// 🔥 this is an error in the Swift 6 language mode.
ekRepository.eventStore
}
init(ekRepository: EKRepository) {
self.ekRepository = ekRepository
}
}
이 코드를 처음 짤 때의 목표는 'eventStore'는 actor(EKRepository) 내부에서만 접근하자'였고, EKIneractor에서는 eventStore를 속성으로 정의하지 않았었다. 하지만 'EKEventEditViewController' 등 애플에서 제공하는 UI(User Interface)에 EKEventStore를 인자로 전달해줘야 하는 등 필요한 경우가 많이 생겨서 ekInteractor에서 eventStore를 노출하게 되었다.
하지만 위 EKInteractor에서 Actor 격리(Actor Isolation)를 무시하고 ekRepository의 eventStore에 접근하고 있기 때문에, 잠재적으로 경쟁 상태(Race Condition)에 놓일 수 있으며 예상하지 못한 문제가 생길 수 있다.
이를 해결하기 위해 Actor 격리(Actor Isolation)를 준수해 경쟁 상태(Race Condition)를 피할 수 있는 형태로 아래처럼 리팩토링(Refactoring)했다.
actor EKRepository {
/// 💡 getEventStore() 메서드로만 eventStore에 접근할 수 있도록 캡슐화
private let eventStore: EKEventStore
init() {
self.eventStore = EKEventStore()
}
}
extension EKRepository {
func getEventStore() -> EKEventStore {
return eventStore
}
}
@MainActor
final class EKInteractor: ObservableObject {
private let ekRepository: EKRepository
private var cachedEventStore: EKEventStore? = nil
/// 💡 Actor 격리(Actor Isolation)를 준수하면서 비동기적으로 접근하여 eventStore 캐싱
/// 먼저 캐시된 eventStore가 있는지 확인하고,
/// 없으면 비동기적으로 EKRepository의 getEventStore 메서드를 호출해 eventStore를 가져와 캐싱.
var eventStore: EKEventStore {
get async {
if let eventStore = cachedEventStore {
return eventStore
}
let store = await ekRepository.getEventStore()
cachedEventStore = store
return store
}
}
init(ekRepository: EKRepository) {
self.ekRepository = ekRepository
/// 💡 초기화 시점에 eventStore를 비동기적으로 가져와 캐시해 이후 접근 시 성능 향상
initEventStore(ekRepository: ekRepository)
}
}
extension EKInteractor {
private func initEventStore(ekRepository: EKRepository) {
/// 💡 [Task] 이 메서드는 비동기적으로 eventStore를 가져와 캐싱하지만,
/// 이 과정이 완료되기 전에 다음 작업이 진행될 수 있음.
Task {
self.cachedEventStore = await ekRepository.getEventStore()
}
}
}
또, '기본 캘린더'를 설정하는 코드에서도 경쟁 상태(Race Condition)에 놓인 코드를 발견했다. 아래 코드에서 Task 블록에서 비동기적으로 newEKEvent의 calendar(EKCalendar)를 설정하고 있는데, getNewEKEvent 함수가 newEKEvent 객체를 반환한 후에 이 비동기 작업이 실행될 수 있다. 반환된 newEKEvent 객체를 다른 곳에서 사용하려 할 때, calendar 속성이 아직 설정되지 않은 상태일 수 있다는 뜻이다. 이는 '실행 순서'에 따라 예측 불가능한 동작이 생길 수 있는 상황이고, 내가 해결하고자 한 간헐적으로 발생하는 버그와 일치하는 상황이다.
private func getNewEKEvent(ekInteractor: EKInteractor, selectedDate: Date) -> EKEvent {
let newEKEvent = EKEvent(eventStore: ekInteractor.eventStore)
// 🔥 문제: 이 Task 블록은 비동기적으로 실행되므로, 함수가 newEKEvent를 반환한 후에도 실행될 수 있음.
Task {
// 🔥 문제: 비동기 작업이 완료되기 전에 newEKEvent가 반환되면, 아직 calendar 속성이 설정되지 않은 상태일 수 있음.
if let defaultEKCalendarToAdd = await ekInteractor.getDefaultEKCalendarToAdd() {
newEKEvent.calendar = defaultEKCalendarToAdd
}
}
// 🔥 문제: 비동기 작업이 완료되기 전에 함수가 newEKEvent를 반환하므로, 데이터 경합(Data Race) 및 경쟁 상태(Race Condition)가 발생할 수 있음.
return newEKEvent
}
정리하면 위 코드는 비동기 작업이 완료되기 전에 객체가 반환되어 발생하는 '경쟁 상태(Race Condition)'에 놓여 있으며, 아래처럼 코드를 리팩토링해 문제를 해결할 수 있다.
/// 💡 getNewEKEvent 함수를 async로 선언해 비동기 작업을 수행할 수 있게 변경해
/// 이 함수의 모든 비동기 작업이 완료된 후 newEKEvent를 반환할 수 있도록 보장.
/// ---> 경쟁 상태(Race Condition)나 데이터 경합(Data Race) 방지
private func getNewEKEvent(ekInteractor: EKInteractor, selectedDate: Date) async -> EKEvent {
/// 💡 eventStore를 비동기적으로 가져오기 위해 await 사용
let newEKEvent = EKEvent(eventStore: await ekInteractor.eventStore)
/// 💡 defaultEKCalendarToAdd를 비동기적으로 가져와서 newEKEvent의 calendar 속성을 설정
if let defaultEKCalendarToAdd = await ekInteractor.getDefaultEKCalendarToAdd() {
newEKEvent.calendar = defaultEKCalendarToAdd
}
/// 💡 비동기 작업이 완료된 후에 newEKEvent를 반환
return newEKEvent
}
마치며
이 문제를 해결하며 Swift의 동시성(Concurrency)을 이전보다 깊이 이해할 수 있게 되었다. 안전한 동시성 코드를 작성할 수 있는 기본기를 다진 것 같아 기쁘다. 무엇보다 곧 업데이트될 스케줄러 앱의 새로운 버전에서 사용자 분들께 결점 없는 '기본 캘린더' 기능을 제공할 수 있게 돼 기쁘다(끝).
-
-
-
'미리 알림'부터 '기본 캘린더'까지 쉽게 연동.
다양한 위젯.
아이폰, 아이패드, 맥 '달력' 앱 스케줄러:
https://apps.apple.com/kr/app/id6467635137
스케줄러 - 캘린더 위젯 & 미리 알림 & 투두 플래너
일정 관리. 필요한 기능만. 말이 필요 없는 단순함. 그 단순함이 주는 편리함. 일정 관리에만 집중할 수 있도록 도와드립니다. - 이 앱은 사용자의 개인 정보를 수집하지 않습니다. 개인 정보
apps.apple.com
참고 자료
1. Swift Documentation: Concurrency
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/
Documentation
docs.swift.org
2. 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
3. Apple Developer Documentation — EKEventStore
https://developer.apple.com/documentation/eventkit/ekeventstore
EKEventStore | Apple Developer Documentation
An object that accesses a person’s calendar events and reminders and supports the scheduling of new events.
developer.apple.com
4. Understanding and Resolving Race Conditions
https://en.wikipedia.org/wiki/Race_condition
Race condition - Wikipedia
From Wikipedia, the free encyclopedia When a system's behavior depends on timing of uncontrollable events Race condition in a logic circuit. Here, ∆t1 and ∆t2 represent the propagation delays of the logic elements. When the input value A changes from l
en.wikipedia.org
5. 공학 분야에서의 경쟁 상태(Race Condition)란?
https://ko.wikipedia.org/wiki/%EA%B2%BD%EC%9F%81_%EC%83%81%ED%83%9C
경쟁 상태 - 위키백과, 우리 모두의 백과사전
위키백과, 우리 모두의 백과사전. 논리 상태에서의 경쟁 상태 공학 분야에서 경쟁 상태(race condition)란 둘 이상의 입력 또는 조작의 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태를 말
ko.wikipedia.org
5. Apple Developer Documentation: Task
https://developer.apple.com/documentation/swift/task
Task | Apple Developer Documentation
A unit of asynchronous work.
developer.apple.com
'Concurrency' 카테고리의 다른 글
| 백그라운드 스레드와 메인 스레드 간 전환으로 비동기 데이터 처리 개선 💡✨ (2) | 2024.06.22 |
|---|