Android에서 오프라인 기능 사용 설정

Firebase 애플리케이션은 일시적으로 네트워크 연결이 끊겨도 정상적으로 작동합니다. 또한 Firebase는 로컬에서 데이터를 유지하고, 접속 상태를 관리하고, 지연 시간을 처리하는 도구를 제공합니다.

디스크 지속성

Firebase 앱은 일시적인 네트워크 중단을 자동으로 처리합니다. 오프라인 상태에서는 캐시된 데이터를 사용할 수 있고, 네트워크 연결이 복원되면 Firebase에서 모든 쓰기 작업을 다시 전송합니다.

디스크 지속성을 사용 설정하면 앱의 데이터를 기기에 로컬로 저장하므로 오프라인 상태일 때도 앱이 현재 상태를 유지할 수 있으며, 사용자 또는 운영체제가 앱을 다시 시작하더라도 유지됩니다.

단 한 줄의 코드로 디스크 지속성을 사용 설정할 수 있습니다.

Kotlin+KTX

Firebase.database.setPersistenceEnabled(true)

Java

FirebaseDatabase.getInstance().setPersistenceEnabled(true);

지속성 동작

지속성을 사용 설정하면 Firebase 실시간 데이터베이스 클라이언트가 온라인 상태에서 동기화하는 모든 데이터가 디스크에 유지되고 오프라인 상태에서 사용 가능해지며, 사용자 또는 운영체제가 앱을 다시 시작하더라도 마찬가지입니다. 따라서 캐시에 저장된 로컬 데이터를 사용하여 온라인일 때와 다름없이 앱이 작동합니다. 로컬 업데이트 시 리스너 콜백도 계속 발생합니다.

Firebase 실시간 데이터베이스 클라이언트는 앱이 오프라인일 때 수행된 모든 쓰기 작업을 자동으로 큐에 유지합니다. 지속성을 사용 설정하면 이 큐가 디스크에도 유지되므로 사용자나 운영체제에서 앱을 다시 시작해도 모든 쓰기 작업이 사라지지 않습니다. 앱이 다시 연결되면 모든 작업이 Firebase 실시간 데이터베이스 서버로 전송됩니다.

앱이 Firebase 인증을 사용하는 경우 앱을 다시 시작해도 Firebase 실시간 데이터베이스 클라이언트에서 사용자의 인증 토큰을 유지합니다. 앱이 오프라인일 때 인증 토큰이 만료되면 앱에서 사용자를 다시 인증할 때까지 클라이언트가 쓰기 작업을 일시중지하며, 인증되지 않으면 보안 규칙으로 인해 쓰기 작업에 실패할 수 있습니다.

최신 데이터 유지

Firebase 실시간 데이터베이스는 활성 리스너의 데이터를 동기화하고 로컬 사본을 저장합니다. 또한 특정 위치의 동기화를 유지할 수 있습니다.

Kotlin+KTX

val scoresRef = Firebase.database.getReference("scores")
scoresRef.keepSynced(true)

Java

DatabaseReference scoresRef = FirebaseDatabase.getInstance().getReference("scores");
scoresRef.keepSynced(true);

Firebase 실시간 데이터베이스 클라이언트는 참조에 활성 리스너가 없어도 이러한 위치의 데이터를 자동으로 다운로드하고 동기화합니다. 동기화를 해제하려면 다음 코드를 사용합니다.

Kotlin+KTX

scoresRef.keepSynced(false)

Java

scoresRef.keepSynced(false);

기본적으로 이전에 동기화한 데이터 중 10MB가 캐시됩니다. 대부분의 애플리케이션에서는 이 용량으로 충분합니다. 구성된 크기보다 캐시가 커지면 Firebase 실시간 데이터베이스가 가장 오래전에 사용된 데이터를 삭제합니다. 동기화가 유지되는 데이터는 캐시에서 삭제되지 않습니다.

오프라인으로 데이터 쿼리

Firebase 실시간 데이터베이스는 쿼리가 반환한 데이터를 오프라인일 때 사용하기 위해 저장합니다. 오프라인일 때 쿼리를 작성한 경우 Firebase 실시간 데이터베이스는 이전에 로드한 데이터를 사용하여 계속 작동합니다. 요청한 데이터가 로드되지 않으면 로컬 캐시의 데이터가 로드됩니다. 네트워크에 다시 연결되면 데이터가 로드되고 쿼리가 반영됩니다.

다음은 점수를 저장하는 Firebase 실시간 데이터베이스에서 마지막 항목 4개를 쿼리하는 코드 예시입니다.

Kotlin+KTX

val scoresRef = Firebase.database.getReference("scores")
scoresRef.orderByValue().limitToLast(4).addChildEventListener(object : ChildEventListener {
    override fun onChildAdded(snapshot: DataSnapshot, previousChild: String?) {
        Log.d(TAG, "The ${snapshot.key} dinosaur's score is ${snapshot.value}")
    }

    // ...
})

Java

DatabaseReference scoresRef = FirebaseDatabase.getInstance().getReference("scores");
scoresRef.orderByValue().limitToLast(4).addChildEventListener(new ChildEventListener() {
    @Override
    public void onChildAdded(@NonNull DataSnapshot snapshot, String previousChild) {
        Log.d(TAG, "The " + snapshot.getKey() + " dinosaur's score is " + snapshot.getValue());
    }

    // ...
});

연결이 끊겨서 오프라인으로 전환된 후 앱을 다시 시작했다고 가정해 보겠습니다. 계속 오프라인인 상태에서 앱은 같은 위치의 마지막 항목 2개를 쿼리합니다. 앱이 위 쿼리에서 항목 4개를 모두 로드했으므로 이 쿼리로 마지막 항목 2개가 성공적으로 반환됩니다.

Kotlin+KTX

scoresRef.orderByValue().limitToLast(2).addChildEventListener(object : ChildEventListener {
    override fun onChildAdded(snapshot: DataSnapshot, previousChild: String?) {
        Log.d(TAG, "The ${snapshot.key} dinosaur's score is ${snapshot.value}")
    }

    // ...
})

Java

scoresRef.orderByValue().limitToLast(2).addChildEventListener(new ChildEventListener() {
    @Override
    public void onChildAdded(@NonNull DataSnapshot snapshot, String previousChild) {
        Log.d(TAG, "The " + snapshot.getKey() + " dinosaur's score is " + snapshot.getValue());
    }

    // ...
});

위 예시에서는 Firebase 실시간 데이터베이스 클라이언트가 지속성 캐시를 통해 최고 점수를 기록한 두 dinosaur에 대해 '하위 요소 추가' 이벤트를 발생시킵니다. 그러나 온라인 상태에서는 해당 쿼리를 실행하지 않았으므로 '값' 이벤트는 발생하지 않습니다.

오프라인 상태인 앱에서 마지막 항목 6개를 쿼리하면 캐시된 항목 4개에 대한 '하위 요소 추가' 이벤트가 즉시 발생합니다. 기기가 다시 온라인으로 전환되면 Firebase 실시간 데이터베이스 클라이언트가 서버와 동기화되고 마지막 2개의 '하위 요소 추가' 및 '값' 이벤트가 발생합니다.

오프라인으로 트랜잭션 처리

앱이 오프라인일 때 수행되는 모든 트랜잭션은 큐에 추가됩니다. 앱이 네트워크에 다시 연결되면 트랜잭션이 실시간 데이터베이스 서버로 전송됩니다.

접속 상태 관리

실시간 애플리케이션에서는 클라이언트가 연결되거나 연결이 해제되는 시점을 감지하면 유용한 경우가 많습니다. 예를 들어 클라이언트의 연결이 끊기면 사용자를 '오프라인'으로 표시할 수 있습니다.

Firebase 데이터베이스 클라이언트는 Firebase 데이터베이스 서버와 연결이 끊길 때 데이터베이스에 데이터를 쓰는 데 사용할 수 있는 간단한 기본 요소를 제공합니다. 이러한 업데이트는 클라이언트 연결이 정상적으로 해제되었는지 여부와 관계없이 발생하므로 연결이 갑자기 끊기거나 클라이언트가 다운되어도 이 업데이트를 사용하여 데이터를 정리할 수 있습니다. 연결이 끊겨도 설정, 업데이트, 삭제 등의 모든 쓰기 작업을 수행할 수 있습니다.

다음은 onDisconnect 기본 요소를 사용하여 연결이 끊길 때 데이터를 쓰는 간단한 예시입니다.

Kotlin+KTX

val presenceRef = Firebase.database.getReference("disconnectmessage")
// Write a string when this client loses connection
presenceRef.onDisconnect().setValue("I disconnected!")

Java

DatabaseReference presenceRef = FirebaseDatabase.getInstance().getReference("disconnectmessage");
// Write a string when this client loses connection
presenceRef.onDisconnect().setValue("I disconnected!");

onDisconnect의 원리

onDisconnect() 작업을 설정하면 Firebase 실시간 데이터베이스 서버에 작업이 상주하게 됩니다. 서버는 보안 검사를 실행하여 요청된 쓰기 이벤트를 사용자가 수행할 수 있는지 확인하고 문제가 있으면 앱에 알립니다. 이제 서버는 연결 상태를 모니터링하다가 연결이 타임아웃되거나 실시간 데이터베이스 클라이언트에 의해 종료되면 서버는 보안 검사를 다시 실행하여 작업이 계속 유효한지 확인한 후 이벤트를 호출합니다.

앱에서는 쓰기 작업의 콜백을 사용하여 onDisconnect가 정상적으로 연결되었는지 확인할 수 있습니다.

Kotlin+KTX

presenceRef.onDisconnect().removeValue { error, reference ->
    error?.let {
        Log.d(TAG, "could not establish onDisconnect event: ${error.message}")
    }
}

Java

presenceRef.onDisconnect().removeValue(new DatabaseReference.CompletionListener() {
    @Override
    public void onComplete(DatabaseError error, @NonNull DatabaseReference reference) {
        if (error != null) {
            Log.d(TAG, "could not establish onDisconnect event:" + error.getMessage());
        }
    }
});

.cancel()을 호출하여 onDisconnect 이벤트를 취소할 수도 있습니다.

Kotlin+KTX

val onDisconnectRef = presenceRef.onDisconnect()
onDisconnectRef.setValue("I disconnected")
// ...
// some time later when we change our minds
// ...
onDisconnectRef.cancel()

Java

OnDisconnect onDisconnectRef = presenceRef.onDisconnect();
onDisconnectRef.setValue("I disconnected");
// ...
// some time later when we change our minds
// ...
onDisconnectRef.cancel();

연결 상태 감지

접속 상태 관련 기능에서는 앱의 온라인 또는 오프라인 전환 시점을 확인하면 유용한 경우가 많습니다. Firebase 실시간 데이터베이스는 Firebase 실시간 데이터베이스 클라이언트의 연결 상태가 바뀔 때마다 업데이트되는 특수 위치인 /.info/connected를 제공합니다. 예를 들면 다음과 같습니다.

Kotlin+KTX

val connectedRef = Firebase.database.getReference(".info/connected")
connectedRef.addValueEventListener(object : ValueEventListener {
    override fun onDataChange(snapshot: DataSnapshot) {
        val connected = snapshot.getValue(Boolean::class.java) ?: false
        if (connected) {
            Log.d(TAG, "connected")
        } else {
            Log.d(TAG, "not connected")
        }
    }

    override fun onCancelled(error: DatabaseError) {
        Log.w(TAG, "Listener was cancelled")
    }
})

Java

DatabaseReference connectedRef = FirebaseDatabase.getInstance().getReference(".info/connected");
connectedRef.addValueEventListener(new ValueEventListener() {
    @Override
    public void onDataChange(@NonNull DataSnapshot snapshot) {
        boolean connected = snapshot.getValue(Boolean.class);
        if (connected) {
            Log.d(TAG, "connected");
        } else {
            Log.d(TAG, "not connected");
        }
    }

    @Override
    public void onCancelled(@NonNull DatabaseError error) {
        Log.w(TAG, "Listener was cancelled");
    }
});

/.info/connected는 부울 값이며, 이 값은 클라이언트의 상태에 좌우되므로 실시간 데이터베이스 클라이언트 간에 동기화되지 않습니다. 즉, 클라이언트 중 하나에서 /.info/connected를 읽은 결과가 false이더라도 다른 클라이언트에서는 다른 값으로 읽힐 수 있습니다.

Android에서 Firebase는 연결 상태를 자동으로 관리하여 대역폭 및 배터리 사용량을 줄입니다. 클라이언트에 활성 리스너, 대기 중인 쓰기 또는 onDisconnect 작업이 없고, goOffline 메서드로 연결을 명시적으로 해제하지 않은 경우 비활성 상태가 60초간 지속되면 Firebase는 연결을 종료합니다.

지연 시간 처리

서버 타임스탬프

Firebase 실시간 데이터베이스 서버는 서버에서 생성한 타임스탬프를 데이터로 삽입하는 메커니즘을 제공합니다. 이 기능과 onDisconnect를 함께 사용하면 실시간 데이터베이스 클라이언트의 연결이 끊긴 시간을 쉽고 정확하게 기록할 수 있습니다.

Kotlin+KTX

val userLastOnlineRef = Firebase.database.getReference("users/joe/lastOnline")
userLastOnlineRef.onDisconnect().setValue(ServerValue.TIMESTAMP)

Java

DatabaseReference userLastOnlineRef = FirebaseDatabase.getInstance().getReference("users/joe/lastOnline");
userLastOnlineRef.onDisconnect().setValue(ServerValue.TIMESTAMP);

시계 보정값

대부분의 읽기/쓰기 작업에서는 firebase.database.ServerValue.TIMESTAMP가 훨씬 더 정확하고 바람직하지만, Firebase 실시간 데이터베이스 서버를 기준으로 클라이언트 시계 보정값을 측정하면 유용한 경우도 있습니다. 이 값을 밀리초 단위로 가져오려면 /.info/serverTimeOffset 위치에 콜백을 연결합니다. Firebase 실시간 데이터베이스 클라이언트는 이 값을 로컬 보고 시간(밀리초 단위 에포크 시간)에 더하여 서버 시간을 추정합니다. 이 오프셋의 정확성은 네트워킹 지연 시간의 영향을 받을 수 있으므로 1초 이상의 상당한 시간 오차를 파악하는 데 주로 사용됩니다.

Kotlin+KTX

val offsetRef = Firebase.database.getReference(".info/serverTimeOffset")
offsetRef.addValueEventListener(object : ValueEventListener {
    override fun onDataChange(snapshot: DataSnapshot) {
        val offset = snapshot.getValue(Double::class.java) ?: 0.0
        val estimatedServerTimeMs = System.currentTimeMillis() + offset
    }

    override fun onCancelled(error: DatabaseError) {
        Log.w(TAG, "Listener was cancelled")
    }
})

Java

DatabaseReference offsetRef = FirebaseDatabase.getInstance().getReference(".info/serverTimeOffset");
offsetRef.addValueEventListener(new ValueEventListener() {
    @Override
    public void onDataChange(@NonNull DataSnapshot snapshot) {
        double offset = snapshot.getValue(Double.class);
        double estimatedServerTimeMs = System.currentTimeMillis() + offset;
    }

    @Override
    public void onCancelled(@NonNull DatabaseError error) {
        Log.w(TAG, "Listener was cancelled");
    }
});

샘플 접속 상태 앱

연결 해제 작업과 연결 상태 모니터링 및 서버 타임스탬프를 결합하여 사용자 접속 상태 시스템을 구축할 수 있습니다. 이 시스템에서 각 사용자는 특정 데이터베이스 위치에 데이터를 저장하여 실시간 데이터베이스 클라이언트가 온라인 상태인지 여부를 알립니다. 클라이언트는 이 위치를 온라인으로 전환될 때 true로, 연결이 끊길 때 타임스탬프로 설정합니다. 이 타임스탬프는 사용자가 마지막으로 온라인 상태였던 시간을 나타냅니다.

사용자가 온라인으로 표시되기 전에 연결 해제 작업을 큐에 두어야 합니다. 그래야 두 명령이 모두 서버로 전송되기 전에 클라이언트의 네트워크 연결이 끊겨도 경합 상태가 발생하지 않습니다.

다음은 간단한 사용자 접속 상태 시스템입니다.

Kotlin+KTX

// Since I can connect from multiple devices, we store each connection instance separately
// any time that connectionsRef's value is null (i.e. has no children) I am offline
val database = Firebase.database
val myConnectionsRef = database.getReference("users/joe/connections")

// Stores the timestamp of my last disconnect (the last time I was seen online)
val lastOnlineRef = database.getReference("/users/joe/lastOnline")

val connectedRef = database.getReference(".info/connected")
connectedRef.addValueEventListener(object : ValueEventListener {
    override fun onDataChange(snapshot: DataSnapshot) {
        val connected = snapshot.getValue<Boolean>() ?: false
        if (connected) {
            val con = myConnectionsRef.push()

            // When this device disconnects, remove it
            con.onDisconnect().removeValue()

            // When I disconnect, update the last time I was seen online
            lastOnlineRef.onDisconnect().setValue(ServerValue.TIMESTAMP)

            // Add this device to my connections list
            // this value could contain info about the device or a timestamp too
            con.setValue(java.lang.Boolean.TRUE)
        }
    }

    override fun onCancelled(error: DatabaseError) {
        Log.w(TAG, "Listener was cancelled at .info/connected")
    }
})

Java

// Since I can connect from multiple devices, we store each connection instance separately
// any time that connectionsRef's value is null (i.e. has no children) I am offline
final FirebaseDatabase database = FirebaseDatabase.getInstance();
final DatabaseReference myConnectionsRef = database.getReference("users/joe/connections");

// Stores the timestamp of my last disconnect (the last time I was seen online)
final DatabaseReference lastOnlineRef = database.getReference("/users/joe/lastOnline");

final DatabaseReference connectedRef = database.getReference(".info/connected");
connectedRef.addValueEventListener(new ValueEventListener() {
    @Override
    public void onDataChange(@NonNull DataSnapshot snapshot) {
        boolean connected = snapshot.getValue(Boolean.class);
        if (connected) {
            DatabaseReference con = myConnectionsRef.push();

            // When this device disconnects, remove it
            con.onDisconnect().removeValue();

            // When I disconnect, update the last time I was seen online
            lastOnlineRef.onDisconnect().setValue(ServerValue.TIMESTAMP);

            // Add this device to my connections list
            // this value could contain info about the device or a timestamp too
            con.setValue(Boolean.TRUE);
        }
    }

    @Override
    public void onCancelled(@NonNull DatabaseError error) {
        Log.w(TAG, "Listener was cancelled at .info/connected");
    }
});