* 아래 글은 Realm-Java 0.87.5 버전을 기준으로 작성되었습니다. 현재는 0.88.0이 나왔으며, 그에따라 약간 다른 내용이 있을 수 있습니다. Changelog를 참조하시면서 글을 보신다면, 혼동하지 않고 볼 수 있습니다.
리멤버는 최근에 API를 v2로 리팩토링 하면서 데이터 스키마를 많이 변경했습니다. 그에 따라 클라이언트도 리팩토링이 필요했고, 자연스럽게 사용하던 기술 스택을 재구성하였습니다. 그 과정에서 큰 부분을 차지했던 Realm에 대해 1. 왜 선택했는지, 2. 어떤 제약이 있는지, 3. 활용 Tip, 4. 장단점 등을 공유하고자 합니다.
로컬DB 선택하기
리멤버는 지금까지 GreenDAO를 사용하였습니다. GreenDAO는 Generator를 통해 DAO 코드를 쉽게 생성하는 방식의 Sqlite기반 ORM 프레임워크입니다. 우리는 새로운 로컬DB 플랫폼으로 최근에 등장하여 빠르게 발전하고 있는 Realm을 주목하였습니다. 그리고 결정에 앞서 다양한 비교를 하였습니다.
1. 벤치마크
Realm은 성능을 강점으로 내세웁니다. 실제로 얼마나 빠른지, 사용하는 GreenDAO와 비교해보았습니다.
데이터가 많아질수록 Realm의 성능이 더 좋은 것을 볼 수 있습니다. 우리는 로컬DB에 쌓아야 할 명함 데이터가 20,000개를 넘는 사용자도 종종 있어, Realm을 사용한다면 성능에서 큰 이점이 있다고 생각하였습니다.
2. 사용성
Realm은 사용성이 편하다고 주장합니다. 링크의 아래쪽을 참고하면 간략한 쿼리 사용법을 알 수 있습니다. 편해 보입니다. 하지만 ORM도 잘 활용하면 충분히 편하게 사용할 수 있습니다. 저는 단순히 쿼리가 간단한 것보다, 다른 사용상의 이점은 없을까 고민하며 스키마를 구성해보았습니다.
public class StackRealm extends RealmObject { @PrimaryKey private long id; private boolean master; // Card 객체를 그대로 Instance에 추가하여 관계를 형성한다. private CardRealm mainCard = new CardRealm(); // 1:n 관계도 편리하게 맺는다. private RealmList<CardRealm> subCards = new RealmList<>(); }
Sqlite는 객체를 그대로 DB에 저장할 수 없으므로, 테이블 간의 관계를 고민하며 스키마를 구성해야 합니다. 그리고 조인 쿼리를 필수로 사용해야 합니다. 하지만 Realm은 테이블 간의 관계를 has로 표현하여, 설계와 쿼리를 덜 고민하도록 합니다. 리멤버는 명함, 그룹 등 양방향 관계가 많습니다. 그래서 저는 여기에서 큰 장점을 느꼈습니다.
제약사항과 선택
Realm은 큰 제약이 2가지 있습니다. RealmObject를 상속받는 클래스의 customize가 허용되지 않고, Realm으로부터 받아온 객체는 쓰레드 간 전달이 불가능합니다. 하지만 저는 제약보다, 성능과 관계 표현의 편리함을 더 큰 장점으로 보았습니다. 그리고 2가지 제약은 대체할 수 있는 방법이 있습니다. RealmObject 클래스의 customize는 static util 클래스를 만들어 대체합니다. 쓰레드 간 전달은 Realm이 제공하는 트랜잭션, Async Method와 Auto Refresh를 통해 해결할 수 있습니다. 위의 사항들을 고려하여 결론적으로 Realm으로의 DB 이전을 결정하고, v2 적용 작업을 시작하였습니다.
public class StackRealmUtil { // RealmObject의 instance 메소드로 어울리는 것들은 이렇게 static한 util 메소드로 대체할 수 있다. public static boolean isEmpty(StackRealm stack) { return (stack.getMainCard() == null || stack.getMainCard().getId() == 0) && (stack.getSubCards() == null || stack.getSubCards().isEmpty()); } }
Tip
Realm은 쓰레드 간 객체 전달이 안되는 큰 제약을 가지고 있기 때문에, 기존과 다르게 사용해야 크래시를 내지 않고 다룰 수 있습니다. 그 중 직접 다루어보지 않으면 알기 어려운 Tip 몇가지를 공유하겠습니다.
1. DTO vs Auto Refresh
Realm은 쓰레드 간 객체 전달이 불가능 합니다. Realm을 처음 시작하면 가장 힘든 부분이 이 제약사항입니다. 가장 편하게 해결하는 방법은 DTO(Data Transfer Object)를 활용하는 것입니다. RealmObejct를 상속한 클래스와 동일한 속성들을 가진 별도의 POJO 클래스를 만듭니다. 이것이 DTO 입니다. 활용 방법은 RealmObject를 DTO 객체에 DeepCopy 합니다. DTO는 Realm과 연결되어 있지 않으므로 쓰레드를 이동할 수 있습니다.
// Realm 객체. RealmObject를 상속받으므로 쓰레드 간 전달이 불가능하다. public class BannerRealm extends RealmObject { @PrimaryKey private String type; private int showCount; private int priority; // Getter, Setter 생략 } // DTO 객체. 쓰레드 간 전달이 가능하다. public class Banner { private String type; private int showCount; private int priority; // Getter, Setter 생략 // Instance 메소드로 Realm 객체 변환 public BannerRealm toBannerRealm() { BannerRealm bannerRealm = new BannerRealm(); bannerRealm.setType(getType()); bannerRealm.setShowCount(getShowCount()); bannerRealm.setPriority(getPriority()); return bannerRealm; } } // static 메소드로 Realm 객체에서 DTO로 변환 public static Banner toBanner(BannerRealm bannerRealm) { if (bannerRealm == null) return null; Banner banner = new Banner(); banner.setType(bannerRealm.getType()); banner.setShowCount(bannerRealm.getShowCount()); banner.setPriority(bannerRealm.getPriority()); return banner; }
하지만 RealmObject에 선언된 관계가 복잡하면 DTO로 변환하는 비용이 급격히 커집니다. 게다가 여러 개의 RealmObject를 DTO로 변환해야 할 경우에는, 급기야 ANR이 발생합니다. 그러므로 객체를 복사하여 넘기는 방법은 바람직하지 않습니다.
그래서 Auto Refresh를 활용하는 것을 권장합니다. (Auto Refresh의 자세한 설명은 링크를 참조해주세요.) Auto Refresh는 하나의 쓰레드에서 Realm 객체를 변경하면, 같은 DB를 가리키는 Realm 객체들에 대해서 모두 자동으로 새로고침되는 Realm의 기능입니다. 굳이 객체를 변환하지 않아도 각 쓰레드마다 Realm에 따로 접근하면, Auto Refresh에 의해 자연스럽게 쓰레드 사이에 값을 전달하는 효과를 얻을 수 있습니다.
2. copyFromRealm()
DTO를 활용하지 않을 경우, Realm으로부터 불러온 객체는 모두 Realm DB와 묶여있게 됩니다. Realm DB에 묶인 객체는 트랜잭션 안에서만 값을 바꿀 수 있고, 원하지 않는 Auto Refresh에 걸리는 등 자유롭지 못합니다. 하지만 Realm DB에 엮이지 않고 그저 지금의 객체 값만 변경하고 싶다거나, DB와 별도로 이 객체의 데이터만 필요한 경우가 있습니다. 이런 경우에는 copyFromRealm()을 활용합니다. copyFromRealm()은 Realm 객체로부터 Realm DB와의 연결을 끊은 POJO 클래스 인스턴스를 복제합니다. RealmObject를 상속받는 클래스를 그대로 사용하면서도, 위에서 설명한 DTO의 원리로 객체를 복사하는 것입니다. (쓰레드 간 전달도 가능합니다.) 하지만 이것 역시 비용이 가볍지 않으므로, 적절한 경우에만 활용하는 것이 중요합니다.
// cardRealm은 Realm과 연결되어 있으며, Auto Refresh 된다. // 쓰레드 간 데이터를 전달할 수 없다. // Setter를 쓰기 위해서는 트랜잭션 안에 있어야 한다. CardRealm cardRealm = CardRealmUtil.getCardById(mRealm, cardId); // card는 일반 객체와 동일하다. Realm과 연결이 없고, Auto Refresh 되지 않는다. // 쓰레드 간 데이터를 전달할 수 있다. // Setter를 자유롭게 사용할 수 있다. CardRealm card = mRealm.copyFromRealm(cardRealm);
3. 시간차 트랜잭션. Realm.refresh()
CRUD를 할 때 메인이 아닌 쓰레드에서 Write를 수행하고, 메인 쓰레드에서 Read를 하는 것은 바람직한 사용 패턴입니다. 하지만 Realm에서 Write와 Read를 쓰레드를 달리하여 수행하면, 가끔 갱신되지 않은 데이터가 Read 되는 것을 발견할 수 있습니다. Write를 마치기 전에 Read 되기 때문입니다. 이렇게 시간차로 트랜잭션이 수행될 때에는, Write 후 Read 하는 부분에서 Realm.refresh()를 활용해주면, 강제적으로 Realm을 모두 새로고침 하면서, 언제나 최신 데이터를 받아올 수 있습니다. 하지만 Realm.refresh()는 비용이 가볍지 않은 작업이므로, 비동기적으로 Write가 일어난 후 바로 Read가 필요한 경우에만 활용하는 것이 좋습니다.
// Banner를 Write한다 BannerUtil.get().addBannerRealmAsync().subscribe(added -> { // Write가 종료되면 Realm을 새로고침한다. mRealm.refresh(); // 새로고침한 Realm으로부터 Banner을 설정한다. BannerUtil.get().setCurrentBanner(mRealm); });
Realm의 단점
처음에는 Realm의 빠른 속도와 편한 사용성에 열광하였습니다. 하지만 점점 사용 범위가 넓어지고 다양해지면서, 기존에 생각지 못했던 단점들을 많이 볼 수 있었습니다.
1. 알 수 없는 예외
BadVersion
findAllAsync(), findAllSortedAsync()를 쓸 경우 간헐적으로 일어납니다. 하지만 어느정도 사용자가 있는 어플리케이션에서는 무시못할 만큼의 크래시가 발생합니다. Realm 내부에서 일어나는 버그이기에 원인도 알 수 없습니다. 0.88.0 부터 해결되었다고 합니다. (아직 확인하지는 못했습니다.) 해결 방법은 findAll, findAllSorted로 대체하는 것입니다.
SharedGroup
Realm에서 제공하는 초기화 메소드 Realm.getDefaultInstance() 에서 발생합니다. BadVersion만큼 자주 발생하지는 않아서 어느정도 무시할 수는 있지만, 이것도 순전히 Realm을 사용한 것만으로 일어나는 크래시이기에, 스트레스의 원인입니다. 초기화를 대체하는 것은 없으므로 해결방법은 없습니다.
아직 1.0을 넘지 못한 버전이기 때문에, 위와 같이 원인을 알 수 없는 예외들이 있는 등 어느 정도의 불안정성이 동반되는 것 같습니다.
2. 다중 쓰레드에서의 Realm 객체 관리
Realm 객체는 쓰레드 간 직접 전달이 불가능하지만, 같은 Realm DB 값의 객체라면, 다른 쓰레드에서 접근하여도, 참조하는 객체는 하나입니다. (위에서 설명한 Auto Refresh가 가능한 원리입니다.) 때문에 하나의 쓰레드에서 Realm 객체를 삭제한다면, 다른 쓰레드에서 동일한 Realm 객체를 참조하지 못합니다. 이것은 삭제 순서를 보장할 수 없는 다중 쓰레드 상황에서 예외를 초래합니다. Realm.isValid() 를 통해 삭제 여부를 체크하여도, 종종 다른 쓰레드에서 삭제가 된 Realm 객체라며 크래시를 발생시킵니다. 최대한 주의를 기울이지만, 다중 쓰레드 환경에서 이러한 시간차를 예방하는 것은 힘듭니다.
사실 sychronoized 등을 활용하면 해결방법이 없지는 않습니다. 하지만 자주 수행해야 하는 작업을 이런 방식으로 처리하는 것은 비효율이 큽니다. 그리고 다행히 이 현상은 자주 일어나지 않습니다. 그래서 지금은 의도적으로 크래시를 감수하고 있습니다.
3. 부족한 쿼리
Realm은 raw 쿼리를 인정하지 않습니다. Realm에서 메소드 형식으로 지원하는 쿼리만으로 CRUD 작업을 수행해야 합니다. 메소드 형식은 사용하기에 직관적인 장점이 있지만, 그만큼 다양한 쿼리를 지원하지 못하고, customize의 여지가 없습니다. 저는 Realm이 지원하지 않는 쿼리가 필요했고(Collate Localized ASC, CASE WHEN, MATCH 등), 몇가지 경우에 편법을 사용할 수 밖에 없었습니다. 앞으로는 차차 다양한 쿼리가 추가되기를 기대해 봅니다.
- Collate Localized ASC – Java 코드에서 결과값을 한번 더 Sorting 하였습니다.
- CASE WHEN – int 값을 저장하는 Language Order Column을 별도로 생성하였습니다.
- MATCH – 명함은 데이터가 많으므로 Contains로는 검색 속도가 나오지 않습니다(Table을 Full Scan 하기 때문입니다). Full Text Search를 활용해야 원하는 속도를 얻을 수 있습니다. 하지만 Full Text Search의 MATCH 쿼리는 Sqlite에서만 지원합니다. 그래서 Sqlite로 명함 Index를 저장, 검색하고 Realm에서 명함을 가져오는 방식으로 DB를 조합하였습니다.
4. Learning curve
쿼리를 사용하거나 복잡한 스키마를 설계하기에 Realm은 정말 편한 DB 입니다. 하지만 쓰레드 간 객체 전달이 되지 않는다는 큰 제약사항 때문에, 위에 공유한 Tip과 같은 시행착오들을 겪은 후에야 자연스럽게 사용할 수 있었습니다. 쓰레드 흐름에 익숙하지 않다면 거의 사용하기 힘듭니다. 이러한 학습 비용은 결코 Realm이 사용성이 좋다고 말할 수 없는 큰 단점입니다.
Realm의 장점
사용하면서 단점만 느낀 것은 아닙니다. 큰 단점들에도 불구하고 Realm을 신뢰할 수 있도록 만들어주는 장점들이 있습니다.
1. 빠른 업데이트
Realm은 길어야 한달 주기로 지속적으로 새로운 버전을 배포합니다. 큰 업데이트는 다양한 호환성을 지원하려는 방향으로 새로운 기능들을 추가합니다. (개인적으로 Rx 지원을 가장 잘 활용하고 있습니다.) 마이너 업데이트는 이슈로 등록된 버그 및 개선사항들을 해결합니다. 이러한 빠른 업데이트는 앞으로 나아질 것을 기대하도록 만듭니다.
2. 문서화
Realm은 한글화가 되어있는 몇 안되는 오픈소스 라이브러리 입니다. 문서화의 퀄리티 또한 매우 높아서, 웬만한 사용법과 이슈는 문서만으로 해결이 가능합니다. 게다가 문서가 버전별로도 관리가 잘 되어있어, Changelog와 함께 본다면, 변경사항을 쉽게 파악할 수 있습니다. Realm을 다룰 때에는, 문서를 한번 정독하고 시작하기를 권장합니다.
3. 커뮤니티
Realm은 모든 진행이 Github에서 공개적으로 이루어집니다. 어떤 이슈를 사람들이 겪었고 해결하였는지, 어떤 수정사항이 이루어지고 있는지를 한눈에 볼 수 있습니다. 그리고 이슈를 올린다면 거의 하루 이내로 답변을 받을 수 있습니다. 이렇게 소통이 가능한 살아있는 커뮤니티는 사용자들에게 신뢰를 주고, 정보 공유를 활발히 하도록 합니다. 만약 Realm을 사용하다가 무슨 문제가 생긴다면, 구글 검색 이전에 Github 이슈를 보는 것이 더 도움이 될 것입니다.
결론
Realm은 빠르고, 설계와 시작이 쉽습니다. 하지만 큰 제약사항 때문에 다양한 경우를 다룰 때에는 학습 비용이 높습니다. 게다가 아직 정식버전 아니여서인지, 불안정한 모습도 보입니다. 하지만 빠른 업데이트와 적극적인 커뮤니티 문화로 그러한 단점들을 덮어가고 있습니다. 이러한 그들의 노력은 앞으로 나아질 거라는 신뢰를 주기에 충분합니다. 지금처럼 성실하게 하나씩 문제를 해결하면서, 빠른 시일 내에 Realm이 모바일 데이터베이스 분야의 독보적인 존재가 되기를 바라는 마음입니다.