읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Delegate(위임)는 객체가 직접 작업을 수행하지 않고 다른 곳에서 작업을 하게끔 맡기는 디자인을 의미한다. 코틀린에서는 기본적으로 Delegation Pattern 구현에 필요한 기능을 지원한다. 그리고 비슷한 맥락으로 변수 선언과 동시에 초기화를 하지 않고 변수만 미리 선언하고 초기화를 뒤로 미룰 수 있다. 초기화를 늦추면 사용하지 않을 데이터를 미리 초기화할 필요가 없어 성능향상에 도움이 된다. 그 중에서도 두 가지 방법이 있다.

Lazy Initialization(By Lazy Delegate) : 변수 선언 시 초기화 코드도 함께 정의하며 사용될 때 초기화 코드를 실행하여 초기화

Late Initialization : 필요할 때 초기화하여 사용하나 초기화하지 않고 사용하면 Exception이 발생함

기능적으로 초기화를 지연한다는 점에서 유사하나 초기화 과정에 조금 차이가 발생한다. 좀 더 제약사항과 동작 방식을 정리해보자.

Lazy Initialization

Lazy Initialization을 사용하기 위해선 val 프로퍼티에서만 사용해야 한다. 그리고 by lazy { ... }라는 구문을 사용하여 초기화 구문을 작성해야 한다. Lambda 구문은 변수를 처음 사용할 때 단 한번 호출되며 마지막 값이 초기값으로 할당된다. 아래 예제 구문에서는 foo 변수를 호출 시 초기화 구문에 따라 100이 할당될 것이다.

val foo: Int by lazy {
println("foo value is 100!")
100
}

초기화 구문이 단 한번 호출됨을 확인하기 위해 아래 코드를 작성하였다.

class Bar {
val foo: Int by lazy {
println("Setting foo 100!")
100
}
}
fun main() {
val buf = Bar()
println(buf.foo)
println(buf.foo)
}

실행 결과는 다음과 같이 출력될 것이다. 초기화 블럭에 정의했던 println 구문이 한 번만 실행되었음을 확인할 수 있다.

Setting foo 100!
100
100

Lazy Initialization 내부 동작

public final class Bar {
@NotNull
private final Lazy foo$delegate;
public final int getFoo() {
Lazy var1 = this.foo$delegate;
Object var3 = null;
return ((Number)var1.getValue()).intValue();
}
public Bar() {
this.foo$delegate = LazyKt.lazy((Function0)null.INSTANCE);
}
}

Bar 클래스 생성 시 {변수이름}$delegate라는 이름을 가진 Lazy 타입의 객체를 선언한다. Bar 클래스 객체를 생성하면서 해당 객체를 정의한다. LazyKt 파일의 lazy 함수를 이용하여 변수에 값을 할당한 뒤 getFoo 함수를 호출하면 객체의 변수를 조회한 뒤 Lazy 타입의 내부 변수에 할당하여 값을 조회하는 함수를 호출하며 리턴한다.

null.INSTANCE는 뭔가요?

직접 컴파일된 파일을 decompile한 경우와는 달리 Kotlin bytecode inspector로 디컴파일을 수행한 경우 실행이 불가능한 코드로 보이는 경우가 있다. 이는 컴파일러가 요구하는 방식과는 다르게 코드를 작성했기 때문인데 따라서 JVM bytecode != Java language specific임을 먼저 알아야 하고 그에 따라 Kotlin generated JVM bytecode != Java generated JVM bytecode라는 사실을 이해해야 한다.

위 사실에 따라 NULL 인스턴스는 디컴파일러가 잘못 인터프리팅한 결과로 값을 제대로 설정하는 항목에서의 모든 참조값이 NULL 인스턴스로 출력될 것이다.

lazy 함수의 thread-safe

public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)

파라미터로 전달받은 initializer를 사용하여 Lazy 타입의 객체를 생성하는 함수다. 그리고 별도로 입력한 initializer 말고도 기본적으로 적용되는 thread-safe한 mode인 LazyThreadSafetyMode.SYNCHRONIZED를 사용한다. 내부적으로 동기화를 구현하므로 외부에서 동기화 구현을 권장하지 않으며 구현하더라도 데드락 발생에 주의해야 한다.

public final class MainKt {
public static final void main() {
Bar buf = new Bar();
int var1 = buf.getFoo();
System.out.println(var1);
var1 = buf.getFoo();
System.out.println(var1);
}
// $FF: synthetic method
public static void main(String[] var0) {
main();
}
}

main 함수를 디컴파일한 결과를 보면 Bar 타입 객체를 선언한 뒤 출력할때마다 해당 객체의 getFoo 메소드를 호출하여 출력함을 볼 수 있다.

Lazy Initialization의 특징

  1. val 프로퍼티에서만 사용 가능
  2. 앞에서 기술하였듯, 객체를 생성하고 초기화가 선행되어야 함에 따라 Immutable한 변수인 val 프로퍼티에서만 사용할 수 있다. 그리고 내부적으로 final 키워드를 사용함에 따라 immutable을 보장해야 함을 알 수 있다.
  3. Thread-safe함
  4. 내부적으로 초기화 시 thread-safe한 모드를 사용함에 따라 lazy 함수를 별도로 overloading하지 않는 이상 thread-safe하다. 따라서, initializer가 최대한 단 한 번 수행됨을 보장할 수 있다.
  5. Java primitive type(Int, Boolean) 사용 가능
  6. Non-null, Nullable 사용 가능

Lazy Initialization의 초기화 확인

Lazy Initialization 변수의 초기화 여부를 확인할 수 있다.

val lazyFoo = lazy{
100
}
val foo by lazyFoo
println(foo) // use foo variable (initialize lazyFoo)
if (lazyFoo.isInitialized()) {
println(foo)
}

우선 lazyFoo 변수를 lazy 키워드를 사용하여 할당하였다. 다만 이전에 초기화했던 방식과는 달리 by 키워드를 사용하지 않아 Lazy<Int> 타입이 할당되었다. 그리고 foo라는 추가 변수를 선언하고 by 키워드를 사용하여 lazyFoo의 초기화 구문을 사용하여 값이 할당되었다. 초기화 여부는 Lazy 타입 변수에 대해 isInitinalized() 함수를 사용하여 확인할 수 있다.

다만, 값 조회 시 Lazy 객체와 값이 서로 분리된다는 점이 마음에 조금 걸린다. 한번에 해결하기 위해 Lazy 객체를 직접 사용하는 방식도 있다.

val lazyFoo = lazy{
100
}
val foo by lazyFoo
if (lazyFoo.isInitialized()) {
println(lazyfoo.value)
}

특정 상황에서의 안전보장 목적이 아니라면 실질적으로 lazy 키워드와 by 키워드를 모두 사용하여 한번에 변수 호출과 동시에 초기화까지 진행되게끔 하는 것이 훨씬 더 효율적이다.

Late Initialization

Late Initialization을 사용하기 위해선 var 프로퍼티에서만 사용해야 한다. 그리고 lateinit라는 키워드를 사용한다. 초기화 구문을 사용하지 않고 나중에 값을 할당하여 초기화를 수행할 수 있다. 변수를 사용하지 않는 한 컴파일러에게 나중에 초기화하겠다고 전달하므로 컴파일 시 에러가 발생하지 않는다. 아래 예제 구문에서는 Temp 타입의 foo 변수를 lateinit 키워드를 사용하여 우선 선언한 뒤 initFoo함수에 파라미터를 전달하여 값을 할당한다.

class Bar {
lateinit var foo: Temp
fun initFoo(param: Temp): Unit {
this.foo = param
}
}
class Temp(val bar: Int)
fun main() {
val buf = Bar()
buf.initFoo(Temp(100))
println(buf.foo.bar)
}

실행결과는 100이 출력될 것이다. initFoo 함수를 호출하지 않고 변수를 참조하면 아직 값이 할당되지 않았기 때문에 Exeception이 발생할 것이다.

fun main() {
val buf = Bar()
println(buf.foo.bar)
buf.initFoo(Temp(100))
}
Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property foo has not been initialized
at Bar.getFoo(Main.kt:2)
at MainKt.main(Main.kt:12)
at MainKt.main(Main.kt)

초기화되지 않은 프로퍼티를 참조했기에 UninitializedPropertyAccessException이 발생함을 볼 수 있다.

Late Initialization 내부 동작

아래 코드는 Bar 클래스를 디컴파일한 코드이다.

public final class Bar {
public Temp foo;
@NotNull
public final Temp getFoo() {
Temp var10000 = this.foo;
if (var10000 == null) {
Intrinsics.throwUninitializedPropertyAccessException("foo");
}
return var10000;
}
public final void setFoo(@NotNull Temp var1) {
Intrinsics.checkNotNullParameter(var1, "<set-?>");
this.foo = var1;
}
public final void initFoo(@NotNull Temp param) {
Intrinsics.checkNotNullParameter(param, "param");
this.foo = param;
}
}
  1. var에서만 사용

우선 foo 변수가 public Temp foo로 선언되어 있음을 볼 수 있다. 즉, lazy initialization과는 달리 수정할 수 있으므로 var 타입으로만 사용해야 함을 확인할 수 있다.

  1. Non-null 프로퍼티로만 사용 가능

추가로 변수를 조회하는 함수인 getFoo를 보면 foo 변수를 로컬 변수에 할당한 뒤 값을 점검하는데 null 체킹을 하고 있음을 확인할 수 있다. 위 로직에 따라 null을 할당할수 없음을 알 수 있겠다. null을 사용할 수 없는 이유는 Intrinsics.checkNotNullParameter로도 확인할 수 있다. 해당 함수의 내부 구현 코드는 다음과 같다.

public static void checkNotNullParameter(Object value, String paramName) {
if (value == null) {
throwParameterIsNullNPE(paramName);
}
}

위 코드에 따라 넘겨받은 파라미터의 값이 null인 경우 Execpetion을 throw하고 있음을 볼 수 있다. 따라서, 조회뿐만 아니라 초기화와 이후에 값을 변경할 때에도 null 값은 할당할 수 없다. 실제로 아래 코드를 작성하면 컴파일 단계부터 에러가 발생한다.

lateinit var foo: Temp?  // compile level error
  1. Custom getter/setter 생성 불가

lateinit으로 선언된 변수는 Custom getter/setter 생성이 불가능하다. 만약 작성하더라도 컴파일 단계에서 에러가 발생한다.

lateinit var foo: Temp?
get() {
foo;
}

그 이유로 lateinit으로 선언한 변수에는 값을 저장하는 backing field가 존재한다. 그리고 lateinit backing field는 노출되어 있기 때문에 원칙적으로 Custom getter/setter를 금지하고 있다.

  1. Java primitive type(Int, Boolean)은 사용이 불가함

Non-null 프로퍼티로만 사용 가능한 이유와 연결된다. lateinit은 null을 변수가 초기화되었는지 그렇지 않는지 판별하기 위한 특별한 값으로 사용한다. 따라서, lateinit 변수에 값을 덮씌우는 것은 가능할지언정 null값의 명시적 선언은 불가하다. 만약 아래 코드가 있다고 해보자.

private lateinit var x: Int

코틀린 컴파일 시 x는 int타입으로 컴파일되어야 한다. 그리고 lateinit 변수이기 때문에 null을 초기화되었는지 판별하기 위해 사용한다. 그러나 null값을 위 코드에 선언한 Int 타입 변수나 다른 primitive 타입에 저장할 수 없다. 만약 null 저장을 위해 아래 코드처럼 변경해보자.

private lateinit var x: Int?

만약 null을 변수에 저장할 수 있도록 허용하면 초기화되지 않은 경우 특별한 holder로써 null의 동작에 문제가 발생한다. 그렇기 때문에 lateinit 변수에 nullable type을 사용할 수 없다. 그리고 lateinit은 변수 타입을 명확히 하고 null 타입 적용의 모호함을 피하기 위한 목적도 있다. 그렇기에 primitive 타입을 nullable하게 사용함은 lateinit의 목적성을 해친다. 만약 내부적으로 타입별 기본값을 초기화되지 않은 변수 판별에 사용하지 않는 한 null과 primitive type이 허용될 일은 없으며 기본값을 사용하더라도 해당 값이 실제 할당될 값일 수 있기에 문제가 발생한다.

Late Initialization의 특징

  1. var 프로퍼티에서만 사용 가능
  2. Non-null프로퍼티만 사용 가능
  3. Custom getter/setter 생성 불가
  4. Java primitive type(Int, Boolean) 사용 불가

Late Initialization의 초기화 확인

코틀린은 Late Initailization이 완료되었는지 확인을 위해 isInitialized 프로퍼티를 제공한다.

lateinit var file: File
if (this::file.isInitialized) {
/* ... */
}

lateinit 변수가 선언된 범위 내에서 this 키워드를 사용하여 초기화되었는지 확인 후 특정 동작을 정의할 수 있다.

By Lazy Delegate와 Late Initialization의 장단점

Lazy Initialization의 장단점

  1. (장점) 초기화 단계에서 Thread-safe를 보장하여 유저가 다중 쓰레드 환경에서의 초기화를 신경쓰지 않아도 된다.
  2. (장점) by 키워드를 사용하여 초기화를 한번에 정의할 수 있으므로 initialize 여부를 신경쓰지 않아도 된다.
  3. (장점?) Lazy 타입 인스턴스를 저장할 수 있기에 다른 곳으로 전달이 가능하다.
    • "가능"하다고 장점으로 단언하기 애매한 특징이다. 되려 오용하여 side-effect가 발생할 수 있기 때문이다.
  4. (단점) by lazy { ... }구문을 사용하여 전달된 lambda가 context가 가진 레퍼런스를 들고있을 위험이 존재한다.
    • Android에서 사용 시 lifecycle 객체가 해제되지 않는 문제가 발생할 수 있어 initializer lambda 함수 내부에서 사용되는 객체들에 주의를 기울여야 한다.

Late Initialization의 장단점

  1. (장점) Non-null 프로퍼티만을 허가하여 null허용에 따른 타입 모호성을 해결한다.
  2. (장점) Android의 경우 선언 위치는 상위이나 초기화는 하위 레벨에서 진행해야 하는 경우에 유용하다.
    • 뷰 관련 변수를 선언할 때 특히 많이 사용된다.
  3. (단점?) Lazy 타입과는 달리 lateinit은 오로지 initialize 여부에 따른 null을 저장할 뿐, 인스턴스를 저장할 수 없다.
    • 오히려 이렇게 제약을 걸어버리는 게 장점으로 작용될 수 있어 애매하다.
  4. (단점) 멀티 스레딩 환경에서의 initialization의 안전성은 오로지 유저 코드가 책임진다.

결론

성능에 도움이 된다고 모든 변수에 대해 초기화를 지연함은 코드 관리 측면에서 바람직하지 않다. 특정 변수가 여러 곳에서 쓰이고 참조값이 변경될 일이 없으며 초기화가 단 한번만 발생해야 하는 경우라면 Lazy Initialization을 사용할 수 있겠다. 변수가 여러 곳에서 쓰이나 초기화 코드를 변수 선언시점에서 정의할 수 없다면 Late Initialization을 사용함이 옳다.

참고자료

  1. Stack Overflow - Why Kotlin decompiler generates null.INSTANCE
  2. Baeldung - Why Kotlin lateinit Can’t Be Used With Primitive Types

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

안드로이드 개발자라면서 안드로이드에 대해 집중하여 공부한 적이 단 한번도 없었기에 해당 포스트를 작성합니다. 해당 포스트는 구글 안드로이드 개발자 문서와 몇몇 블로그 포스팅을 참고하여 작성되었습니다. 이번 포스팅에서는 인텐트가 무엇이며 인텐트에 데이터 보관/조회, 명시적or암시적 인텐트와 인텐트 필터, 펜딩 인텐트에 대해 기초적으로 다루며 API Level 31 기준 어떤 이슈가 존재하는 지 간략하게 정리합니다.

인텐트(Intent?)

인텐트(Intent) : 컴포넌트를 실행하기 위해 시스템에 전달하는 메세지 객체로 기능을 수행하는 함수를 제공하지 않고 데이터를 담는 클래스이다.
안드로이드의 컴포넌트 클래스는 개발자가 코드에서 직접 생성해 실행할 수 없기에 Activity 객체를 직접 생성할 수 없으므로 Intent를 시스템에 전달하여 컴포넌트를 실행해야 한다. 그리고 앱 내에서 메세지를 전달함에 그치지 않고 외부 앱의 컴포넌트와 연동할 때도 사용한다.
액티비티 등 안드로이드 컴포넌트를 manifest에 등록하는 것도 시스템에 컴포넌트에 알린 뒤 요청을 받은 Intent가 시스템에 요청하여 해당 컴포넌트를 실행하기 때문이다.

인텐트가 주로 사용되는 곳은 액티비티 시작, 서비스 시작, 브로드캐스트 전달 등이 있다.

인텐트 엑스트라 데이터(Intent Extra Data)

A 액티비티에서 B 액티비티를 실행하면서 데이터를 전송하려면 액티비티 객체에 매개변수로 넘기는 방법밖에 떠오르지 않는다. 그러나, 컴포넌트 객체는 시스템이 생성하므로 개발자 코드로 접근할 수 없기 때문에 불가능하다. intent에 컴포넌트 실행을 요청할 때 데이터를 함꼐 전달하기 위해 Extra Data를 사용한다. putExtra()메소드를 호출하여 그 안에 데이터를 추가한다. putExtra()메소드는 각 타입 별 데이터를 담을 수 있도록 오버로딩되어 있다.

val intent: Intent = Intetn(this, SecondActivity::class.java)
intent.putExtra("data1", "hello")
intent.putExtra("data2", 10)
startActivity(intent)

위 코드와 같이 컴포넌트를 실행하기 앞서 intent에 키-값 형태로 데이터를 넣는다. 컴포넌트가 실행된 뒤 함께 전달받은 데이터를 조회하기 위해 각 타입 별로 get[type]Extra()형태의 메소드를 지원한다.

val data1 = intent.getStringExtra("data1")
// 2번째 파라미터는 데이터가 없을 때 default value를 지정함을 의미
val data2 = intent.getIntExtra("data2", 0)

Explicit Intent, Implicit Intent

Explicit Intent(명시적 인텐트)

클래스 타입 레퍼런스 정보 등 컴포넌트 이름(Component Name)을 활용한 인텐트로 내부 앱의 컴포넌트를 요청하는 인텐트 객체를 만들 때 사용하며 외부 앱의 컴포넌트 호출하려면 패키지 명을 fullname으로 작성해야 한다.

val intent: Intent = Intent(this, SecondActivity::class.java)
// SecondActivity::class.java -> 클래스 타입 레퍼런스
  • 명시적 인텐트로 다른 앱을 실행할 수는 없는가?

    가능하다. ComponentName을 정확히 명시해서 다른 앱의 컴포넌트를 실행시킬 수 있다.

ComponentName componentName = new ComponentName(packageName,className);
Intent intent = new Intent();
intent.setComponent(componentName);
intent.putExtra("key","1234");
startActivity(intent)

Inplicit Intent(암시적 인텐트)

인텐트 필터와 연관이 있는 인텐트다. 외부 앱의 컴포넌트는 클래스 타입 레퍼런스를 사용할 수 없으며 매니페스트 파일에 선언된 인텐트 필터를 이용하여 속성을 지정한 뒤 코드로 호출한다. 암시적 인텐트를 호출한 액티비티로부터 안드로이드 시스템이 인텐트 객체를 전달받아 실행할 수 있는 액티비티 탐색 후 조건에 맞는 액티비티를 실행한다.

  • manifest.xml
<activity android:name=".FirstActivity"/>
<activity android:name=".SecondActivity"
android:exported="true">
<intent-filter>
<action android:name="ACTION_VIEW"/>
</intent-filter>
</activity>
  • classCode.kt
// "https://naver.com" uri를 보여줄 수 있는 아무 앱이나 실행
val intent = Intent()
intent.action = Intent.ACTION_VIEW
intent.data = Uri.parse("https://naver.com")
startActivity(intent)

인텐트 구성요소와 인텐트 필터(Intent Filter)

인텐트 필터는 매니페스트 파일에 정의하는 태그 집합이다. 해당 컴포넌트가 수신하고자 하는 인텐트의 유형을 표현한다. 인텐트 필터 정의 시 인텐트 구성요소를 사용한다. 시스템은 정의된 인텐트 필터들 중 통과되는 경우가 있을 때만 암시적 인텐트를 앱 컴포넌트에 전달한다.

인텐트에 포함된 기본적인 구성요소는 암시적 인텐트와 연관이 있고 인텐트 필터 태그 정의 시 비슷하게 등장하기에 함께 작성하였다.

Action, <action>

보통의 액티비티를 정의할 때처럼 name 속성만 매니페스트 파일에 명시한 경우 해당 액티비티는 명시적 인텐트로만 실행할 수 있다. action 태그의 name 값은 개발자가 임의로 지정할 수 있고 그 값이 앱에서 유일하지 않아도 되나 컴포넌트의 기능을 나타낼 것을 권장하고 있다. 예를 들어, 데이터를 보여주는 기능이라면 android.intent.action.VIEW라고 선언하거나 데이터를 편집한다면 android.intent.action.EDIT을, 데이터를 다른 앱을 통해 공유하려면 android.intent.action.SEND로 선언할 수 있다. 코드 상에서는 intent.action = Intent.ACTION_EDIT 와 같이 지정하거나 setAction() 함수를 사용한다.

코드 상에서도 직접 정의할 수 있는데 그 경우엔 앱의 패키지 이름을 접두어로 사용한다. (ex. const val ACTION_TIMETRAVEL = "com.example.action.TIMETRAVEL")

Action 권한 요구

findViewByID<View>(R.id.btnCMainCall).setOnClickListener {
val intent = Intent().apply {
action = Intent.ACTION_CALL
data = Uri.parse("tel:123-4567")
}
startActivity(intent)
}

위와 같이 버튼 클릭 리스너를 정의 후 버튼을 클릭하면 에러가 발생한다. 그 이유는 ACTION_CALL의 경우 코드에서 직접 전화를 걸게끔 하기 때문에 권한 요청이 필요하기 때문이다. 먼저 매니페스트에 <uses-permission> 태그를 사용해 권한 요청 선언을 하고 코드로 권한을 요구해야 한다.

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

위와 같이 매니페스트에 권한 요청을 등록해두고 사용자에게 권한을 요청하기 위해registerForActivityResult() 함수를 사용할 수 있다. Contracts 객체로 한 가지의 권한을 요청하는 RequestPermission 을 선언하고 Callback 객체를 lambda로 정의하자.

val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
// 사용자가 권한을 허용했으면 isGranted = True
if (isGranted) {
val intent = Intent().apply {
action = Intent.ACTION_CALL
data = Uri.parse("tel:123-4567")
}
startActivity(intent)
} else {
Toast.makeText(this, "Call Permission Denied", Toast.LENGTH_SHORT).show()
}
}

위 코드에서 isGranted를 수신하는 파라미터 변수명으로 쓰였는데 it이 아니라 isGranted로 파라미터 이름을 바꾼 이유는 RequestPermission 의 내부 코드를 보면 알 수 있다. 아래 코드는 RequestPermission 클래스의 정의와 parseResult 메소드 코드 일부를 발췌한 결과다.

public static final class RequestPermission extends ActivityResultContract<String, Boolean> {
@NonNull
@Override
public Boolean parseResult(int resultCode, @Nullable Intent intent) {
if (intent == null || resultCode != Activity.RESULT_OK) return false;
int[] grantResults = intent.getIntArrayExtra(EXTRA_PERMISSION_GRANT_RESULTS);
if (grantResults == null || grantResults.length == 0) return false;
return grantResults[0] == PackageManager.PERMISSION_GRANTED;
}
...
}

grantResult[0]의 값이 PackageManger.PERMISSON_GRANTED를 만족시키지 못하면 결과를 리턴하지 않는 것으로 보아 grant 여부가 중요해 보인다. 따라서, lambda 함수 정의 시에도 단순하게 수신하는 파라미터를 it으로 정의하지 않고 grant 여부를 알 수 있도록 isGranted로 명시함이 코드 관리 측면에서 좋다. launcher를 정의했으니 클릭 리스너 코드를 정의해보자. 아래 코드에서 분기문을 사용했는데 launcher 객체를 사용하여 이미 권한을 획득한 상태인 경우 더 이상 권한 요청할 필요가 없으므로 바로 인텐트를 실행한다. 권한 여부를 비교하는 상수는 RequestPermission() 내부 코드에서 볼 수 있듯이 PackageManager.PERMISSION_GRANTED 상수임을 확인했으므로 분기문 조건 내에 삽입했다.

val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
// 사용자가 권한을 허용했으면 isGranted = True
if (isGranted) {
val intent = Intent().apply {
action = Intent.ACTION_CALL
data = Uri.parse("tel:123-4567")
}
startActivity(intent)
} else {
Toast.makeText(this, "Call Permission Denied", Toast.LENGTH_SHORT).show()
}
}
findViewById<View>(R.id.btnMainCall).setOnClickListener {
if ((ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE)) == PackageManager.PERMISSION_GRANTED) {
val intent = Intent().apply {
action = Intent.ACTION_CALL
data = Uri.parse("tel:123-4567")
}
startActivity(intent)
} else {
launcher.launch(Manifest.permission.CALL_PHONE)
}
}

위 코드를 살펴보니 인텐트를 실행하는 코드가 중복이 되고 있다. 이걸 공통 함수로 정리하면 아래 코드와 같이 클래스 코드를 정의할 수 있다.

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
startCallActivity()
} else {
Toast.makeText(this, "Call Permission Denied", Toast.LENGTH_SHORT).show()
}
}
findViewById<View>(R.id.btnMainCall).setOnClickListener {
if ((ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE)) == PackageManager.PERMISSION_GRANTED) {
startCallActivity()
} else {
launcher.launch(Manifest.permission.CALL_PHONE)
}
}
}
private fun startCallActivity() {
val intent = Intent().apply {
action = Intent.ACTION_CALL
data = Uri.parse("tel:123-4567")
}
startActivity(intent)
}
}
  • Action 커스텀
val intent = Intent("NAVER", Uri.parse("https://naver.com"))
startActivity(intent)

위 코드를 실행하면 NAVER라는 액션을 갖는 액티비티를 찾을 수 없어 Exception이 발생할 것이다. 그렇다면 커스텀 액션 사용 시 사용자 기기에 해당 액션을 갖는 액티비티를 찾는 기능을 함께 적용해야 한다. 매번 개발자가 액션 존재 여부를 파악할 수 없으므로 resolveActivity 함수를 사용해 실제로 액션을 갖는 액티비티가 존재하는지 확인할 수 있다.

val intent = Intent().apply {
action = "android.intent.action.NAVER"
data = Uri.parse("https://naver.com")
}
// API Level 30부터는 manifest에 queries 태그를 선언해야 외부 앱 탐색이 가능하다
val resolveResult = intent.resolveActivity(packageManager)
if (result != null) {
startActivity(intent)
} else {
Toast.makeText(this, "resolve result is null", Toast.LENGTH_SHORT).show()
}
<quries>
<intent>
<action android:name="android.intent.action.NAVER"/>
<data android:scheme="https"/>
</intent>
</quries>

Category, <category>

category 태그는 컴포넌트가 어느 범주인지 등 추가 정보를 담는다. 개발자가 임의로 지정할 수는 있지만 대부분 플랫폼 API에서 제공하는 문자열을 이용한다. 예를 들어 android.intent.category.LAUNCHER는 런쳐가 실행하는 컴포넌트임을 의미한다. android.intent.category.BROWSABLE은 브러우저가 실행하는 컴포넌트임을 의미한다

암시적 인텐트를 수신하는 입장이라면 CATEGORY_DEFAULT 카테고리를 반드시 인텐트 필터에 포함시켜야 한다.

Data, <data>

data 태그는 컴포넌트가 어떤 성격의 데이터를 처리하는지 보여준다. 필요한 만큼 선언할 수 있으며 URI 형태로 표현한다. URI의 각 부분은 별도의 특성(scheme, host, port, path)으로 구성된다. android:scheme, android:host, android:port, android:mimeType 등의 속성을 이용한다.

  • URI(Uniform Resource Identifier)?

    리소스를 나타내는 식별자이다. <scheme>://<host>:<port>/<path>의 구조로 이루어진다.

  • MIME Type?

    전달되는 파일, 콘텐츠 식별자이다. type/subtype으로 이루어진다. 예를 들자면, text/plain, image/png, video/mp4 등이 있다.

코드에서는 MiME Type과 URI를 지정한다. 메소드는 setData(), setType(), setDataAndType() 3가지가 있으며 각각에 대해 서로 덮어씌우기 때문에 3가지 함수 중 1개만 사용해야 한다.

  1. setData() : 데이터 URI 설정

    public Intent setData(Uri data) {
    mData = data;
    mType = null;
    return this;
    }
  2. setType() : 데이터 MIME Type 설정

    public Intent setType(String type) {
    mData = null;
    mType = type;
    return this;
    }
  3. setDataAndType() : 두 가지 모두 설정

    public Intent setDataAndType(Uri data, String type) {
    mData = data;
    mType = type;
    return this;
    }

인텐트 필터 정의 시<scheme>://<host>:<port>/<path>의 형태로 요청하기에 각각에 대해 명시해야 한다. 다만, 순서대로 종속성을 갖기에 앞쪽 속성이 생략되면 뒤의 속성들도 모두 생략된다. content://com.example.project:200/folder/subfolder/etc/test.jpg uri의 데이터를 다룬다고 하면 다음과 같이 매니페스트에서 정의할 수 있다.

<data android:scheme="content"/>
<data android:host="com.example.project"/>
<data android:port="200"/>
<data android:path="/folder/subfolder/etc/test.jpg"/>
<data android:mimeType="image/jpeg"/>

코드에서 인텐트 전송 시 매니페스트에 선언된 데이터 태그 형식과 인텐트 데이터 설정이 정확히 일치해야 성공적으로 전송할 수 있다. 만약 매니페스트에서 URI와 MIME Type을 모두 선언했는데 코드에선 MIME Type가 누락되면 정상적으로 동작하지 않는다.

Extra

추가 정보를 담는 key-value이다. 인텐트를 이용해 액티비티 간 데이터를 주고받는 목적으로 많이 사용된다.

// 인텐트를 보내는 액티비티에서 데이터를 담음
intent.putExtra("foo", "bar")
// 인텐트를 받는 액티비티에서 조회하는 데이터 자료형에 따라 적절한 함수 호출
val data1 = intent.getStringExtra("foo")

Flag

액티비티를 어떻게 시작할 것인지 설정을 정의한다. launchMode로 이해할 수 있다. 코드 상에선 setFlags() 함수를 이용해 Intent.FLAG_ACTIVITY_CLEAR_TOP과 같이 정의할 수 있으며 직접 커스텀할 수는 없다.

메인 액티비티 인텐트 필터

<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>

위 xml 코드는 메인 액티비티를 매니페스트 파일에 정의한 형태이다. 여기선 action, category 태그가 보인다. 별다른 인텐트를 기대하지 않는 앱의 첫 화면이기에 action name은 MAIN으로 지정되었다. 매니페스트 파일에 인텐트 필터를 등록했기에 사용자가 런쳐 앱(외부 앱)에서 아이콘을 클릭해 실행할 수 있는 것이다. 외부 앱인 런쳐 앱은 시스템에서 LAUNCHER 카테고리의 정보를 가져와 앱 아이콘을 나열한다. 그렇기에 앱의 첫 화면에 해당하는 액티비티의 인텐트 필터는 action name을 MAIN으로 지정하고 category를 LAUNCHER로 지정해야 한다.

메인 액티비티가 아닌 다른 액티비티의 인텐트 필터

메인 액티비티가 아니더라도 인텐트 필터를 명시하면 외부 앱과 연동할 수 있다.

<activity
android:name=".SecondActivity"
android:exported="true">
<intent-filter>
<action android:name="ACTION_EDIT"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="https"/>
<data android:host="www.google.com"/>
</intent-filter>
</activity>

위 xml 코드대로 SecondActivity 정보를 매니페스트에 작성했다고 가정하자. 해당 액티비티를 시작하기 위해 인텐트를 사용하려면 인텐트 필터에 선언된 정보에 맞춰야 한다.

val intent = Intent()
intent.action = "ACTION_EDIT"
intent.data = Uri.parse("https://www.google.com")
startActivity(intent)

인텐트의 action과 data 프로퍼티에 실행 대상인 컴포넌트 정보를 지정하는 코드이다. 매니페스트 파일에 선언한 대로 컴포넌트 정보를 지정하였다. 프로퍼티에 설정하지 않고 생성자의 매개변수로 지정해도 된다.

val intent = Intent("ACTION_EDIT", Uri.parse("http://www.google.com")
startActivity(intent)

앞서 매니페스트 파일에 SecondActivity의 인텐트 필터 category 태그의 값을 DEFAULT로 지정했으나 코드에서는 설정하지 않았는데, 인텐트에 카테고리 정보를 전달하지 않으면 기본값으로 DEFAULT로 지정된다. 인텐트가 필터를 통과하기 위해 코드 상으로 요청하는 모든 인텐트 카테고리는 매니페스트에 선언한 카테고리 범주 내에 있어야 한다. 다만, 역은 성립하지 않기에 인텐트가 갖는 카테고리보다 필터가 더 많은 카테고리를 가지더라도 인텐트가 가진 카테고리 정보가 모두 필터에 존재한다면 통과된다. 따라서, 어떠한 카테고리를 지정하지 않은 인텐트 수신을 위해 매니페스트의 인텐트 필터 카테고리에 DEFAULT 추가를 권장하는 것이다.

<activity
android:name=".SecondActivity"
android:exported="true">
<intent-filter>
<action android:name="ACTION_EDIT"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
</intent-filter>
</activity>

위와 같이 scheme, host 태그를 제거하고 mimeType 태그를 새로이 선언했을 경우 인텐트를 다음과 같이 정의해야 한다.

val intent = Intent("ACTION_EDIT")
intent.type = "image/*"
startActivity(intent)

SecondActivity의 인텐트 필터를 하나 추가해보자.

<activity
android:name=".SecondActivity"
android:exported="true">
<intent-filter>
<action android:name="ACTION_EDIT"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="image/*"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data
android:scheme="http"
android:port="80"
android:host="naver.com"
android:mimeType="text/plain"/>
</intent-filter>
</activity>

그리고 버튼을 하나 추가하고 그 안에 리스너 코드를 작성하자.

findViewById<Button>(R.id.btnSecond).setOnClickListener {
val intent = Intent()
intent.action = Intent.ACTION_SEND
intent.addCategory(Intent.CATEGORY_BROWSABLE)
intent.setDataAndType(Uri.parse("http://naver.com:80"), "text/plain")
startActivity(intent)
}

만약 코드 상으로 BROWSABLE을 설정했는데 인텐트 필터에 정의가 되어있지 않아 수신할 컴포넌트가 없다면 error가 발생한다. 그러나 역으로 코드에선 설정하지 않고 인텐트 필터 상으로 정의해두면 필터 범주 내에 포함된 경우 정상적으로 동작한다. 위에서 정의한 버튼 리스너를 동작시키면 코드에서 암시적 인텐트를 전송한다. 이후 매니페스트에 SecondActivity 인텐트 필터로 해당 인텐트를 수신받게끔 선언했으므로 버튼 클릭 시 코드에서 전송한 인텐트를 SecondActivity가 받아 재생성됨을 확인할 수 있다.

Intent 수신 테스트

앱이 인텐트를 잘 수신하는지 테스트를 위해 매번 다른 앱을 이용하기에는 조금 번거로울 수 있다. 그럴 땐 terminal에서 ADB를 사용하면 된다.

adb shell am start -a <ACTION> -t <MIME_TYPE> -d <DATA> \ -e <EXTRA_NAME> <EXTRA_VALUE> -n <ACTIVITY>
adb shell am start -a android.intent.action.SEND -d http://naver.com:80 -t "text/plain"

개수에 다른 액티비티 인텐트 동작

인텐트로 액티비티를 실행할 경우 실행할 액티비티가 0, 1, n의 가지수를 갖게 된다. 다만, 명시적 인텐트는 클래스 타입 레퍼런스 정보를 이용하므로 액티비티가 없거나 여러 개일 수는 없다. 암시적 인텐트는 모든 경우에 해당될 수 있다.

  1. 실행할 액티비티의 개수가 0 : 인텐트를 시작한 곳에서 오류 발생
  2. 실행할 액티비티의 개수가 1 : 문제없이 실행됨
  3. 실행할 액티비티의 개수가 여러 개 : 사용자 선택으로 하나만 실행됨

인텐트로 실행할 액티비티가 없음

val intent = Intent("ACTION_FOO")
startActivity(intent)

ACTION_FOO라는 액션 값은 존재하지 않으므로 시스템에 해당 정보로 실행할 액티비티가 없을 것이다. 이 경우 ActivityNotFoundException 에러가 발생한다. 오류가 발생하면 앱이 강제 종료되는 상황이 발생하기 때문에 이에 대해 예외처리를 해야 한다.

val intent = Intent("ACTION_FOO")
try{
startActivity(intent)
} catch(e: Exception) {
Toast.makeText(this, "액티비티의 action 값을 찾을 수 없습니다", Toast.LENGTH_SHORT).show()
}

인텐트로 실행할 액티비티가 여러 개

인텐트로 실행할 수 있는 액티비티가 여러 개라면 사용자에게 선택할 수 있는 옵션을 제공한다. 다음의 코드는 암시적 인텐트를 실행하는 동작을 정의한다.

val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:37.7749,127.4194")
startActivity(intent)

특정 좌표를 검색하는 코드이다. 만약 사용자 단말기에 여러 가지의 앱(여기서는 지도)들이 설치되었다면 다이얼로그(App Chooser)를 띄워 어떤 앱의 액티비티를 실행할 것인지를 묻는다.

Android_Intent_001.png

만약 특정 플랫폼에 의존하는 앱을 제작한다면(구글이나 네이버 등) 사용자에게 옵션을 제공하기 보다는 특정 앱의 액티비티로 실행하고 싶을 수 있다. 이럴 때는 패키지 명을 지정해야 한다.

val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:37.7749,127.4194")
intent.setPackage("com.google.android.apps.maps") // 원칙적으로 구글 맵 앱을 실행
startActivity(intent)
  • App Chooser는 기본값으로만 활용해야 하는가?

    그렇지 않다. val chooser = Intent.createChooser(intext, "Custom Chooser Title") 의 코드를 첨부하여 startActivity 코드를 실행하면 원하는 타이틀이 작성된 Chooser가 화면에 출력된다.

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:37.7749,127.4194"))
/*
val intent = Intent(Intent.ACTION_VIEW)
intent.putExtra("body", "else?")
*/
val chooser = Intent.createChooser(intent, "Custom Chooser Title!")
startActivity(chooser)
}
}
  • 유저 단말기에 setPackage 메소드에 지정한 앱이 존재하지 않는 경우
    무작정 setPackage에 패키지 이름을 넣고 실행할 경우, 열고자 하는 앱이 설치되지 않은 단말기에서는 에러가 발생하며 강제종료된다. 따라서, PackageManager로 설치여부를 검사한 뒤 통과되지 않으면 setPackage 메소드로 경로를 지정하지 말아야 한다. 혹은, 앱 시작 시 원하는 앱을 필수 설치 항목으로 제한해야 한다. 간략하게 예제를 작성하면 다음과 같다.
class MainActivity : AppCompatActivity() {
private val context: Context = this
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:37.359413874091246, 127.10488305986283"))
try {
val packageManager:PackageManager = context.packageManager
packageManager.getPackageInfo("com.nhn.android.nmap", PackageManager.GET_ACTIVITIES)
intent.setPackage("com.google.android.apps.maps")
} catch (e:Exception) {
Toast.makeText(context, "don't have naver", Toast.LENGTH_SHORT).show()
}
startActivity(intent)
}
}

Pending Intent(보류 인텐트)

다른 Application에게 권한을 허가하여 내 Application 프로세스에서 앱 컴포넌트를 실행하는 것처럼 동작하기 위해 Intent를 wrapping하고 있는 Class이다. 즉, 액티비티의 실행 주체가 자신의 앱이 아님에도 실행할 수 있도록 한다.

  1. 사용자가 Notification으로 특정 작업 수행 시 인텐트가 실행되도록 선언

    LINE SNS 앱의 경우 새로운 메세지 푸시 알림 터치 시 라인 메세지 앱이 실행되는데 시스템의 NotificationManager가 라인 메세지 엑티비티를 실행하도록 하는 Intent를 실행한다.

  2. App Widget으로 어떤 작업을 수행할 때 인텐트가 실행되도록 선언

    App Widget이란 다른 앱에 삽입되어 추가적인 업데이트를 받을 수 있는 소형 애플리케이션 뷰다. 유튜브나 음악 스트리밍 앱 실행 중 홈 키를 누르면 메인 화면에 소형화된 상태로 축소됨을 볼 수 있는데 해당 동작이 홈 스크린 앱이 Intent를 실행한 결과이다.

  3. 향후 지정된 시각에 인텐트가 실행되도록 선언

    AlarmManager가 인텐트를 실행하는 동작이 포함된다.

Pending Intent 선언

인텐트는 앱 컴포넌트(액티비티, 브로드캐스트 리시버, 서비스)가 처리한다. Pending Intent 또한 각 컴포넌트와 연결해줘야 한다. Pending Intent 선언은 PendingIntent 클래스를 활용한다.

  • Activity를 시작하는 인텐트 정의 시 PendingIntent.getActivity()
  • Service를 시작하는 인텐트 정의 시 PendingIntent.getService()
  • BroadcastReceiver를 시작하는 인텐트 정의 시 PendingIntent.getBroadcast()

다음 코드에서는 액티비티를 시작하는 간단한 보류 인텐트를 정의한다.

// requestCode를 통해 PendingIntent를 수신할 때 구분하기 위한 id 값이다.
// intent를 통해 실제로 실행할 Intent를 정의
val requestCode = 1234
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT)

4번째 파라미터에 플래그를 선언했음을 볼 수 있다. 어떤 플래그가 있는지 확인해보자.

  1. FLAG_UPDATE_CURRENT : 이미 존재하면 덮어씌움

  2. FLAG_CANCEL_CURRENT : 이미 존재하면 cancel하고 재생성

  3. FLAG_IMMUTABLE : 이미 생성한 경우 새로 전송한 인텐트 파라미터 수정 요청을 무시

  4. FLAG_MUTABLE : 생성된 인텐트 수정 가능

    API Level 31에서 추가된 flag로 API Level 30 이전까지는 기본 값으로 mutable하게 처리되었으나 31부터는 pending intent 생성 시 mutability를 정확히 명시해야 한다. 물론, 레퍼런스에서는 FLAG_IMMUTABLE 사용을 강력히 권장하고 있다.

  5. FLAG_NO_CREATE : 생성하지 않고 이미 존재하는 인텐트를 가져옴

  6. FLAG_ONE_SHOT : 일회용으로 FLAG_NO_CREATE로 가져올 수 없음

Immutable Pending Intent 업데이트

레퍼런스에서는 API Level 30 이전까지는 PendingIntent의 기본값이 MUTABLE이었으나 이젠 Mutable Flag 사용 시 주의해야 한다고 설명한다. PendingIntent 업데이트를 하려면 변경가능한(mutable) 상태여야 할 것 같은데 immutable 상태에서 어떻게 수행해야 하느지 의문이 든다. IMMUTABLE 플래그를 적용한 상태에서 PendingIntent 업데이트를 하려면 추가로 FLAG_UPDATE_CURRENT 플래그를 함께 적용해서 생성하면 된다. 아래 코드에서는 API Level 수준을 고려하여 분기문을 적용하였다. IMMUTABLE 플래그는 API Level 23부터 추가되었기에 분기문 조건을 strict하게 설정하였다.

val requestCode = 1234
val pedingIntentFlag =
if (SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
requestCode,
intent,
pedingIntentFlag)

Mutable Pending Intent 사용

어떤 상황에서 Mutable한 Pending Intent를 사용해야 하는지 의문이 생긴다. 리서치를 조금 해본 결과 pending event가 mutable함을 보장받지 못하면 Geofences가(위치 정보를 사용하여 사용자가 특정 위치 반경에 진입/머물기/이탈 등의 이벤트를 처리할 수 있는 기능) trigger되지 않는다는 이슈를 볼 수 있다. 즉, 레퍼런스의 권장사항과는 달리 Mutable flag 사용이 강제되는 상황이 있음을 알 수 있다.

레퍼런스에서는 기능적으로 이미 존재하는 인텐트를 수정해야 하는 로직에서만 적용할 것을 권고하는데 PendingIntent that needs to be used with inline reply or bubbles.라는 예시를 제시한다.

  • inline reply

    푸시알림을 수신받았을 때 알림창에서 답장할 수 있게끔 동작하는 기능이 inline reply이다.

  • bubble

    페이스북 메신저 기능에서 볼 수 있듯이 다른 앱 위에서 floating하며 사용자가 의도하는 위치에 표시하는 기능

mutable pending intent를 사용해야 하는 경우 어떻게 인텐트를 수정하는지 코드로 작성해보자. 아래는 이제까지 작성해 온 PendingIntent 객체 생성 코드에서 flag 설정을 MUTABLE로 바꾼 결과이다. 인텐트가 실행되면 MainActivity가 실행된다.

val requestCode = 1234
val pedingIntentFlag =
if (SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val intent = Intent(this, MainActivity::class.java)
val mutablePendingIntent = PendingIntent.getActivity(
this,
notificationRequestCode,
intent,
pedingIntentFlag)

아래 코드는 기존에 생성했던 pendingIntent를 새롭게 추가할 정보와 함께 요청 코드를 수정하고 send 함수를 호출한다. PendingIntentsend 함수를 호출하면 인텐트를 실행한다. 따라서, send 함수를 호출하여 pendingIntent를 실행하면 이전에 getActivity로 액티비티를 지정한 값이 남아있으므로 해당 액티비티에 대해 startActivity 함수를 실행하되 수정된 requestCode와 추가된 message가 담긴 인텐트를 전달한다. 결과적으로 MainActivity가 열리면서 PENDING_INTENT_CODE로 전달된 인텐트로 메세지를 수신받아 동작을 하게 된다.

val intentWithExtraMessage = Intent().apply {
putExtra(EXTRA_MESSAGE_KEY, messageBody)
}
mutablePendingIntent.send(
applicationContext,
PENDING_INTENT_CODE,
intentWithExtraMessage
)

참고자료

  1. Android Developers - 인텐트 및 인텐트 필터
  2. All About PendingIntents - Mutable PendingIntents
  3. Android 12, PendingIntent Mutability, and Geofences
  4. Android Developers - PendingIntent
  5. Do it! 깡쌤의 안드로이드 앱 프로그래밍 with 코틀린

개발자이고 기술 블로그를 운영하는 입장에서 항상 분량 조절 문제는 언제나 발목을 붙잡는다. 현재 안드로이드에 대해 공부한 내용들을 정리하고 있는데 기본서를 기준으로 정리하자니 너무 분량이 얉으면서 넓고 구글 안드로이드 개발자 문서를 기준으로 정리하자니 겁나 딥하고 좁다. 게다가 개발자 문서에 달린 링크들을 파고들면 더더욱 그러하다. 표면적으로 동작하는 결과만 확인해선 발전이 없으니 결국 내부 동작 원리나 코드를 뜯어가다 보면 결국 해당 섹션을 별도의 게시글로 빼야 할 수준의 분량이 나와버린다.

본래 이 블로그를 시작할 땐 남들이 보던 참고를 하던 신경 ㅈ도 안쓰고 나를 위해 작성하자는 마인드였다. 시간이 지남에 따라 사람들이 은근 찾아오고 나조차도 종종 참고하러 들어오다 보니 이 부분에 대해 고민할 시점이 왔음을 체감한다. 이제껏 정리한 내용은 개발자가 되기 위해 작성을 해오다보니 뉴비 수준의 글을 작성했는데 이젠 그러기엔 기준이 올라갔으니 새로운 기준을 정립해야 한다. 어차피 블로그 내부를 순회하면서 모든 글을 훑기보다는 검색 기반으로 접근할테니 글의 수준이 섞이는 점은 고려하지 않아도 괜찮지 싶다.

한창 부서에 적응하느라 글만 오지게 쓰고 있을 뿐 정리를 못하고 있는데 어느정도 정리가 끝나면 시간을 내서 어떻게 시리즈를 구성할 것인지 고민을 좀 해봐야겠다. 분량만 따지면 거의 20-30개 어치가 나오고 있는데 미래의 내가 어떻게든 욕을 하며 치워주겠지

이번 글은 다소 감정이 격앙된 상태에서 작성하였다. 내 잘못이 없다고는 못하겠지만 다시 생각해봐도 너무 불합리한 제도장치라고 생각하기에 글을 써본다. 이전까진 그래도 약간의 국뽕과 국가관이 조금은 남아있던 사람이었는데 이번 기회에 아무 미련없이 털어버릴 수 있는 기회가 되었다. 만약 과태료 처분까지 나오게 된다면 소송절차까지 진행해볼까 고민이다. 다만, 판례로 미루어보아 법 자체의 문제로 인해 과태료 부과를 철회하긴 어려워 보이니 그냥 적게 나오길 바랄 수밖에... 공무원으로 나라를 위해 일하다 생계를 위해 재취업하여 민간인 신분으로 전환된 사람에 대해 취급이 이리 미개했다니 어이가 없다.

사건의 시작

필자는 경찰 공무원으로써 겅위 직급으로 재직하다가 네이버 신입 공채 시험에 합격하여 현재 네이버 게임 앱 개발부서에 근무하고 있다. 으레 알다시피 공직자윤리법에 의거하여 재산등록의무자였던 퇴직 공직자는 퇴직 후 어딘가에 취직하려고 한다면 공직자윤리위원회에 취업심사를 받은 후 취업해야 하며 심사 결과 밀접한 관련성이 있는 경우라면 제한된다. 그런데 가만 생각해보면 새롭게 취업한 기관이 기존에 했던 업무와 관련성이 없다면 문제가 없어야 함이 지당하다는 생각이 든다. 여기서 한 가지 독소조항처럼 보이는 조항이 있다. 이에 대해 짤막한 이야기를 써보려 한다.

취업제한제도의 독소조항

제18조 제1항을 위반하여 취업제한 여부의 확인을 요청하지 아니하고 취업제한기관에 취업한 사람에게는 1천만 원 이하의 과태료를 부과한다(제30조 제3항 제2호).

개인적으로 재취업한 공직자의 업무 관련성 검증 프로세스는 필요하다 생각한다. 다만, 위 조항에는 치명적인 문제가 있다. 미리 채용 담당자로부터 합격을 확답받고 사전에 취업제한 확인 신청을 하지 않고서야 대부분 퇴직 공무원들은 합격 확정 후 입사까지 길어야 2주 정도의 시간을 갖는다. 게다가 공개채용 시험으로 합격한 경우 다른 지원자들의 일정을 진행해야 하기에 취업 절차를 진행하지 못한 당사자는 고스란히 심사 결과가 나오기까지 그 시간을 버려야 하고 간략하게 요약하거나 자료를 공유할지언정 특정 지원자를 위해 이미 지나간 일정에 대해서 챙겨주지 않는다. 관계자에게 들어보니 합격 소식을 듣자마자 취업제한 심사 요청을 신청한 뒤 회사가 요구한 입사 일자에 취업한 경우 만약 취업하는 일자에 심사 결과가 나오지 않았다면 과태료 부과 대상자가 된다. 결국 아무리 빠르게 움직인다 하더라도 손해를 감수하라고 국가 법령이 말하고 있다. 저 조항을 요약하자면 말 그대로 "괘씸죄"를 명문화한 것으로밖에 보이질 않는다.

위 조항을 보고 합리적으로 생각하는 사람이라면 취업 절차를 진행한 이후 심사를 받더라도 업무 관련성이 없으면 문제없는 것 아닌가? 라고 의문을 품을 수 있다. 그러나 제도의 현실이 이러하다. 업무 관련성이 없어 "취업 승인" 판정이 나오더라도 이미 확인을 요청하지 아니하고 제한 기관에 취업했으므로 어쨋건 부과 대상이다.

열악한 퇴직 후 취업제한 여부 확인 안내 절차

심지어 취업 심사 요청을 위해 공직자윤리위원회에 특정 양식을 작성한 뒤 전달해야 한다. 서류들은 본인과 취업 기관이 작성해야 한다. 그런데 웃긴 점은 이러한 사실을 취업한 이후 4개월이 지나고서야 심사요청을 하지 않았다면서 이전 소속기관 청문감사관실로부터 연락을 받아 알았다는 점이다. 적어도 퇴직할 때 제출할 양식정도는 줄 수 있는 것 아닌가? 그런 건 전혀 받은 바 없이 심사를 받아야 한다는 사실 하나만 문자로 전달해주고 연락이 없다가 이제서야 감사결과에 기록이 되니 당사자에게 과태료 대상자라면서 사유서까지 작성받았다.

공직윤리위원회 사이트에 가서 관련 양식을 다운 받아서 처리할 수 있지 않느냐라고 물어볼 수 있겠으나 관련 양식을 다운받아서 보면 어느 공직윤리위원회로 송신해야 하는지 OO로 처리되어 있다. 결국 당사자가 직접 발품을 뛰어서 찾아봐야 한다는 소리이다. 업무와는 전혀 관련없이 본인의 노력으로 공채 시험에 합격하였는데 "무사히" 합격하기 위해 정부 기관에다가 발품을 뛰어가며 확인해달라 읍소해야 하는 상황인 것이다.

결론

저렇게 법률을 만든 것으로 보아 뭘 하건 본인에겐 과태료 처분이 내려질 예정이고 그게 꽤 많은 액수일 것임이 자명하다. 솔직히 3개월이 지나고서야 취업제한 확인 요청을 한다는 사실 자체가 필자의 잘못이 없다고는 못하겠으나 합격사실을 듣자마자 신청했어도 높은 확률로 과태료 부과 대상자가 되거나 고스란히 신입 적응을 위한 온보딩 기간을 날려야 한다는 점이 너무나도 화를 주체할 수 없게끔 만들었다.

이번 사건을 계기로 그나마 털끝만큼 남아있던 애국심과 국가관을 말끔히 털어낼 수 있었다. 앞으로 만나는 사람들 중에 필자 앞에서 국가관과 사명감, 애국심을 들먹인다면 안타까운 눈으로 바라봐주지 않을까 싶다.

막연히 미국 기업으로의 이직과 영주권 취득에 관심을 갖고 있었는데 더더욱 열심히 준비해서 한국이라는 국가에서 얻을 수 있는 혜택만 열심히 취하는 성실한 얌체족으로 흑화할 듯한 기분이다.

진행상황

이 글을 작성한 게 지방경찰청으로부터 취업제한심사 관련 소식을 듣고 분노에 찼을 때였는데 저번주에나 취업심사 결과가 나왔습니다. 정확히는 6월 24일에 심사가 완료되었고 제게 통보된 건 6월 29일이군요. 지방경찰청에서 저와 관련된 서류를 취합하느라 4월 19일이 아니라 거의 25일 쯤에 심사 신청이 되었을테니 사실상 2달 가까이 소요되었네요. 결국 제가 직접 12월 중순 합격 소식을 받자마자 심사 신청을 했어도 심사 결과가 나오는 시점은 거의 2월 중순일겁니다. 물론 심사결과는 "취업 가능"이었습니다 ㅎ...

네이버 공개채용은 굉장히 관대한 편이라 6개월은 유예시켜주긴 합니다. 다만 이는 졸업을 앞둔 학생을 위해 운영하는 지침으로 저처럼 굳이 학업이 중요하지 않은 사람에게는 유예할 이유가 전혀 없죠. 즉, 저는 취업심사 결과가 가능하다고 확신함에도 하반기 채용 합격자였던 저는 심사결과가 나오기까지 사실상 2달이 걸리기 때문에 입사 유예를 신청하여 상반기에 입사하거나 하반기 초반 신입사원 적응을 위한 2개월의 시간을 고스란히 허비한 채로 입사를 해야 합니다. 사실상 이게 가장 불합리한 점이 되겠네요. 사후 심사 결과로 "취업 가능" 결과가 나오더라도 결국 신청한 뒤 결과가 나오기 전에 취업했으니 법령 위반 대상자가 된다는 점이 어이가 없습니다 ㅋㅋ 이걸 우리는 "괘씸죄"라고 부르기로 했어요 씨발롬들아...

확인서 말미에 법령 위반 대상자이니 법원에 과태료 처분 대상자 통보했다는 문구가 이렇게 열뻗치게 만들다니 재주가 좋네요 아주 ㅋㅋ 과태료 부과에 이의 신청 하려면 받은 날로부터 7일 이내에 해당 법원에 신청해야 한다니까 처분 소식 나오고 금액에 따라 얌전히 내야할 지 이의신청을 할 지 지켜봐야겠군요. 저보다 앞서 카카오로 이직하셨던 선배도 과태료 처분 대상자였다던데 금액이 얼마인지 들어나봐야겠어요. 월급이 매달 350정도 되는지라 3개월이나 지난 시점에서 과태료 1천만원 풀로 때리지 않을까 걱정되긴 하네요 ㅋㅋ 해당 사안은 정치권에서도 굳이 관심가질 사안은 아니라 개선될 기미가 보이지 않는다는 점에서 굉장히 암울합니다 ㅅㅂ

읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

NAT Traversal에 대한 개념을 찾던 중 STUN은 Symmetric NAT에서는 동작하지 않는다고 하는 등 사전 지식으로 NAT Type을 요구하기에 먼저 정리했습니다. 이번 포스팅에서는 Cone NAT와 Symmetric NAT 중심으로 작성했으며 Full Cone, Restriced Cone, Port Restricted Cone, Symmetric에 대해 설명합니다. 비록 본문의 출처인 RFC3489는 RFC5389로 대체되었으나 NAT Type에 대한 내용은 P2P 통신에서 필요한 개념으로 해당 문서를 참고하였습니다.

Cone NAT

Cone NAT는 NAT 장비가 NAT 네트워크의 Host에 대해 공인 IP:Port로 매핑한 주소가 변하지 않는다는 특징을 갖는다.

Full Cone NAT - 제한사항 없음

가장 개방적인 형태의 NAT 방식으로 외부 네트워크 누구든지 NAT 장비에 매핑된 Public IP:Port로 접근 시 내부 네트워크 Host에 통신을 시도할 수 있다.

Network_NAT_Type_001

앞서 Cone NAT 방식에 따라 NAT 장비가 Host의 외부 Public IP를 매핑해두면 외부 네트워크의 상대방은 해당 주소로 언제든지 통신을 시도한다. 따라서 Host가 Server A에 패킷을 전송하거나 Server A가 Host로 패킷을 전송할 수 있다. 게다가 연결했던 기록이 없는 Server B가 Host로 패킷을 바로 전송할 수도 있다.

Restricted Cone NAT - ip 검증

Network_NAT_Type_002

이전에 Host가 외부로 데이터를 전송한 적이 있는 ip만 허용한다. 따라서, Server B처럼 통신한 적이 없는 주소로부터 패킷을 받으면 드랍한다. 다만 Server A의 경우 통신한 적이 있기에 다른 포트인 Service a, b는 일방적으로 Host에게 패킷을 전송할 수 있게 된다.

Port Restricted Cone NAT - ip/port 검증

Network_NAT_Type_003

Restricted Cone NAT와 비교해서 Port까지 제한사항이 생겼다. 말 그대로 이전에 통신하던 IP 주소와 Port가 일치해야만 패킷을 수신하겠음을 의미한다. 즉, 기존에 통신하던 Service가 아니면 모든 Service는 일방적으로 Host에게 패킷을 보낼 수 없는 환경임을 의미한다.

Symmetric NAT - ip/port 검증

Symmetric NAT는 연결된 Destination에 따라 다른 외부 IP:Port로 매핑된다는 특징을 갖는다. 외부 네트워크 통신에서의 제약사항은 Port Restricted Cone NAT와 동일하게 적용된다. 다만, Host의 Private NAT에 대해 NAT 장비가 매핑하는 정보가 통신 대상에 따라 달라진다.

Network_NAT_Type_004

그림에 따르면 각 통신 대상에 따라 NAT 장비는 매핑 테이블에 각기 다른 정보를 기록해서 작업을 수행한다. 따라서 고정 매핑 방식이 아니라 가변적인 매핑 방식으로 이해할 수 있다.

출처 : RFC3489 - STUN

+ Recent posts