0%

구글은 점점 더 안드로이드 아키텍처를 리엑티브하게 구조화 하려는 움직임을 보이고 있고, Databinding, LiveData 그리고 RxJava 는 그러한 구조화 작업의 핵심 토대를 담당하고 있다. 또한 주력으로 사용되는 Retroift 부터 AAC 의 Room, Paging 에 이르기까지 많은 안드로이드 라이브러리가 RxJava 를 지원하고 있다.

시대의 흐름에 따라 이제는 거의 필수가 되어버린 RxJava 이기에 더이상 미루지 못하고 활용 방법을 익히기 시작했으나 워낙 제공되는 operation 이 많고 사람들이 사용하는 스타일도 제각각 달라서 스스로 사용법을 익히기까지 많은 시행착오를 겪어야 했다. 위 시리즈는 그동안 RxJava 를 사용하면서 겪었던 경험을 바탕으로 놓치기 쉽거나, 활용했을 때 좋았던 방식들을 적어 나가는 포스트가 될 것 같다. 깊은 수준의 내용은 아니지만, 이 글들을 통해 조금이나마 삽질의 시간을 줄이고 적절하게 RxJava 를 활용하는데 도움이 되었으면 좋겠다.

subscribeOn, observeOn 잘 사용하고 있나?

Schedular 퀴즈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// # 1
myService.getUsers()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.flatMap(Observable::fromIterable)
.filter(User::isMember)
.map(this::saveToCache)
.toList()
.subscribe(View::showUser);


// # 2
myService.getUsers()
.subscribeOn(Schedulers.io())
.flatMap(Observable::fromIterable)
.filter(User::isMember)
.map(this::saveToCache)
.toList()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(View::showUser);

위 두 케이스는 user 리스트를 요청하는 API 를 호출한 후 필요한 비즈니스 로직을 수행하는 코드이다.

각 케이스의 차이점은 observerOn, subscribeOn 의 호출 순서가 다르다는 점이다. 2개의 코드에서 각각의 스트림 연산이 어느 쓰레드에서 수행되는지 잠시 예상해보자.

만약 정확히 얘기할 수 있다면 이미 subscribeOn, observeOn 에 대한 이해가 충분할 것이라 예상되어 해당 글은 리마인드 차원에서 가볍게 보고 넘어가면 될 것 같다. 👏

위 문제의 답은 다음과 같다.
첫번째 케이스는 getUsers() 에 대한 연산만 io Schedular 쓰레드 위에서 수행되며, 나머지 모든 하위 스트림 연산은 메인쓰레드에서 수행된다.
두번째 케이스는 getUsers() ~ toList 연산까지 io Schedular 위에서 수행되며, subscribe 내에서 콜백으로 최종 데이터를 전달받는 연산만 메인쓰레드에서 수행된다.

따라서 첫번째 케이스는 굳이 메인 쓰레드에서 수행하지 않아도 될 비즈니스 로직을 수행하고 있는 것 이므로 자원의 낭비가 있는 코드라고 볼 수 있다. (만약 엄청나게 많은 리소스가 요구되는 비즈니스 로직이라면…😭)

정의와 올바른 활용법

사실 각 케이스에서 subscribeOn 은 어느 순서에 호출하든 결과는 변하지 않는다. 위 결과의 차이를 만드는 것은 observeOn 의 호출 위치이다.
호출위치라고? subscribeOn, observeOn 의 정의를 한번 살펴보자.

subscribeOn

  • observable source 가 observer 에 의해 subscribe 됐을 때, source 가 데이터를 다음 스트림으로 전달하는 액션을 수행하는 스케쥴러를 지정.

observeOn

  • observerOn 이후 수행되는 스트림의 액션을 수행하는 스케쥴러를 지정

간단히 얘기하면

  • subscribeOn 는 [첫번째 스트림 ~ observeOn 호출 전 까지의 스트림] 의 쓰레드를 지정
  • observeOn 은 [해당 observeOn 호출 이후의 스트림] 의 쓰레드를 지정한다.

위 정의에 따르면 observeOn 은 어느 순서에 호출되느냐에 따라 영향을 받는 스트림이 달라지게 된다. 따라서 첫번째 케이스는 getUsers() 이후 바로 observeOn 이 호출 됐으므로 이후 스트림의 연산이 메인쓰레드에서 수행되게 되는 것이다.

만약 이러한 특성을 고려하지 않고 기계적으로 코드를 작성하게 될 경우, 첫번째 케이스 처럼 작성하게 될 가능성이 있고 이는 RxJava 가 의도한 특성을 제대로 활용하지 못하고 있는 것이라고 볼 수 있다. (내가 그랬다..)

위 정의와 본래 의도에 맞게 다시 코드를 작성하면 아래와 같다.

1
2
3
4
5
6
7
8
9
myService.getUsers()
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.computation())
.flatMap(Observable::fromIterable)
.filter(User::isMember)
.map(this::saveToCache)
.toList()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(View::showUser);
  • computation 쓰레드로 바꾸지 않고, io 쓰레드 위에서 그대로 진행되어도 상관은 없으나, 각 schedualars 가 본래 역할에 맞게 사용될 수 있도록 하기위해 바꾸었다.

그외 subscribeOn, observeOn 활용시 도움이 될만한 사실들

  • subscribeOn, observeOn 호출은 필수가 아닌 옵션이다.
  • subscribeOn, observeOn 모두 호출하지 않았을 경우 subscribe() 를 호출한 thread 에서 스트림연산이 수행된다. (일반적으로는 메인쓰레드 일 것이다.)
  • subscribeOn 은 최초 1회 호출만 적용되며, 그 이후 다시 호출하는 것은 무시된다.
  • observeOn 은 호출 횟수에 제한이 없다.
  • subscribeOn 만 호출할 수 있다. 따라서 subscribeOn 정의에 따라 모든 스트림 연산은 subscribeOn 에서 지정한 쓰레드 위에서 수행된다.
    • observeOn(AndroidSchedulars.mainThread()) 를 기계적으로 호출하지 말자. 백그라운드 상에서 수행될 작업만 있는 스트림일 경우 subscribeOn(Schedulars.xx) 호출만으로 충분하다.
  • observeOn 만 호출할 수 있다. (하지만 활용한 케이스 X)
  • subscribeOn 의 호출 순서는 결과에 영향을 주지 않지만, 되도록 첫번째 혹은 마지막에 호출하는 것이 가독성 측면에서 좋다고 느낀다. 특히 메소드 체이닝이 길어질 수록 더더욱 흐름 파악에 도움이 된다.

결론

subscribeOn / observeOn 를 상황에 맞게 활용하고, 특히 observeOn 은 호출 순서에 주의하자.

상반기에 많은 일들이 있었던 것 같은데 막상 적으려니까 잘 떠오르지 않아 당황스러웠다. 이래서 평소에 기록을 해두는 습관을 형성 해두는 것이 중요한 것 같다.

이직

1년 4개월 동안 다니던 회사를 뒤로하고 첫 이직을 했다. 조이에서 개발자를 시작한 것은 정말 현명한 선택이었다. 입사 전과는 비교할 수 없을 만큼 많이 배우고 성장하는 시간을 보냈다. 그렇게 좋은 환경이었음에도 불구하고 이직을 하게 된 계기는 단순했다. 내 사고가 어느 한 방향으로 굳기 전에 새로운 변화를 맞이하여 자극을 주고 싶었고, 구체적인 자극으로서 좀 더 많은 유저가 사용하는 큰 스케일의 프로젝트를 개발 해보고 싶었다. 목표가 생기자 퇴근 후 새벽까지 이직 공부를 하며 여러 곳에 지원을 했고 결과적으로 NAVER에 입사를 하게 되었다.

사실 업무와 이직 준비를 동시에 하는 기간은 정신적으로나 육체적으로나 정말 힘들었고 포기하고 싶었던 순간이 많았다. 하지만 결과적으로 그 시간을 겪으면서 현재 내 수준을 정확히 점검할 수 있었고 당시에 정리했던 공부 내용들이 지금까지도 개발하면서 아주 큰 도움이 되고 있다. 이직할 생각이 없더라도 정기적으로 면접을 보는 경험이 좋다는 얘기가 이런 맥락에서 였나 싶었다.

네이버 내에서 맡게 된 서비스는 ‘네이버 카페’ 다. 사실 입사 전에 네이버 카페를 자주 쓰는 편이 아니었어서(…) 실 사용자수가 어느정도 인지 감이 없었는데, 생각했던 수치보다 10배나 높았다! 아주 많은 자극을 받게 될 것 같다. 👏 또한 개인적으로 인복이 정말 많다고 생각되는 것이, 전 회사 그리고 현재에도 정말 좋은 팀과 동료 들을 만나게 되었다. 특히 시니어분들이 보여주시는 모습은 나중에 내가 그 위치로 올라갔을 때 꼭 닮고 싶은 부분이었다. 현재는 입사 프로젝트를 마치고 작은 테스크부터 점차 수행 해나가고 있으며, 빨리 새로운 환경에 적응을 마치고 깃헙에 많은 잔디를 심고 싶다.

개발

작년 한해는 RxJava, Dagger, Clean Architecture, 함수형 프로그래밍 등 특정 기술 영역을 익히는데 관심과 시간을 투자 했다면 올해 초부터는 기본기를 좀 더 다지는 한 해를 보내고 있다. 개발을 막 시작하던 때에는 기술만 도입하면 모든 문제가 해결될 것 처럼 보였지만 역시 은탄환은 없었다. 좀 더 복잡하고 어려운 스펙, 난해한 이슈들을 맞이할수록 결국에는 기본 영역 레벨로 내려가 고민을 하게 된다. 그리고 그러한 상황들을 맞이할 때마다 내 부실한 지식과 한계를 명확히 느꼈다. 동료 개발자와 기초적인 지식과 관련하여 커뮤니케이션을 할 때, 잘 모르는 내용 이었지만 일단 아는 척을 하고 집에 가서 그 내용을 찾아볼 때는 부끄러움과 약간의 분함(?)도 찾아왔다. 위와 같은 경험을 한 뒤로는 각 분야의 기초 서적들을 구입해 퇴근 후 틈틈히 공부하고 그 내용을 노션에 정리하기 시작했다.

공부를 하면 할 수록 내가 겉핥기 수준으로 알고 있는 부분이 많았고, 기존의 코드들도 좀 더 깊이 이해가 되는 부분들이 생기기 시작했다. 실제로 라이브러리나 프레임워크 내부 소스코드를 분석하다 보니 곳곳에 객체지향 원칙, 자료구조 등 기본적인 지식들이 녹아져있음을 깨닫게 되었다. 아직도 부족한 수준이지만, 꾸준히 채워 나가는데 의의를 두려한다.

개발 외적인 것들

주짓수

친구의 지속적인 권유로 마지못해 주짓수를 시작했다. 무술류 운동은 평생 해볼일이 없을 줄 알았는데 이게 하다 보니 생각보다 재미있다..? 요즘은 입사 적응 기간이라 좀 꺾였지만 한동안은 모든 여가 시간을 주짓수로 보낼 만큼 열성을 다해서 했다. 꾸준히 운동을하면서 느낀 것이 꼭 주짓수가 아니더라도 규칙적으로 땀을 흘리는 활동은 일상생활에 많은 플러스를 가져다 준다는 것이었다. 하지만 역시나 스스로 운동을 시작한다는 것은 참으로 어려운 의지의 영역 이기에 글또 처럼 운또(?)와 같은 모임을 만들어서 운동을 꾸준히 할 수 있는 환경을 구축하는 것도 좋은 시도가 될 수 있을 것 같다.

집밖으로!
주말이든 휴가든 집에서 거의 나가지 않는 생활을 했었는데, 요즘은 의도적으로 집을 떠나려고 노력하고 있다. 집에서는 한없이 나태해지기 쉽기 때문에 이것을 경계 하려는 목적도 있고 다양한 환경, 다양한 사람들을 접하며 보고 느끼는 것이 삶에 긍정적인 영향을 주고 있음을 느끼고 있기 때문이다. 최근 목표로는 몽골 여행을 생각하고 있다. 일반적인 여행보다는 많이 힘들다는 얘기들이 많지만, 그렇기에 더더욱 체력이 점점 떨어지기 전에 도전 해보고싶다.

2019년 하반기 목표

개발적인 측면에서는 다음과 같은 목표를 이루고 싶다.

  • 네트워크 기초지식 다지기
  • 서버 프레임워크를 통해 기본적인 서버구조 익히기
  • AAC 라이브러리를 활용하여 스펙 구현하기

개발 외적인 측면에서는 다음과 같은 목표를 이루고 싶다.

  • 주짓수 블루벨트 달성
  • 계족산 황톳길 등산하기
  • 스노클링
  • 다른 직군의 사람들과 네트워킹 2회 이상

글또 3기 다짐

꾸준히 무엇 인가를 하는 습관을 형성하기 위해서는 어느 정도의 강제성과 보상이 주어지는 환경이 중요하다고 생각한다. 그런 측면에서 글또라는 모임은 정말 좋은 취지로 그러한 환경을 잘 조성 해주는 것 같아서 운영하시는 분들과 성실히 참여해주시는 분들이 대단하다고 생각했었다. 1기 때부터 지원을 고민했지만 언젠간 하겠지라는 생각으로 미뤘었고, 그 언젠가는 오지 않는걸 알기에 이번에는 공고가 나오자마자 신청을 하게 됐다. 이번 회고 글을 쓰면서 평소에 글을 써본 경험이 적어서 작은 분량임에도 불구하고 작성하는 데에도 많은 시간이 소요됐지만 신기하게도 시간가는 줄 모르고 재밌게 적게되는 경험을 하게 됐다. 남은 기간 동안 서로의 글들을 통해 많은 배움과 성장이 있음을 기대하며 나도 열심히 달려 나가야겠다.

한번도 NullPointerException (이하 NPE)를 겪어보지 않은 사람은 있을지 몰라도, 한번만 NPE 를 겪어본 사람은 없을것이다. NPE 로 인한 크래시가 나기 전까지는 그 존재를 알 수 없기 때문에 항상 Null 값 처리를 신경 쓰면서 개발을 진행해야 한다. 잊을만하면 나타나는 NPE를 바라보면서 한숨을 쉬고 있을 때쯤, 동료 개발자에게 미리 NPE 예방에 도움을 주는 Nullaway 라이브러리를 소개받았다.

Nullaway 는 @Nullable 어노테이션을 이용하여 Compile Time 에 Null check 검사를 수행한다. 간결하고, 강력해보인다.

Nullaway 세팅하기

gradle 기반으로 가이드가 되어있고, Android / Non - Android 방식으로 나뉘는데 거의 차이가 없다. 이 글에서는 Android 방식을 기준으로 설명한다.

App 모듈 Gradle 파일에 다음과 같이 추가해주면 설정이 끝난다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
apply plugin: 'net.ltgt.errorprone'

buildscript {
repositories {
maven {
url "https://plugins.gradle.org/m2/"
}
}
dependencies {
classpath "net.ltgt.gradle:gradle-errorprone-plugin:0.0.13"
}
}

dependencies {
<!--0-->

}

tasks.withType(JavaCompile) {
if (!name.toLowerCase().contains("test")) {
options.compilerArgs += [
"-Xep:NullAway:ERROR",
"-XepOpt:NullAway:AnnotatedPackages=com.your.package"]
}
}

Configuration

1
2
3
4
5
6
7
tasks.withType(JavaCompile) {
if (!name.toLowerCase().contains("test")) {
options.compilerArgs += [
"-Xep:NullAway:ERROR",
"-XepOpt:NullAway:AnnotatedPackages=com.your.package"]
}
}

다른 부분은 그대로 사용해도 상관없지만 해당 부분은 각 프로젝트에 맞게 수정을 해줘야 한다. Nullaway의 적용범위를 설정할 수 있다.

필수로 추가해줘야 하는 부분은 -XepOpt:NullAway:AnnotatedPackages 옵션이다. 해당 옵션값은 Nullaway 가 검사할 패키지를 지정한다. 위 옵션값으로 지정된 패키지 및 그 하위 패키지는 Nullaway가 요구하는 @Nullable 어노테이션 처리가 모두 되어있다고 가정하며, 만약 처리가 안되어 있을 경우 빌드시 에러를 발생시킨다.

그외에 자주 쓰이는 옵션은 다음과 같다.

  • -XepOpt:NullAway:UnannotatedSubPackages : Nullaway가 요구하는 어노테이션 처리가 안되어 있는 패키지를 지정한다. 해당 패키지 및 그 하위패키지는 Nullaway 검사에서 제외된다.
  • -XepOpt:NullAway:ExcludedClasses : Nullaway가 요구하는 어노테이션 처리가 안되어 있는 클래스를 지정한다. 해당 클래스는 Nullaway 검사에서 제외된다.

2개 이상의 패키지 혹은 클래스를 옵션값으로 주고 싶을 경우

같은 옵션의 값을 여러번 추가하면 될 거 같지만, 실제로는 가장 마지막에 추가된 값만 옵션에 적용되기 때문에 원하지 않는 결과가 나올 수 있다. 따라서 2개 이상의 클래스 / 패키지를 지정하고 싶을 경우에는 정규식을 이용해야 한다.

com.project 의 하위 패키지인 foo, bar 패키지를 Nullaway 검사에서 제외시키고 싶을 때

  • "-XepOpt:NullAway:UnannotatedSubPackages=com.project.(foo|bar)"

prefix로 test_ 가 붙은 패키지들을 Nullaway 검사에서 제외시키고 싶을 때

  • "-XepOpt:NullAway:UnannotatedSubPackages=com.project.test_[a-zA-Z0-9.]*"

그외 자세한 설명은 Docs 를 참고!

사용 방법

Android Support Library 에서 제공하는 @Nullable 어노테이션을 이용한다. Null 이 될 수 있는 필드, Null을 리턴할 수 있는 메소드에 @Nullable, Null 이 될 수 없는 필드, Null 을 리턴하지 않는 메소드는 @NonNull 을 추가해주면 된다. 아무런 어노테이션도 추가되지 않은 변수, 메소드는 @NonNull 로 인식한다. @Nullable 이 추가되어있는 필드는 항상 Null safe 체크 이후에 사용될 수 있고, @NonNull 이 추가되어 있는 필드는 Null 값이 들어올 수 없다는 것을 기본전제로 Null check 검사를 수행한다.

예제

아래 코드를 빌드할 경우 NonNull 타입 파라미터에 Null 이 들어갔으므로 Nullaway가 에러를 낸다.

1
2
3
4
5
6
7
public void testModel(Model model) {
Log.e("TEST", model.isValid());
}

public void call() {
testModel(null);
}

따라서 다음과 같이 처리해줘야 한다.

1
2
3
4
5
public void testModel(@Nullable Model model) {
if (model != null) {
Log.e("TEST", model.isValid());
}
}

만약 Null check 없이 그대로 model 을 사용하려고 할 경우, 역시 에러를 낸다.

Nullaway가 에러를 띄워주는 케이스는 Error Messages 에 정리되어 있다.

실제 프로젝트에 적용해보면서 느낀 점

  1. 방어코드를 잘 추가했다고 생각했음에도 처리되지 않았던 부분이 많았고, 관련 코드를 모두 수정하여 앱의 안정성을 올리는데 도움을 주었다.
  2. Nullaway 소개글에 나온 것 처럼 처리 속도가 빨라 빌드시간이 추가로 더 늘어나지는 않음.
  3. 코드를 작성할 때 Nullable, NonNull 을 고려하게 된다는 점이 좋았음.

코틀린으로 넘어가게 되면 사용하지 않아도 무방하고 자바로 계속 개발할 경우 한번 적용해보는 것을 추천한다.

샘플 링크 https://github.com/vagabond95/NullAwaySample

발단

며칠 전부터 이슈트래킹 대시보드에서 아래 에러가 빈번하게 리포트되는 일을 겪었다.

Fatal Exception: java.lang.IndexOutOfBoundsException: setSpan (-1 ... -1) starts before 0
       at android.text.SpannableStringInternal.checkRange(SpannableStringInternal.java:442)
       at android.text.SpannableStringInternal.setSpan(SpannableStringInternal.java:163)
       at android.text.SpannableStringInternal.setSpan(SpannableStringInternal.java:152)
       at android.text.SpannableString.setSpan(SpannableString.java:46)
       at android.text.Selection.setSelection(Selection.java:76)
       at android.widget.TextView.semSetSelection(TextView.java:13203)

코딩을 할 때 신경써서 범위를 지정하지 않을 경우 종종 겪는 에러였는데, 디버깅을 해도 해당 부분은 모두 예외처리가 잘 되어있어 원인을 쉽게 찾을 수 없었다.

해결

그러던 중 우연히 재현 조건을 찾게 되었고, 조건은 TextView 에서 특정 텍스트를 복사하려고 드래그를 시도할 때 랜덤하게 크래시가 발생했다. 복사 기능은 TextView 의 textIsSelectable 옵션을 활용하여 제공하고 있었기 때문에, ‘textIsSelectable’ 과 ‘IndexOutOfBoundsException’ 키워드를 엮어 내용을 찾아보니 힌트가 될만한 정보 를 얻을 수 있었다.

내용인즉, textIsSelectable 옵션과 LinkMovementMethod 를 같이 사용했을 때 의도하지 않은 결과가 나올 수 있다는 것이었다. LinkMovementMethod 는 안드로이드에서 제공하는 녀석이어서 원인이 될 것이라고 생각하지 못했다. 우리 프로젝트에서도 textIsSelectable 옵션이 적용된 TextView 에서 LinkMovementMethod 를 같이 사용하고 있었기에, 혹시나 하는 마음으로 위 내용을 참고하여 CustomMovementMethod 만든 후 적용시켜봤다. 그리고 다시 테스트를 반복하여 시도해본 결과 더이상 크래시가 발생하지 않았다.

정확한 원인은?

버그를 해결하고 나서 위 문제가 발생한 원인을 다시 생각해 봤다.

  1. 발단은 span 을 적용하는 과정에서 문제가 발생한 것이었다.
  2. 위 재현조건에서 span 을 적용하는 상황은 textIsSelectable 옵션을 활성화한 TextView 에서, 특정 text 에 대해 드래그를 했을 배경에 컬러가 입혀지는 상황이다.
  3. ‘textIsSelectable 옵션만 적용했을 때’ 는 위 크래시가 발생하지 않는것으로 보아, LinkMovementMethod 내부에서 드래그 영역의 컬러에 대한 span이 적용되는 범위를 임의로 컨트롤하는 로직이 있음을 유추할 수 있었다. 좀 더 세밀하게 추적하기 위해 LinkMovementMethod 코드를 그대로 가져와서 로그를 추가한 뒤 다시 상황을 재현해보았다.
  4. 원인을 찾았다!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
ClickableSpan[] links = buffer.getSpans(off, off, ClickableSpan.class);

if (links.length != 0) {
if (action == MotionEvent.ACTION_UP) {
links[0].onClick(widget);
} else if (action == MotionEvent.ACTION_DOWN) {
Selection.setSelection(buffer,
buffer.getSpanStart(links[0]),
buffer.getSpanEnd(links[0]));
}
return true;
} else {
Selection.removeSelection(buffer);
}
  1. LinkMovementMethod 의 경우 TextView 에 대한 touch 이벤트를 감지할 수 있는 onTouchEvent 라는 콜백 메소드가 존재하며, 위 코드가 해당 콜백의 핵심 로직이다. 코드를 잘 살펴보면 buffer 로 들어온 text에 ClickableSpan이 없을 경우에는 buffer 의 selection 을 초기화 하는 과정을 수행한다. 그런데 위에서 적었던 stacktrace 내용을 살펴보면 드래그 영역에 대한 span 의 범위는 결국 selection 으로 부터 가져오기 때문에 selection을 초기화해버릴 경우에 의도하지 않은 동작이 발생하게 되는 것이다.

결론

위 이슈를 겪으면서 얻은 결론은 두가지이다.

  1. TextView 에서 textIsSelectableLinkMovementMethod 를 같이 사용하는 것은 좋지 않으며, 부득이하게 사용해야 할 경우 Custom 하게 만들어서 사용하는 것이 좋다.
  2. Android 에서 제공하는 API도 문제의 원인이 될 수 있다. 다만 이 부분은 직접 겪기전까지는 알기 어려울 듯 하다.

들어가며

안드로이드에서 제공하는 알림(Notification) 은 강력한 기능들을 제공하고 있다. 그리고 매번 API 버전이 올라갈 때마다 빈번하게 Update 가 되는 녀석이기 때문에 계속해서 주시하고 있어야하는 놈이기도 하다. 그동안 아무생각없이 copy & paste 만 하면서 사용하고 있던 스스로를 돌아보며 다시한번 알림의 전반적인 사용방법에 대해 정리하고자 포스트를 적었다.

개발 환경

  • supportLibrary Version - 26.1.0
  • complileSDK Version - 26
  • targetSDK Version - 26

TL; DR

오레오에서 알림 대응은 피할 수 없다. 미대응시 작동 X 😱

오레오대응.. 더이상은 피할 수 없다?

아직 비교적 최신 버전인 오레오 대응을 굳이 처음부터 다르는 이유는 다음과 같다.

  1. 새로운 Google Play 방침 에 따르면 play store 에 등록되어 있는 앱을 업데이트 하거나, 새로 앱을 등록 할 경우 targetSdkVersion >= 26 을 만족해야 한다. 따라서 오레오(8.0) 대응을 피할 수 없게 되었다.

    2018년 하반기 부터

  2. 오레오 여러 변경 사항 중 특히 신경써줘야 할 부분은 알림(Notification) 에 대한 변경사항인데, 이를 반영하지 않을 경우 알림이 오지않는 치명적인 상황이 발생한다.

NotifcationChannel 의 등장

우리가 처리해줘야하는 부분은 오레오에서 새롭게 등장한 NotificationChannel 과 관련되어있다. 왜 google 은 이 녀석에 대한 처리를 강제했을까? 다음 상황을 살펴보면 그 이유를 짐작할 수 있다.

나는 스마트폰에 알림이 쌓여있는 걸 좋아하지 않아서, 보통 대부분 앱의 알림을 꺼놓는다. 하지만 그때 아쉬운 점이 하나 있었는데, 확인하고 싶은 일부 알림 역시 받을 수 없다는 것이었다.

Android 7.1 (API level 25) 이하 버전에서는 모든 알림이 하나로 묶여 관리됐기 때문에 위의 아쉬운 점을 해결할 수 없었고 이에 대한 대안으로 오레오에서 NotificationChannel 이 새롭게 추가되었다.

NotificationChannel 을 적용할 경우 더이상 각 알림이 하나로 묶이지 않고, channel 별로 분리되어 유저가 유연하게 알림을 설정할 수 있다. 위와 같이 notification_practice 앱에는 각각 확인하고싶은 알림보고싶지 않은 알림 채널이 있으며 각 채널별로 수신여부, 잠금화면 표시여부, 소리 & 진동 설정 등 여러 옵션을 세밀하게 설정할 수 있다. 👏👏👏

NotificationChannel 적용하기

적용과정은 다음과 같다.

  1. NotifcationChannel 생성
  2. NotificationManager 에 만들어둔 NotifcationChannel 을 등록
  3. Notification builder 에 등록된 NotifcationChannel의 id 를 등록

NotificationChannel 생성 및 등록

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    String uniqueId = "uniqueId";
    String channelName = "확인하고 싶은 알림";
    String description = "확인하고 싶은 알림 채널입니다.";
    int importance = NotificationManager.IMPORTANCE_HIGH;

    NotificationChannel notificationChannel = new NotificationChannel(uniqueId, channelName, importance);
    notificationChannel.setDescription(description);

    NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
    notificationManager.createNotificationChannel(notificationChannel);
  }

NotificationChannel 은 support library 에서 사용할 수 없다. 즉, 오레오 이전버전에서는 지원되지 않기 때문에 반드시 버전체크를 해줘야 한다.

NotificationChannel 을 생성할 때 요구되는 id, name, importance 그리고 description 에 대해 간략히 설명하면 다음과 같다.

  • id : 각 채널을 구분할 수 있는 unique 한 값
  • name : 유저에게 표시되는 채널 이름
  • importance : 알림에 대한 중요도이며 이 값에 따라 알림의 동작이 달라진다. API 25 이하버전에서 사용되던 notification priority 개념과 동일. 각 importance 값에 따른 동작의 차이는 importance level에 잘 나와있다.
  • description : 채널에 대한 설명, 특정 채널 설정 창에서 표기됨

Notification builder 생성 및 channel id 등록

NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(context, channelId);

    notificationBuilder
        .setSmallIcon(iconResource)
        .setContentTitle(title)
        .setContentText(content)
        .setPriority(NotificationCompat.PRIORITY_HIGH);

    // 기존 빌더에 Channel을 추가할 경우
    notificationBuilder
        .setSmallIcon(iconResource)
        .setContentTitle(title)
        .setContentText(content)
        .setChannlId(channlId)
        .setPriority(NotificationCompat.PRIORITY_HIGH);

    // 알림 생성!
    NotificationManagerCompat.from(context).notify(++notificationId, notificationBuilder.build());

새롭게 알림 빌더를 구현할 경우 생성자에 channelId 를, 이미 사용하고 있는 빌더가 있다면 해당 빌더에 channel Id 를 추가해주면 된다. (내부 코드는 동일하다.)

setChannelId 을 이용하여 채널을 등록하는 방법을 택했을 때, API 25 이하 버전에서는 null 이 들어가겠지만 내부에서 무시되기 때문에 안심해도 된다.

Notification Channel Tips

채널은 딱 한번만 생성해주면 된다.

즉, 매번 알림을 만들때마다 채널을 다시 만들어줄 필요가 없다. 채널을 관리하는 유틸 클래스를 만들거나, application 클래스 등을 활용하여 재사용하는 방식으로 사용하면 된다.

채널 정보 변경하기

이미 등록되어 있는 채널의 정보를 다시 바꿀 수 있을까? 가능하다! 하지만, 바꿀 수 있는 정보는 name, description 뿐이며, 그외의 채널정보를 바꾸고 싶을 경우 해당 채널을 삭제한 후 새로운 정보를 가지고 있는 채널을 다시 생성해줘야 한다.

  • 등록된 채널의 name, description 을 변경하고 싶을 경우

    NotificationChannel notificationChannel = new NotificationChannel(uniqueId, newChannelName, importance);
    notificationChannel.setDescription(newDescription);

    NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
    notificationManager.createNotificationChannel(notificationChannel);

변경하고자 하는 name, description 을 가진 채널을 재등록해주면 된다. 이때 변경 전, 변경 후 채널 id가 동일한지 잘 확인하자.

  • 등록된 채널을 삭제하고 싶을 경우

    NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
    notificationManager.deleteNotificationChannel(channelId);

Sound 설정도 잊지말자

  • 기존에는 builder 상에서 sound 설정을 해줬지만, 오레오 부터는 알림이 채널별로 관리되므로 sound 설정도 각 채널 별로 해줘야 한다.

    AudioAttributes audioAttributes = new AudioAttributes.Builder()

    .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
    .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
    .build();

    notificationChannel.setSound(soundUri, audioAttributes);

  • setSound() 메소드에는 2개의 인자가 들어가는데 첫번째, sound 는 기존에 사용하던 파일의 uri 를 넣어 주면된다. 두번째 AudioAttributes 가 이번에 새롭게 생긴 녀석인데, 위 코드와 같이 추가로 설정해주지 않으면 소리가 나오지 않는다.

추가로 알게된 사실들

  1. priority 와 importance level 이 다를경우 무엇이 우선될까?

    알림 빌더 생성시 API 25 이하 버전을 위해 priority 값을 반드시 설정해줘야 한다. 따라서 오레오(26) 이상의 버전은 importance 값과 priorty 값 모두 가지고 있게 되는데, 이때 (매우 드물겠지만) 각각의 값이 다를 경우 어떤 값을 따라갈까?

    • importance 는 IMPORTANCE_HIGH 설정 (가장 높은 기대값)

    • priority 는 PRIORITY_MIN 설정 (가장 낮은 기대 값)

      결과는 importance 를 따라감을 확인 했다.

  2. 설정한 importance / priority 값이 항상 알림의 동작을 결정한다고 보장할 수 없다.

    위의 importance 값에 대해 설명할 때 해당 값이 알림의 동작을 결정한다고 얘기했었다. 그런데 이제와서 보장할 수 없다니?

    docs 에서 이부분을 명확히 설명해주고 있다.

Although you must set the notification importance/priority as shown here, the system does not guarantee the alert behavior you’ll get. In some cases the system might change the importance level based other factors, and the user can always redefine what the importance level is for a given channel

즉, 정리하면 importance / priority 값이 알림의 동작을 결정하는 것은 맞으나 다양한 상황에서 해당 값이 바뀔 수 있으므로 항상 설정해준 값대로 알림이 동작할 것이라고 판단하지 말라는 의미이다. 실제로 importance 값을 high 로 세팅한 channel 이 있어도 해당 channel 설정에서 중요도(importance) 값을 변경할 경우 high 가 아닌 가장 최근에 변경한 값으로 적용됨을 확인 할 수 있다. (사실 유저입장에서는 유저가 변경한 설정이 유지되는게 맞으므로 자연스러운 로직이다.)