Realm의 DB transaction은 비동기로 백그라운드에서 처리하고, 그 결과(성공, 실패)에 따라 후속 처리를 해주도록 구현하고 싶었습니다. 백그라운드에서 처리하려는 이유는 Main thread에서 처리하게되면 UI의 응답성을 저하시킬 수 있기 때문입니다. 비동기적으로 결과값을 처리하려면 completion handler를 사용해야하는데, 이는 코드의 가독성을 떨어트리고 흐름을 파악하기 힘들게 만듭니다. 따라서 Swift Concurrency의 async/await
을 사용해 리팩토링을 시도해보았습니다. 이 글은 그 과정을 기록한 내용입니다.
RecentSearchKeywordRepository
에 저장되고, RecentSearchKeywordRepository
에서 검색 기록을 페치한 다음, 최근 검색어 테이블뷰에 뿌려줍니다. RecentSearchKeywordRepository
에서의 모든 CRUD는 completion handler를 호출합니다. CRUD 작업이 완료된 후 결과를 핸들링해주고, 비동기적으로 CURD를 처리해주기 위해서입니다. RecentSearchKeywordRepository
에 저장합니다.AppSearchUsecase
에 구현합니다. AppSearchUsecase
는 RecentSearchKeywordRepository
를 사용합니다. RecentSearchKeywordTableViewModel
은 AppSearchUsecase
를 사용하여 검색로직을 실행하고, 결과에 따라 뷰를 업데이트합니다. 여기서 결과에 따라 뷰를 업데이트 시키기 위해 completion에 뷰 업데이트 코드를 작성합니다. 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을 메인 스레드가 아닌 특정 스레드에서 만들고, 특정 스레드에서만 작업이 처리되도록 컨트롤을 하는 방법을 생각해보았습니다.
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를 제거하고 코드를 동기적으로 변경하여 문제를 해결하고자 했습니다.
먼저 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
를 통해 비동기 작업을 하는 메서드인지 명시적으로 드러냄코드에 대한 피드백과 생각 나눔은 환영입니다.🫶
잘보고 갑니다~ 새해복 많이 받으세요