본문 바로가기

Android

안드로이드 RoomDatabase in Java

반응형

이전에도 Room에 관해 글을 쓴 적이 있었지만, 여전히 이해가 가지 않는 부분이 있어 보충 설명을 위해 글을 작성합니다...

 

Kotlin 이슈 7 Room 사용하여 안드로이드 로컬 db 시작하기

위시리스트를 저장하고 싶어! 안드로이드 어플에 데이터를 저장하는 방법은 여러가지가 있지만, 저는 서버가 없는 관계로 로컬 db인 SQLite를 사용하기로 했습니다. Room 은 SQLite 성능을 최대화하

roomedia.tistory.com

RoomDatabase 소개

RoomDatabase는 DB 생성, 쿼리 등에 필요한 모든 작업을 캡슐화 해놓은 라이브러리로, Entity(Table), SQLite(Database), DAO(Data Access Object)로 구성되어 있으며, 쿼리에 대한 runtime validation을 제공하여 SQLite를 직접 사용하는 것보다 안전하고, 보일러 플레이트 코드가 적어 편리하며, LiveData, Data Observation을 사용하기 적합하다는 특징을 가지고 있습니다. 또한, RoomDatabase는 스키마 변경 시 migration 코드를 작성할 필요 없이 자동으로 업데이트, 관리합니다.

간단한 연락처 저장 어플리케이션을 만들어보며 Room 사용법을 익혀보도롭 합시다.

RoomDatabase 의존성 추가

ROOM 라이브러리를 사용하기 위해선, 다음과 같이 build.gradle (:app) 파일에 dependency를 추가하는 과정이 필요합니다. 코틀린이나 RxJava의 경우 추가적으로 필요한 의존성 주입은 아래 페이지에서 확인 가능하며, 의존성 주입을 마쳤으면 Sync를 눌러 gradle을 설정합니다.

// in file `build.gradle (:app)`
dependencies {
  ...
  // Room components
  implementation "androidx.room:room-runtime:2.2.5"
  annotationProcessor "androidx.room:room-compiler:2.2.5"
  androidTestImplementation "androidx.room:room-testing:2.2.5"

  // Lifecycle components
  implementation "androidx.lifecycle:lifecycle-viewmodel:2.2.0"
  implementation "androidx.lifecycle:lifecycle-livedata:2.2.0"
  implementation "androidx.lifecycle:lifecycle-common-java8:2.2.0"
}
 
 

Room을 사용하여 로컬 데이터베이스에 데이터 저장  |  Android 개발자  |  Android Developers

Room 라이브러리를 사용하여 더 쉽게 데이터를 유지하는 방법 알아보기

developer.android.com

 

Entity 생성

먼저, 연락처를 저장할 Contact 클래스를 model 폴더 아래에 생성합니다. 클래스 Contact는 id, 이름, 직업을 변수로 가지며, Constructor, getter 함수를 가지고 있습니다. 여담이지만 Constructor나 getter를 생성할 때 ctrl + N (cmd + N)을 이용하면 편리합니다.

public class Contact {

    private int id;
    private String name;
    private String occupation;

    public Contact() {
    }

    public Contact(int id, String name, String occupation) {
        this.id = id;
        this.name = name;
        this.occupation = occupation;
    }

    public int getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getOccupation() {
        return occupation;
    }
}

그러나 아직은 그저 클래스일 뿐... 여기에 다음과 같이 @Entity Annotation을 붙여주는 순간 Contact는 Room에서 사용하는 Entity가 되며, 각 변수는 Entity의 Column이 됩니다.

@Entity(tableName = "contact_table")
public class Contact {

    private int id;
    private String name;
    private String occupation;

    public Contact() {
    }

    public Contact(int id, String name, String occupation) {
        this.id = id;
        this.name = name;
        this.occupation = occupation;
    }

    public int getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getOccupation() {
        return occupation;
    }
}

다음과 같이 @PrimaryKey Annotation을 이용하여 id를 자동 생성할 수 있습니다. 이제 id는 자동 생성 되기 때문에 Constructor에서 제외하겠습니다. 모든 Column 이름은 Database 명령어와 겹치지 않는 한 변수 이름과 동일합니다. Column 이름을 변경하고 싶다면 @ColumnInfo Annotation을 사용하여 이름을 변경해줍니다. 반드시 값을 채워주고 싶은 Column의 경우 @NonNull Annotation을 사용합니다.

@Entity(tableName = "contact_table")
public class Contact {

    @PrimaryKey(autoGenerate = true)
    private int id;
    private String name;
    private String occupation;
    
//    @ColumnInfo(name = "select_bool")
//    private boolean select;

    public Contact() {
    }

    public Contact(@NonNull String name, String occupation) {
        this.name = name;
        this.occupation = occupation;
    }

    public int getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getOccupation() {
        return occupation;
    }
}

 

DAO 생성

DAO는 Data Access Object의 약자로, SQLite와 Entity 간 transaction을 수행합니다. 다시 말해, DAO는 SQLite나 Entity로부터 데이터를 받아와 Repository에 제공하는 등, 데이터베이스 CRUD(Create - Read - Update - Delete)를 수행하는 역할을 맡습니다. DAO는 interface 형태로 구현하는 걸 권장합니다. data 폴더 아래에 ContactDao 인터페이스를 생성한다.

@Dao
public interface ContactDao {
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    void insert(Contact contact);

    @Query("DELETE FROM contact_table")
    void deleteAll();

    @Delete
    void delete(Contact... contacts);

    @Query("SELECT * FROM contact_table ORDER BY name ASC")
    LiveData<List<Contact>> getAll();
}

Annotation을 적용한 것만으로 Database 삽입, 삭제, 조회의 모든 구현이 끝났습니다. @Insert Annotation의 onConflict 옵션은 다음과 같이 선택할 수 있습니다. @Delete는 주어진 파라미터를 Entity에서 삭제하며, deleteAll은 @Query를 사용하여 구현합니다. @Query Annotation은 getAll에서 보여지는 것처럼, 쿼리문을 자유롭게 사용할 수 있다.

getAll() 메소드는 Contact 리스트를 LiveData 형태로 반환합니다. LiveData는 데이터의 상태를 감시하며, 데이터가 변경되면 연결된 Listener에 알림을 보내 UI가 변경될 수 있도록 하는 기능을 제공한다.

RoomDatabase Singleton

Entity와 DAO를 생성하였으니, 이제는 Room Database를 생성할 차례입니다. RoomDatabase는 DAO를 통해 SQLite DB에 접속하며, 데이터를 제어할 수 있는 권한을 갖습니다.

Room Database를 생성해봅시다. util 폴더 아래에 ContactRoomDatabase 가상 클래스를 생성합니다. 가상 클래스의 메소드는 구현이 필요하지 않습니다.

@Database(entities = {Contact.class}, version = 1, exportSchema = false)
public abstract class ContactRoomDatabase extends RoomDatabase {

    public abstract ContactDao contactDao();
    public static final int NUMBER_OF_THREADS = 4;

    private static volatile ContactRoomDatabase INSTANCE;
    public static final ExecutorService databaseWriteExecutor
            = Executors.newFixedThreadPool(NUMBER_OF_THREADS);

    public static ContactRoomDatabase getDatabase(final Context context) {
        if (INSTANCE == null) {
            synchronized (ContactRoomDatabase.class) {
                if (INSTANCE == null) {
                    INSTANCE = Room.databaseBuilder(context.getApplicationContext(),
                            ContactRoomDatabase.class, "contact_database")
                            .build();
                }
            }
        }
        return INSTANCE;
    }
}

@Database Annotation은 entities, version, exportSchema 속성을 가지며, 각각 Entity 클래스 배열, 버전 정수 값, 스키마를 폴더에 내보낼 지에 대한 bool 값을 가집니다. database 클래스는 데이터를 조작할 수 있도록 Dao를 가지며, 싱글톤 INSTANCE를 가지고, ExecutorService를 통해 백그라운드에서 작업을 수행할 수 있습니다. 작업 쓰레드의 수는 적절히 4개로 설정했습니다.

자바에서 volatile은 해당 변수를 Cache가 아닌, 항상 Main Memory에서 Read/Write 하겠다는 의미이며, 자신의 값을 스스로 제거할 수 있음을 나타냅니다. 싱글톤을 표현하기 위해 volatile 키워드를 쓰는 이유는 해당 변수가 사용되는 쓰레드가 다를 수 있으며, 각기 다른 쓰레드에서 동일한 값에 접근하기 위해서입니다. 변수를 Cache에 저장할 경우, 변수 값 불일치 문제가 발생할 수 있습니다. 안드로이드는 퍼포먼스를 위해 Main Thread에서 데이터를 조작할 수 없도록 제어합니다. Main Thread는 오로지 UI 표현을 위해서만 사용되어야 한다. 따라서 Room 쿼리는 백그라운드에서 처리됩니다.

아래와 같이 INSTANCE 생성 시 callback 메소드를 추가하여 초기 데이터를 추가하는 것이 가능합니다. setInitialRoomDatabaseCallback 메소드는 현재 추가된 모든 데이터를 지우고, 새로운 데이터 두 개를 추가하는 함수입니다.

@Database(entities = {Contact.class}, version = 1, exportSchema = false)
public abstract class ContactRoomDatabase extends RoomDatabase {

    public abstract ContactDao contactDao();
    public static final int NUMBER_OF_THREADS = 4;

    private static volatile ContactRoomDatabase INSTANCE;
    public static final ExecutorService databaseWriteExecutor
            = Executors.newFixedThreadPool(NUMBER_OF_THREADS);

    public static ContactRoomDatabase getDatabase(final Context context) {
        if (INSTANCE == null) {
            synchronized (ContactRoomDatabase.class) {
                if (INSTANCE == null) {
                    INSTANCE = Room.databaseBuilder(context.getApplicationContext(),
                            ContactRoomDatabase.class, "contact_database")
                            .addCallback(setInitialRoomDatabaseCallback) // modified
                            .build();
                }
            }
        }
        return INSTANCE;
    }

    // modified
    private static final RoomDatabase.Callback setInitialRoomDatabaseCallback =
            new RoomDatabase.Callback() {
                @Override
                public void onCreate(@NonNull SupportSQLiteDatabase db) {
                    super.onCreate(db);
                    databaseWriteExecutor.execute(() -> {
                        ContactDao contactDao = INSTANCE.contactDao();
                        contactDao.deleteAll();

                        Contact contact = new Contact("roomedia", "student");
                        contactDao.insert(contact);

                        contact = new Contact("enghyu", "blogger");
                        contactDao.insert(contact);
                    });
                }
            };
}

Repository 생성

사실, Repository 클래스는 Room Database의 일부가 아닌, 그저 권장 사항일 뿐입니다. 하지만, Repository 클래스에서만 데이터를 보관하고, 조작하면 ViewModel에서 LiveData를 관리하는 과정이 간편해집니다. Repository는 clean API, clean 인터페이스를 제공하며, User 인터페이스와 데이터를 주고 받을 수 있습니다. 일반적으로 Repository는 DAO로부터 데이터를 받아오고, Network나 text file을 연결하여 다른 사람의 데이터를 받아올 수 있는 일종의 Helper입니다. Repository가 추상화 레이어 역할을 하기 때문에, ViewModel은 DAO나 Network, text file이 데이터에 어떻게 작용하는지를 신경쓰지 않고 LiveData를 관리할 수 있습니다.

public class ContactRepository {
    private ContactDao contactDao;
    private LiveData<List<Contact>> allContacts;
    
    public ContactRepository(Application application) {
    	ContactRoomDatabase db = ContactRoomDatabase.getDatabase(application);
        contactDao = db.contactDao();
        allContacts = contactDao.getAllContacts();
    }
    
    public LiveData<List<Contact>> getAllContacts() { return allContacts; }
    public void insert(Contact contact) {
        ContactRoomDatabase.databaseWriteExecutor.execute(() -> {
            contactDao.insert(contact);
        });
    }
}

ViewModel 생성

ViewModel은 UIController와 데이터를 연결해주는 자료구조입니다. 앞서 Repository가 데이터의 출처(로컬 DB, 네트워크)에 따라 처리해주는 역할을 하기 때문에, ViewModel은 데이터 바인딩에만 치중할 수 있습니다. ViewModel은 앱의 UI와 연관된 데이터를 ViewModel에 등록된 context 라이프사이클만큼 보관하고 있습니다. 때문에 기기 회전 등의 이유로 Activity가 재생성되며 데이터가 소실된 경우 ViewModel에서 데이터를 가져올 수 있습니다.

LiveData 또한 라이프사이클을 가지므로 ViewModel은 LiveDta와 함께 쓰일 수 있고, LiveData는 Observable이나 Notification 기능을 제공합니다.

class ContactViewModel extends AndroidViewModel {
    private ContactRepository repository;
    LiveData<List<Contact>> contacts;

    ContactViewModel(Application application) {
        super(application);
        repository = new ContactRepository(application);
        contacts = repository.getAllContacts();
    }
    
    LiveData<List<Contact>> getAllContacts() {
    	return contacts;
    }

    void insert(Contact contacts) {
        repository.insert(contacts);
    }
}
public class MainActivity extends AppCompatActivity {
    private ContactViewModel contactViewModel;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        contactViewModel = new ViewModelProvider.AndroidViewModelFactory(MainActivity.this
        	.getApplication())
        	.create(ContactViewModel.class);
            
        contactViewModel.getAllContacts().observe(this, contacts -> {
        	for (Contact contact: contacts) {
            	Log.d("TEST", "onCreate: " + contact.getName());
            }
        }
    }
}

Database Inspector 사용하기

안드로이드 스튜디오 4.1 이상부터 Database Inspector를 이용하여 실행 중인 앱의 데이터베이스 검사하고, 쿼리를 날리거나 수정하는 기능을 제공합니다. 이를 통해 데이터베이스를 디버깅할 수 있습니다. Database Inspector는 SQLite와 이를 기반으로 빌드된 Room과 같은 라이브러리에 대해 이와 같은 기능을 지원합니다.

 

Database Inspector로 데이터베이스 디버그하기  |  Android 개발자  |  Android Developers

Android 스튜디오 4.1 이상에서는 Database Inspector를 사용하여 앱 실행 중에 앱의 데이터베이스를 검사하고 쿼리 및 수정할 수 있습니다. 이 방법은 데이터베이스 디버깅에 특히 유용합니다. Database In

developer.android.com

  1. API 수준 26 이상을 실행하는 에뮬레이터 또는 연결된 기기에서 앱을 실행합니다.

  2. 메뉴 바에서 View > Tool Windows > Database Inspector를 선택합니다.

  3. 실행 중인 앱 프로세스를 드롭다운 메뉴에서 선택합니다.

  4. 현재 실행 중인 앱의 데이터베이스가 Databases 창에 나타납니다. 검사하려는 데이터베이스의 노드를 확장합니다.

Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR)

  1. Android 11 에뮬레이터에서는 Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR)와 같은 오류와 함께 앱이 비정상 종료될 수 있습니다.

  2. Tools > SDK Manager > SDK Platforms > Show Package Details를 체크합니다.

  3. Android 11 에뮬레이터를 버전 9 이상으로 선택합니다.

이후 안드로이드 스튜디오가 실행되지 않는 경우 다음 링크를 참고하세요.

 

Android 스튜디오 및 Android Gradle 플러그인의 알려진 문제  |  Android 개발자

Android 스튜디오 및 Android Gradle 플러그인의 현재 알려진 문제에 관해 알아보세요.

developer.android.com

반응형