먹고 기도하고 코딩하라

[Swift] Realm (1) 스레딩 본문

앱/Swift

[Swift] Realm (1) 스레딩

사과먹는사람 2023. 4. 15. 17:02
728x90
728x90

 

iOS 앱에서 로컬에 데이터를 저장하는 방법은 여러 가지가 있다. 퍼스트파티인 CoreData도 있고, SQLite나 Realm을 쓸 수도 있다. 그 중 Realm의 장점은 속도가 빠르다, 안드로이드나 윈도우, 맥 등 크로스플랫폼 사용이 가능하다, SQL처럼 데이터 스키마를 Table 형식으로 구성하고, 데이터를 row 등으로 관리하는 게 아니라 OOP 패러다임에 맞게 클래스와 객체로 관리한다 등.. 여러 가지가 있다. 

우리 회사 iOS 앱은 현재 클립 데이터를 다운로드받는 데에 Realm을 사용하고 있다. 개인 프로젝트에서도 Realm을 쓰긴 했는데 기획도 개발도 내가 하다 보니 요구 사항이 심플해져서 딥하게 쓸 일은 딱히 없었다. 하지만 회사 앱은 기획에 맞게 타이트하게 기능 개발을 해야 해서 이 기회에 공부 좀 했다.

 

이 시리즈에서는 Realm의 기본적인 CRUD 작업 등은 다루지 않는다. 이 정도는 공식 문서와 여러 블로그에서 자세하게 다루고 있기 때문에 스레딩, 마이그레이션, Realm의 내부 구조, best practices 등을 위주로 다룬다. 쉽게 말해 realm을 쓸 때 주의하지 않으면 그대로 헬게이트 오픈되면서 성능 나락 가는 것들에 대해 쓸 예정이다.

Case study처럼 관련된 이슈를 다루면서, 이슈의 원인과 개선 방안을 설명하는 방식으로 구성할 예정이다. 

 


 

1. 스레딩

동일한 Realm 객체를 다른 스레드에서 사용할 수 있을까?

이것이 가능한가에 대한 물음은 Realm 문서에서 단 한 줄로 정리하고 있다. 

You cannot share realm instances across threads. (안 된다)

문서live object, 콜렉션, Realm 파일은 Thread-confined라는 설명이 적혀 있다.

말인즉슨, Realm 파일과 거기서 가져온 Object와 Object 배열은 realm을 연 스레드에 종속되어 있다는 것이다.

동일한 realm 파일을 다른 스레드에서 접근하고 싶다면 각 스레드마다 새로운 realm 객체를 열어야 한다. 같은 Configuration 객체를 사용하는 한 항상 동일한 realm 파일에 맵핑하게 된다고 한다.

 

여기서 혼동할 수 있는 게 Realm 파일과 realm 객체에 대한 내용인데.. 나도 처음에 엄청 헷갈렸다.

Realm 파일은 DB 그 자체의 개념이다. configuration으로 파일의 버전과 마이그레이션 함수 블록을 설정할 수 있는데, 이게 DB에 대한 설정이다. Realm 객체는 이 파일에 접근하기 위해 코드상에서 사용하는 것이다. 실재하는 것에 대한 일종의 레퍼런스라고 볼 수 있다. 

 

 

메인 스레드에서 가져온 Live Object를 다른 스레드에서 절대 못 쓰는 건 아니다

아깐 스레드에 종속되어 있다면서 뭔 소리여.. 할 수도 있지만 swift 5.6 이상에서는 @ThreadSafe 프로퍼티 랩퍼를 이용해서 Live Object를 다른 스레드에서도 사용할 수 있다.

이 부분은 프로젝트에서 테스트해보지 않아서 뇌피셜이 섞일 수 있기 때문에 문서만 링크하겠다.

https://www.mongodb.com/docs/realm/sdk/swift/crud/threading/#pass-instances-across-threads

 

 

Realm을 꼭 메인 스레드에서만 열 필요는 없다

메인 스레드(=UI 스레드=1번 스레드)가 아닌 백그라운드 스레드에서는 Realm에 접근할 수 없을까? 그렇지 않다.

사실 클로저 내부에서 작업 중이거나, 특별히 serial이나 concurrent 큐를 만들어서 거기에서 Realm 파일을 열고 작업하는 것이 아닌 이상 보통 Realm 파일을 여는 곳은 메인 스레드이긴 하다.

참고로 Realm 파일을 연다는 것은 다음을 의미한다.

let realm = try! Realm()

만약 메인 스레드가 아닌 다른 곳에서 수행되는 클로저 내부에서 Realm 파일을 열었다면 그 Realm 파일과 거기서 접근하는 Object들은 해당 스레드에서만 접근되는 식이다.

하지만 보통은!! 메인 스레드에서 Realm 파일을 열게 된다.

그렇게 연 Realm 파일을 다른 스레드에서 접근하려고 하면 앱이 100% 죽는다.

그렇기 때문에 이러한 스레딩 문제에서 자유로워지려면 Realm 파일을 메인 스레드에서만 사용해야 한다, DispatchQueue.main.async 로 감싸야 한다는 오해가 생긴 듯하다.

 

 

Realm을 백그라운드 스레드에서 사용하기

어떤 작업을 UI 스레드가 아니라 백그라운드 스레드에서 동작하게 하고 싶을 수 있다. Realm에서 처리하는 작업이 UI 업데이트에 영향을 주지 않는 작업 등이 그렇다.

그래서 의도적으로 다른 스레드에서 동작하게 하고 싶다면, DispatchQueue를 만들고 거기에서 realm 작업을 할 수 있다. 이 때, serial, concurrent 중 어떤 큐에 던질지 정할 수 있다.

여담인데 이번에 Realm 작업을 하면서 GCD의 참 뜻을 알게 됐다. 우리는 메인 스레드를 제외하고는 어떤 작업을 수행할 스레드를 직접 정할 수는 없다. 다만 여러 작업이 있을 때 그 작업들을 실행하는 방법을 결정하고, 해당하는 큐에 작업을 던질 뿐이다. 그럼 큐가 알아서 스레드에 분배해서 작업을 직렬 혹은 병렬적으로 수행하는 것이 GCD의 큰 뜻이다.

암튼 GCD 얘기는 여기까지 하고, 백그라운드 스레드에서 사용하려면 큐에 레이블 달아서 거기서 realm 열고 작업하면 된다는 게 요지다. 예제 코드를 보자. 

// 시리얼 큐를 만들고, realm 연산 수행
let serialQueue = DispatchQueue(label: "serial-queue")
serialQueue.async {
    let realm = try! Realm(configuration: .defaultConfiguration, queue: serialQueue)
    // 메인 스레드가 아닌 다른 스레드에서 수행할 작업을 밑에 쓰기
}

DispatchQueue를 레이블만 달면 기본적으로는 serial 큐가 생성되는데, concurrent로 하려면 attributes에 concurrent 넣어주면 된다.

 

 

이제 실제 케이스를 보자.

 

Case 1. Realm 파일에 저장된 Object를 다른 스레드에서 접근 시도

때는 두 달 전인 2월, 당시에 J님이 위젯 작업을 하고 나는 오프라인 작업을 병행하고 있었다. 시기상으로는 위젯 업데이트 버전이 먼저 출시되고 그 다음에 오프라인이 나가기로 되어 있었다. 자세한 설명은 이 곳에..

현상 : 오프라인 모드에서 한 클립을 다 재생한다.
결과 : 앱이 강제종료된다. (죽는다)
빈도 : 100%
원인 : 클립이 다 재생되었을 때 호출되는 함수들 중 하나가 Live Object를 가져온 스레드와 다른 스레드에서 Live Object를 접근하고 있었다.
해결 방안 : Live Object를 복사해서 사용한다. 

2개월 전 포스팅인데, 글에는 원인과 해결 방안을 얼추 맞게 적긴 했지만 사실 DispatchQueue.main.async 로 Realm 접근 코드를 감싸는 건 좋은 접근법은 아니다.

글에서 쓴 게 대략적으로는 맞는 말이긴 하다. 그런데 사실 더 정확히 하자면, Realm 파일이 아니라 Realm의 Live Object를 다른 스레드에서 접근한 거다. 물론 @ThreadSafe를 사용하지 않은 상태였다. 

 

온라인 모드에서는 한 클립을 다 들으면 새로 API를 요청해서 메타데이터를 가져온다. 하지만 오프라인 모드에는 통신 코드가 없으므로, 한 클립을 다 들으면 위젯에 나타날 데이터를 Realm 파일에 있는 Object 내의 데이터로 갈아끼우는 작업을 트리거한다. 갈아끼우는 작업이 시작할 때 Realm 파일에서 Object를 가져오면 문제가 없는데 이걸 프로젝트 내부에서 미리 가져와놨다는 것이다.

즉, 프로젝트 내부에서 해당 Object를 가져오는 스레드와 갈아끼우는 작업이 도는 스레드가 다르면 앱이 죽는다. 불행히도 Object를 가져오는 작업이 있는 메소드는 항상 메인 스레드에서 도는 것이 보장되었고, WidgetCenter가 제공하는 getCurrentConfigurations 컴플리션 핸들러는 메인 스레드에서 돌지 않았다. 그래서 앱이 매번 죽는 이슈가 나온 것이다. 

 

생각할 수 있는 해결 방안은 3가지였다.

  1. getCurrentConfigurations에서 매번 Realm.object로 Object를 불러온다.
  2. ThreadSafe로 원본 Object에 대한 레퍼런스를 보관하다가 쓴다.
  3. 프로젝트 내부에서 Object 데이터를 복사해서 갖고 있는다.

이 중 나는 복사하는 방법을 택했다. ThreadSafe의 경우 제대로 쓰지 못한 것인지 Incorrect access 하면서 앱이 죽는 문제가 있었고, 사정상 클로저 내부에서 Realm.object를 할 수도 없었다. (Realm 객체를 싱글턴으로 관리하고 있었기 때문이다. ㅠ.ㅠ)

복사할 때는 다음과 같이 value 파라미터에 Realm Object를 넣어주면 된다. value로 들어가는 Object와 완전히 똑같은 값의 Object가 새롭게 만들어지지만, 이 Object는 아직 Realm 파일에 create되지 않았기 때문에 자유로운 Object이다. 

다음 코드에서 우리는 눈에 넣어도 아프지 않은 강아지 "버터"를 DB의 Dog 타입 Object들 중에서 찾아내고, 클론 버터를 만들 것이다. 이 작업은 메인 스레드에서 수행된다.

// Thread 1
let butter: Dog? = realm.object(ofType: Dog.self, forPrimaryKey: "butter")
let cloneButter: Dog? = Dog(value: butter)

이제 메인 스레드가 아닌 다른 스레드에서 도는 클로저가 있다고 생각해보자. 여기서 버터의 생일 정보를 가져오려고 하면 앱이 죽는다.

doLaterClosure { 
	// Thread 15 (어쨌든 메인 스레드가 아닌 다른 스레드)
    let birthday: String? = butter?.birthday	// 버터는 1번 스레드에서 왔으므로 앱은 여기서 죽는다
}

Realm accessed from incorrect thread (RLMException) 하면서 앱이 죽을 것이다. 

하지만 복사해놓은 클론버터의 생일 정보를 가져오는 건 괜찮다. 상술했다시피, 클론버터는 아직 realm 파일에 create되지 않았기 때문이다.

doLaterClosure { 
	// Thread 15 (어쨌든 메인 스레드가 아닌 다른 스레드)
    let birthday: String? = cloneButter?.birthday	// 클론버터는 1번 스레드에서 만들어졌지만, 아직 realm 파일에 저장되지 않았다
}

Object는 생성된 스레드와 다른 스레드에서 접근하면 죽는다면서?? 라고 생각할 수도 있는데, 그래서 Realm 문서에는 명확하게 "live object"라고 적어놓았다. 그냥 Realm에서 사용하는 Object 타입의 객체를 다른 스레드에서 접근하는다고 죽지는 않는다. 문제는 접근하려는 것이 live, 즉 realm 파일에 저장된 Object일 때 일어난다.

 

처음에는 DispatchQueue.main.async로 감싸놨던 코드를 나중에 클립 데이터를 따로 복사해서 사용하는 식으로 해결했다. 사실 그렇게 하면서도 마뜩찮았던 게, 일단 realm의 best practices를 어긴다는 점이 별로였고 main.async로 돌리기 때문에 언제 수행될지 타이밍을 정확히 알 수 없다는 것도 별로였다. 

DispatchQueue.main.async를 덕지덕지 붙이면서 메인 스레드가 아닌 다른 스레드에서 수행될 여지가 있는 코드에서는 항상 스레드를 신경써야 했던 게 매우 거슬렸는데, 이렇게 또 하나 배우면서 async 코드를 지울 수 있었다.

 

 

Case 2. Realm 객체를 다른 스레드에서 접근 시도

위의 사례와 비슷한데 이번에는 Realm 객체를 다른 스레드에서 접근 시도한 것이다.

저번 포스팅에도 적었듯이 우리 회사 앱에서는 Realm을 관리하는 Manager 클래스를 하나 만들어서 Realm 객체를 싱글턴으로 관리하고 있다. 사실 이렇게 Realm을 사용하는 건 그다지 권장되는 패턴은 아니다. 팀에서도 이 사실을 모르고 있는 건 아니었는데, 메모리 이슈 때문에 싱글턴으로 관리하기로 했고, 싱글턴으로 관리하자 그 이슈가 사라졌다는 증언이 있었다.

사실 Out of Memory는 특별한 경우가 아니라면 원인을 특정하기 어렵다. 개인적으로는 그 이슈와 함께 나온 다른 이슈 티켓들을 처리하면서 겸사겸사 해결됐거나 Realm 이슈와는 별개의 다른 이슈 때문에 해결된 게 아닐까 생각하고 있긴 하지만... 그걸 증명할 방법이 없어서 수긍할 수밖에 없었다. ㅠ

어쨌든, 이 Realm을 static let으로 선언해서 사용하고 있었다.

static let realm = try? Realm()

이렇게 realm 파일을 여는 스레드는 당연지사 메인 스레드였고, 역시 메인 스레드에서 수행되지 않는 클로저 등에서 이 realm을 갖다쓰려고 하면 앱이 죽었다.

doLaterClosure { 
	// Thread 15 (어쨌든 메인 스레드가 아닌 다른 스레드)
    let dogs = realm.objects(Dog.self)	// realm은 1번 스레드에서 열렸으므로 앱은 여기서 죽는다
}

이 경우에는 해결방안이 3가지다.

 

1. 싱글턴 패턴을 포기하고, realm 파일 접근이 필요할 때마다 try! Realm()으로 realm 파일을 새로 연다.

사실 이게 best practices 중에 하나다. 특별한 이유가 없다면 이렇게 써주는 게 좋다.

캐싱에 관련된 부분은 추후에 Realm 내부 구조 글에서 좀 더 자세히 써보겠다. 나도 try! Realm()으로 매번 realm을 불러오는 게 과연 타당한 것일까 고민했는데 realm 파일은 스레드마다 하나씩만 존재하며, 이미 해당 스레드에 realm이 있다면 캐싱된 것을 불러오고 없을 때만 새로 생성된다고 한다. 

doLaterClosure { 
	// Thread 15 (어쨌든 메인 스레드가 아닌 다른 스레드)
    let realm = try! realm()	// realm은 15번 스레드에 새로 생성되거나, 15번 스레드에 이미 있다면 캐싱된 realm 파일을 불러온다
    let dogs = realm.objects(Dog.self)
}

 

2. 싱글턴 패턴을 써야겠다면, realm을 최초로 연 스레드와 같은 스레드에서 접근할 것을 보장한다.

이 경우에는 메인 스레드에서 열렸으므로 DispatchQueue.main.async 로 감싼다.DispatchQueue.main.sync를 쓰면 안 될까? 할 수도 있는데 sync 작업의 경우 작업이 블락되어 앱이 죽을 확률이 높고, 죽지 않는다 한들 UI 스레드에서 함부로 sync 쓰면 퍼포먼스가 나락갈 수 있으므로 주의해야 한다. 

doLaterClosure { 
	// Thread 15 (어쨌든 메인 스레드가 아닌 다른 스레드)
    DispatchQueue.main.async {
	    let dogs = realm.objects(Dog.self)	// realm은 1번 스레드에서 열렸으므로 앱은 여기서 죽는다   
    }
}

 

3. 2의 바리에이션으로, realm 작업이 async하게 실행돼도 괜찮다면 realm의 writeAsync를 사용한다. 단, READ 작업은 불가능하고 그 외 create, update, delete 작업만 가능하다.

기존의 realm.write가 sync하게 realm 트랜잭션을 수행한다면 writeAsync는 말그대로 async하게 수행한다.

회사 앱의 경우 앱이 시작되고 기존의 realm schema 버전이 configuration의 schema 버전보다 낮다면 마이그레이션을 시도하는데, 이 마이그레이션 작업도 당연히 메인 스레드에서 수행해야 했다. (싱글턴이니까...)

그런데 다들 알다시피 메인 스레드는 UI 작업을 수행하는 스레드라서, sync하게 너무 많은 realm 트랜잭션을 처리할 때 UI 반응성이 심각하게 저하되는 문제가 있었다. (스플래시 화면에서 너무 오래 멈춘다든가, 스크롤이 잘 안 먹는다든가)

그래서 기존에 sync하게 write하던 코드를 수정해서 writeAsync를 사용했다. 똑같은 수의 트랜잭션이 일어나지만, 메인 스레드에서 async하게 일어나기 때문에 UI 작업에 크게 간섭을 주지 않았다.

writeAsync는 write와 달리 onComplete 컴플리션 핸들러가 따로 존재한다. error가 있을 경우 첫 번째 매개변수로 들어오게 되므로 error가 존재하는지 체크해서 에러가 있다면 따로 핸들링을 해줄 수 있다. 

let realm = try! Realm()

realm.writeAsync {
    // async하게 처리할 create, delete 작업 등
} onComplete: { error in
	guard error == nil else {
    	// 에러 핸들링
    } 
    // write 작업이 성공적으로 끝났을 때의 작업
}

 

다음 포스팅에서는 Realm의 마이그레이션에 대해 살펴본다.

사실, 처음부터 Realm Object 클래스 구조를 적절히 짰고, 급진적인 변화가 일어나지 않는다면 마이그레이션에서 골머리를 썩을 일은 잘 없다. 그러나 우리 코드는... 리액트 네이티브에서 네이티브로 오는 과정에서 지각 변동이 일어났고... 후략.. 

 

 

728x90
반응형
Comments