[Android/푸시] 알림 Notification

권한 확인 동작

  • API 32 이하 앱의 경우, 시스템이 자동으로 퍼미션 팝업을 띄웠을 때, 사용자가 Don’t allow 버튼을 한번 누르면, 앱이 재시작해도 팝업이 발생하지 않는다.
  • API 33 이상 앱의 경우, 앱이 퍼미션 팝업을 띄웠을 때, 사용자가 Don’t allow 버튼을 두 번 누르기 전까지 퍼미션 팝업을 계속 띄울 수 있다.
  • 사용자가 Don’t allow 버튼을 눌러서 더 이상 팝업을 띄울 수 없다면 사용자가 직접 앱 설정에 들어가서 권한을 허가해줘야한다.

Android 13에서 변경된 사항

  • POST_NOTIFICATIONS (Notification Permission) 은 Target SDK API 33 이상부터 추가 가능
  • Target SDK API 32 이하의 앱이 Android 13 디바이스에 설치되면 Notification Channel을 등록할 때 자동으로 Notification 권한 요청 팝업이 나옴
  • Target SDK API 33 이상의 앱이 Android 13 디바이스에 설치되면 Notification 권한요청을 개발자가 원하는 타이밍에 노출 가능
  • Target SDK API 33 이상의 앱이 Android 12 이하 디바이스에 설치되면 기존과 동일하게 Notification 권한 요청 없이 사용 가능
  • Target SDK API 32 앱을 33으로 업데이트 시 기존 알림 권한 동의 상태라면 업데이트 이후 기본으로 허용이지만 예외 있음
    • 기기에 따라 자동으로 허용되지 않고 다시 한 번 권한을 얻어야하는 경우가 있음
    • 그렇기 때문에 Notification 권한이 허가되어 있는지 한 번 확인하는 작업을 넣기를 권장함

특성

  • 삼성 폰에서만 노티 아이콘에 색제한이 없다. 구글에서 권장하는 아이콘 색상배열은 투명배경에 흰색을 사용한 아이콘이다.
    배경색이 있는 런처 아이콘을 사용한 경우 픽셀 단말에서 아이콘이 깨진다.
  • 채널 중요도 수준

    출처: Android 공식문서

    ⚠️ setTicker넣었더니 Android API 27에서 테스트하길, 아이콘도 제대로 안나오고 priority high로 설정했는데 헤드업이 안뜸.

  • 가시성 잠금화면상태(system ui가 신뢰할 수 없는)에서 알림의 존재와 내용을 표시하는 방법, 가시성을 설정 (setVisibility()를 이용해 notificaiton에서 설정)
    • VISIBILITY_PUBLIC: 알림의 전체 콘텐츠가 잠금 화면에 표시된다.
    • VISIBILITY_SECRET: 알림의 어느 부분도 잠금 화면에 표시되지 않는다.
    • VISIBILITY_PRIVATE: 알림 아이콘과 콘텐츠 제목과 같은 기본 정보만 잠금 화면에 표시된다. 알림의 전체 콘텐츠가 표시되지 않는다.
  • [Google] 푸시 펼치기 기능은 OS가 아닌 단말에 따라 존재 유무가 달라진다.

[번역: Kotlin Coroutines: Deep Dive] (2) 코틀린 코루틴에서 일시 정지 작업을 하는 방법

이 포스팅은 Kotlin Coroutines: Deep Dive의 글을 번역한 것입니다. 오역, 많은 의역, 생략이 있으니 감안하여 읽어주십시오.

하나의 코루틴을 일시정지하는 것은 중간에 멈추는 것을 의미한다. 이건 세이브한 게임을 멈추고 당신이 다른 일에 집중할 수 있는 상태와 유사하다. 그리고 후에 언제든 세이브포인트에서 게임을 다시 시작할 수 있다. 코루틴은 일시정지하면 Continuation을 반환한다. 이를 사용하여 멈춘 지점에서 다시 시작할 수 있다.

이는 save가 아니라 block하는 스레드와는 매우 다르다는 사실에 주의하라. 코루틴은 매우 강력하다. 일시정지했을 때 어떤 리소스도 사용하지 않는다. 코루틴은 다른 스레드를 다시 시작할 수 있다. (적어도 이론 상으로는) 하나의 continuation은 직렬화, 비직렬화되어있을 수 있고 다시 시작될 수도 있다.

다시 시작

1
2
3
4
5
suspend fun main() {
println("Before")
suspendCoroutine<Unit> { }
println("After")
}

위 코드를 실행하면 “After” 글자를 볼 수 없을 것이다. 그리고 (main 함수는 절대 끝나지 않기 때문에) 코드가 실행을 멈춘 것도 아니다. 우리는 게임을 멈췄지만 다시 시작하지는 않았다. 그렇다면 어떻게 다시 시작할 것인가? 앞서 언급한 Continuation는 어디에 있는가?

suspendCoroutine 블록의 람다식을 다시 보자. argument를 받는 이 함수는 일시정지하기 전에 불리워진다. 그때의 매개변수가 continuation이다.

1
2
3
4
5
6
7
suspend fun main() {
println("Before")
suspendCoroutine<Unit> { continuation ->
println("Before too")
}
println("After")
}

같은 곳에서 다른 함수를 부르는 이러한 함수는 전혀 새로운 것이 아니다. 이는 let이나 apply, useLines와 유사하다. suspendCoroutine 함수는 이와 같은 방식으로 디자인되었다. 즉 일시정지하기 전에 continuation을 사용할 수 있도록 하는 것이다. suspendCoroutine이 호출된 후면 너무 늦을 것이다. 그래서 람다식을 통해 그 전에 부를 수 있도록 한다. 람다는 continuation을 어디서든 저장하거나 이를 다시시작할지 여부를 계획하기도 한다.

즉시 다시 시작하려면 이렿게하면 된다.

1
2
3
4
5
6
7
8
9
suspend fun main() {
println("Before")
suspendCoroutine<Unit> { continuation ->
continuation.resume(Unit)
}
println("After")
}
// Before
// After

위에서 “After”가 suspendCoroutine 안에서 resume을 호출했기 때문에 출력되었음에 주의하라.

Kotlin 1.3 이후로 Continuation 정의가 바뀌었다. resume과 resumeWithException 대신 하나의 결과값이 있는 resumeWith 함수가 있다. 우리가 사용하고 있는 resume과 resumeWithException 함수는 resumeWith를 사용한 표준 라이브러리에 있는 extension 함수이다.

1
2
3
4
inline fun <T> Continuation<T>.resume(value: T): Unit =
resumeWith(Result.success(value))
inline fun <T> Continuation<T>.resumeWithException(exception: Throwable): Unit =
resumeWith(Result.failure(exception))

설정된 시간동안 sleep할 다른 스레드를 시작할 수도 있다. 이는 언제든지 다시 시작할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
suspend fun main() {
print("Before ")
suspendCoroutine<Unit>{ continuation ->
thread {
print("Suspended ")
Thread.sleep(1000)
continuation.resume(Unit)
print("Resumed ")
}
}
print("After ")
}
// Before Suspended (1 second delay) After Resumed

중요한 부분이다. 정의된 시간이 끝난 후에 continuation을 다시 시작할 함수를 만들 수 있음에 주목하라. 여기서 continuation은 아래 보이는 것처럼 람다식에 의해 좌우된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun continueAfterSecond(continuation: Continuation<Unit>) {
thread {
Thread.sleep(1000)
continuation.resume(Unit)
}
}

suspend fun main() {
print("Before ")
suspendCoroutine<Unit> { continutaion ->
continueAfterSecond(continuation)
}
print("After ")
}
// Before (1 sec) After

이건 오류 없이 작동은 한다. 하지만 단 일 초 동안만 비활성화 상태가 되었다가 끝나는 스레드를 불필요하게 생성한다. 스레드는 비용이 싸지 않은데도 왜 낭비하고 있는가? 더 좋은 방법은 “알람”을 설정하는 것이다. JVM에서 그것을 구현하기 위해 ScheduledExecutorService를 사용했다. 여기서는 정의된 얼마간의 시간 후에 몇 개의 continuation.resume(Unit)을 호출하는 걸로 알람을 설정할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private val executor = 
Executor.newSingleThreadScheduledExecutor {
Thread(it, "scheduler").apply { isDaemon = true }
}

suspend fun main() {
print("Before ")
suspendCoroutine<Unit> { continuation ->
executorschedule({
continuation.resume(Unit)
}, 1000, TimeUnit.MILLISECONDS)
}
print("After ")
}
// Before (1 seconds delay) After

설정한 시간동안 일시정지하는 건 유용한 피처로 보인다. 그러니 이걸 delay라는 함수로 추출해보자.

1
2
3
4
5
6
suspend fun delay(timeMillis: Long): Unit = 
suspendCoroutine { continuation ->
executor.schedule({
continuation.resume(Unit)
}, timeMillis, TimeUnit.MILLISECONDS)
}

executor가 여전히 스레드를 사용하고 있지만 delay 함수를 사용하는 모든 코루틴에 한 개의 스레드만을 할당한다. 이 점은 하나의 스레드를 매번 블록하는 것보다는 낫다.

이는 코틀린 코루틴 라이브러리에서 지원하는 delay의 방식이다. 구현은 더 복잡하지만 필수적인 요소는 이렇다.

어떤 값을 가지고 다시 시작하기

resume 함수에서 Unit을 넘겨주는 거에 대해 걱정할 수도 있다. 그러면서 suspendCoroutine의 매개변수로 Unit을 사용하는 이유가 궁금해질 것이다. 이 두가지가 같다는 사실은 우연이 아니다. Unit은 함수로부터 반환되고, Continuation 타입 파라미터는 제네릭 타입이다.

1
2
3
4
val ret: Unit = 
suspendCoroutine<Unit> { cont: Continuation<Unit> ->
cont.resume(Unit)
}

suspendCoroutine이 호출될 때, 이 continuation에서 반환되는 타입이 어떤 것인지 명확해진다. 그 타입은 resume을 호출할 때 사용되곤 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
suspend fun main() {
val i: Int = suspendCoroutine<Int> { cont ->
cont.resume(42)
}
println(i) // 42

val str: String = suspendCoroutine<String> { cont ->
cont.resume("Some text")
}
println(str) // Some text

val b: Boolean = suspendCoroutine<Boolean> { cont ->
cont.resume(true)
}
println(b)
}

이는 (save-resume) 게임 비유에는 잘 들어맞지 않는다. 저장본을 다시 시작할 때 게임 내에서 어떤 것(값)을 가져올 수 있는 게임을 나는 알지 못한다. 그러나 이 점은 코루틴을 사용하면 완벽하게 이해된다. 종종 우리는 API로부터 네트워크 response를 받는 것처럼 어떤 데이터를 받아오기 위해 대기하곤 한다. 흔한 시나리오다. 스레드는 특정 데이터가 있는 지점에 도달할 때가지 비즈니스 로직을 실행시킨다. 그래서 네트워크 라이브러리에 이를 전달할 것을 요구한다. 코루틴 없이, 이 스레드는 앉아서 기다릴 수밖에 없다. 스레드가 비싼 자원이기 때문에 이는 엄청난 낭비다. 특히 안드로이드의 메인스레드와 같이 중요한 스레드라면 더욱 그렇다. 코루틴을 사용하면 그냥 일시정지 시켰다가, 라이브러리에게 “이 데이터를 받자마자 resume 함수에게 그냥 넘겨”라는 명령과 함께 continuation을 넘기기만 하면 된다. 그러면 스레드는 다른 일을 하러 갈 수 있다. 데이터가 오면 바로 그 스레드는 코루틴이 일시정지한 지점부터 다시 시작할 것이다.

실제로 이를 확인하기 위해 데이터를 수신할 때까지 일시정지하는 방법을 살펴보자. 아래 예제에서는 외부에서 가져온 requestUser 콜백함수를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
suspend fun main() {
println("Before")
val user = suspendCoroutine<User> { cont ->
requestUser { user ->
cont.resume(user)
}
}
println(user)
println("After")
}
// Before
// (1 second delay)
// User(name=Test)
// After

suspendCoroutine을 직접 호출하는 것은 편리하지 않다. 대신 suspend 함수를 만드는 것을 선호한다. 그래서 이를 추출할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
suspend fun requestUser(): User {
return suspendCoroutine<User> { cont ->
requestUser { user ->
cont.resume(user)
}
}
}

suspend fun main() {
println("Before")
val user = requestUser()
println(user)
println("After")
}

현재 suspend 함수는 Retrofit이나 Room과 같은 많은 유명한 라이브러리에서 지원하고 있다. 그물게 suspend 함수 안에 콜백 함수를 쓸 때가 있다. 하지만 그런 경우라면 suspendCoroutine 때신suspendConcellableCoroutine을 사용하는 걸 추천한다. 이는 Cancellation 챕터에서 설명할 것이다.

1
2
3
4
5
6
7
suspend fun requestUser(): User {
return suspendCancellableCoroutine<User> { cont ->
requestUser { user ->
cont.resume(user)
}
}
}

여기서 API가 데이터가 아닌 다른 문제가 발생했을 때는 어떻게 될지 궁금할 수도 있다. 서비스가 죽거나 오류를 수신할까? 위 예제에서는 데이터를 반환하지 않는다. 대신 코루틴이 일시정지한 곳에서 예외가 발생해야 한다. 예외를 받고 다시 시작한다.

예외를 받은 resume

우리가 호출하는 모든 함수는 어떤 값을 반환하거나 예외를 던진다. suspendCoroutine도 같다. resume이 호출되면 매개변수로 데이터를 넘긴다. resumeWithException이 호출되면 매개변수로 전달된 예외가 일시정지한 지점에 개념적으로 던져진다.

1
2
3
4
5
6
7
8
9
10
11
12
class MyException : Throwable("Just an exception")

suspend fun main() {
try {
suspendCoroutine<Unit> { cont ->
cont.resumeWithException(MyException())
}
} catch (e: MyException) {
println("Caught!")
}
}
// Caught!

이 매커니즘은 다른 문제에서도 쓰인다. 한 예로, 네트워크 예외 신호를 보낼 때가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
suspend fun requestUser(): User {
return suspendCancellableCoroutine<User> { cont ->
requestUser { resp ->
if (resp.isSuccessful) {
cont.resume(resp.data)
} else {
val e = ApiException(
resp.code,
resp.message
)
cont.resumeWithException(e)
}
}
}
}

suspend fun requestNews(): News {
return suspendCancellableCoroutine<News> { cont ->
requestNews(
onSuccess = { news -> cont.resume(news) },
onError = { e -> cont.resumeWithException(e) }
)
}
}

함수가 아닌 코루틴을 일시정지하는 것

하나 강조할 것은 함수가 아닌 코루틴을 일시정지 시킨다는 것이다. 함수를 일시정지할 수 있는 것은 코루틴이 아니라 코루틴을 일시정지할 수 있는 함수이다. 어떤 변수에 함수를 저장하는 상상를 해보아라. 그리고 함수 호출 후 다시 시작시켜라.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 정말 이렇게 짜지 마세요
var continuation: Continuation<Unit>? = null

suspend fun suspendAndSetContinuation() {
suspendCancellableCotouine<Unit> { cont ->
continuation = cont
}
}

suspend fun main() {
println("Before")

suspendAndSetContinuation()
continuation?.resume(Unit)

println("After")
}
// Before

이건 좀 이해가 안 된다. 이건 게임을 멈추고 이전 지점에서 재시작하는 프로세스와 동일한데도 resume이 전혀 호출되지 않는다. 출력결과에는 오직 “Before”만 보인다. 그리고 다른 스레드나 코루틴에서 resume을 호출하지 않으면 이 프로그램은 영원히 끝나지 않는다. 이를 위해 1초 후 reusme하는 다른 코루틴을 배치할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 정말 이렇게 짜지 마세요. 잠재적인 메모리 누수의 원인이 됩니다.
var continuation: Continuation<Unit>? = null

suspend fun suspendAndSetContinuation() {
suspendCancellableCotouine<Unit> { cont ->
continuation = cont
}
}

suspend fun main() {
println("Before")

launch {
delay(1000)
continuation?.resume(Unit)
}
suspendAndSetContinuation()
println("After")
}
// Before
// (1 second delay)
// After

[Android/FCM] 토큰 정책

등록 토큰 변경 조건

- 새 기기에서 앱 복원
- 사용자가 앱 제거/재설치
- 사용자가 앱 데이터 소거
- FCM에서 기존 토큰이 만료된 후 앱이 다시 활성화(active)

비활성 토큰

FCM에 1개월(30일, const EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30;) 넘게 연결되지 않은 비활성(inactive) 기기와 연결된 토큰은 비활성(stale, 탁한) 토큰이다.

비활성 등록 토큰은 FCM에 1개월 넘게 연결되지 않은 비활성 기기와 연결된 토큰입니다. 시간이 지날수록 기기가 FCM에 다시 연결될 가능성은 점점 줄어듭니다. 이러한 비활성 토큰의메시지 전송 및 주제 팬아웃은 전송되지 않을 가능성이 높습니다(are unlikely to ever be delivered.).

문서에 이렇게 나와 있는데,

  • “FCM에 1개월 넘게 연결되지 않은”은 ‘메세지가 마지막으로 정상전송된 이후로 1개월’을 의미하는 걸로 생각됨.
  • “전송되지 않을 가능성이 높다”라는 의미는 사용자가 해당 토큰과 연결된 기기(앱)를 사용하지 않아서 수신이 제대로 되지 않을 수 있음을 의미하며, (만료되었을 때처럼) FCM이 메세지전송을 막아서는 아닌 것으로 보임.

단, 비활성 토큰이 270일 동안 활동이 없으면 FCM에서 만료된(expired) 토큰으로 간주한다. 이때 만료된 토큰으로는 FCM에서 유효하지 않은 것으로 표시하고 이때, 메세지 전송을거부한다. (FCM은 기기가 다시 연결되고 앱이 열리는 경우에 앱 인스턴스의 새 토큰을 발급한다.)

따라서 토큰의 유효기간은 마지막 정상전송 후로부터 최대 300일(30일 후 비활성 + 만료 유예기간 270일)로 예상된다.

자세한 내용은 [Firebase 문서 - FCM 등록 토큰 관리를 위한 권장사항] 부문을 통해 확인할 수 있다.

[Kotlin] var / val / const val / companion object 특성

이 포스팅은 아래 Kotlin 공식문서의 일부를 번역(의역) + 의견을 추가한 것입니다.

var / val

변수의 정의가 가변(mutable)하는 경우 var 키워드(variable)를 사용하고, read-only일 경우 val 키워드(value)를 사용한다. 변수의 값이 가변하는 경우가 아니다. 예를 들어, area의 값은 width와 height에 따라 변하지만 정의는 변하지 않는다.

1
val area: Int = this.width * this.height

코틀린에서 backing field는 단지 메모리에 값을 유지하기 위해서만 사용되는 것에 불과하다. field는 직접 선언될 수 없으며 변수의 setter나 getter 내에서만 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
var counter = 0 // initializer가 field에 직접 값을 할당한다.
set(value) {
if (value >= 0) {
field = value
/* 'counter'라는 실제 변수명을 사용하는 건
setter를 다시 부르는 일이기 때문에 StackOverflow Error가 난다.
*/
// counter = value
}
}

이런 식의 암시적인 field 체계 대신 아래와 같이 backing 변수를 만들 수도 있다.

1
2
3
4
5
6
7
8
private var _table: Map<String, Int>? = null
public val table: Map<String, Int>
get() {
if (_table == null) {
_table = HashMap() // 여기서 파라미터의 타입이 추론된다.
}
return _table ?: throw AssertionError("Set to null by another thread")
}

const val

컴파일 타임 상수이다. 다음 조건을 모두 충족시키는 경우에 해당한다.

  • 최상위 레벨 속성이거나 object나 companion object의 멤버여야 한다.
  • String이나 원시(primitive) 타입으로 초기화되어야 한다.
  • 커스텀 getter를 사용할 수 없다.

컴파일 시 inline 형태로 이 변수를 변수 대신 실제 값으로 대치한다. 그러나 필드가 사라지는 건 아니라서 relection으로 상호작용은 여전히 가능하다.

1
const val SUBSYSTEM_DEPRECATED: String = "this subsystem is deprecated"

companion object(동반 객체)

class 내부에 object 선언은 “companion” 키워드를 써서 할 수 있다.

1
2
3
4
5
class MyClass {
companion object Factory {
fun create(): MyClass = MyClass()
}
}

companion object의 멤버는 단순하게 클래스명을 통해 호출할 수 있다.

1
val instance = MyClass.create()

companion object의 이름은 빼도 되고, 이때는 Companion으로 접근한다.

1
2
3
4
class MyClass {
companion object { }
}
val x = MyClass.Companion

클래스 멤버는 companion object 내의 private 멤버에 접근할 수 있다.

-

[번역: Kotlin Coroutines: Deep Dive] (1) Kotlin Coroutines를 사용하는 이유

이 포스팅은 Kotlin Coroutines: Deep Dive의 글을 번역한 것입니다. 오역, 많은 의역, 생략이 있으니 감안하여 읽어주십시오.

우리가 Kotlin Coroutines을 배우는 이유가 무엇인가? RxJava, Reactor과 같이 이미 잘 만들어진 JVM 라이브러리가 있는데도 말이다. 더욱이 자바는 멀티스레딩을 지원한다. 즉 이미 비동기를 구현하기 위한 다양한 방법이 있다.

Kotlin Coroutines는 그것보다도 더 많은 것을 제공한다. 그 중 하나는 멀티플랫폼을 제공한다는 것이다. 즉슨 코틀린 플랫폼 사이에서 코루틴을 사용할 수 있음을 의미한다.

연습해보자. 코루틴과 다른 방법들 사이에 얼마나 차이가 있는지 보라. 아래에서는 안드로이드와 백엔드 비즈니스 로직의 두 가지 전형적인 예시를 보여줄 것이다.

안드로이드(와 다른 프론트엔드 플랫폼)에서 사용하는 코루틴

앱 로직을 수행할 때 가장 자주 진행되는 과정은 다음과 같다.

  • API, 데이터베이스 등과 같은 소스의 데이터 가져오기
  • 데이터 가공하기
  • 그 데이터로 뷰에 보여주는 등의 작업 수행하기

더 잘 이해하기 위해 앱을 개발하는 중이라고 가정해보자. API를 통해 뉴스 “정보를 받아와서” 이를 “정렬”하고, “화면에 보여주는” 서비스를 만들 것이다. 원하는 기능을 그대로 넣은 게 다음과 같다.

1
2
3
4
5
fun onCreate() {
val news = getNewsFromApi()
val sortedNew = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNew)
}

슬프지만 이대로 끝나선 안 된다. 안드로이드에서 각 애플리케이션은 뷰를 업데이트하는 데에 단 하나의 스레드를 가지고 사용한다. 이 스레드(Main Thread)는 매우 중요하여 절대 block(차단)되어서는 안 된다. 그런데 위 코드는 이 점을 만족시키지 못한다. 만약 메인 스레드에서 실행되었다면 getNewsFromApi 는 이 스레드를 block할 것이고, 앱은 죽을 것이다. 만약 다른 스레드였다면 앱은 showNews가 호출될 때 죽을 것이다. 이 작업은 메인 스레드에서 수행되어야 하는 작업이기 때문이다.

스레드 변경(switching)

이 문제는 스레드를 변경함으로써 해결할 수 있다. 우선 block할 수 있는 스레드에서 메인 스레드로 변경하자.

1
2
3
4
5
6
7
8
9
fun onCreate() {
thread {
val news = getNewsFromApi()
val sortedNew = news.sortedByDescending { it.publishedAt }
runOnUiThread {
view.showNews(sortedNew)
}
}
}

이러한 스레드 변경은 어떤 앱에서는 여전히 사용하는 방식이긴하다. 그러나 이 방식은 몇가지 문제를 안고 있다.

  • 이 스레드들을 cancel할 수 있는 방도가 없어서 종종 메모리 누수(memory leak) 상태에 직면한다.
  • 스레드 비용이 많이 든다.
  • 빈번한 스레드 변경은 혼란스럽고 관리를 어렵게 만든다.
  • 이 코드는 불필요하게 크고 복잡하다.

당신이 뷰를 연 후 빠르게 닫는 걸 상상해보라. 열 때 몇 개의 스레드들이 시작하며 데이터를 처리한다. 뷰를 닫았음에도 존재하지 않는 뷰를 업데이트하기 위해 이 작업을 cancel하지 않고 계속 진행하게 된다. 불필요하다는 것이다.

이러한 문제에 대해 생각해보며 더 좋은 해결책을 보도록 하자.

콜백(Callbacks)

콜백은 이 문제를 해결하는 방법 중 하나이다. 이 방식은 기능이 non-blocking하게 만든다. 그러나 콜백 함수가 끝나야만이 시작할 수 있는 작업을 수행할 때만 적용할 수 있다. 코드에 적용하면 이렇다.

1
2
3
4
5
6
fun onCreate() {
getNewsFromApi { news ->
val sortedNew = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNew)
}
}

여기서 작업을 cancel 할 수 없음에 주목하라. 취소할 수 있는 콜백 함수를 만들 수도 있을 것이다. 그러나 쉽지 않다. cancel을 위해 각 콜백 함수를 커스텀해야 할 뿐만 아니라 cancel을 할 가능성이 있는 모든 객체를 별도로 수집해야 한다.

1
2
3
4
5
6
fun onCreate() {
startedCallbacks += getNewsFromApi { news ->
val sortedNew = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNew)
}
}

이러한 콜백 구조는 단점이 있다. 이걸 이해하기 위해 더 복잡한 예시를 보자. 세 개의 엔드포인트로부터 데이터를 가져오는 경우이다.

1
2
3
4
5
6
7
8
9
fun showNews() {
getConfigFromApi { config ->
getNewsFromApi(config) { news ->
getUserFromApi { user ->
view.showNews(user, news)
}
}
}
}

이 코드는 아래와 같은 이유로 완벽과는 거리가 멀다.

  • news와 user 데이터는 사실 동시에 받아와도 된다. 그런데 지금 콜백 구조로는 그럴 수 없다.
  • 전에 말한대로, 다른 더 많은 데이터를 불러오려고 할 때 cancel할 필요가 있다.
  • 요구하는 게 점점 많아질수록 이 코드는 읽기 어려워질 것이다. 그러한 상황을 콜백 지옥(callback hell)이라고 부른다.
  • 콜백을 사용할 때 작업의 순서를 제어하기 힘들다. 인디케이터를 다음과 같이 사용해도 진행도가 제대로 나타나지 않는다.
    1
    2
    3
    4
    5
    6
    fun onCreate() {
    showProgressBar()
    showNews {
    hideProgressBar()
    }
    }

이게 콜백 구조가 어떤 면에서는 완벽하지 않은 이유이다. 다른 접근방식으로 RxJava를 사용한 예를 보자.

RxJava와 그밖의 reactive streams

Java에서 유명한 이 대체방식은 reactive streams(또는 Reactive Extensions)를 사용한다. 즉, RxJava 또는 successor Reactor를 말한다. 이 방식으로 말할 것 같으면, 모든 동작은 시작하여(started) 진행된(processed) 관찰되어지는(observed) 데이터 스트림(일련의 작업) 내에서 일어난다. 그래서 종종 앱에서는 동시에 진행되곤 한다.

다음 코드가 RxJava를 사용한 문제 해결 방식이다.

1
2
3
4
5
6
7
8
9
10
11
fun onCreate() {
disposables += getNewsFromApi()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.map { news ->
news.sortedByDescending { it. publishedAt }
}
.subscribe { sortedNews ->
view.showNews(sortedNews)
}
}

위 예제에서 disposables은 사용자가 화면을 벗어났을 때 이 스트림을 cancel하기 위한 요소이다.

이는 분명 콜백보다 더 괜찮은 해결책이다. 메모리 누수가 없고, 작업 취소(cancel)를 할 수 있으며 스레드를 사용하는 데도 적절하다. 유일한 문제는 복잡하다는 것이다. 만약 맨처음 코드인 “이상적인” 코드와 비교한다면 공통점이 거의 없음을 알 수 있을 것이다.

subscribeOn, observeOn, map 그리고 subscribe라는 함수들을 모두 알아야지만 코드를 이해할 수 있다. cancelling(cancel할 수 있는 기능)은 분명 필요하다. 함수는 Observable이나 Single 클래스 안에 있는 객체를 반환해야 한다. 실제로 RxJava를 소개할 때 흔히 우리 코드를 아래와 같이 바꾼다.

1
fun getNewsFromApi(): Single<List<News>>

두 번째 문제를 생각해보자. 데이터를 화면에 보여주기 전에 세 개의 엔드포인트를 호출해야 한다. 이건 RxJava에서는 사실 풀 수 있는 문제다. 그러나 코드가 지금보다 훨씬 더 복잡해진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fun showNews() {
disposables += Observable.zip(
getNewsFromApi()
.flatMap { getNewFromApi(it) }
.subscribeOn(Schedulers.io()),
getUserFromApi()
.subscribeOn(Schedulers.io())
) { news: News, user: User ->
Pair(news, user)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (news, user) ->
view.showNews((news, user))
}
}

이 코드는 정말 메모리 누수가 없고, concurrent하다. 그러나 zip, flatMap과 같은 RxJava 함수를 넣어야 하고, Pair로 값을 묶어야 하며, 이 구조를 해제해야 한다. 이 옳은 구현은 정말 복잡하다. 그래서 이번에야말로 코루틴으로 짠 코드를 보도록 하자.

코틀린 코루틴 사용하기

코루틴이 소개하는 핵심 기능은 어떤 지점에서 코루틴을 suspend(일시 정지)하고 후에 다시 resume(재시작) 할 수 있다는 것이다. 이 덕분에 메인 스레드에서 코드를 run(실행)하다가 API 데이터를 요청했을 때 suspend 할 수 있다. 코루틴이 suspend 되었을 때 스레드는 block(차단)되지 않고 뷰를 바꾸거나 다른 코루틴을 진행하는 등에 계속 사용할 수 있다. 데이터가 준비되면 코루틴은 메인 스레드를 기다린다. 드문 상황이지만 코루틴 대기열이 있을 수도 있다. 기다리던 스레드를 사용할 수 있게 되면 중지된 지점부터 계속 진행한다.

1
2
3
4
5
6
7
8
9
10
11
12
suspend fun updateNews() {
showProgressBar()
val news = getNewsFromApi()
val sortedNews = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
hideProgressBar()
}

suspend fun updateProfile() {
val user = getUserData()
view.showUser(user)
}
1
2
3
4
5
6
val scope = CoroutineScope(Dispatchers.Main)

fun onCreate() {
scope.launch { updateNews() }
scope.launch { updateProfile() }
}

Kotlin Coroutine Example

그림에서 메인 스레드에서 분리된 코루틴으로 실행되는 updateNews와 updateProfile 함수를 보자. 두 함수(또는 코루틴)는 순서가 바뀌어도 된다. 스레드를 block하지 않고 코루틴을 suspend하기 때문이다. updateNews 함수가 네트워크 응답을 기다리고 있을 때 메인 스레드는 updateProfile이 사용한다. 여기서는 사용자 데이터는 이미 캐싱되었기 때문에 getUserData에서 suspend하지 않았다고 가정한다. 그러므로 작업을 완료할 수 있다. 네트워크 응답 시간이 충분하지 않아서 데이터를 받아오는 게 늦어지면 메인 스레드는 그 시간동안 사용되지 않는다(다른 함수가 사용할 수 있다). 데이터를 받으면 메인 스레드를 가져와 getNewsFromApi() 바로 다음 지점부터 시작하여 updateNews를 resume(재개)한다.

정의에 따르면 코루틴은 suspend와 resume이 가능한 컴포넌트다. JavaScript나 Rust, Python과 같은 언어에서 볼 수 있는 async/await나 generators와 같은 개념도 코루틴을 사용하지만 그 기능은 매우 제한적이다.

그래서 첫 번째 문제점은 다음 방식으로 해결한다.

1
2
3
4
5
6
7
fun onCreate() {
viewModelScope.launch {
val news = getNewsFromApi()
val sortedNews = news.sortedByDescending { it.publishedAt }
view.showNews(sortedNews)
}
}

위 코드에서 현재 안드로이드에서 가장 흔한 viewModelScope를 사용했다. 이걸 대신해서 커스텀 scope를 사용할 수도 있다.

이 코드는 우리가 원하는 것에 거의 가깝다! 이 해결책에서 코드는 메인 스레드에서 run하지만 절대 block 하진 않는다. suspend 기법 덕에 데이터를 기다릴 필요가 있을 때 해당 코루틴을 block 대신 suspend 한다. 코루틴이 일시 정지했을 때 메인 스레드는 진행도를 예쁘게 보여주는 등의 다른 일을 할 수 있다. 그리고 데이터가 준비되면 코루틴은 메인 스레드를 다시 받아 멈췄던 부분부터 다시 시작한다.

그렇다면 어떻게 세 개의 API를 호출할까? 이 또한 유사한 방식으로 만들 수 있다.

1
2
3
4
5
6
7
8
fun showNews() {
viewModelScope.launch {
val config = getCondfigFromApi()
val news = getNewsFromApi(config)
val user = getUserFromApi()
view.showNews(user, news)
}
}

이 해법은 제법 괜찮아보인다. 그러나 최선은 아니다. API 호출들을 하나가 끝나면 다음 호출을 부르듯, 순서대로 진행되고 있다. 그래서 각 작업이 1초 걸린다고 하면, 전체 함수는 2초가 아닌 3초가 걸린다. 여기서 코틀린 코루틴 라이브러리는 async와 같은 기능을 지원한다. 즉 일부 요청으로 다른 코루틴을 즉시 시작하고 (await 함수로)그 결과가 나중에 도착할 때까지 기다리는 데에 사용할 수 있다.

1
2
3
4
5
6
7
8
fun showNews() {
viewModelScope.launch {
val config = async { getCondfigFromApi() }
val news = async { getNewsFromApi(config.await()) }
val user = async { getUserFromApi() }
view.showNews(user.await(), news.await())
}
}

코드는 여전히 단순하고 가독성 있다. 이는 JavaScript나 C#에서 잘 사용되는 async/await 패턴을 이용한다. 효과적이고 메모리 누수도 없다. 단순한데다가 잘 구성되어있기까지 하다.

다른 예제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 모든 페이지를 동시에 로드하기
fun showAllNews() {
viewModelScope.launch {
val allNews = (0 until getNumberOfPages())
.map { page -> async { getNewsFromApi(page) }
.flatMap { it.await() }
view.showAllNews(allNews)
}
}

// 다름 페이지를 차례로 로드하기
fun showPagesFromFirst() {
viewModelScope.launch {
for (page in 0 until getNumberOfPages()) {
val news = getNewsFromApi(page)
view.showNextPage(news)
}
}
}