작은 앱 프로젝트

오직 필요한 기능만 제공하는 '작은 앱'을 디자인하고 개발합니다.

Concurrency

Swift 동시성 코드 Race Condition 버그 해결 과정 💡✨

김경환 - 작은 앱 프로젝트 2024. 6. 20. 16:14

 

[Swift Concurrency] 잘못된 동시성 코드로 경쟁 상태(Race Condition)에 놓여 발생한 버그 해결 과정 💡

 

 

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