Firebase는 Realtime Database외에 Cloud Firestore (beta)라는 클라우드 기반 데이터베이스 솔루션을 제공한다.
구글에 따르면 Cloud Firestore는 유연하고 확장 가능한 NoSQL 클라우드 데이터베이스로,
두 데이터 베이스에 대한 차이는
Firebase 문서에 잘 정리되어 있다.
이번에는 앞서 정리한 Realtime Database기반의 예제를 Cloud Firestore로 변환하여 구현한다.
두 데이터 베이스의 간단한 변환은 Node.js 예제로 정리하였고, 여기서 확인할 수 있다.
Node.js 예제나 Firebase 문서에서 알 수 있듯이 기본적인 입출력이나 사용법은 둘 다 비슷하다.
Node.js 예제에서 Cloud Firestore가 검색 기능과 정렬 기능이 더 뛰어난 것을 확인할 수 있지만,
저장 방식의 차이로 인해 꽤 다르게 구현해야 하는데,
이와 관련된 적절한 예제가 없어서,
보다 복잡한 예제인 Realtime Database 예제(database)를 Cloud Firestore로 구현하면서 이와 관련된 정리를 한다.
소스는 Github에서 받을 수 있다.
저장 방식에 있어서 Realtime Database는 Json 형태로 데이터를 저장하고,
Json 형태로 자식(Child) 데이터를 저장한다.
Cloud Firestore는 Json 형태로 데이터를 저장하지만
최상위 Json 노드를 컬렉션(collection)이라고 부르고 컬렉션에 저장하는 데이터를 문서(Document)로 부른다.
다시 문서 하위에 컬렉션(collection)을 생성하고 문서를 저장할 수 있다.
비슷한 것 같지만 사용상에 차이가 있고, 개인적으로 하위 문서 관리에는 Cloud Firestore의 컬렉션이 더 불편한 것 같다.
Realtime Database는 부모를 가지고 오면 자식(child)도 바로 사용할 수 있지만
Cloud Firestore는 부모를 가지고 오면 자식(collection)을 사용할 수 없다.
대신 필드 타입에 배열(Array)과 객체(Object)를 추가하여 비슷한 기능을 편하게 사용할 수 있다.
이상의 개념을 가지고 예제의 데이터 저장 방식을 정리하면 다음과 같다.
Realtime Database 예제의 데이터 저장은 다음 그림에서 회색 부분을 포함한 부분(빨간색 제외)이고
Cloud Firestore에서는 회색을 제외하고 빨간색으로 표시(추가)한 부분이 저장 구조를 의미한다.
Realtime Database 예제의 정확한 데이터 저장 구조는 이전 정리 자료에서 확인 할 수 있다.
Realtime Database 예제는 게시판(post) 예제로 (포스트가 게시물, 하나의 글이라고 보면 된다.)
Realtime Database에서는 사용자(users), 사용자별 포스트(user-posts), 전체 포스트(posts), 댓글(posts-comments)의 4가지 스키마(schema => table)로 개발 되었고,
Cloud Firestore는 사용자(users), 전체 포스트(posts)의 2가지 스키마로 구성하여 변환하였다.
Realtime Database는 검색 기능이 약해서 전체 포스트(posts)에서 특정 사용자의 포스트만 조회(Query)할 수 없다.
따라서, 사용자가 글을 작성하면 사용자별 포스트(user-posts)와 전체 포스트(posts)에 동시에 저장해야 한다 (구조가 동일하다).
Cloud Firestore에서는 검색 기능을 이용하여 사용자별 포스트를 구현할 수 있기 때문에 사용자별 포스트(user-posts)를 생략하였다.
사용자들이 글을 읽고 좋아요(like)를 누르는 것과 비슷한 별(star) 기능은 별을 클릭한 사용자 아이디(uid)를 저장하여 관리하는 방법으로 개발한다.
저장하는 방식에 있어서
Realtime Database에서는 포스트(Json) 하위의 자식 노드(Json)로 stars 필드를 정의하였다.
(그림에서 회색 부분의 stars – uid)
Cloud Firestore는 자식 노드의 개념이 하위 컬렉션(collection)인데,
사용이 조금 불편한것도 있지만
별을 클릭한 사용자 아이디와 같이 단순 정보를 저장하기에는 배열이 적당하기 때문에 stars 필드를 배열([])로 정의하였다.
마지막으로 댓글(posts-comments)은 Realtime Database처럼 별도의 컬렉션으로 구현해도 되지만
하위 컬렉션 사용법을 익히기 위해 Cloud Firestore에서는 문서의 하위 컬렉션으로 정의하였다.
이상으로 Realtime Database 예제를 Cloud Firestore로 변환하기 위해 필요한 기본적인 개념을 정리하였다.
이 내용을 이해해야만 코드를 제대로 이해하고 작성할 수 있다.
다음 코드는 이상의 개념을 구체적으로 구현한 것이니 꼭 이상의 내용을 이해하고 넘어가야 한다.
실제 변환을 진행하기 전에 3가지를 먼저 정리한다.
기존의 gradle 파일에서 다음과 같이 수정한다.
implementation 'com.google.firebase:firebase-database:16.0.1'
implementation 'com.google.firebase:firebase-firestore:17.1.0'
Realtime Database 라이브러리 대신에 Cloud Firestore 라이브러리를 등록하는 것으로
Cloud Firestore 라이브러리는 17.1.0 이상을 지정하는 것이 좋다.
17.1.0 부터 배열 타입 사용에 유용한 whereArrayContains 같은 함수를 사용할 수 있기 때문이다.
변환이 끝난뒤 Realtime Database 라이브러리를 제거하면 된다.
다음으로 데이터 베이스에 연결하는 방법의 차이를 기억한다.
private DatabaseReference mDatabase;
mDatabase = FirebaseDatabase.getInstance().getReference();
private FirebaseFirestore db;
db = FirebaseFirestore.getInstance();
기존에 mDatabase로 명명한 것을 구별하기 위해 Cloud Firestore에서는 db라고 이름 붙였다.
Firebase 콘솔에 접속해서 왼쪽 메뉴 중 Database를 실행한다.
Realtime Database와 Cloud Firestore를 선택하는 화면에서 Cloud Firestore를 선택한다.
다음 그림이 실행되면 편의를 위해 보안 규칙을 [테스트 모드로 시작]으로 하고 [사용설정]을 선택한다.
이제 Cloud Firestore를 사용할 준비가 되었다.
먼저, 글쓰기(NewPostActivity.java)부터 변환한다 (편의상 포스트라는 단어보다 "글"이라는 단어를 사용한다).
글쓰기에서는 사용자가 작성한 글 제목(title)과 내용(body)을 Firebase 실시간 데이터베이스에 저장하는 기능을 구현한다.
구현 절차는
사용자가 글을 작성하고 저장 버튼을 누르면
사용자의 아이디로 사용자의 이름을 서버에서 가지고 와서 게시물 내용과 같이 저장소(posts)에 저장한다.
(RDMBS는 사용자 아이디를 저장하지만 NoSQL에 계열에서는 이런 식으로 저장한다.)
Realtime Database는 다음과 같다.
private void submitPost() {
~~ 생략 ~~
final String userId = getUid();
mDatabase.child("users").child(userId).addListenerForSingleValueEvent(
new ValueEventListener() {
@Override
public void onDataChange(DataSnapshot dataSnapshot) {
User user = dataSnapshot.getValue(User.class);
if (user == null) {
~~ 생략 ~~
} else {
// Write new post
writeNewPost(userId, user.username, title, body);
}
~~ 생략 ~~
}
private void writeNewPost(String userId, String username, String title, String body) {
String key = mDatabase.child("posts").push().getKey();
Post post = new Post(userId, username, title, body);
Map<String, Object> postValues = post.toMap();
Map<String, Object> childUpdates = new HashMap<>();
childUpdates.put("/posts/" + key, postValues);
childUpdates.put("/user-posts/" + userId + "/" + key, postValues);
mDatabase.updateChildren(childUpdates);
}
Cloud Firestore에서는 다음과 같이 작성한다.
private void submitPost() {
~~ 생략 ~
final String userId = getUid();
DocumentReference docRef = db.collection("users").document(userId);
docRef.get().addOnSuccessListener(new OnSuccessListener<DocumentSnapshot>() {
@Override
public void onSuccess(DocumentSnapshot documentSnapshot) {
User user = documentSnapshot.toObject(User.class);
if (user == null) {
~~ 생략 ~
} else {
writeNewPost(userId, user.username, title, body);
}
~~ 생략 ~
}
private void writeNewPost(String userId, String username, String title, String body) {
Post post = new Post(userId, username, title, body);
db.collection("posts").add(post);
}
데이터를 가지고 오는 함수나 구조 자체는 당연히 다르기 때문에 정리하지 않는다.
하지만 사용하는 개념은 비슷하다.
Realtime Database는 Users라는 자식(Table)의 특정 사용자 아이디(userId - uid)라는 자식(행)의 데이터를 가지고 와서 getValue로 클래스(User.class)화 하여 사용한다.
Cloud Firestore는 Users라는 컬렉션(Table)의 특정 사용자 아이디(userId - uid)라는 문서(행)의 데이터를 가지고 와서 toObject로 클래스(User.class)화 하여 사용한다.
실제 저장하는 부분에 큰 차이가 있는 것 같지만 Realtime Database는 다음과 같이 수정할 수 있다.
원 예제에서는 일종의 트렌젝션 처리를 위해 updateChildren를 사용한 것 같다.
private void writeNewPost(String userId, String username, String title, String body) {
String key = mDatabase.child("posts").push().getKey();
Post post = new Post(userId, username, title, body);
mDatabase.child("posts").child(key).setValue(post);
mDatabase.child("user-posts").child(userId).child(key).setValue(post);
}
Realtime Database는 posts라는 자식(Table)에 데이터(post)를 저장한다 (setValue).
Cloud Firestore는 posts라는 컬렉션(Table)에 데이터(post)를 저장한다 (add - set).
Realtime Database에서 getKey()로 행 번호를 미리 구하는 것은
같은 행 번호로 사용자별 포스트(user-posts), 전체 포스트(posts) 테이블에 저장하기 위한 방법으로
Cloud Firestore에서는 하나의 테이블에만 데이터를 저장하기 때문에 키를 먼저 구하는 방법을 사용하지 않았다.
Cloud Firestore에서도 키를 먼저 구할 수 있으며, 키를 구하는 각각의 코드는 다음과 같다.
mDatabase.child("posts").push().getKey()
db.collection("posts").document()
두 번째로 글 리스트(fragment)를 변환한다.
먼저 데이터를 조회하는 Query를 정리하면 다음과 같다.
최신글
(RecentPostsFragment) |
public Query getQuery(DatabaseReference databaseReference) { Query recentPostsQuery = databaseReference.child("posts") .limitToFirst(100); return recentPostsQuery; }
|
public Query getQuery(FirebaseFirestore databaseReference) { return databaseReference.collection("posts"); } |
내가 작성한 글
(MyPostsFragment) |
public Query getQuery(DatabaseReference databaseReference) { return databaseReference.child("user-posts").child(getUid()); }
|
public Query getQuery(FirebaseFirestore databaseReference) { return databaseReference.collection("posts").whereEqualTo("uid", getUid()); } |
내가 작성한 인기 글 (MyTopPostsFragment) |
public Query getQuery(DatabaseReference databaseReference) { String myUserId = getUid(); Query myTopPostsQuery = databaseReference.child("user-posts").child(myUserId) .orderByChild("starCount"); return myTopPostsQuery; }
|
public Query getQuery(FirebaseFirestore databaseReference) { String myUserId = getUid(); return databaseReference.collection("posts").whereEqualTo("uid", getUid()).orderBy("starCount", Query.Direction.DESCENDING); } |
최신 글(RecentPostsFragment)를 가지고 오는 것은 자식과 컬렉션의 차이일 뿐 동일하다.
Cloud Firestore에서는 갯수 제한(limit)을 두지 않았다.
기존 예제는 최신글이고 수정한 예제는 전체 글리스트가 된다.
기존 예제처럼 최신 글로 구현하려면 코드에서 limit (100)을 추가하면 된다.
Cloud Firestore의 내가 작성한 글(MyPostsFragment)에서는 조건(where)이 사용되었다.
Cloud Firestore는 다양한 조건절이 지원되며, Firebase 문서를 참고하면 된다.
내가 작성한 인기 글(MyTopPostsFragment)은 조건(where)에 정렬(orderBy)을 사용하였다.
Realtime Database은 오름차순만 지원되지만,
Cloud Firestore에서는 내림차순(DESCENDING)도 지원된다.
정렬과 개수 제한(limit)에 대한 자세한 내용은 Firebase 문서에 정리되어 있다.
이러한 Query를 실행해서 데이터를 출력하는 PostListFragment의 내용에는 큰 차이가 없다.
다만, Realtime Database FirebaseRecyclerAdapter를 사용했고
Cloud Firestore에서는 FirestoreAdapter를 사용한다.
FirebaseRecyclerAdapter는 Realtime Database 라이브러리와 같이 제공되지만
FirestoreAdapter는 그렇지 않다.
FirestoreAdapter는 Firestore 예제(Friendly Eats)에서 가지고 와야 한다.
사용법은 조금 차이가 있지만 거의 유사하기 때문에 여기에 따로 정리하지 않는다.
Friendly Eats 예제는 레스토랑 추천 앱으로 Cloud Firestore의 다양한 기능을 학습하기 위해 제작된 예제이다.
PostListFragment에서 리스트를 출력하는 것 외에
출력 후 사용자가 별(star)을 클릭하면 해당 글에 별을 주거나 취소할 수 있다.
이 기능을 Realtime Database는 다음과 같이 작성한다.
~~ 생략 ~
viewHolder.bindToPost(model, new View.OnClickListener() {
@Override
public void onClick(View starView) {
DatabaseReference globalPostRef = mDatabase.child("posts").child(postRef.getKey());
DatabaseReference userPostRef = mDatabase.child("user-posts").child(model.uid).child(postRef.getKey());
onStarClicked(globalPostRef);
onStarClicked(userPostRef);
}
});
~~ 생략 ~
private void onStarClicked(DatabaseReference postRef) {
postRef.runTransaction(new Transaction.Handler() {
@Override
public Transaction.Result doTransaction(MutableData mutableData) {
Post p = mutableData.getValue(Post.class);
if (p == null) {
return Transaction.success(mutableData);
}
if (p.stars.containsKey(getUid())) {
// Unstar the post and remove self from stars
p.starCount = p.starCount - 1;
p.stars.remove(getUid());
} else {
// Star the post and add self to stars
p.starCount = p.starCount + 1;
p.stars.put(getUid(), true);
}
// Set value and report transaction success
mutableData.setValue(p);
return Transaction.success(mutableData);
}
~~ 생략 ~
Cloud Firestore에서는 다음과 같이 작성한다.
viewHolder.bindToPost(post, new View.OnClickListener() {
@Override
public void onClick(View starView) {
db.collection("posts").document(postKey).get()
.addOnSuccessListener(new OnSuccessListener<DocumentSnapshot>() {
@Override
public void onSuccess(DocumentSnapshot documentSnapshot) {
Post post = documentSnapshot.toObject(Post.class);
if (post.stars.indexOf(getUid())==-1) {
post.stars.add(getUid());
} else {
post.stars.remove(getUid());
}
post.starCount = post.stars.size();
documentSnapshot.getReference().set(post);
}
});
}
});
코드 차이가 커 보이지만 기본적인 개념은 동일하다.
사용자가 별을 클릭한 문서를 가지고 오도록 쿼리를 실행한다 (이상의 코드에서 파란색 표시).
둘다 문서 내용을 클래스(Post)로 변경한후
stars 필드를 Json으로 구현한 Realtime Database는 containsKey 로,
배열로 구현한 Cloud Firestore는 indexOf를 사용한다.
값을 수정하고 각각의 방법으로 저장하여 구현한다.
배열을 사용하기 때문에 별의 개수에 대한 연산 (+, -)을 할 필요가 없고,
사용자별 포스트(user-posts), 전체 포스트(posts)에 각각 저장하기 위해 두 번 실행할 필요가 없어서
Cloud Firestore가 더 간단하게 구현된 것 같이 보인다.
Cloud Firestore에서는 배열 외에도 HashMap등을 사용할 수 있도록 Object 타입도 있다.
마지막으로 글 상세(PostDetailActivity) 부분을 변환한다.
사용자가 선택한 글 내용을 가지고 오는 코드는 기존 변환 작업과 동일하게 진행된다.
글 상세에서는 2가지 주요한 코드가 사용되었다.
해당 글의 댓글을 가지고 오는 방법에 차이가 있다.
다음 코드에서 보듯이 Realtime Database는 댓글(post-comments)을 최상위 노드(테이블)에 두고 사용하였다.
mPostReference = FirebaseDatabase.getInstance().getReference()
.child("posts").child(mPostKey);
mCommentsReference = FirebaseDatabase.getInstance().getReference()
.child("post-comments").child(mPostKey);
Cloud Firestore도 이와 같이 구현하면 되는데
Realtime Database 예제에서 별점주기(starts)를 Cloud Firestore에서 배열로 구현하면서
하위 컬렉션(subcollection)을 이해하는 데 도움되기 위해 댓글(post-comments)을 게시글(posts)의 하위 컬렉션으로 구현하였다.
mPostReference = FirebaseFirestore.getInstance().collection("posts").document(mPostKey);
mAdapter = new CommentAdapter(mPostReference.collection("post-comments"));
사용된 이 코드를 연결해서 표현하면 다음과 같다.
FirebaseFirestore.getInstance().collection("posts").document(mPostKey).collection("post-comments")
posts 컬렉션에 있는 mPostKey 문서에서 post-comments 컨렉션에 있는 내용을 모두 가지고 오라는 의미이다.
다시 의역하면
posts 테이블 문서 중 mPostKey에 등록된 댓글(post-comments)을 모두 가지고 오라는 의미가 된다.
이것을 다시 Realtime Database로 표현하면 다음과 같다.
FirebaseDatabase.getInstance().getReference().child("posts").child(mPostKey).child("post-comments")
다음으로 특이한 코드는 댓글 리스트 데이터 전체를 서버에서 가지고 오는 것이 아니고,
처음에는 모두 가지고 오고, 이후에는 추가되거나 수정된 내용만 가지고 오도록 구현하는 것이다.
Realtime Database와 Cloud Firestore는 실시간 데이터 베이스로 현재 보고 있는 데이터의 수정된 내역이 바로 반영되어 보인다.
(에뮬레이터를 두 개 실행해서 같은 글을 보도록 하고 댓글을 추가하면 다른 쪽에 추가되는 것을 볼 수 있다.)
Realtime Database에서는 다음과 같이 작성되었다.
ChildEventListener childEventListener = new ChildEventListener() {
@Override
public void onChildAdded(DataSnapshot dataSnapshot, String previousChildName) {
Comment comment = dataSnapshot.getValue(Comment.class);
mCommentIds.add(dataSnapshot.getKey());
mComments.add(comment);
notifyItemInserted(mComments.size() - 1);
}
@Override
public void onChildChanged(DataSnapshot dataSnapshot, String previousChildName) {
Comment newComment = dataSnapshot.getValue(Comment.class);
String commentKey = dataSnapshot.getKey();
int commentIndex = mCommentIds.indexOf(commentKey);
if (commentIndex > -1) {
mComments.set(commentIndex, newComment);
notifyItemChanged(commentIndex);
~~ 생략 ~~
}
@Override
public void onChildRemoved(DataSnapshot dataSnapshot) {
String commentKey = dataSnapshot.getKey();
int commentIndex = mCommentIds.indexOf(commentKey);
if (commentIndex > -1) {
mCommentIds.remove(commentIndex);
mComments.remove(commentIndex);
notifyItemRemoved(commentIndex);
~~ 생략 ~~
}
Cloud Firestore에서는 다음과 같이 작성한다.
EventListener childEventListener = new EventListener<QuerySnapshot>() {
@Override
public void onEvent(@Nullable QuerySnapshot snapshots,
@Nullable FirebaseFirestoreException e) {
if (e != null) {return;}
String commentKey;
int commentIndex;
Comment comment;
for (DocumentChange dc : snapshots.getDocumentChanges()) {
switch (dc.getType()) {
case ADDED:
comment = dc.getDocument().toObject(Comment.class);
mCommentIds.add(dc.getDocument().getId());
mComments.add(comment);
notifyItemInserted(mComments.size() - 1);
break;
case MODIFIED:
comment = dc.getDocument().toObject(Comment.class);
commentKey = dc.getDocument().getId();
commentIndex = mCommentIds.indexOf(commentKey);
if (commentIndex > -1) {
mComments.set(commentIndex, comment);
notifyItemChanged(commentIndex);
~~ 생략 ~~
case REMOVED:
commentKey = dc.getDocument().getId();
commentIndex = mCommentIds.indexOf(commentKey);
if (commentIndex > -1) {
mCommentIds.remove(commentIndex);
mComments.remove(commentIndex);
notifyItemRemoved(commentIndex);
~~ 생략 ~~
}
}
}
};
listenerRegistration = query.addSnapshotListener(childEventListener);
Realtime Database에서는
글 하나를 추가(onChildAdded)하거나 수정(onChildChanged) 또는 삭제(onChildRemoved)하면
각각의 이벤트가 발생하고 해당 내용만 전송해서 처리한다.
Cloud Firestore에서는 하나의 이벤트에서 이벤트 타입(getType)이 추가(ADDED)인지 수정(MODIFIED)인지 삭제(REMOVED)인지를 판단해서 처리한다.
그외 코드는 거의 동일하다.
이상으로 Firebase realtime database로 제작된 게시판(post) 예제를 Cloud Firestore로 변환하는 방법을 정리하였다.
소스는 Github에서 받을 수 있다.