Playground (우리동네, 아이랑 놀곳 Ver.서울)는 

서울 시내에서 아이랑 갈만한 실내외 놀이터, 박물관/미술관, 도서관등의 장소(약 3,700군데)들을 모아서, 

일목요연하게 보여 주는 웹 사이트로 

주말 같은 여유 시간에 아이랑 어디를 가야 할지 고민하는 부모들을 위해 제작한 웹 사이트이고, 

Playground 안드로이드(Android) 앱은 이 사이트의 앱 버전으로 

아직 개발 중이지만, 

다음과 같이 2 가지 핵심기능을 동일하게 제공하고 있다.

  • 우리 동네: 지도상에 보고싶은 지역의 좌표(위도, 경도)를 지정하면, 좌표를 중심으로 2Km 내의 시설/장소 정보 표시
  • 추천 코스: 특정 장소를 중심으로 갈만한 장소들을 묶어서 하나의 코스로 정리

반응형으로 제작된 웹 사이트와 앱의 핵심 기능은 동일하지만

지정된 위치의 시설들을 조회하는 [우리 동네]의 경우,

웹 사이트에는 SSL 문제 때문에 위치를 검색해서 지정하는 방식으로 구현하였다.

안드로이드 앱 버전은 GPS를 이용해서, 현재 위치를 지정하는 방식으로 구현하였다.

Playground 에 대한 웹 사이트와 기능에 대한 상세한 설명은 여기서 확인할 수 있고,

안드로이드 앱 버전의 프로그램 소스는 Github에서,

안드로이드 앱은 구글 play에서 다운 받아서 설치 할 수 있다.

 

주요 기능에 대한 사용법을 정리하면,

메뉴나 메인 화면에서 [우리동네] 를 클릭하면

다음과 같이 현재 위치나 원하는 위치를 지정하는 화면이 나타난다.

GPS로 현재 위치를 지정하거나 지도를 클릭해서 위치를 지정하고,

화면 우측 하단에 있는 조회(돋보기) 버튼을 클릭하면

지정된 위치를 중심으로 2Km내의 실내외 놀이터, 박물관등이

그림과 같이 색깔별로 표시 된다.

표시된 점(위치)를 선택하면,

해당 장소에 대한 간단한 정보가 하단에 표시된다.

 

[추천코스]도 메뉴나 메인 화면에서 클릭해서 실행할 수 있다.

서울 시내에 갈만한 곳을 묶어서 보여 주는 기능으로

현재는 관리자(개발자)가 개인 경험을 기반으로 작성한 내용들이 나타난다.

사용자가 등록하는 기능은 추가할 예정이다.

 

리스트에서 항목을 선택하면

코스에 해당하는 코스, 소개글, 지도 등의 상세한 내용을 확인할 수 있다.

안드로이드 앱은 웹 사이트에 비해

아직 세부 기능이 부족하지만, 계속 추가 개발되고 있다.

 

많은 Android앱들이 죽지 않는 서비스를(Immortal Service) 이용하는데,

개발 중인 제품에 필요하여 인터넷을 검색해 사용해 보니 제대로 작동하지 않거나 관련 자료가 부족해서 직접 개발하였다 (해야 했다...).

국내외국 자료를 검색해서 찾은 결과들을 토대로 구현하였고,

소스는 Github에서 받을 수 있다.

(따라서, 충분하게 검증된 기술이 아니기 때문에 문제가 발생할 수 있으며, 관련 문제를 공유하거나 더 나은 방법을 공유해줬으면 하는 기대로 이 글을 작성합니다.)


Android의 죽지 않는 서비스는 일반적인 서비스를 startForeground로 실행하면 간단하게 구현 할 수 있다.

이 방법의 문제는 Notification을 이용하여 알림창에 표시해야만 한다는 것이다.

알림창을 제거하기 위해 다양한 방법들이 논의 되었지만 [참고],

Android Oreo 버전에서는 작동되지 않는다.


이 내용들을 토대로 운영체제가 서비스를 죽이면 다시 실행하는 방법으로 죽지 않는 서비스를 구현하였다.

다시 실행시키는 방법은 알람(Alarm)을 이용한다.

서비스가 종료되면(onDestroy) 1초 뒤에 알람이 실행되게 하고,

알람에서 해당 서비스를 다시 실행한다.

이때, Android Oreo에서는 서비스의 백그라운드 실행을 금지하기 때문에 문제가 발생한다.

따라서, Oreo 이전과 이후 버전으로 나누어서 구현한다.


Oreo 이전 버전은 startService로 해당 서비스를 실행하고,

Oreo 이후 버전은 한 단계를 더 거쳐서 구현한다.

Oreo에서는 서비스를 백그라운드(Background)에서 실행하는 것을 금지하기 때문에

포그라운드(Foreground)에서 실행해야 한다.

즉, 알람은 백그라운드이기 때문에 서비스를 실행할 수 없다.

따라서 알람에서 포그라운드인 startForegroundService(Notification)으로 서비스를 실행하고, 이 서비스 안에서 해당 서비스를 실행한다.

그리고, startForegroundService으로 실행한 서비스는 죽이는 방식으로 구현한다.

그림으로 정리하면 다음과 같다.


기본 개념을 코드로 구현하면,

먼저, 앱을 실행하고 종료(onDestroy)할 때 서비스(RealService)를 종료(stopService)한다.

public class MainActivity extends AppCompatActivity {
private Intent serviceIntent;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

if (RealService.serviceIntent==null) {
serviceIntent = new Intent(this, RealService.class);
startService(serviceIntent);
} else {
serviceIntent = RealService.serviceIntent;//getInstance().getApplication();
Toast.makeText(getApplicationContext(), "already", Toast.LENGTH_LONG).show();
}
}

@Override
protected void onDestroy() {
super.onDestroy();
if (serviceIntent!=null) {
stopService(serviceIntent);
serviceIntent = null;
}
}

MainActivity.java


무엇인가 백그라운드에서 서비스를 제공할 서비스 클래스는 RealService.java이다.

메신저 앱의 경우 메시지를 수신하는 등의 처리를 구현하는데, 여기서는 1분에 한번씩 시간을 알려주도록 (Notification) 했다.


가장 처음에는 앱(MainActivity)이 실행되기(Foreground) 때문에 RealService는 startService로 실행하면 된다.

다만, 앱이 다시 실행되었을때 충돌을 방지하기 위해 RealService에 serviceIntent를 static 변수로 두어 서비스 실행시 intent를 가지고 있도록 한다.

serviceIntent의 값이 있으면 이미 서비스가 실행 중이니 넘어가고 없을 때만 서비스를 실행한다.


앱을 종료(onDestroy)할 때 서비스(RealService)를 종료(stopService)하여

서비스의 종료(onDestroy) 이벤트가 실행되게 작성한다.

public void onDestroy() {
super.onDestroy();

serviceIntent = null;
setAlarmTimer();
Thread.currentThread().interrupt();

if (mainThread != null) {
mainThread.interrupt();
mainThread = null;
}
}

protected void setAlarmTimer() {
final Calendar c = Calendar.getInstance();
c.setTimeInMillis(System.currentTimeMillis());
c.add(Calendar.SECOND, 1);
Intent intent = new Intent(this, AlarmRecever.class);
PendingIntent sender = PendingIntent.getBroadcast(this, 0,intent,0);

AlarmManager mAlarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
mAlarmManager.set(AlarmManager.RTC_WAKEUP, c.getTimeInMillis(), sender);
}

RealService.java

RealService 서비스가 종료(onDestroy)될때 마다 1초뒤 알람이 실행되게 작성한다(setAlarmTimer).

public class AlarmRecever extends BroadcastReceiver{

@Override
public void onReceive(Context context, Intent intent) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent in = new Intent(context, RestartService.class);
context.startForegroundService(in);
} else {
Intent in = new Intent(context, RealService.class);
context.startService(in);
}
}

}

AlarmRecever.java

알람(AlarmRecever)에서는

Android가 Oreo 이후 버전이면 (SDK_INT >= O)

Android에서 제공하는 죽지않는(Foreground) 서비스인 RestartService를 startForegroundService로 실행한다.

이전 버전은 RealService 서비스를 실행하면 끝이다.


Oreo 이후 버전은 startForegroundService로 실행한 RestartService의 코드는 다음과 같다.

public class RestartService extends Service {
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, "default");
builder.setSmallIcon(R.mipmap.ic_launcher);
~~ 생략 ~~

Notification notification = builder.build();
startForeground(9, notification);

/////////////////////////////////////////////////////////////////////
Intent in = new Intent(this, RealService.class);
startService(in);

stopForeground(true);
stopSelf();

return START_NOT_STICKY;
}

RestartService.java

RestartService 의 코드는 Android에서 제공하는 죽지않는(Foreground) 서비스의 코드이다.

포그라운드(Foreground)로 실행되지만, Android 알림창에 표시되는 문제가 있다.

따라서, RestartService를 startForeground로 실행하고,

RealService를 실행한다 (startService).

그리고 실행된 RestartService를 stopForeground와 stopSelf를 실행하여 RestartService를 종료한다.

RestartService가 실행되고 종료되는 시간이 짧기 때문에 알림창에는 표시가 생기지 않는다.


이외에 부팅후 실행을 위한 코드(RebootRecever)도 있지만 알람과 동일한 코드라 생략한다.


이상의 죽지 않는 서비스를 구현하기 위해 필요한 2개의 서비스와 2개의 Recever를

AndroidManifest.xml 파일에 다음과 같이 등록해야 한다.

<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

<application
~~ 생략 ~~
<activity android:name="com.damonet.service9.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<service android:name="com.damonet.service9.RealService"
~~ 생략 ~~
<service android:name="com.damonet.service9.RestartService"
~~ 생략 ~~
<receiver android:name="com.damonet.service9.AlarmRecever"/>

<receiver
android:name=".RebootRecever"
~~ 생략 ~~
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>

AndroidManifest.xml

추가적으로 부팅이 완료되면 서비스를 시작하기 위하여 부팅이 완료되었다는 것을 수신할 권한(permission)을 등록한다(RECEIVE_BOOT_COMPLETED).

또, Oreo API 28 부터는 FOREGROUND_SERVICE권한을 등록해야 포그라운드(Foreground) 서비스를 실행할 수 있다.


테스트 결과

Oreo 이전 버전에서는 큰 이상을 찾을 수 없었고,

Oreo에서는 2~5분 마다 서비스가 다시 실행되는 것을 확인 할 수 있었다.


---------------------------- 2019년 8월 19일 추가 ----------------------------

2~5분 마다 서비스가 다시 실행되는 문제는 절전 모드(doze) 문제로,

REQUEST_IGNORE_BATTERY_OPTIMIZATION를 이용하여 절전 모드를 사용하지 않는 예외 앱으로 처리하면 된다.

상세한 내용은 다른 자료를 참고 하고,

이 기능은 채팅 또는 통화 앱 등에만 허용하는 것으로, 메신저가 아니라면 웹 스토어 등록이 거절 될 수 있다 (참고).


먼저, AndroidManifest.xml에 다음 권한을 추가한다.

<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>

그리고, MainActivity.java 파일에 절전 모드를 해제하는 권한을 얻는 코드를 추가해 준다.

protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

PowerManager pm = (PowerManager) getApplicationContext().getSystemService(POWER_SERVICE);
boolean isWhiteListing = false;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
isWhiteListing = pm.isIgnoringBatteryOptimizations(getApplicationContext().getPackageName());
}
if (!isWhiteListing) {
Intent intent = new Intent();
intent.setAction(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
intent.setData(Uri.parse("package:" + getApplicationContext().getPackageName()));
startActivity(intent);
}

MainActivity.java

추가된 코드는 기존의 github에 추가되어 있고,

계속 테스트 중이지만 현재까지 성능 문제 없이 잘 사용하고 있다.




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에서 받을 수 있다.


Firebase 실시간 데이터베이스(Realtime database) 예제를 쉽게 이해하기 위해 전체적인 개념을 정리하였고,

여기서는 주요 코드에 대한 설명을 정리한다.

여기에서는 Firebase 실시간 데이터베이스를 이용하여 구현한 코드(Android)들을 중심으로 정리한다.

Firebase 실시간 데이터베이스에 대한 구체적인 설명은 관련 문서를 한번 읽어 보길 바란다.


1. Firebase realtime database 실행과 테스트

2. Firebase realtime database 구조와 프로그램 구조

3. Firebase realtime database 코드

4. Firebase realtime database를 Cloud Firestore로


포스트(post) 예제는 데이터 베이스 단순 입출력 예제로 단순 게시판과 유사하기 때문에

편의상 포스트(post)라는 단어보다 [글]이라는 단어로 사용한다.

먼저, 글쓰기(NewPostActivity.java)부터 정리한다.

글쓰기에서는 사용자가 작성한 글 제목(title)과 내용(body)을 Firebase 실시간 데이터베이스에 저장하는 기능을 구현한다.

NewPostActivity.java

코드가 복잡해 보이지만

사용자가 저장 버튼을 누르면 전처리를 위한 submitPost()함수[라인 4]를 호출한다.

submitPost()에서는 사용자 이름을 조회해서[라인6] 없으면[라인 14] 오류를 발생시키고,

조회가 되면 실제 데이터를 저장하는 writeNewPost() 함수를 호출한다.

현재 글을 작성하는 사용자의 UID(고유번호) [라인 5]로 사용자 이름을 조회하는 것은 글을 저장할 때 같이 저장하기 위해서 이다.

이 방식은 좋은 방식이 아니지만,

Firebase 실시간 데이터베이스가 NoSQL로 RDBMS의 Join과 같은 기능이 없어서 이렇게 구현한 것 같다.

writeNewPost() 함수에서는 사용자가 입력한 값(title, body)을 사용자 이름, 사용자 UID와 함께Post 개체(모델)에 담아서 저장한다 [라인 45].


여기서 사용된 저장 방식은 관련 문서에 없는 내용으로 제법 유용한 팁을 제공해 준다.

먼저, 데이터가 중복되면 안되기 때문에 getKey()을 이용하여 고유한 값(Primary Key, Unique value)을 받아온다 [라인 44].

저장할 정보를 Post 개체(모델)로 생성하고 [라인 45],

생성한 Post 개체를 Map로 변환한다 [라인 46].

Firebase 실시간 데이터베이스는 Json형태(Map과 비슷)로 저장하기 때문이다.


다시 Map을 하나 더 생성한다 [라인 48].

이 맵은 작성한 글을 두 군데 동시에 저장하기 위해서 사용한 방법이다.

앞서서 데이터 베이스 구조에서 정리하였지만

사용자가 작성한 글은 전체 게시글(posts) [라인 49]과 작성자별 게시글(user-posts)에 [라인 50] 각각 저장한다.

이렇게 하는 것은 Firebase 실시간 데이터베이스가 검색 기능이 약하기 때문이다.

전체 게시글에 데이터를 저장하고, 지정된 사용자 글만 가져오도록 구현 할 수 없다(?).

따라서, 기능에 맞추어 데이터를 미리 가공해 두어야 한다.


전체 글은 posts 스키마 하위의 고유값(key) 아래에 저장하고 [라인 49]

사용자별 게시글은 user-posts 스키마 하위의 사용자별 UID 하위에 고유값(key) 아래에 저장한다 [라인 50]

사용자별 UID 하위에 저장해야 사용자별 글만 가져올 수 있다.

updateChildren으로 한번에 두 개의 스키마에 저장한다 [라인 52].


Firebase 콘솔로 접속해서 다음과 같이 저장된 결과를 볼 수 있다.

전체 글은 posts 스키마 하위의 고유값(key) 아래에 ,

사용자별 게시글은 user-posts 스키마 하위의 사용자별 UID 하위에 고유값(key) 아래에 저장된 것을 볼 수 있다.


저장 기능을 구현한 writeNewPost()의 내용은 다음과 같이 작성할 수 있다.

    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);

}

이 내용은 Firebase 문서에 있는 방식으로 구현한 것으로 저장(setValue)을 두번 실행하도록 작성한 것이다.

데이터 계층은 슬래쉬(/)로 작성해도 되고 child로 지정해서 작성해도 된다.


간단해 보여서 좋아 보지만

예제가 이렇게 작성하지 않은 이유는 Transaction처리 때문으로 보여 진다.

위와 같이 작성하면, posts에 저장하고 user-posts에 저장하다 여러가지 이유로 문제가 생겨서 저장하지 못 할 경우

두 스키마 간에 불일치가 발생할 수 있다.

둘다 없거나 둘다 있어야지 하나만 저장되면 안되기 때문에 두 스키마에 저장할 경우에는 Transaction처리 해야한다.


Firebase에서는 Transaction처리를 지원하는데 (문서 하단 참조), Transaction의 의미가 RDMBS와는 조금 다른 것 같다.

이 부분은 좀더 사용해 보고 찾아보고 차후에 추가할 예정이다.

다음 예제에서는 Transaction을 사용하였다.


이번에는 글 리스트(PostListFragment)에 대해서 정리한다.

앞서 정리했지만, 글 리스트는 세가지가 존재하고 PostListFragment에 모든 기능이 구현되어 있다.

코드가 많아 보이지만, 주의 깊게 봐야할 코드는 FirebaseRecyclerAdapter와 Transaction 사용이다.

PostListFragment.java

리스트를 사용하기 위해 RecyclerAdapter를 사용하는데,

좀더 쉽게 사용할 수 있는 기능을 제공하는 FirebaseRecyclerAdapter가 사용되었고 [라인 17],

사용자가 게시물에 별표시(좋아요)를 눌렀을 떼, 관련 처리를 하기 위해Transaction을 사용하였다 [라인 68].

하나씩 정리하면 FirebaseRecyclerAdapter은 RecyclerAdapter와 대부분 동일하지만 데이터를 관리하는 기능을 좀더 단순화 시켰다.

RecyclerAdapter는 출력할 데이터들을 List나 ArrayList 형태의 변수로 보관하고 있지만

위 코드에서는 이것과 관련된 코드를 볼 수 없다.

RecyclerAdapter에서 화면에 출력할(onBindViewHolder) 때에는 [라인 26]

다음 코드처럼 현재 출력하는 데이터 순번(position)으로 List나 ArrayList 변수에서 해당 개체를 찾아서 처리한다.

          Post model = List.get(position)

FirebaseRecyclerAdapter에서는 onBindViewHolder의 마지막 파라메터에 현재 출력하려는 데이터 개체를 지정해서(Post model) 반환해 준다.

즉, Post 클래스에 값을 넣어서 model 개체로 제공된다 [라인 26].

이렇게 제공된 model의 값을 각각의 view(TextView등)에 넣어서 출력한다 [라인 49].

기본적인 값 출력은 별도의 파일인 ViewHolder의 bindToPost에 작성되어 있다[라인 49].


이렇게 쉽게 사용하기 위해, 데이터를 조회하도록 하는 부분에 차이가 있다.

Firebase는 데이터를 가지고 오는 조건에 ValueEventListener나 SingleValueEventListener를 사용한다.

여기에서는 그런 지정없이 FirebaseRecyclerAdapter 생성시에 데이터 조건을 FirebaseRecyclerOptions으로 지정하여 처리한다 [라인 11~17].

사용법의 차이일 뿐이니 기억해서 사용하면 된다.

다른 곳에서 값을 변경하면 자동으로 반영되는 것을 볼 때,

FirebaseRecyclerAdapter는 ValueEventListener를 사용하는 것 같다.

(SingleValueEventListener을 사용하면 한번만 호출하는 것으로 값이 변해도 반영되지 않는다.)


다음으로 주의 깊게 봐야 하는 것은 Transaction 처리 방법이다 [라인 68~].

사용자가 별 표시를 누르면,

사용자 uid를 해당 글의 stars 필드에 넣어주거나 [라인 83] 빼고 [라인 79]

별의 개수를 증가 [라인 82] 시키거나 감소[라인 78]시킨다.

문서에 따르면, 여러 사용자가 동시에 별 표시를 눌렀을 경우를 대비하여 구현하였다고 한다.

전체 게시글 [라인 57]과 사용자 게시글을 [라인 58]을 따로 Transaction처리하였는데

여기에 Transaction을 처리하는 것이 맞지 않나 라고 생각한다.


이외에 게시물 하나(행)을 선택하면 게시물 읽기로 가기 위해

행에 클릭 이벤트 핸들러를 작성하였다 [라인 31].

행은 viewHolder의 itemView을 의미한다.


마지막으로 글 읽기(PostDetailActivity.java)를 정리한다.

코드 양이 많아 게시글 읽기, 댓글 저장, 댓글 리스트로 기능에 따라 3가지로 나누어 정리한다.




PostDetailActivity.java


게시글 읽기는 전형적인 Firebase 실시간 데이터베이스의 데이터 가져오기 코드로 작성되었다.

글 리스트에서 사용자가 선택한 글번호(EXTRA_POST_KEY)를 받아와서 [라인 10],

Firebase 실시간 데이터베이스에서 해당 글 내용을 가지고 오도록 조건을 설정한다 [라인 16].

지정된 데이터는 ValueEventListener [라인 30]을 이용하여 받아서 화면에 출력하도록 [라인 34~38]

주어진 조건을[라인 16] 실행한다 [라인 52].


addValueEventListener를 사용하여 다른 곳에서 수정하여도, 수정된 내용이 반영되도록 하였다.

(수정 기능이 없으므로 Firebase 콘솔에서 수정해 보면 알 수 있다.)

addValueEventListener는 해당 Activity가 종료되어도 계속 작동하기 때문에 별도의 핸들러를 생성하여 [라인 16],

Activity가 종료될 때(onStop) 해당 핸들러를 종료시켜야 한다 [라인 69].


PostDetailActivity.java

댓글 저장은 (postComment) 게시글 저장과 동일한 방식으로 작성되었다.

해당 글 작성자의 UID로 [라인 2] 작성자의 이름을 가지고 와서 [라인 3~, 특히 라인 9]

Comment 모델에 필요한 정보(UID, 이름, 댓글)을 지정하여 개체를 생성하고 [라인 13]

이 개체(comment)를 setValue로 저장하였다 [라인 16].

일반적인 데이터 저장 방식이다.


작성자 이름은 한번만 호출해서 가져오면 되기 때문에 addListenerForSingleValueEvent가 사용되었다 [라인 4].


글 읽기의 마지막 기능으로 댓글 리스트가 구현되었다.

이 코드에서 재미 있는 코드는 childEventListener이다.

PostDetailActivity.java

ValueEventListener는 조회 중인 데이터에 변화가 생기면 전체 리스트를 다시 반환하지만

childEventListener는 변화가 생긴 데이터에 대하여만 반환하고 발생한 기능 이벤트 별로 처리한다.

즉, 어떤 글(데이터)이 추가 되거나(add), 수정(Changed) 또는 삭제(Removed)되면 해당 이벤트에 대하여 각각 발생 발생한다.

여기서는 새로운 댓글이 추가되면 onChildAdde가 실행되고 [라인 18]

추가된 글 내용이 반환된다 [라인 22].

추가된 글을 데이터를 관리하는 리스트에 추가하고 [라인 26, 27],

RecyclerView에 마지막에 행이 추가되었다고(notifyItemInserted()) 알려준다 [라인 28].

댓글이 수정되면 동일한 방식으로 onChildChanged가 실행되고 [라인 33],

데이터 관리하는 리스트에서 수정된 데이터를 찾아서 [라인 42] 수정하고 [라인 45]

RecyclerView에 지정된 행이 수정되었다고(notifyItemChanged()) 알려준다 [라인 48].


삭제도 같은 방식으로 작성되었다 [라인 56].

데이터의 위치가 바뀌면 발생하는 onChildMoved는 예제로 작성되지 않았다 [라인 79].


childEventListener는 처리가 발생한 데이터에 대하여 구체적인 기능에 따라 처리하기 때문에 효율적으로,

보다 상세한 기능 설명은 관련 문서를 읽어보는 것이 좋다.

ValueEventListener는 모든 데이터에 대하여 처리하므로 비효율적이지만 코딩 양이 적은 장점이 있다.

이런 것이 왜 Firebase 실시간 데이터베이스(Realtime database)라고 하는지에 대한 이유가 될 것 같다.


이외에 조금 특이한 작성법은 mCommentIds와 mComments로, 2 개의 List를 사용한 것이다.

mComments는 Comment형으로 선언되었고[라인 8] 댓글 정보를 저장하는 리스트이다 [라인 27].

mCommentIds는 문자열(String)로 선언되었고[라인 7] 댓글 번호를(getKey) 가지고 있다 [라인 26].


수정(onChildChanged)과 삭제(onChildRemoved) 이벤트가 발생되었을때, 

수정된 행의 정보만 가지고 있기 때문에 수정된 행이 mComments의 몇 번째 글인지 알 수가 없다.

몇 번째 인지 알아야 mComments에서 수정하거나 삭제하고 RecyclerView도 갱신할 수 있다.

몇 번째 인지 알기 위해서는 반환된 정보에 있는 글번호(key)로 mComments의 개수만큼 돌면서 같은 값을 찾으면 된다.

다소 복잡한 코드를 작성해야 하는데,

이러한 코드를 줄이기 위해 mComments와 동일한 순서로 mCommentIds에 글 번호를 저장하고

mCommentIds의 indexOf로 찾은 위치가 mComments에서의 위치인 것이 된다.

mCommentIds는 문자열로 구성되니 indexOf를 사용할 수 있지만,

mComments는 Comment로 구성되어 indexOf와 같은 검색 기능을 사용할 수 없기 때문에 이렇게 구현 한 것 같다.




구글에서는 Firebase에서 제공하는 다양한 기능을 문서예제로 제공하고 있다.

제공되는 예제를 이해 하면서 Firebase 사용법 뿐만 아니라 Android 개발에 유용한 방법 등을 익힐 수 있다.

여기에서는 Firebase 예제 중에서 realtime database 예제를 쉽게 이해 할 수 있도록 정리하였다.

Firebase realtime database 예제는 데이터 입출력(CRUD) 예제로 일종의 게시판 예제로,

보다 쉽게 이해하기 위해 다음과 같이 3단계로 나누어 정리하였다.


1. Firebase realtime database 실행과 테스트

2. Firebase realtime database 구조와 프로그램 구조

3. Firebase realtime database 코드

4. Firebase realtime database를 Cloud Firestore로


Firebase 실시간 데이터베이스의 기본 개념이나 사용법에 대해서는 따로 정리하지 않는다.

관련된 상세한 설명은 구글의 문서Node.js로 정리한 내용을 참고하면 된다.


먼저, 구글에서 제공하는 예제 소스를 GitHub에서 다운로드 받는다.

CMD 창에서 clone https://github.com/firebase/quickstart-android.git 로 받아도 되고

그림과 같이 Git GUI 도구를 이용해도 된다.

Android studio에서 받아도 되지만,

Android studio로 받는 경우 여러가지 설정과 라이브러리 다운로드 등이 자동으로 진행되면서 시간도 오래 걸리고 제대로 작동하지 않다.


다운받은 예제는 다음 그림과 같이 하나의 예제가 아니고 여러 개의 예제로 구성되어 있다.

각 폴더를 클릭해 보면, 각각의 폴더에 Android 프로젝트에 필요한 파일(gradle등)이 있는 것을 볼 수 있다.

각 폴더가 하나의 예제이다.


이 예제 중에서 database가 실시간 데이터베이스(Realtime database) 예제이므로,

Android studio에서 database 폴더을 연다.



예제를 열면 다음 그림의 하단과 같이 오류가 발생하는 것을 볼 수 있다.

이 오류는 Firebase와 연동하지 못해서 발생하는 오류 (google-services.json 파일이 없음)이다.


상세한 설정 방법은 Firebase 메신저 예제 설정의 중간 내용을 참고 하면 된다.

Firebase를 처음 사용하는 경우에는 Firebse 서버에서 설정이 필요한데,

이 문서의 내용 인증과 실시간 데이터 베이스 관련 설정을 참고하면 된다.


간단하게 정리하면 Tools > Firebase 메뉴를 눌러서 연동한다.


Firebase와 연동후 실행하면 다음 그림과 같이 로그인 화면이 실행된다.

가입된 회원이 없기 때문에 아이디와 비번을 입력한 후에 회원 가입(Sign Up) 버튼을 선택한다.


회원 가입과 동시에 로그인 되고, 다음 그림과 같이 글 리스트 화면이 실행된다.

최신글 (Recent), 내가 작성한 글(My Posts),  내가 작성한 인기 글(My Top Posts)이 TabLayout으로 개발되어 있다.


하단의 플로팅 버튼을 눌러서 다음 그림과 같이 새글을 작성한다.

제목과 게시글 내용을 작성한 뒤에 하단의 플로팅 버튼을 눌러서 저장한다.

글이 저장된 후, 화면이 최신글 리스트로 이동한다.


작성된 글 리스트에서 글 하나를 선택하면,

선택된 글의 상세 내용을 볼 수 있고, 댓글(comment)을 작성할 수 있다.


이상으로 Firebase 실시간 데이터베이스(Realtime database) 예제를 실행시켜 보았다.

제법 많은 기능이 있는 것 같지만 다음과 같이 간단한 게시판 구조로 구현되어 있다.

Fireabse 콘솔로 접근해 보면

사용자(users), 사용자별 글(user-posts), 작성된 글(posts)이 생성되어 데이터가 저장된 것을 볼 수 있다.

사용자별 작성 글(user-posts)과 전체 작성 글(posts)은 동일한 구조와 데이터를 가진다.

사용자별 작성 글(user-posts)은 한 사용자가 작성한 모든 글을 저장하는 스키마이고,

전체 작성 글(posts)은 사용자에 관계없이, 사이트내의 모든 글을 저장하는 스키마이다.

사용자가 하나의 글을 작성하면 사용자별 작성 글(user-posts)과 전체 작성 글(posts)에 저장하고,

사용자별 글(user-posts)은 사용자가 작성한 글이기 때문에 사용자 UID 별로 작성된 글(posts)을 저장한다.

(사용자 UID는 사용자 고유 번호를 의미한다.)

Firebase 실시간 데이터 베이스는 검색 기능이 거의 없기 때문에 이러한 방식으로 데이터를 저장한다.


Android Studio에서 파일을 살펴보면, Espresso 기반의 UI 테스트 파일을 볼 수 있다.


newPostTest 파일을 선택하고, 마우스 오른쪽 버튼을 클릭해서 Run newPostTest을 실행한다.

그림처럼 테스트에 실패하는 경우도 있고, 별 문제없이 테스트가 진행되는 경우도 있다.

위 그림과 같이 실패한 경우에는, 메시지 창(run)에서 오류가 발생한 [newPostTest: 78]을 클릭해서 오류가 발생한 부분으로 이동한다.


해당 코드의 앞에 다음과 같이 2초 정도 후에(Thread.sleep(2000)) 코드가 실행되도록 작성한다.

사용하는 컴퓨터의 사양이 느리면 UI 실행이 느려서 오류가 발생한다.

따라서 2초 정도 지연 시간을 준 것이다.

다시 테스트를 진행하면, 자동으로 랜덤하게 사용자가 생성되고 글이 작성되는 것을 볼 수 있다.


Espresso의 사용법은 쉬우니, 이 동영상을 참고하거나 자료를 찾아보면 된다.

간단하게 정리하면,

Run > Record Espresso Test 메뉴를 실행하고,

해당 앱에서 여러가지 실행을 하면 자동으로 기록해서 테스트 코드(newPostTest)가 작성된다.


테스트 후에 정상적으로 실행하려고 해도 테스트로 실행된다.

Android Studio 우측 상단에 있는 실행 버튼 옆의 선택 상자에서 app을 선택하고 실행하면 제대로 실행이 된다.


이상으로 Firebase 예제 중에서 realtime database 예제를 실행하는 방법을 정리하였다.

다음으로, 예제의 database 구조와 프로그램 구조, 코드 특성 등을 정리한다.

보안(Rule)등과 관련된 내용은 여기에서 정리하지 않으니 Git 예제 설명이나 Firebase 문서를 참고하면 된다.









앞서서 Android기반 Firebase 실시간 데이터베이스(Realtime database) 예제를 실행하고 테스트 하는 것을 정리하였고,

이번에는 예제 소스를 이해하는 데 도움이 될 수 있도록 파일 구조, 데이터 베이스 구조등을 정리한다.

여기에서는 Firebase에 대하여 정리하지 않고, Android 프로그래밍 중심으로 정리한다.

예제를 이해하는데에는 Firebase 실시간 데이터베이스에 알면 조금 더 쉽게 이해할 수 있을 것이다.

관련 문서를 한번 읽어 보길 바란다.


1. Firebase realtime database 실행과 테스트

2. Firebase realtime database 구조와 프로그램 구조

3. Firebase realtime database 코드

4. Firebase realtime database를 Cloud Firestore로


Android Studio에서 패키지 구조를 살펴 보면 다음 그림의 좌측과 같이 제법 많은 파일(클래스)이 있는 것을 볼 수 있다.


각 파일을 열어서 보려면 익숙하지 않아서 이해하기 어렵고,

다음 그림과 같이 각 파일을 기능으로 매핑 시켜서 정리하면 보다 쉽게 이해 할 수 있다.

예제는 4개의 Activity와 3개의 Fragment로 구성되어 있다.

최신글(RecentPostsFragment), 내가 작성한 글(MyPostsFragment), 내가 작성한 인기글(MyTopPostsFragment)은 Fragment로 작성되어

메인 화면 (MainActivity)의 Tablayout에 사용되었다.

메인 화면 (MainActivity)에서 플로팅 버튼(글쓰기)을 선택하면 글작성(NewPostActivity) Activity가 실행된다.

작성된 글 리스트( 3개의 Fragment)에서 하나의 글을 선택하면 글 읽기(PostDetailActivity)가 실행되고,

댓글(comments)을 작성할 수 있다.


조금 더 자세히 정리하면,

최신글(RecentPostsFragment), 내가 작성한 글(MyPostsFragment), 내가 작성한 인기글(MyTopPostsFragment)은 동일한 화면 구조를 가지고 있다.

출력되는 데이터가

최신글은 전체 글 리스트를 시간 순으로 출력한 것이고,

내가 작성한 글은 내가 작성한 글 리스트를 출력한 것이다.

내가 작성한 인기글은 내가 작성한 글 중에서 인기 있는(별을 많이 받은) 글 리스트를 출력한 것이다.

모두 사용자가 작성한 글 리스트를 출력하는 것으로,

Firebase 실시간 데이터베이스에서 가져오는 데이터만 다르고 모든 것이 동일하다.

즉, 조회 조건만 다를 뿐이다.


따라서, 하나의 Fragment에 모든 기능을 구현하고 (PostListFragment)

각각의 파일에서는 이 기능을 상속 받아서 데이터를 가져오는 코드만 적절하게 작성하여 구현했다.


부모 Fragment인 PostListFragment에서 주어진 데이터를 적절한 리스트 디자인(fragment_all_posts)에 맞추어 (ViewHoder) 출력하게 작성한다.


그리고, 데이터를 가져오는 getQuery 메소드를 추상적(abstract)으로 선언하였다.

public abstract class PostListFragment extends Fragment {

    ~~ 생략 ~~
    @Override
    public void onActivityCreated(Bundle savedInstanceState) {
    ~~ 생략 ~~
        Query postsQuery = getQuery(mDatabase);

    ~~ 생략 ~~

    public abstract Query getQuery(DatabaseReference databaseReference);

}

추상적으로 선언된 getQuery메소드를 onActivityCreated 에서 호출하도록 작성하였다.

즉, Activity가 생성될때 필요한 데이터를 가지고 오도록 getQuery를 호출한다.


PostListFragment를 상속받은(extends) 각각의 자식 Fragment에서는 getQuery메소드만 필요에 맞게끔 구현한다(Override).

최신글

 (RecentPostsFragment)

public class RecentPostsFragment extends PostListFragment {
    @Override
    public Query getQuery(DatabaseReference databaseReference) {
        Query recentPostsQuery = databaseReference.child("posts")
                .limitToFirst(100);
        return recentPostsQuery;
    }
}

내가 작성한 글

(MyPostsFragment)

  public class MyPostsFragment extends PostListFragment {
    @Override
    public Query getQuery(DatabaseReference databaseReference) {
        return databaseReference.child("user-posts")
                .child(getUid());
    }
}

 내가 작성한 인기 글

(MyTopPostsFragment)

 public class MyTopPostsFragment extends PostListFragment {
    @Override
    public Query getQuery(DatabaseReference databaseReference) {
        String myUserId = getUid();
        Query myTopPostsQuery = databaseReference.child("user-posts").child(myUserId)
                .orderByChild("starCount");
        return myTopPostsQuery;
    }
}


4의 Activity도 하나의 클래스(BaseActivity)에서 상속받아 구현하였다.


Activity들의 부모로 사용되는 BaseActivity는 Fragment처럼 복잡하게 구성하지 않고 다음 코드와 같이 단순하다.

BaseActivity.java

어떤 처리에 대한 진행 상태를 나타내는 ProgressDialog의 설정을 미리 작성해 두고

보이게(showProgressDialog)하거나 [라인5], 숨기게(hideProgressDialog) [라인15]하는 메소드를 가지고 있다.

또, 모든 Activity에서 사용하는 사용자 UID을 가져오는 기능을 getUid 메소드로 구현해 두었다 [라인21].

즉, 많이 사용하는 기능을 부모에 구현해두고, 자식에서 사용하는 구조로 작성된 것이다.


저장되는 데이터 구조를 정리하면 다음과 같이 4개의 스카마로 작성되었다.


Fireabse 콘솔로 접근해 보면,

사용자(users), 사용자별 글(user-posts), 작성된 글(posts), 코멘트(post-comments)가 생성되어 데이터가 저장된 것을 볼 수 있다.

사용자별 작성 글(user-posts)과 전체 작성 글(posts)은 동일한 구조와 데이터를 가진다.

사용자별 작성 글(user-posts)은 한 사용자가 작성한 모든 글을 저장하는 스키마이고,

전체 작성 글(posts)은 사용자에 관계없이, 사이트내의 모든 글을 저장하는 스키마이다.

사용자별 글(user-posts)은 사용자가 작성한 글이기 때문에 사용자 UID 별로 작성된 글(posts)을 저장한다.


사용자별 작성 글(user-posts)과 전체 작성 글(posts)에 있는 stars 필드는 각각의 글에 별(start)을 누른 사용자들의 uid를 저장한다.

어떤 글에 대하여 내가 별을 선택한 경우 다시 해제하는 기능을 구현하는데 사용한다.

어떤 글에 대하여 내가 별을 선택하면 stars 필드에 내 uid가 추가된다.

이후에 다시 그 글을 읽으면, 내가 별을 선택했다는 의미로 별에 색이 채워져서 표시된다.

즉, 해당 글의 stars 필드에 내 uid가 있으면 표시를 한다.

다시 별을 클릭하면(취소하면) stars 필드에서 내 uid를 제거하고 채워진 별이 외곽선만 남게 된다.

좋아요(별)를 선택했다가 취소하는 기능을 구현하는 것이다.


코멘트(post-comments)는 전체 작성글 번호마다 각각의 코멘트가 작성된 순서데로 저장되고

사용자(users)에는 이메일과 이름이 저장되어 있다.

Firebase에서 제공되는 인증(Authentication) 기능이 사용자를 관리하는 기능이지만,

다른 사용자에 대한 정보에 접근을 관리자(admin)으로 제한하기 때문에 사용자의 로그인 기능으로만 사용하고,

글 리스트에서 작성자 이름을 출력하는 등의 다른 사용자에 대한 정보를 가져오는 것은 별도의 사용자(users) 스키마에 저장해서 구현한다.


이상으로 구글에서 제공하는 Firebase realtime database 기반의 Android 예제를 정리하였다.

모바일에서 많이 사용되는 서비스를 구현한 것으로 제법 재미있게 작성되었다.

다만, 작성한 글을 삭제하거나 수정할 수 있는 기능이 구현되어 있지 않다.

최근에 작성한 코멘트가 위에 있는 것이(Descending) 좋겠지만 마지막에(Ascending) 출력된다.

정리된 내용을 기초 개념으로 하여 미 구현된 기능을 구현하거나 보강하면 실력향상에 도움이 될 것 같다.


[Tip] 사용자 입력 값 체크하기

SignInActivity의 validateForm 함수는 개발에 유용한 코드가 사용되었다.

private boolean validateForm() {
boolean result = true;
if (TextUtils.isEmpty(mEmailField.getText().toString())) {
mEmailField.setError("Required");
result = false;
} else {
mEmailField.setError(null);
}

if (TextUtils.isEmpty(mPasswordField.getText().toString())) {
mPasswordField.setError("Required");
result = false;
} else {
mPasswordField.setError(null);
}

return result;
}

사용자가 입력한 값(getText)이 비었는지 확인하고 (TextUtils.isEmpty)

비었을 경우 다음과 같이 입력하라고 알려주는(setError) 기능이 사용되었다.





DirectTalk9은 Firebase 학습을 위한 응용 예제로,

이전에 정리한 Node.js기반의 Firebase 게시판 만들기를 Android 버전으로 응용한 것으로,

Firebase의 실시간 데이터 베이스(Realtime Database)기능을 이용하여,

사용자들이 실시간으로 대화 할 수 있도록 만든 커뮤니케이션 도구이다.

(사실 메신저도 게시판이라는...)


2018년 8월에 실시간 데이터 베이스(Realtime Database)을 Cloud Firestore로 변환하였고,

기존의 실시간 데이터 베이스는 realtime이라는 브랜치로 저장해 두었다.


현재는 텍스트로 1:1 채팅과 그룹 채팅이 가능하다.

아직 개인별 친구 리스트 기능을 구현하지 않아서, 전체 사용자 리스트가 출력되기 때문에

카카오톡처럼 일반 사용자가 사용하기 보다는

조직 구성원 전체가 나오는 기업용 메신저에 적합할 것 같다.


DirectTalk9을 안드로이드에 설치하는 것은 해킹 시도 등의 오해를 일으킬 수 있기 때문에 데모나 별도의 apk 파일은 제공되지 않고,

github에서 소스를 다운로드 받아서 직접 실행해야 한다.

설치 방법은 github에서 DirectTalk9 소스를 다운로드 받고, Firebase에 프로젝트를 설정하고 이 둘을 연동하면 된다.

github에서 DirectTalk9 소스를 다운로드 받고, Firebase와 연동하는 것은 여기에 정리하였고,

Firebase 콘솔에서 Firebase의 여러 기능을 설정하는 것은 따로 정리하였다.

사용법이라 내용이 많지 않지만, 이미지가 많아서 별도로 작성하였다.

설치를 완료하고 실행하면 다음 그림과 같이 로그인 화면이 나타난다.

별도의 회원 가입창이 없고

로그인 페이지에서 아이디(이메일)와 비번을 입력하고,

하단의 가입(SingUp) 버튼을 누르면 회원 가입이 되고 로그인 상태가 되어 앱을 사용할 수 있다.


상단에 사용자 리스트, 채팅방 리스트, 개인 정보 설정을 할 수 있는 세개의 아이콘이 나타난다.

사용자 리스트, 채팅방 리스트는 첫 그림을 참고하고,

개인 정보 설정 화면은 다음과 같다.

대화에 사용할 이름(name, displayname), 상태 메시지를 수정할 수 있다.

이미지를 터치하면 개인 사진을 등록할 수 있다.

저장(save) 버튼을 선택해서 수정한 내용을 저장한다.

비밀 번호를 변경할 경우 Change Password 버튼을 눌러 별도의 창에서 수정할 수 있다.


채팅 예제를 실행해 보려면 다수의 사용자가 필요하다.

상단의 툴바()를 눌러서 로그아웃(logout) 메뉴을 실행한다.



다시 로그인 창에서 다른 사용자로 가입해서 채팅을 진행하면 된다.


DirectTalk9는 Firebase의 Realtime Database를 사용하는 버전과 Cloud Firestore(beta)를 사용하는 버전으로 개발되었다.


Realtime Database 버전에서 데이터를 저장하는 기본 구조는 다음과 같다.

크게 사용자 정보를 저장하는 users와 채팅(방) 정보를 저장하는 rooms로 구성하였다.

users는 사이트 회원 정보로 사용자 고유번호(uid), 아이디, 이름 등으로 구성한 일종의 회원 테이블이다.

로그인과 회원에 대한 기본 정보는 Firebase 인증(Authentication)으로 처리하지만

Firebase 인증으로 처리되지 않는 정보들을 users 스키마에서 저장하고 관리한다.


rooms는 채팅 정보를 관리하는 스키마로,

메시지(messsge), 마지막 메시지(lastmessage), 채팅방 참여자(users), 메시지를 읽은 사용자(readusers)로 구성된다.

메시지(messsge)는 사용자가 작성한 메시지로

메시지 문장(msg), 메시지 종류(텍스트, 이미지, 파일), 작성시간(timestamp), 작성자(uid)로 구성했다.

메시지를 읽은 사용자(readusers)는 메시지별 안 읽은(unread) 사용자 수(참여자수-읽은 사용자수)를 보여주는데 사용한다.

마지막 메시지(lastmessage)는 메시지(messsge)와 동일한 구조를 가지고,

채팅방 리스트를 출력할 때 마지막 메시지 내용과 시간을 보여주기 위해 사용했다.

rooms의 users는 채팅방 참여자로 사용자의 uid 값을 키(필드)로 사용한다.

unread는 채팅방 사용자들이 읽지 않은 메시지의 합계를 의미하고,

unread도 users처럼 사용자 uid를 키 값으로 구성하였다.


마지막으로 이미지와 파일을 전송할 경우, 파일명(filename)과 파일크기(filesize)를 채팅방별로 files에 저장한다.


Cloud Firestore 버전에서 데이터를 저장하는 기본 구조는

Realtime Database과 비슷한 것 같지만 다음과 같이 차이가 있다.

Realtime Database에서는 채팅방별 마지막 메시지(lastmessage), 읽지 않은 메시지(unread), 채팅방 참가자(users), 이미지나 파일(files)을 별도의 Json 자식으로 구현하였다.

Cloud Firestore에서는 컬렉션(collection)으로 구현해야 하지만

컬력션 기능이 제한적이고, 단순 데이터는 배열이나 Object(Map)가 효율적이라 다르게 구현했다.

채팅방별 마지막 메시지(lastmessage)와 이미지나 파일(files)은 합쳐서 채팅방(rooms)의 필드로,

읽지 않은 메시지(unread)는 채팅방 참가자(users)들과 합쳐서 배열로 구현하였다.

메시지(messages)에서도 별도로 저장하던 파일을 같이 저장하도록 구성하였다.

그리고 메시지(messages)는 채팅방(rooms)의 컬렉션으로 구성하였다.

Realtime database와 Cloud Firestore의 구성에 대한 또 다른 설명은 여기에서 얻을 수 있다.

이상으로 DirectTalk9의 설치와 사용법 등을 정리하였다.

DirectTalk9은 Firebase 학습을 위한 응용 예제로 시작했지만

안드로이드 개발을 쉽게 하기 위한 템플릿 프로젝트(필요한 기능을 미리 구현해 둔 뒤에, 실제 프로젝트에서 복사/붙여넣기하는 용도)로

필요한 기능들을 계속 추가할 예정이다.



Android Studio 3에서 Github의 소스를 다운 받아 설치하는 방법을 정리한다.

Github의 소스를 다운 받아 설치하는 방법은 아주 간단하기 때문에 쉽게 익힐 수 있다.

다만, Firebase를 기반으로 하는 예제는 Firebase에 연동을 시켜주는 과정이 필요해서 조금 복잡하게 느껴질 수 있다.

여기에서는 Firebase기반의 메신저인 DirectTalk9를 대상으로 정리한다.


DirectTalk9의 Github 구성이 2018년 9월 1일자로 두가지 버전으로 나누었다.

Github의 branch를 기존에 제작한 Realtime Database 기반 예제인 realtime과

새로 추가한 Cloud Firestore 기반 예제인 master (기본)로 제작하였다.

Github에서 다운로드 받을 때 이 사항을 고려해야 하고, branch를 바꾸는 방법은 이 페이지 하단 부분에 있다

DirectTalk9의 Github주소는 다음과 같다.

https://github.com/gujc71/DirectTalk9


먼저, 그림과 같이 Android Studio 시작 화면에서 Git을 선택한다.

또는 Android Studio 실행 화면에서 File > New > Project from Version Control > git 메뉴를 선택한다.

Git이 설치되어 있어야 한다.

[주의] Git이 설치되지 않은 경우 Git을 다운받아 설치하고,

File > Settings 메뉴을 실행하여, 왼쪽 설정 중에서 Version Control > Git을 선택한다.

Path to Git executable에 설치한 git의 실행파일 위치를 지정한다.


다음으로 다운받을 Github주소를 입력하고, [Clone] 버튼을 선택한다.


다음 화면들은 [Yes]나 [OK]를 선택하고 넘어간다.




여기까지 진행하면 Github에서 소스를 다운 받은 것이다.

그냥 git 메뉴를 실행하고, Github 주소를 입력해 주면 된다.

일반적인 예제는 이 상태에서 실행하면 된다.

개인의 설정에 따라 다르겠지만 Gradle 버전업을 할 것인지 물어보면 각자 알아서 결정하면 된다.


Firebase기반의 메신저인 DirectTalk9는 다음 그림의 하단과 같이 오류가 발생한다.

google-service.json 파일이 없다는 오류로,

Firebase에 접속할 정보가 필요하는 의미이다.


Android Studio의 Tools > Firebase 메뉴를 실행하여, 다음 그림과 같이 Firebase Assistant를 실행한다.

나열된 Firebase의 기능 중에서 아무 것이나 선택하여 세부 메뉴을 실행한다.

여기서는 인증(Authentication)을 선택해서, [Email and password Authentication]을 선택한다.


다음 그림과 같이 접속하는 버튼(Connect to Firebase)이 나타나면 클릭한다.

별문제가 없는 경우 웹 브라우저가 실행되면서 Gmail 로그인을 하라고 한다.

이미 로그인되어 있는 경우,

다음 그림과 같이 Firebase에서 사용할 프로젝트(project)를 선택하는 화면이 나타난다.

새로 생성하거나 (Create new Firebase project)나 기존에 사용던 프로젝트를 선택한다.

여기서는 DirectTalk9 프로젝트를 새로 생성한다.

Android Studio의 Build 메뉴의 Clean Project나 Rebuild Project 메뉴을 실행하고, 앱을 실행하면 된다.


[팁] Firebase와 Android를 연동하는 이상의 설명은 이 링크에서 [Add Firebase to your Android project]라는 짧은 동영상을 참고해도 된다.


앱 실행후 접속 정보를 입력하고,

로그인(Login)이나 회원가입(SignUp)을 선택하면,

Firebase 인증과 데이터 베이스가 설정되어 있지 않아서 오류가 발생한다.

Firebase 설정이 필요한 것으로 상세한 내용은 다른 문서로 정리하였으니 참고하면 된다.


[추가] Android Studio에서 Github의 branche를 이용하기 위해서는 VCS > Git > Branches 메뉴를 실행하면 된다.

실행된 Branches메뉴에서 realtime (Realtime Database)와 master(Cloud Firestore)를 선택할 수 있다.

별도의 선택을 하지 않은 경우 master(Cloud Firestore)가 기본적으로 선택되어 있다.



[주의 1] Android Studio에서 처음으로 앱을 실행할 때 다음 그림과 같은 창이 실행되며

하단에 "Please select Android SDK"란 메시지가 출력될 때가 있다.

이 경우 Android Studio의 File 메뉴에 있는 Sync Project with Gradle Files 메뉴을 실행한 뒤에 다시 실행하면 된다.


[주의 2] Firebase 접속(Connect to Firebase 클릭)시 [Could not parse the android application Module's Gradle Config. Resolve gradle build issues and/or resync.]라는 오류가 발생할 수 있다.


build.gradle(Project) 파일에서 com.google.gms:google-services의 버전을 올려주고, [Sync now]를 실행한다.


[주의 3] Github에서 소스를 다운로드 받은 후에 이상의 복잡한 과정을 거치지 않고,

다음 그림과 같이 Firebase 콘솔에서 google-services.json 파일을 다운로드 받아서 app 폴더에 붙여넣기 해도 된다.


Firebase 콘솔에서 Firebase 프로젝트(DirectTalk9)을 선택한 후에,

프로젝트 설정을 실행하고(그림 상단 좌측),

두번째 탭인 클라우드 메시징 탭을 선택한다 (현재 그림).

하단 우측에 있는 [앱 추가] 버튼을 클릭하여 앱(DirectTalk9)을 추가한 뒤에

google-services.json버튼을 클릭하면 google-services.json 파일을 다운로드 받을 수 있다.





앞서서 다운로드 받은 DirectTalk9을 제대로 실행시키기 위해,

Firebse 콘솔에서

인증(Authentication)과 데이터 베이스(Realtime Database), 푸시 서버(Google Cloud Messaging)에 관련된 설정을 정리한다.


먼저, 웹 브라우저로 Firebase콘솔에 접속해서 앞서 생성한 DirectTalk9 프로젝트를 선택한다.


왼쪽의 Firebase 메뉴 중에서 Authentication(인증)를 선택하고,

화면 중앙의 하단에 있는 [로그인 방법 설정]을 선택한다.


그림과 같이 나열된 로그인 방법 중 [이메일/비밀번호]의 상태 수정을 실행한다.

Firebase에서는 전화, Gmail, Facebook등으로 로그인하고 관리하는 기능을 제공한다.

여기서는 아이디(이메일)와 비번을 입력해서 로그인하는 방식을 사용한다.


실행된 팝업 창에서 [사용설정]을 가능으로 바꾸어주고 [저장]한다.


[이메일/비밀번호]의 상태가 [사용 설정됨]으로 변경된 것을 확인 할 수 있다.

이제 Firebase에서 제공하는 로그인(인증) 기능을 사용할 수있다.


다음으로 데이터 베이스 설정을 진행하기 위해, Firebase 콘솔의 왼쪽 메뉴 중에서 Database를 선택한다.

Firebase에서는 Cloud Firestore와 Realtime Database의 두 가지 데이터 베이스를 제공하고 있다.


DirectTalk9 메신저는 두가지 모두 지원하도록 제작되었다 .

github의 branch가

realtime이면 Realtime Database 버전의 메신저

master(기본)이면 Cloud Firestore 버전의 메신저로 사용한다 (2018년 9월 1일자로 추가됨).


먼저, Realtime Database로 사용하려면

웹 브라우저의 스크롤을 내려서 Realtime Database의 [데이터 베이스 만들기]를 선택한다.


편의를 위해 [테스트 모드로 시작]을 선택하고 [사용 설정] 버튼을 눌러서 완료한다.


다음 그림과 같이 나타나면 실행할 준비가 된 것이다.


Cloud Firestore 버전으로 사용할 경우에는

데이터 베이스 선택 화면에서 Cloud Firestore를 선택한다.

다음 그림이 실행되면 편의를 위해 보안 규칙을 [테스트 모드로 시작]으로 하고 [사용설정]을 선택한다.

Github에서 소스를 다운 받은 후 branche 변경을 하지 않은 경우

기본 branche는 master로 Cloud Firestore 버전이 설치된다.

[팁] Cloud Firestore를 활성화 하기 어려운 경우 이 링크에서 [Enable Firestore in Firebase] 동영상을 보면 쉽게 이해 할 수 있다.


DirectTalk9을 실행하고,

아이디와 비밀번호를 입력한 뒤에 SignUp 버튼을 눌러서 회원가입부터 먼저 실행한다.



마지막으로 푸시 기능(Google Cloud Messaging)을 사용하도록 설정한다.

앱이 실행 중이지 않을 때, 메시지를 수신하여 사용자에게 알려주는 기능으로, 필요하지 않은 경우 다음 설정을 따라하지 않아도 된다.


Firebase 콘솔에서 프로젝트 설정을 실행한다.


설정 화면에서 [이전 서버키] 값을 복사한다.


Firebase 콘솔에서 복사한 값을

Android Studio 에서 ChatActivity.java파일을 열어서 SendGCM 함수의 Authorization 헤더에 key 값으로 넣어준다.

주석처리된 SendGCM함수의 주석을 제거하고

DirectTalk9을 두 개 실행해서 메시지 수신을 확인하면 된다.





그림과 같이 입력한 값을 보여주는 입력상자(EditText) 하나와 15개의 버튼으로 구성된 간단한 계산기를 만들면서 안드로이드 프로그래밍 기초를 정리한다.

정리 방법은 다음과 같이 단계별로 예제를 구현하고, 예제 소스는 GitHub에서 받을 수 있다.

  1. 계산기 디자인과 기본 코드 (branch: master)
  2. 생성한 클래스를 이벤트 리스너로 사용 (branch: step1)
  3. 코드 줄이기  (branch: step2)
  4. 실제 계산 기능 구현 (branch: step3)
  5. 후위 표기법으로 계산 기능 구현 (branch: step4)
  6. 동적 생성 (branch: step5)

각각의 단계별 예제는 GitHub의 branch에서 받을 수 있다.


먼저, 계산기를 그림과 같이 디자인 하면서

레이아웃(Layout) 사용법과 디자인된 개체에 이벤트를 연결하는 방법을 익힌다.


안드로이드 스튜디오에서 새로운 프로젝트를 생성하고,

디자인을 위해 activity_main.xml (app/src/main/res/layout/) 파일을 열어서 다음과 같이 코드를 작성한다.


전체코드

전체 레이아웃을 LinearLayout으로 지정하여 생성하고 [라인 1],

전체 레이아웃의 자식으로 EditText(입력상자) 하나 [라인 10]와 4개의 LinearLayout를 생성한다 [라인 18~].

하위에 있는 4개의 LinearLayout은 각각 4개(마지막은 3개)의 버튼을 자식으로 가진다 [라인 23~].

자식으로 가진다는 의미는 XML에서 <LinearLayout>와 </LinearLayout>사이에 코딩 한다는 의미이다.


간단하게 LinearLayout, EditText, Button의 주요 속성을 정리한다.

LinearLayout의 layout_width와 layout_height는 위젯(widget)의 크기를 의미한다.

너비(width)와 높이(height)를 지정하는 것으로 match_parent나 wrap_content로 지정하거나 직접 값을 dp단위로 입력할 수 있다.

match_parent는 부모 크기에 100%로 맞추는 것이고,

wrap_content는 그 위젯의 내용물(content)에 맞추어 자동으로 크기를 조절하는 것이다.

여기에서는 스마트폰의 해상도에 맞게 크기가 조절되도록 값을 직접 입력하지 않고,

match_parent나 wrap_content를 이용하였다.


최상위 LinearLayout은 너비와 높이를 모두 부모에 맞추었다 [라인 5, 6].

최상위 LinearLayout은 부모가 없기 때문에 휴대폰 크기에 맞추

orientation은 자식 위젯의 나열 방향을 의미하는 것으로

EditText와 4개의 LinearLayout는 수직(vertical)으로 쌓았고, 각 버튼들은 수평(horizontal)으로 쌓았다.

LinearLayout외에도 FrameLayout,TableLayout, GridLayout, RelativeLayout 등이 있다.


EditText는 사용자가 입력하는 값을 받는 위젯으로 너비와 높이는 레이아웃과 동일하게 사용한다 [라인 10].

너비는 화면 너비만큼[라인 12], 높이는 컨텐츠 높이로 지정하였다 [라인 13].

id는 Java에서 XML에 작성된 위젯을 찾기 위한 식별자를 의미한다 [라인 11].

실제 Id의 값은 @+id/위에 지정되고, 지정된 id는 안드로이드에서 관리된다.

gravity는 입력한 값에 대한 정렬을 의미하는 것으로 왼쪽(left), 오른쪽(right), 위쪽(top) 등으로 지정할 수 있다 [라인 16].


Button의 id, 너비, 높이는 EditText나 Layout과 동일한 개념이고,

버튼에 표시할 텍스트를 나타내는 text 속성과 가중치(layout_weight) 속성 등이 있다.

현재 Button이 4개 생성되었고, 버튼의 너비는 컨텐츠 길이만큼(wrap_content) 생성하도록 지정하였다.

이경우 화면이 Button의 너비 합보다 크기 때문에 우측에 여백이 생기게 된다.

이 여백을 각 Button의 가중치만큼 나누어 가지도록 하는 속성이 layout_weight이다 [라인 27,~].

모두 1씩 지정하였으니 같은 비율로 커지게 된다.


디자인을 마쳤으니, MainActivity.java (/src/main/java/com/gujc/calculatorsample/)을 열어서 다음과 같이 작성한다.


전체코드

먼저 사용할 위젯을 변수로 지정하여 Java에서 제어한다.

EditText는 TextView 클래스로 선언한 editText (인스턴스)변수로 제어한다 [라인2].

이 변수와 EditText 위젯을 연결하기 위해 findViewById 함수를 사용한다 [라인10].

위젯 생성시 지정한 id를 이용하여 해당 위젯을 찾아서 변수에 할당하고,

이 클래스 변수를 이용하여 값을 가지고 오거나 지정한다.


15개의 버튼 중 2개의 버튼만 코드로 작성했다 (나머지는 각자).

button0은 클래스내에서 전역 변수로 [라인 3],

button1은 onCreate 함수(이벤트)내에서 사용하는 지역 변수로 선언하였다 [라인20].

개념을 위해 이렇게 한 것으로 용도에 따라 전역으로 사용할 것인지, 지역으로 사용할 것인지 결정하면 된다.


setOnClickListener를 이용하여 각 버튼을 클릭했을 때 [라인 14, 22],

editText에 버튼의 값이 찍히도록 작성하였다.

클릭된 버튼의 값을 얻기(getText) 위해 클릭된 버튼을 알아야 한다.

여기서는 button0, button1을 각각 다르게 두가지 방식으로 작성했다.

button0은 클릭하면 button0의 값을 가지고 오도록 했고 [라인 16],

button1은 클릭 이벤트의 파라미터를 이용하였다 [라인 24, 25].

클릭 이벤트의 파라미터는 클릭된 개체(버튼)를 넘겨 주는데, Button 클래스가 아닌 클래스들의 부모(View)로 넘겨준다.

따라서 이것을 버튼으로 형변환 한 뒤에 사용하면 된다 [라인 24].

button0 코딩보다 button1 코딩이 더 깔끔하고 방어가 잘 된 코딩인데

그 이유는 나머지 13개의 버튼에 대하여 클릭 이벤트를 작성해 보면 알 수 있다.

버튼을 클릭하면 클릭된 버튼의 값만 출력된다.

앞서 클릭된 모든 버튼의 값이 이어서 출력되도록 작성해 보길 바란다.


모든 버튼에 클릭 이벤트를 작성하면,

두 번째 방식아 코드 양은 한 줄 더 많지만, 복사/붙여넣기 하고 수정하는 글자 수가 적다는 것을 알 수 있다.

이외에 더 많은 장점이 있다.

두 번째 예제로 이 장점을 정리하고, 이벤트 코드 양을 줄이는 방법을 정리한다.


전체 코드

앞서의 예제와 다르게 이상의 코드는 위젯을 찾고 이벤트 리스너를 등록하는 코드에서 button0, button1 등의 변수를 사용하지 않았다 [라인 2-16].

클래스 변수로 뭔가를 할 경우에는 사용하지만, 그렇지 않다면 생략하고 사용해도 된다.


첫 예제와 다른 것은 이벤트 클래스를 생성해서 사용하는 것이다.

첫 예제는 다음 코드처럼 OnClickListener 클래스를 생성(new)해서 변수에 담지 않고 버튼의 이벤트 리스너로 지정했다.

             button0.setOnClickListener(new View.OnClickListener() {

이번 예제는 생성해서 mClickListener 변수에 담아서 사용한다 [라인 19].

             Button.OnClickListener mClickListener = new View.OnClickListener() {

그리고 이 mClickListener변수를 모든 버튼에 지정했다.

즉, 15개의 버튼이 하나의 이벤트 리스너로 처리 되는 것이다.


OnClickListener 이벤트에서는 사용자가 클릭한 버튼을 파라미터(View)로 제공한다 [라인 20].

클릭한 버튼(view)의 고유번호(id)를 이용하여(getId()) [라인 21],

클릭한 버튼이 Button0이면 입력상자(editText)에 0을 넣고,

클릭한 버튼이 Button1이면 입력상자(editText)에 1… 등을 넣어 준다.

코드를 2개의 버튼에 대해서만 작성했지만, 15개 버튼에 대해서 모두 작성해야 한다.


이렇게 클릭된 버튼이 무엇인지 파악해서 필요한 행동을 하게 작성하는데 switch 문(다수의 if)이 사용되었다 [라인 22, 25].

이러한 코드는 일반적인 코드 작성법이지만 15개의 버튼에 대해서 작성한다면 아주 많은 코드를 작성해야 한다.

이 부분에 정리는 뒤에 하고 여기서는 넘어간다.


여기서 id (getId())는 고유한 숫자 값이다 [라인 21].

앞서 xml에서 지정한 id는 문자열이지만,

이 값을 안드로이드가 숫자로 변환해서 가지고 있고, 개발자가 지정한 문자는 일종의 상수처럼 선언되어 사용된다.

그래서 id를 사용할 때 “button0”처럼 사용하지 않고, R.id.button0으로 사용한다 [라인 22, 25].


이 예제 코드는

15개의 findViewById() 함수와

15개의 case 문 (실제로는 if문)이 사용되었다.

더욱이 이 예제는 버튼을 클릭하면 해당 값만 출력되지만 다음 코드와 같이 누적되게 작성하면 더 복잡하게 보인다.

           editText.setText(editText.getText().toString()  + "0");

첫 예제에서 클릭된 변수를 인식하는 2번째 방식을 이용하여 [라인 24],

복잡한 코드를 다음과 같이 줄여서 구현할 수 있다.

    Button.OnClickListener mClickListener = new View.OnClickListener() {
        public void onClick(View view) {
            Button button= (Button) view;
            editText.setText(editText.getText().toString() + button.getText().toString());
        }
    };


개념은 아주 간단하다.

클릭된 버튼이 무엇인지 알아야 했던 이유는

해당 버튼이 (view.getId())

무엇인지 (button0, button1, button2...) 알아서 해당 값(0, 1, 2 ...)을 지정하기 위한 것이었다.

1번 버튼이 클릭되면 값 1을 지정하기 위한 것이다.

이 1 값은 버튼의 텍스트(text)로 지정되어 있으니,

버튼이 클릭되면 해당 버튼의 값(getText())을 editText의 값에 넣어준다 (setText()).


세 번째 예제로, 15번이 사용된 findViewById() 함수를 줄여본다

두 번째 예제의 코드를 살펴보면,

버튼의 id가 button0, button1, button2, button? … 으로 숫자만 바뀌는 것을 알 수 있다.

따라서, 반복문을 이용하여 0부터 14까지 15번을 반복하고,

해당 id를 문자열로 생성해서 가지고 올 수 있으면, 다음과 같이 몇 줄로 구현할 수 있다.

    for(int i=0; i<15; i++) {
        String buttonID = "button" + i;
        int resID = getResources().getIdentifier(buttonID, "id", getPackageName());
        findViewById(resID).setOnClickListener(mClickListener);
    }

전체 코드

"button" 문자열과 변수 i의 값을 이용하여, 15 버튼의 id를 문자열로 생성한다 (buttonID).

앞서서 몇 번 정리했지만,

안드로이드는 xml에서 문자열로 지정한 id를 고유 번호를 부여해서 따로 관리하기 때문에 변수buttonID의 값으로 찾을 수 없다.

따라서 리소스 정보에 접근할 수 있는 getResources()를 이용하여, 지정된 문자열에 맞는 id 값을 받고 (resID),

이 id를 findViewById()함수로 찾아서 구현한다.


안드로이드(Android) 프로그래밍은 간단한걸 구현해도 제법 많은 코드가 필요하다.

그래서 Kotlin 등이 각광 받을 테지만

이상의 방법과 같이 작성하면

Android를 사용하든 Kotlin을 사용하든, 쉽고 가독성 좋은 코드를 작성할 수 있을 것이다.


다음으로 실제 계산하는 기능을 구현한다.



부록

지금까지 Java(Android) 사용법과 코드를 줄이는 방법을 정리하였다.

이번에는 화면(XML)에서 코드를 줄이는 방법을 간단하게 정리한다.


다음의 activity_main.xml 파일을 자세히 보면,

모든 위젯은 너비(layout_width)와 높이(layout_height)가 있고,

Layout은 orientation가,

Button은 layout_weight가 동일하게 (“=” 버튼 예외) 사용된 것을 알 수 있다.

        ~~ 생략 ~~   
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <Button
            android:id="@+id/button7"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="7"
            android:textSize="14sp" />

        <Button
            android:id="@+id/button8"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="8" />
        ~~ 생략 ~~   

전체코드

이렇게 디자인과 관련된 코드는 웹 개발처럼 스타일(Style)로 미리 지정해서 사용하면 쉽게 사용할 수 있다.

app/src/main/res/values/styles.xml 파일을 열어서 다음과 같이 작성한다.

<resources>
    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

    <style name="layoutStyle">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:orientation">horizontal</item>
    </style>
    <style name="buttonStyle">
        <item name="android:layout_width">wrap_content</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:layout_weight">1</item>
    </style>
</resources>

전체코드

layoutStyle은 LinearLayout에 공통으로 사용할 스타일이고

buttonStyle은 Button에 공통으로 사용할 스타일이다.

공통으로 사용할 스타일을 styles.xml 파일에 미리 등록해서 사용한다.


그리고, 다음과 같이 activity_main.xml 파일을 수정한다.

        ~~ 생략 ~~   
    <LinearLayout  style="@style/layoutStyle">
        <Button
            android:id="@+id/button7"
            android:text="7"
            android:onClick="mClickListener"
            style="@style/buttonStyle"/>

        <Button
            android:id="@+id/button8"
            android:text="8"
            android:onClick="mClickListener"
            style="@style/buttonStyle"/>

        <Button
            android:id="@+id/button9"
            android:text="9"
            android:onClick="mClickListener"
            style="@style/buttonStyle"/>
        ~~ 생략 ~~   
       

전체코드

기존의 반복 코드를 지우고,

style 속성에 layoutStyle, buttonStyle을 지정해서 사용한다.

HTML의 CSS 클래스처럼 사용하는 것이다.


activity_main.xml 파일을 보면,

모든 버튼에 다음 코드가 추가 된 것을 볼 수 있다.

         android:onClick="mClickListener"

이벤트 리스너를 Java에서 지정한 것이 아니고,

HTML처럼 디자인에서 지정하였다.

버튼을 클릭하면(onClick) mClickListener함수를 호출한다.


MainActivity.java 파일을 다음과 같이 수정해야 한다.

    public void mClickListener (View view) {
            Button button= (Button) view;
            editText.setText(editText.getText().toString() + button.getText().toString());
    };

전체코드

기존에는 mClickListener를 OnClickListener 클래스의 변수로 선언해서 사용했는데,

이 클래스 변수를 디자인(xml)에서는 알 수가 없기 때문에

전역(public) 함수로 선연해서 디자인에서 호출할 수 있도록 해준다.




+ Recent posts