Realm이랑 Concurrency같이 쓰기

Lily·2023년 1월 7일
0
post-thumbnail

Realm의 DB transaction은 비동기로 백그라운드에서 처리하고, 그 결과(성공, 실패)에 따라 후속 처리를 해주도록 구현하고 싶었습니다. 백그라운드에서 처리하려는 이유는 Main thread에서 처리하게되면 UI의 응답성을 저하시킬 수 있기 때문입니다. 비동기적으로 결과값을 처리하려면 completion handler를 사용해야하는데, 이는 코드의 가독성을 떨어트리고 흐름을 파악하기 힘들게 만듭니다. 따라서 Swift Concurrency의 async/await을 사용해 리팩토링을 시도해보았습니다. 이 글은 그 과정을 기록한 내용입니다.

들어가기 전, 프로젝트 코드 간단 설명!

  • iOS어플들을 검색할 수 있는 앱입니다.
  • 검색을 할 때마다 검색 기록(keyword, 나라 설정, 플랫폼 설정)이 RecentSearchKeywordRepository에 저장되고, RecentSearchKeywordRepository에서 검색 기록을 페치한 다음, 최근 검색어 테이블뷰에 뿌려줍니다.
    • RecentSearchKeywordRepository에서의 모든 CRUD는 completion handler를 호출합니다. CRUD 작업이 완료된 후 결과를 핸들링해주고, 비동기적으로 CURD를 처리해주기 위해서입니다.
  • 검색 기록 셀을 탭하면 해당 기록을 이용해 API호출을 하고, 검색 결과를 화면에 보여줍니다. 그리고 새로운 검색 기록을 만들어 RecentSearchKeywordRepository에 저장합니다.
  • 검색을 수행하는 API는 AppSearchUsecase에 구현합니다. AppSearchUsecaseRecentSearchKeywordRepository를 사용합니다.
  • 최근 검색어 테이블뷰를 관리하는 RecentSearchKeywordTableViewModelAppSearchUsecase를 사용하여 검색로직을 실행하고, 결과에 따라 뷰를 업데이트합니다. 여기서 결과에 따라 뷰를 업데이트 시키기 위해 completion에 뷰 업데이트 코드를 작성합니다.

🚨 async/await 코드를 Realm과 쓰면서 발생했던 문제

Realm은 스레드에 민감한 객체입니다. 따라서 Realm 인스턴스는 생성된 스레드에서만 유효합니다.

제가 만든 Realm 인스턴스는 아래와 같이 Main thread에서 생성되었습니다.

struct RealmSearchKeywordRepository: SearchKeywordRepository {
    
    private let realm: Realm! 
    
    init?() {
        if let searchKeywordRealm = SearckKeywordRealmStore()?.defaultRealm {
            realm = searchKeywordRealm
            print("📂\(self)'s file URL : \(searchKeywordRealm.configuration.fileURL)")
        } else {
            return nil
        }
    }
    
    func create(
        keyword: RecentSearchKeyword,
        completion: @escaping (Result<RecentSearchKeyword, Error>) -> Void)
    {
        let searchKeyword = RecentSearchKeywordRealm(model: keyword)
        do {
            try realm.write {
                realm.add(searchKeyword)
            }
            completion(.success(searchKeyword.toDomain()!))
        } catch {
            print("failed in \(self): \(error)")
            completion(.failure(RealmSearchKeywordRepositoryError.realmOperationFailure))
        }
    }
    ...
    
 }
 

final class SearckKeywordRealmStore {
    
    let defaultRealm: Realm!
    
    init?() {
        do {
        	// main thread에서 생성
            defaultRealm = try Realm()
        } catch  {
            print("Error initiating new realm \(error)")
            return nil
        }
    }
    
}

그런데 Swift Concurrency를 도입하고 Repository를 사용하는 Usecase의 함수를 async로 변경하고, 실행하니 아래와 같은 크래시가 발생을 했습니다.

그 이유는 기존엔 Repository의 create가 메인 스레드에서 실행되어서 문제가 없었지만, Usecase의 메서드가 async로 변경되면서 create는 백그라운드 스레드(Thread7)로 작업이 할당되었기 때문입니다. Realm 인스턴스는 메인 스레드에서 탄생했지만, 백그라운드 스레드에서 접근하려고 하니 잘못된 스레드에서 접근이 되었다는 에러가 발생하는 것입니다.

또한 DB작업을 메인 스레드에서 처리하면 앱의 응답성을 저하시킬 수 있는 문제도 있습니다.

 // RealmSearchKeywordRepository
 func create(
        keyword: RecentSearchKeyword,
        completion: @escaping (Result<RecentSearchKeyword, Error>) -> Void)
    {
        let searchKeyword = RecentSearchKeywordRealm(model: keyword)
        do {
        	// thread7에서 접근하니 오류🚨
            try realm.write {
                realm.add(searchKeyword)
            }
            completion(.success(searchKeyword.toDomain()!))
        } catch {
            print("failed in \(self): \(error)")
            completion(.failure(RealmSearchKeywordRepositoryError.realmOperationFailure))
        }
    }
    

따라서 Realm을 메인 스레드가 아닌 특정 스레드에서 만들고, 특정 스레드에서만 작업이 처리되도록 컨트롤을 하는 방법을 생각해보았습니다.

✅ SerialQueue를 사용해 Realm을 백그라운드 스레드에서 사용하기

Realm을 특정스레드에서만 사용하기 위해 SerialQueue를 생성해주고, 이 SerialQueue에서만 transaction을 수행하도록 변경해보겠습니다.

final class SearckKeywordRealmStore {
    
    var defaultRealm: Realm!
    let serialQueue: DispatchQueue
    
    init?() {
    	// Realm이 사용할 Serail Dispatch Queue 생성
        serialQueue = DispatchQueue(label: "serial-queue")
        do {
            try serialQueue.sync {
                defaultRealm = try Realm(configuration: .defaultConfiguration, queue: serialQueue)
            }
        } catch  {
            print("Error initiating new realm \(error)")
            return nil
        }
    }
    
}

struct RealmSearchKeywordRepository: SearchKeywordRepository {
    
    private let realm: Realm!
    private let realmQueue: DispatchQueue!
    
    init?() {
        if let searchKeywordRealm = SearckKeywordRealmStore() {
            realm = searchKeywordRealm.defaultRealm
            realmQueue = searchKeywordRealm.serialQueue
            print("📂\(self)'s file URL : \(realm.configuration.fileURL)")
        } else {
            return nil
        }
    }
    
    func create(
        keyword: RecentSearchKeyword,
        completion: @escaping (Result<RecentSearchKeyword, Error>) -> Void)
    {
    	// serialQueue에 async하게 작업을 보냄
        realmQueue.async {
            let searchKeyword = RecentSearchKeywordRealm(model: keyword)
            do {
                try realm.write {
                    realm.add(searchKeyword)
                }
                completion(.success(searchKeyword.toDomain()!))
            } catch {
                print("failed in \(self): \(error)")
                completion(.failure(RealmSearchKeywordRepositoryError.realmOperationFailure))
            }
        }
    }
    ...
}

작성을 하고 실행을 하니 도 다른 스레드 크래시가 났습니다..

그 이유는 create의 completion에 tableView.reloadData()와 같은 UI조작 코드를 전달했고, 결과적으로 백그라운드 스레드에서 UI 코드가 호출되었기 때문입니다. Repository의 create은 Usecase의 함수에서 호출되고, Usecase의 함수는 ViewModel에서 호출됩니다.

// RecentSearchKeywordTableViewModel
    
extension RecentSearchKeywordTableViewModel: UITableViewDelegate {
    
    // 셀이 눌리면 호출
    func tableView(
        _ tableView: UITableView,
        didSelectRowAt indexPath: IndexPath)
    {
        tableView.deselectRow(at: indexPath, animated: true)
        Task {
        	// 선택된 셀의 검색 기록 정보를 이용해서 검색 API를 실행 후, 결과를 리턴
            // 내부에서 Repository에 새로운 검색 기록을 생성
            let result = await cellDidSelected(at: indexPath)
            switch result {
            case .success(let appDetail):
                if appDetail.count == 1 {
                    appDetailViewPresenter?.pushAppDetailView(of: appDetail.first!)
                } else {
                    searchAppResultTableViewUpdater?.updateSearchAppResultTableView(
                        with: appDetail)
                }
            case .failure(let alertViewModel):
                searchAppResultTableViewUpdater?.presentAlert(alertViewModel)
            }
            // 새로운 검색 기록을 보여주기 위해, Repository에서 데이터를 페치한 후 테이블뷰를 업데이트
            fetchLatestData {
                tableView.reloadData()
            }
        }
    }
    
}

// fetchLatestData의 completion을 fetchAllRecentSearchKeyword에 전달
func fetchLatestData(completion: @escaping () -> Void) {
        fetchAllRecentSearchKeyword(completion: completion)
    }

  
private func fetchAllRecentSearchKeyword(completion: @escaping () -> Void) {
	// Usecase의 모든 검색 기록을 페치해오는 API를 호출한 후, 결과에 따라 컴플리션을 호출
    recentSearchKeywordUsecase.allRecentSearchKeywords
          { [unowned self] result in
                switch result {
                case .success(let fetchedKeywords)
                	// 테이블 뷰 데이터 업데이트
                    self.keywords = fetchedKeywords
                   // 컴플리션 호출
                    completion()
                case .failure(let failure):
                // 검색 기록 불러오기 실패를 알리는 알림을 보여줌
                    print("Failed to fetch RecentSearchKeyword. error: \(failure)"
             }
         }
    }


    

completion이 결국 메서드를 타고 타고 들어가 serialQueue에서 호출이되고 있었던 것입니다😰

여기서 제 코드가 스레드를 너무나 자유롭게 넘나들고 있는데 별도의 스레드 컨트롤이 없기 때문에 굉장히 위험한 코드란 생각이 들었습니다.

또한 completion handler의 전달에 의해 코드의 흐름이 직관적으로 파악되지 않았고, 따라서 스레드의 흐름 또한 파악하기 어렵다고 생각했습니다.

Swift Concurrency를 사용해 completion handler를 제거하고 코드를 동기적으로 변경하여 문제를 해결하고자 했습니다.

async/await로 장풍코드 없애고, 가독성 높이기

먼저 RealmSearchKeywordRepository의 함수를 withCheckedThrowingContinuation로 감싸주어 async메서드로 변경했습니다.

struct RealmSearchKeywordRepository: SearchKeywordRepository {
    
    private let realm: Realm!
    private let realmQueue: DispatchQueue!
    
    init?() {
        if let searchKeywordRealm = SearckKeywordRealmStore() {
            realm = searchKeywordRealm.defaultRealm
            realmQueue = searchKeywordRealm.serialQueue
            print("📂\(self)'s file URL : \(realm.configuration.fileURL)")
        } else {
            return nil
        }
    }
    
    func create(keyword: RecentSearchKeyword) async throws -> RecentSearchKeyword {
        try await withCheckedThrowingContinuation { continuation in
            realmQueue.async {
                let searchKeyword = RecentSearchKeywordRealm(model: keyword)
                do {
                    try realm.write {
                        realm.add(searchKeyword)
                    }
                    continuation.resume(returning: keyword)
                } catch {
                    print("failed in \(self): \(error)")
                    continuation.resume(throwing: RealmSearchKeywordRepositoryError.realmOperationFailure)
                }
            }
        }
    }
    
   ...
 }

RealmSearchKeywordRepository를 사용하는 RecentSearchKeywordManagementUsecase async 메서드로 변경하여 결과 값을 리턴하도록 수정했습니다.

struct RecentSearchKeywordManagementUsecase {
    
    func allRecentSearchKeywords() async throws -> [RecentSearchKeyword] {
        let isActive = isActiveSavingSearchingKeyword()
        if isActive {
            return try await searchKeywordRepository.readAll(sorted: false)
        } else {
            return []
        }
    }
    
    ...   
}

RecentSearchKeywordTableViewModel에서는 async메서드를 Task로 래핑하여 비동기 메서드를 호출합니다.
최신 데이터를 페치한 후, MainActor에서 tableview를 업데이트 해줍니다.

extension RecentSearchKeywordTableViewModel: UITableViewDelegate {
    
    func tableView(
        _ tableView: UITableView,
        didSelectRowAt indexPath: IndexPath)
    {
        tableView.deselectRow(at: indexPath, animated: true)
        Task {
            let result = await cellDidSelected(at: indexPath)
            switch result {
            case .success(let appDetail):
                if appDetail.count == 1 {
                    appDetailViewPresenter?.pushAppDetailView(of: appDetail.first!)
                } else {
                    searchAppResultTableViewUpdater?.updateSearchAppResultTableView(
                        with: appDetail)
                }
            case .failure(let alertViewModel):
                searchAppResultTableViewUpdater?.presentAlert(alertViewModel)
            }
            await fetchLatestData()
            // main thread에서 작업을 처리시키기 위해 MainActor에서 실행
            await MainActor.run {
                tableView.reloadData()
            }
        }
        
    }

    func fetchLatestData() async {
        keywords = await fetchAllRecentSearchKeyword()
        cellModels = keywords.compactMap{ RecentSearchKeywordCellModel(keyword: $0) }
    }
    
    
    private func fetchAllRecentSearchKeyword() async -> [RecentSearchKeyword] {
        do {
            return try await recentSearchKeywordUsecase.allRecentSearchKeywords()
        } catch {
            print("Failed to fetch RecentSearchKeyword. error: \(error)")
            return []
        }
    }
}

Concurrency로 리팩터링을 한 후 아래와 같은 장점을 느낄 수 있었습니다.

  • async를 통해 비동기 작업을 하는 메서드인지 명시적으로 드러냄
  • completion handler 대신 결과 값을 리턴함으로서, 실행 스레드를 분리시킴
  • 실행 흐름을 직관적으로 파악 가능

코드에 대한 피드백과 생각 나눔은 환영입니다.🫶

profile
i🍎S 개발을 합니다

1개의 댓글

comment-user-thumbnail
2023년 1월 14일

잘보고 갑니다~ 새해복 많이 받으세요

답글 달기