Realm과 함께 하는 안드로이드 아키텍처 컴포넌트

Google IO 2017에는 하나의 글로 담기 어려운 많은 흥미로운 주제가 있었습니다. 정말 인상적이었던 코틀린 다음으로 가장 관심이 가는 것은 안드로이드 아키텍처 컴포넌트입니다.

드디어 구글이 안드로이드에서 권장하는 아키텍처 가이드를 제공한다고 합니다. 유연하고 모듈화 방식이므로 개발자의 필요에 따라 다른 모듈이나 프레임워크에 결합할 수 있습니다.

이깃 보야(Yiğit Boyar)는 Google IO 2017에서 다음 항목들의 동작 방식을 설명했습니다. 또한, 앞으로 더 많은 컴포넌트와 권고 사항이 뒤따를 것이라고 예고했습니다.

  • Room
  • ViewModel
  • Lifecycle
  • LiveData

이 아키텍처 주제에 대한 비디오를 아직 보지 못했다면 꼭 한번 보시길 바랍니다. 가장 잘 정리된 이 영상을 추천합니다.

이 글에서는 각각의 아키텍처 내용을 간단히 설명하고 Realm 개발을 단순화하기 위해 어떻게 사용할 수 있을지 알려드리겠습니다.

Room

Room은 SQLite 위에 만든 구글의 새로운 ORM입니다. 이전의 SQLite를 사용하는 구글 API에 비해 크게 발전했고 이름도 참 흥미롭습니다. 😉

많은 SQLite ORM과 달리 Room은 개발자가 자료를 질의하기 위한 SQL을 작성해야 합니다. 또한, 반환된 객체의 자식에 대한 게으른 로딩(lazy loading)을 지원하지 않습니다. 이는 개발자에게 보이지 않게 SQL을 생성하는 많은 ORM에 비해 실질적인 강점입니다. 물론 게으른 로딩과 시간에 따라 SQL을 수동으로 생성하거나 관리할 필요가 없는 방식이 처음에는 매력적으로 보일 수 있습니다. 하지만 객체들의 관계를 따라가면서 크고 비효율적일 수도 있는 새 질의를 수행하면서 성능이 크게 감소할 수 있다는 사실을 ORM을 많이 써본 사람들은 이미 알고 있을 겁니다.

데이터베이스로부터 수동으로 질의하고 데이터를 조인하는 동안 Room은 @Query 어노테이션을 정의해서 쉽게 SQL로부터 값을 가져올 수 있게 합니다. 또한, DAO(Data Access Object, 데이터 접근 객체) 인터페이스에 질의를 지정하고 반환형을 설정하는 어노테이션을 붙이고, 컴파일 타임에 DAO의 구현을 만듭니다.

몇 가지 예를 살펴볼까요?

@Query("SELECT Loan.id, Book.title as title, User.name as name, Loan.startTime, Loan.endTime " +
       "FROM Book " +
       "INNER JOIN Loan ON Loan.book_id = Book.id " +
       "INNER JOIN User ON User.id = Loan.user_id " +
       "WHERE User.name LIKE :userName " +
       "AND Loan.endTime > :after "
)
public LiveData<List<LoanWithUserAndBook>> findLoansByNameAfter(String userName, Date after);

@Query("SELECT * From Loan")
LiveData<List<Loan>> findAll();

SQLite를 안드로이드 로컬 저장소로 사용했다면 Room으로 사용자의 작업 성능을 크게 향상할 수 있습니다. 더 자세한 정보는 Google Room 문서를 확인하세요.

ViewModel

액티비티에 관련된 UI 데이터 접근을 제공하고 구글의 새로운 LiveData, LiveCycle 컴포넌트와 결합하도록 설계된 구글 ViewModel은 향후 안드로이드 앱 개발 방법을 바꿀 것입니다. 안드로이드 개발자 문서의 다이어그램에서 볼 수 있듯 ViewModel은 액티비티가 정리될 때까지 살아있습니다.

ViewModel 생애주기 다이어드램

Realm의 경우 Realm의 생애주기가 ViewModel에 관리되고 더 이상 ViewModel이 관리되지 않을 때 닫을 수 있음을 의미합니다.

이는 생애주기라 불리는 개념과 생애주기 인지 컴포넌트에 의해 모두 작동합니다.

Lifecycle

안드로이드 프레임워크에 정의된 대부분의 앱 컴포넌트들은 생애주기에 붙어 있습니다. Realm도 마찬가지입니다. Realm.getDefaultInstance()을 호출할 때마다 그 인스턴스가 GC 되기 전에 realm.close()로 닫아야 합니다. 또 Realm 인스턴스를 닫기 전에 열린 트랜잭션도 닫고 변경 리스너도 제거해야 합니다. “생애주기를 인지하는” 컴포넌트를 쉽게 만들 수 있게 여러 클래스와 인터페이스를 안드로이드 Lifecycle 패키지에서 제공합니다. 코드 샘플을 보며 실제로 어떻게 동작하는지는 나중에 살피고, 먼저 마지막 부분인 LiveData부터 보겠습니다.

이런 개발 뉴스를 더 만나보세요

LiveData

LiveData는 Activity가 ViewModel의 변경을 폴링할 필요 없이 스트림 데이터를 ViewModel로부터 Activity나 UI로 보내줍니다. 더 나아가 화면을 갱신하기 위해 ViewHolder에서 Activity의 참조를 가질 수 있습니다. 액티비티는 LiveData에 연동하여 데이터 변경에 대처합니다.

Google이 사용한 실시간 데이터 컨셉은 정말 훌륭합니다. LiveData 확장 클래스는 Realm의 관측 가능한 라이브 데이터와 잘 작동하며 추상 계층을 제공하기 때문에 Activity는 RealmResults와 RealmObject에 노출되지 않습니다.

새 아키텍처 컴포넌트에서 제일 좋은 점은 플러그가 가능한 점입니다. 모든 데이터는 LiveData로 나타낼 수 있습니다. 생애주기를 인식하게 컴포넌트를 만들 수 있고 앱의 요구사항에 맞게 ViewModel을 만들 수 있습니다. Google은 방해 없이 도움을 줄 수 있는 툴과 가이드라인을 만드는 대단한 일을 했습니다. 구글에 찬사를 보내고 싶네요! 🎉

구글 코드 랩의 android-persistence를 시작점으로 삼고 SQLite 테이블 구조를 Realm 객체 데이터 모델로 바꾸어 Realm을 어떻게 쓰는지 살펴볼까요? 변경 이전과 이후는 다음과 같습니다.

android-persistence 데이터 모델 비교

가장 큰 차이점은 관계에 있습니다. Realm의 경우 User와 Book 모두 대출(loans)의 집합을 가지고, Loan은 관련된 Book과 User에 관한 참조를 가집니다. 반면, SQLite라면 이런 관계를 Loan 테이블에 저장된 외래키(FK) 관계로 추론하고 Loan 테이블은 우리가 보는 시점에 SQL 질의에 의해 조인됩니다.

모델 클래스를 Realm으로 바꾸려면 조금은 수정해야 합니다. Loan 모델 객체의 변경 이전과 이후의 예를 보세요.

Loan 모델 코드

질의

질의도 조금 바꾸어야 합니다. findLoansByNameAfter를 위한 SQL문과 인터페이스 메서드를 정의하는 대신 RealmQuery를 사용하고 메서드 몸체를 작성해야 합니다.

아키텍처 컴포넌트를 Realm DAO로 변경하기

변경된 모델이 어떻게 하나로 합쳐졌는지 스택의 맨 위에서부터 살펴봅시다.

액티비티

Activity는 LifecycleActivity를 상속합니다. 결국 이 부분은 AppCompat 라이브러리에 합쳐집니다. 이렇게 해서 앞으로 볼 ViewModel과 같은, 생애주기 인지 컴포넌트를 사용할 수 있게 됩니다.

public class CustomResultUserActivity extends LifecycleActivity {

    private CustomResultViewModel mShowUserViewModel;
    private TextView mBooksTextView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.db_activity);
        mBooksTextView = (TextView) findViewById(R.id.books_tv);

        // 안드로이드가 ViewModel을 생성합니다.
        // ViewModel 최고의 장점은 configurationChanges에서도 살아남는 점입니다!
        mShowUserViewModel = ViewModelProviders.of(this).get(CustomResultViewModel.class);

        // LiveData Loan 문자열의 갱신을 관찰합니다.
        mShowUserViewModel.getLoansResult().observe(this, new Observer<String>() {
            @Override
            public void onChanged(@Nullable final String result) {
                mBooksTextView.setText(result);
            }
        });
    }

    public void onRefreshBtClicked(View view) {
        mShowUserViewModel.simulateDataUpdates();
    }
}

내장된 ViewModelProviders.of(...)를 이용해서 onCreate가 ViewModel의 인스턴스를 얻는다는 점을 주의하세요. 이전에 이 액티비티 생애주기를 위한 CustomResultViewModel이 없었다면 새롭게 생성합니다. 사용자가 폰을 회전하여 설정이 변경되면 액티비티는 파괴되고 다시 생성되지만, ViewModel은 살아남아 같은 ���스턴스가 다음 Activity.onCreate 호출에 반환됩니다. Activity가 종료되고 ViewModel이 더 이상 사용되지 않는다면, 시스템은 ViewModel이 파괴되기 전에 리소스를 정리할 기회를 줍니다.

ViewModel은 mShowUserViewModel.getLoansResult()를 통해 __LiveData__를 액티비티에게 노출하고 액티비티는 변경을 관찰할 수 있습니다. onPause()onStop()에서 관찰을 멈추는 코드를 추가할 필요가 없습니다. LiveData는 생애주기를 인지하며 액티비티의 생애주기에 결합됩니다. 그리고 생애주기 인지를 위해 액티비티를 .observe(...)의 첫 번째 인자로 전달합니다.

ViewModel

ViewModel은 UI 데이터를 저장하고 관리하며 Activity에 simulateDataUpdates()와 같은 액션들을 노출합니다.

public class CustomResultViewModel extends ViewModel {

    private Realm mDb;
    private LiveData<String> mLoansResult;

    public CustomResultViewModel() {
        mDb = Realm.getDefaultInstance();
        subscribeToMikesLoansSinceYesterday();
        simulateDataUpdates();
    }

    public void simulateDataUpdates() {
        DatabaseInitializer.populateAsync(mDb);
    }

    public LiveData<String> getLoansResult() {
        return mLoansResult;
    }

    private void subscribeToMikesLoansSinceYesterday() {
        LiveRealmData<Loan> loans = loanModel(mDb)
                .findLoansByNameAfter("Mike", getYesterdayDate());
        mLoansResult = Transformations.map(loans, new Function<RealmResults<Loan>, String>() {
            @Override
            public String apply(RealmResults<Loan> loans) {
                StringBuilder sb = new StringBuilder();
                SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm",
                        Locale.US);

                for (Loan loan : loans) {
                    sb.append(String.format("%s\n  (Returned: %s)\n",
                            loan.getBook().getTitle(),
                            simpleDateFormat.format(loan.getEndTime())));
                }
                return sb.toString();
            }
        });
    }

    /**
     * 이 메서드는 ViewModel이 더이상 사용되지 않고 파괴될 때 호출됩니다.
     * Realm 인스턴스같이 ViewModel이 어떤 데이터를 관찰하고 ViewModel이 새는 것을 막기 위해
     * 구독을 취소할 필요가 있는 경우 유용합니다.
     */
    @Override
    protected void onCleared() {
        mDb.close();
        super.onCleared();
    }

    private Date getYesterdayDate() {
        Calendar calendar = Calendar.getInstance();
        calendar.set(Calendar.DATE, -1);
        return calendar.getTime();
    }
}

CustomResultViewModel의 Realm 버전은 구글 코드랩 버전과 유사합니다. 다만 Loan과 User 데이터를 위해 개별적인 DTO(Data Transfer Object, 데이터 전달 객체)를 생성하는 대신, 여기에 포함된 User를 참조하는 Loan을 참조한다는 점이 다릅니다. ORM과 SQL에서는 이런 방식을 효과적으로 수행할 수 없습니다. Realm은 관계를 매우 자유롭게 설정할 수 있으므로 Realm을 사용하면 이런 제한이 없습니다. 조인도 않고 추가로 수행할 질의도 없습니다. 단순하게 말하자면 Realm의 관계는 객체 그래프입니다.

마지막으로 여기에서 볼 수 있듯이 LiveRealmData<T>LiveData<RealmResults<T>>입니다.

public class LiveRealmData<T extends RealmModel> extends LiveData<RealmResults<T>> {

    private RealmResults<T> results;
    private final RealmChangeListener<RealmResults<T>> listener = 
        new RealmChangeListener<RealmResults<T>>() {
            @Override
            public void onChange(RealmResults<T> results) { setValue(results);}
    };

    public LiveRealmData(RealmResults<T> realmResults) {
        results = realmResults;
    }

    @Override
    protected void onActive() {
        results.addChangeListener(listener);
    }

    @Override
    protected void onInactive() {
        results.removeChangeListener(listener);
    }
}

생애주기를 인지하는 LiveData처럼 이들을 노출하기 위한 RealmResults 래퍼입니다.

DAO

다른 컴포넌트와의 상호작용 테스트를 도우려면 DAO 뒤로 데이터베이스 상호작용을 숨기는 것이 좋습니다. 예를 들어 ViewModel은 DAO와 별도로 테스트 될 수 있고 유닛 테스트를 위해 목업을 사용할 수 있습니다.

코드랩 예제는 Room 데이터베이스에 메서드를 상속받기 위해 상속을 하였고, 싱글턴 인스턴스를 얻기 위해 다음처럼 팩토리 메서드를 가지고 있습니다.

@Database(entities = {User.class, Book.class, Loan.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {

    private static AppDatabase INSTANCE;

    public abstract UserDao userModel();
    public abstract BookDao bookModel();
    public abstract LoanDao loanModel();

    public static AppDatabase getInMemoryDatabase(Context context) {
        if (INSTANCE == null) {
            INSTANCE =
                    Room.inMemoryDatabaseBuilder(context.getApplicationContext(), AppDatabase.class)
                    // 코드랩을 단순화하기 위해 메인 스레드에서 질의를 허용합니다.
                    // 실제 앱에서는 사용하지 마세요! PersistenceBasicSample 예를 참고하세요.
                    .allowMainThreadQueries()
                    .build();
        }
        return INSTANCE;
    }

    public static void destroyInstance() {
        INSTANCE = null;
    }
}

(Realm은 이미 하나만 제공되며, ViewModel 생애주기에 결합되기 때문에) Realm을 가져오기 위한 특별한 팩토리가 필요 없지만, 필요하다면 주어진 Realm 인스턴스에 연관된 DAO 인스턴스를 받아 사용할 수 있습니다. 단, 모든 Realm 모델은 자신을 받은 Realm 인스턴스의 생애주기에 묶여있다는 것을 상기시켜드립니다.

이를 위해 간단히 RealmUtils.java를 작성했었는데 이런 지루한 작업을 피하고자 앞으로는 코틀린 익스텐션 기능을 대신 사용하겠습니다.

@file:JvmName("RealmUtils") // JAVA로부터 호출될 경우 유틸리티 클래스 이름입니다.

...
fun Realm.userModel(): UserDao = UserDao(this)
fun Realm.bookModel(): BookDao = BookDao(this)
fun Realm.loanModel(): LoanDao = LoanDao(this)

// LiveRealmData로 RealmResults을 반환하는 편의 확장
fun <T:RealmModel> RealmResults<T>.asLiveData() = LiveRealmData<T>(this)

이제 자바 코드에서 Book DAO를 얻기 위해 RealmUtils.bookDao(realm)을 쓸 수 있고, 코틀린 코드로부터 realm.bookDao()로 간단히 사용할 수 있습니다.

이제 DAO를 생성할 방법이 있으니 간단한 Realm DAO를 살펴봅시다. 특정 날짜 이후 이름으로부터 대출을 찾기 위해 CustomResultViewModel에서 사용되는 LoanDao는 아래와 같습니다.

public class LoanDao  {

    private Realm mRealm;

    public LoanDao(Realm realm) { this.mRealm = realm; }

    public LiveRealmData<Loan> findLoansByNameAfter(final String userName, final Date after) {
        return asLiveData(mRealm.where(Loan.class)
                .like("user.name", userName)
                .greaterThan("endTime", after)
                .findAllAsync());
    }

    public void addLoan(final Date from, final Date to, final String userId, final String bookId) {
        User user = mRealm.where(User.class).equalTo("id", userId).findFirst();
        Book book = mRealm.where(Book.class).equalTo("id", bookId).findFirst();
        Loan loan = new Loan(from, to, book, user);
        mRealm.insert(loan);
    }
}

이제 참조 예제에서 라이브 데이터 피드 시뮬레이터를 다시 작성하는 작업만 하면 됩니다.

데이터 갱신 시뮬레이션하기

액티비티를 시작하고 새로 고침 버튼이 다시 눌릴 때마다 데이터는 지워지고 새로운 시뮬레이션 데이터가 데이터베이스에 저장됩니다.

SQLite 예제에 이 기능을 넣었습니다. Realm 시뮬레이션 코드는 원래 코드와 비슷하지만, Realm에 원래 있는 Async 트랜잭션 메서드들을 이용해서 백그라운드에서 작업하도록 했습니다. 실제 Realm 앱에서는 IntentService를 사용해서 이런 작업을 할 수 있습니다.

    // 각 Loan 삽입마다 지연을 줘서 블록킹 연산 지연을 시뮬레이션합니다.
    private static final int DELAY_MILLIS = 500;

    public static void populateAsync(final Realm db) {

        Realm.Transaction task = populateWithTestDataTx;
        db.executeTransactionAsync(task);
    }

    private static Realm.Transaction populateWithTestDataTx = new Realm.Transaction() {
        @Override
        public void execute(Realm db) {

            db.deleteAll();
            checkpoint(db);

            User user1 = addUser(db, "1", "Jason", "Seaver", 40);
            User user2 = addUser(db, "2", "Mike", "Seaver", 12);
            addUser(db, "3", "Carol", "Seaver", 15);

            Book book1 = addBook(db, "1", "Dune");
            Book book2 = addBook(db, "2", "1984");
            Book book3 = addBook(db, "3", "The War of the Worlds");
            Book book4 = addBook(db, "4", "Brave New World");
            addBook(db, "5", "Foundation");
            try {
                // UI가 변경을 반영하도록 지연을 주고 Loan을 삽입합니다.

                Date today = getTodayPlusDays(0);
                Date yesterday = getTodayPlusDays(-1);
                Date twoDaysAgo = getTodayPlusDays(-2);
                Date lastWeek = getTodayPlusDays(-7);
                Date twoWeeksAgo = getTodayPlusDays(-14);

                addLoan(db, user1, book1, twoWeeksAgo, lastWeek);
                Thread.sleep(DELAY_MILLIS);
                addLoan(db, user2, book1, lastWeek, yesterday);
                Thread.sleep(DELAY_MILLIS);
                addLoan(db, user2, book2, lastWeek, today);
                Thread.sleep(DELAY_MILLIS);
                addLoan(db, user2, book3, lastWeek, twoDaysAgo);
                Thread.sleep(DELAY_MILLIS);
                addLoan(db, user2, book4, lastWeek, today);
                Log.d("DB", "Added loans");

            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    };

    private static Date getTodayPlusDays(int daysAgo) {
        Calendar calendar = Calendar.getInstance();
        calendar.set(Calendar.DATE, daysAgo);
        return calendar.getTime();
    }

    private static void checkpoint(Realm db) {
        db.commitTransaction();
        db.beginTransaction();
    }
   
    private static void addLoan(final Realm db, final User user, final Book book, Date from, Date to) {
        loanModel(db).addLoan(from, to, user.getId(), book.getId());
        checkpoint(db);
    }

    private static Book addBook(final Realm db, final String id, final String title) {
        Book book = bookModel(db).createOrUpdate(new Book(id, title));
        checkpoint(db);
        return book;
    }

    private static User addUser(final Realm db, final String id, final String name,
                                final String lastName, final int age) {
        User user = userModel(db).createOrUpdate(new User(id, name, lastName, age));
        checkpoint(db);
        return user;
    }

수정된 예제sms 여기에서 다운로드할 수 있습니다.

결론

구글은 가이드와 프레임워크 지원으로 안드로이드 개발을 이전보다 깔끔하고 쉽게 해줍니다. 코틀린을 정식으로 지원하고 많은 아키텍처 컴포넌트가 나왔으며, (SQLite의 빠른 반응형 NOSQL 대안도 나온) 지금은 안드로이드 개발자에게 그 어떤 시기보다 행복한 시간이 될 것 같습니다!

다음: 안드로이드를 위한 Realm #6: Realm과 함께 하는 안드로이드의 단방향(Uni-directional) 아키텍처 안내서

General link arrow white

컨텐츠에 대하여

이 컨텐츠는 저자의 허가 하에 이곳에서 공유합니다.


Eric Maxwell

Eric은 Realm의 제품 엔지니어입니다. 그는 십 년 이상 의료, 보험, 도서관학, 민간 항공을 비롯한 여러 산업 분야의 다양한 회사를 위해 소프트웨어를 설계하고 개발해 왔습니다. 현재는 교육, 멘토링 및 모바일 개발에 주력하고 있으며, Java, Android, iOS 강의를 개발하고 강의했습니다. 여가시간에는 가족과 함께 시간을 보내고 여행하고 즉흥 코메디 쇼를 즐깁니다.

4 design patterns for a RESTless mobile integration »

close