읽기 전

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

안드로이드 개발자라면서 안드로이드에 대해 집중하여 공부한 적이 단 한번도 없었기에 해당 포스트를 작성합니다. 해당 포스트는 구글 안드로이드 개발자 문서와 몇몇 블로그 포스팅을 참고하여 작성되었습니다. 이번 포스팅에서는 인텐트가 무엇이며 인텐트에 데이터 보관/조회, 명시적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

1월, 2월
새해가 되었다고 해서 특별히 달라진 점은 없었어서 11월부터 시작했던 알고리즘 공부를 계속 하고 있었다. 개발하던 프로젝트도 런칭했고 당장 구현할 기능이 없으니 매일같이 스터디카페에 도장찍는 일상이었다. 그런데 1월 중순에 집에서 스트레칭을 하던 중 목이 삐끗하는 감각과 함께 통증이 찾아왔다. 참고 공부를 해보려 했지만 목을 똑바로 세울 수가 없어 도저히 할 수가 없었다. 정형외과에 가보니 목디스크가 와도 이상하지 않을 수준의 거북목이 있었고 척추도 상태가 그리 좋지 않다는 소견을 받았다. 어쩐지 대학생 시절 학회에서 찍은 사진에서부터 심상치 않다 싶었다. 어릴 때부터 그리 운동을 하는 편도 아니었어서 좋을리는 없다는 예상을 했지만 생각보다 너무 심각한 수준이었다. 즉시 주사치료를 받고 통증이 가라앉으면 도수치료를 진행해야 한다는 의사 선생님의 강요를 받았다. 게다가 1월 말 경에 이사를 하면서 장비 세팅도 되어있지 않았고 이리저리 어수선한 집 분위기에 옳다구나 하고 1-2월 동안 신나게 게임하면서 치료에 집중했다. 덕분에 약 1개월 반을 시원하게 날려먹었다.
집에서 요양하는 동안 새로운 기능추가 이슈가 있었어서 공부보다는 프로젝트 구현에 초점을 맞췄다. 코드를 나름 신경쓰며 작성했다 생각했는데 역시나 변수나 로직 측면에서 많은 지적을 받아 리퀘스트에 달린 리뷰를 수정하는데만 며칠을 소요했던 기억이 있다.

3월
공부를 스터디카페에서 하는데 다소 작은 그램 노트북으로 공부를 해서 그런지 편한 자세가 나오질 않아 공부보다는 프로젝트에 집중했던 것 같다. 치료에도 얼추 차도가 있어 그래도 다시 공부를 조금씩 시작했다. 프로젝트는 REST로 동작하던 API를 GraphQL로 바꾸자는 의견이 있어 AWS AppSync를 써보기도 하다가 결국 Lambda의 Python Graphene 모듈 사용을 채택했다. 전환이유는 AppSync에 몇 가지 문제가 있었는데 Velocity 템플릿 언어가 강제였고 버전 관리가 전혀 이루어지지 않아 동시작업이 불가능했기 때문이다. 아는 동생을 영입해서 같이 둘이서 API Migration 작업을 했는데 이 친구는 병역특례로 이미 개발자로 활동 중이라 역시나 큰 도움이 되었다. 내 구직 활동이 길어짐에 따라 아직 도입되진 않았지만 그래도 무사히 Migration을 수행하고 관련 내용을 문서로 정리했다.

4월
API Migration이 끝나고 코딩테스트를 대비해 알고리즘을 좀 더 견고히 만드는데 집중했다. 알고리즘 문제를 풀면서 막혔던 문제들을 복습하며 블로그에 올리는 시간을 가졌다. 프로젝트에선 커뮤니티 기능을 만들자는 의견이 있어 최대한 빠르게 테스트 앱을 만들어보자는 목표를 스스로 세워봤다. 다만 허술하게 만들면 안되니 체크리스트를 만들어 뭘 구현해야 하는지, 어떤 기능을 지원해야 하는지 정리하면서 테스트 앱을 만드는 시간을 가졌다. 아직도 완성하진 않았지만 실제 서비스되고 있는 커뮤니티 앱인 '아카라이브' 앱의 UI/UX 동작이 어떻게 되는지 구성요소 별로 사용자 액션을 줬을 때 동작하는 방식을 눈으로 보고 코드를 뜯어보면서 어떤 UI 컴포넌트가 들어갔는지 알아보면서 비슷하게 클론코딩을 시도해봤다. 비슷하게 텍스트 위주로 앱 구현을 했으나 작성된 글자를 취합해서 API에 전송하고 조회하는 로직에 문제가 생겨서 그런지 생각보다 잘 동작하진 않았다. 그래도 안드로이드 컴포넌트들 중 액티비티를 슬라이딩하면서 진입한다던지 Dialog를 커스터마이징 한다던지 소득이 없지는 않았다.

5월
약 6개월에 걸쳐 상길북 1회독을 완수했다. 그래도 분명 막히는 문제가 있을 것으로 보여 온전히 책을 정복하기 위해 항상 하던대로 알고리즘 문제를 풀고 막힌 문제는 답을 보고 이해한 뒤 다음 날 다시 풀어보고 종이로 다시 포스트를 작성하는 방식으로 최대한 기억에 남길 수 있도록 노력했다. 그리고 자료구조도 블로깅하겠다고 결심하여 정말 많은 블로그 포스트들을 참고하면서 파이썬으로 구현한 내용을 포스팅했다. 작년에 부모님이 제대로 된 건강검진을 받은 적이 없던 게 생각이 나 돈은 차마 지원하지 못 해드렸지만 강제로 신촌 세브란스 병원에 건강검진을 받으라고 강요했고 생각보다 발견된 게 많아 신촌 세브란스 병원으로 이관되어 치료를 이어가던 중 수술이 필요하다는 소견을 전해들었다. 심적으로 부담이 있긴 했지만 직접 수술받는 환자가 아니니 내색하지 않으려 노력했다. 되려 부모님께는 왜이리 반응이 없냐는 핀잔을 받기는 했다.

6월
수술날짜가 잡히고 내가 간병인으로 들어가기로 했다. 아무래도 동생은 학원일정이 있으니 어느정도 일정이 소화되는 동안 차도를 보면서 판단을 내리기로 했다. 그 사이 진도를 최대한 나가야 한다는 의무감으로 변함없이 자료구조를 블로그에 정리했다. 책으로 문제를 풀면서 알고있긴 했지만 막상 정리해보니 배운 내용에 구멍이 조금 있었다. 나는 학원을 전혀 다니지 않고 오로지 책과 다른 분들의 블로그에 의존해서 공부를 하고 있었으니 병원에 들어가서도 공부를 할 수 있으리라 예상을 했는데 나라는 사람의 의지는 그리 강하지 않았다. 힘들지는 않았지만 어쨋건 환자의 상태를 옆에서 계속 지켜봐야 했어서 결국 자료구조를 정리하는 작업은 포기하고 DFS, BFS, 브루트포스에 속하는 문제를 백준사이트에서 검색해 풀었다. 아무래도 코테에 빈출하는 주제다보니 노는 것보단 낫지 않겠냐는 판단이었다. 간병 자체는 힘든 게 없었지만 간병인은 침대 옆의 간이의자에 누워 자야했어서 허리가 아픈 것이 무엇보다 힘들었다. 그래도 돈을 지원해드리진 못했지만 강요하여 비교적 초기에 발견했으니 공부를 약 1달간 못했어도 크게 아쉽진 않았다.

7월
퇴원절차를 마치고선 부모님은 자취하던 집에 잠시 머무르셨다. 그래도 내가 해야할 일은 환부 드레싱을 가끔 하는 것 말고는 없었으니 다시 공부에 집중할 수 있었다. 역시나 알고리즘을 정리해 포스팅하고 정리한 주제에 속하는 문제를 찾아 푸는 일상의 반복이었다. 7월 말에 가서는 얼추 책에 있는 알고리즘을 모두 정리하여 슬슬 문제를 풀고 백준과 리트코드를 더불어 프로그래머스에도 눈을 돌리기 시작했다. 다만, 백준의 '단계별 문제 풀기' 내용이 꽤 좋아 백준에 몰두했다. 네이버 웹툰 채용 챌린지에 참가했는데 백엔드 직군은 자바로 언어제한이 걸려있어서 다른 직군으로 지원해서 문제만 풀어봤다.

8월
책의 내용은 모두 정리했으나 백준의 '단계별 문제 풀기'의 주제들을 계속 정리했다. 그리고 프로그래머스의 '고득점 Kit'의 문제가 좋다고 하여 역시나 풀면서 정리했다. 당시엔 레벨 2 문제도 막히는 게 많아 블로그에 올리는 빈도가 잦았다. 8월 부턴 채용이 빠른 기업들의 경우 코딩테스트를 슬슬 시작할 때라 하나씩 지원서를 넣기 시작했다. 토스에서 채용 챌린지를 하기에 문제를 풀어봤는데 6문제 중 5문제를 풀었음에도 2차 테스트가 있다는 걸 놓쳐서 불합격하고 말았다.
기술면접을 염두에 두고 CS를 공부해야겠다는 결정을 내렸다. 앞서 2020 하반기 카카오 공채에 합격해 입사하신 학교 선배에게 책을 추천받아 OS를 정리했다. 알고리즘은 리트코드 매일 챌린지를 보면서 이지-미디엄 난이도인 경우 풀어서 블로그에 올리는 루틴을 이어나갔다. 그렙 채용 챌린지에 참가하여 올솔을 했으나 이력서에 백엔드 이력이 없어서 그런지 불합격했다. 이후 레드블랙 트리 등 복잡한 자료구조 포스트를 정리하기도 하고 MOBIS 알고리즘 대회에 참가도 했다. 문제가 정말 어려웠는데 본선에 가려면 만점을 받아야 했다는 말을 듣고 갈길이 멀다는 생각을 했다. 그래도 나름 점수를 확보해서 그런지 몇달 후 서울모터쇼 2인 티켓을 받아 학교 동기에게 전달해줄 수 있었다.

9월
9월의 가장 큰 행사는 2022 KAKAO BLIND 코딩테스트가 있었다. 이를 위해 아껴두었던 카카오 코딩테스트 인턴/공채 기출을 모조리 풀어보기 시작했다. 겁나 어렵긴 했지만 시간을 두고 측정했을 때 합격컷은 적당히 나오길래 나름 자신감이 생기긴 했다. 문제는 코딩테스트 날짜가 나오고서 라인과 카카오가 동일한 날짜에 개최되었다. 라인 코딩테스트를 오전에 응시했다. 6문제 중 5문제를 풀어 얼추 통과하겠다는 생각이 들었다. 투포인터 위주로 나와 생각보다 애를 먹는 지원자가 많았으리라 생각한다. 문제는 카카오 공채였는데 123번까지 약 1시간 동안 풀고 5번의 정확성을 30분에 풀어낸 뒤 남은 3시간 반 동안 한 문제라도 풀면 되겠다는 생각으로 임했는데 무슨 생각이었는지는 모르겠지만 마지막 7번을 가지고 거의 2시간을 삽질하는 등 너무 안일하게 시간을 소비하여 결국 3.5솔로 마무리했다. 분명 5번이 2차원 누적합이라는 생각을 했고 그에 대한 키워드까지 찾아냈어서 검색까지 했는데 누적합에 대해 정리한 적이 없어 보면서도 빠르게 이해되질 않아 못 풀어낸 게 패착이라 생각한다. 나름 카카오만을 생각해 알고리즘을 열심히 약 1년 간 공부했는데 말아먹어 끝나고서 너무 서러운 마음이 들었다.
서러운 건 서러운거고 라인 필기테스트가 있을 예정이니 카카오는 깔끔하게 포기하고 블로깅할 시간은 부족하니 다른 사람들이 신입 개발자를 위한 기술면접 개념 정리 깃헙을 참고하면서 정말 빠르게 벼락치기를 시작했다. 그리고 라인 필기테스트에 응시했는데 풀면서도 이건 벼락치기로 커버될 내용이 아님을 깨달았다. 그래서 합격은 포기하고 최대한 공부를 한다면 어느수준까지 공부를 해야하는지 머리 속에 문제를 저장하고 나왔다. 라인은 CS를 아직 다 정리하지 못했다는 생각이 들어서인지 필기테스트를 망했음에도 크게 아쉬운 마음이 들진 않았다. 다만 당시에 네이버 서류를 통과하지 못할 거란 예상을 했어서 깔끔하게 대기업 공채는 포기하고 프로젝트 보강을 해야한다는 판단을 내렸다. 혼자 프레임워크를 공부해 프로젝트를 만든다면 너무나도 오랜 시간 헤매게 될 거란 결론을 내리고 학원을 찾아본 결과 F-Lab이라는 백엔드 개발교육 전문 학원을 찾아 등록했다. 그리고 거기서 사전 배우고 오기를 추천하는 도서들을 보면서 네트워크 개념을 정리해 포스팅했다. 당시엔 정말 자존감이 많이 낮아져 기준이 다소 낮아진 상태였지만 학원에 등록한 이상 최대한 상향지원을 하되 잘 풀리지 않으면 내년 상반기까지 봐야한다는 생각이 들어 가사휴직 요건을 알아보기도 했다.

10월
네이버 자소서가 통과되어 코딩테스트 응시자격이 주어졌다. 나로서는 굉장히 놀랄만한 소식이었다. 왜냐하면 학교 선배는 애당초 코딩테스트 응시기회조차 주어지지 않았기에 나 또한 힘들 거란 예상을 했기 때문이다. 그러나 자격이 주어졌고 4문제 중 3문제를 해결했다. 당시엔 자소서 + 코딩테스트 결과로 통과시킨다는 의견이 많아 3개를 풀었음에도 합격할 거란 확인이 서지 않았다. 불안감에 떨어봐야 답이 없으니 학원에서 진행하는 자바학습에 집중했다. 그래도 매칭된 멘토님 덕분에 공부를 하는 방법과 공부하다 막힌 개념에 대한 답변을 들으며 블로그에 자바 관련 포스팅을 할 수 있었다. 과거에 언어에 대해 정리하기엔 너무 기본적인 내용이고 방대해 포스팅하지 않기로 결정했지만 기본서가 아니라 조금 찾아봐야 알 수 있는 개념들은 포스팅하기로 했다. 같이 팀으로 매칭된 팀원 분도 의욕이 대단하여 질문의 깊이가 상당했다. 덕분에 파트너가 질문한 내용들도 찾아보면서 더 깊은 공부를 할 수 있었다. 개발자들이 스터디를 꾸준히 참여하는 이유를 알 수 있던 시간이었다.
10월 말에 Dev-Matching 백엔드 포지션이 있어 기업 몇 군데를 골라 지원했다. 문제는 전부 풀어서 제출했으나 이력서를 보고 역시 대다수의 기업에서 탈락했다. 그런데 생각보다 살아남은 기업들이 좋아 의외긴 했다. 그리고 Allganize 코리아에서 첫 번째 기술면접을 봤는데 지금와서 생각해보면 이 면접에서 발생한 대참사 덕분에 이후 면접에서 블로그로 정리하긴 했지만 면접 전에 복습하지 않으면 큰일난다는 정말 소중한 교훈을 얻었다. 이후 응시한 기술면접에서는 적어도 대참사가 발생하진 않았다.
별개로 NHN과 네이버 파이낸셜 등 다른 코딩테스트도 응시했는데 아직 자바에 능숙하지 않아 문제를 잘못 풀거나 과제 테스트가 있어 포기했었다. 카카오 엔터프라이즈 pre 인터뷰를 진행했는데 면접자 3, 면접관 2로 진행되어 하나의 주제에 대해 서로 다른 3개의 질문을 돌아가면서 답변하는 방식이었다. 정말 기본적인 자료구조나 알고리즘, 네트워크 지식을 물어보는 방식이고 할당된 질문이 별로 없어 꽤 유창하게 답변할 수 있었다.

11월
슬슬 자바 7버전에 대해 기본서 학습이 끝나고 있었다. 네이버 코딩테스트에 통과하여 1차 면접을 응시했는데 생각보다 대참사가 발생하진 않았지만 마지막 기술 질문에 말려들어가 불안한 나날을 보냈다. 그리고 백엔드 데브매칭으로 추가 전형을 진행한 카카오 엔터프라이즈도 1차 면접을 봤다. 기술면접 느낌으로 몇 가지 CS 관련 질문을 하면서 계속 꼬리물기를 한다던지 사내에서 겪는 어려움을 제시하면서 어떻게 해결할 것인지 논의하는 면접이었다. 할당된 시간은 1시간이었는데 말하다보니 재미있기도 하고 면접관 분들도 내 이력에 관심이 있었어서 그런가 20분을 초과해 1시간 20분 동안 진행했다.
Dev-Matching 실리콘 밸리도 참가했는데 MOLOCO가 TO에 있어 깜짝 놀랐다. 개인적으로 MOLOCO는 세미구글이라고 밸류를 매긴 기업이었기 때문이다. 결과는 간신히 올솔하여 36등에 랭크되었다. 순위권에 들어온 건 또 처음이라 기분이 좋기도 했다.

12월
슬슬 F-Lab에서 프로젝트에 대한 이야기가 나오기 시작했다. 평소 살면서 불편했던 점들을 하나씩 뽑아서 주제화시키느라 주제를 정하는 데는 큰 어려움이 없었다. 프레임워크와 함께 온전한 서비스 구현 사이클을 배우려고 왔는데 큰 착각을 하고 있었음을 깨달았다. 설계 단계에서부터 기초를 단단하게 만들어야 했는데 이제까지 견고하게 설계해온 줄 알고 있었다. MOLOCO에서 진행한 자체 코딩테스트를 통과하고 면접에 응시했다. 최초의 구글 방식의 알고리즘 라이브코딩 면접이었는데 나름대로 문제를 잘 푼다고 생각했는데 면접에서 푸는 것은 완전히 다른 문제였다. 엄청나게 실수가 잦았고 한국 기업만 우선 준비하느라 DP에 굉장히 취약한 상태였는데 아니나다를까 DP문제에다가 누적합 문제가 출제되었다. 이 놈의 누적합은 카카오부터 계속 나를 괴롭히는 듯하다. 정말 면접관 분이 배려를 많이 해주신 덕에 PTSD로 남진 않았다. 앞으로 PS를 할 때 어떻게 문제를 접근하고 풀이 방법을 설계해나가야 하는 지 감을 잡을 수 있던 시간이었다.
네이버 2차 면접응시 기회가 주어져서 인성면접 준비에 박차를 가했다. 이번에야말로 면까몰이 없게 만들겠다 마음을 먹고 준비를 했으나 역시 시간이 부족해서인지 석사과정 관련한 질문이 들어왔을 때 얼버무리면서 다시 끝나고서 일말의 불안감을 남기고 말았다. 그래도 전체 내용을 온전히 복기해냈기에 다음 면접에 들어갈 때 보강할 수 있도록 조치를 했다. 이후 합격을 하면서 석사과정 질문은 그대로 저편으로 보내게 되었다. 사실 석사과정을 졸업할 생각도 없고 학교 선배가 해외 대학원 CS 석사과정을 굉장히 힘들고 실패비율이 높긴 하지만 온라인으로 취득할 수 있음을 알려주었고 실제로 준비 중인 걸로 안다. 이젠 개발자 직군과 상관이 없는 정보보안 전공을 굳이 졸업할 필요는 없으니 비싼 학비가 굉장히 아깝긴 하나 앞으로 받을 연봉과 직장생활 동안 그리 사치를 부리지 않았으니 적당히 묻기로 했다.
F-Lab 과정은 꽤 만족도가 높아 합격하고 나서도 네이버 개발자 교육과정을 병행을 하며 어떻게든 끌고 나가려 했는데 중간에 파트너가 중도하차를 하여 진도를 나가는 모멘텀이 힘을 잃고 말았다. 허나, 멘토님이 멘토링을 종료하고 나서도 종종 연락을 하라는 조언을 주기도 했고 신입 교육과정이 그리 만만치는 않을 거란 의견을 줘서 환불절차를 진행하기로 했다.

총평
거북목 치료와 이사를 하며 쉬기도 하고 간병을 하며 공부를 놓기도 했지만 나머지 기간동안 나름대로 공부를 꾸준히 이어가서 그런지 3분기에선 꽤 작은 회사들에도 탈락의 고배를 마셨는데 점점 4분기로 가면서 꽤 규모있는 기업들에서도 면접 기회가 주어짐을 볼 수 있었다. 그러면서 자신감을 회복할 수 있는 계기도 되어주었다.
네이버에서 첫 커리어를 시작했지만 여기서 멈출 생각은 없다. 남들보다 2년 정도 늦게 커리어를 시작한만큼 최소 4년 동안은 끊임없이 다른 개발자들보다 더 노력을 해야 따라잡을 수 있을 거란 생각을 한다. 분명 내부에서 잡음이 좀 있을 수 있겠지만 네이버라는 회사는 그래도 좋은 회사임은 의심할 여지가 없다. 그래서 더욱 더 타성에 젖어 그 자리에 주저앉지 않도록 스스로에 대해 경계를 해야 한다. 하지만 앞으로의 커리어에 대해 걱정보다는 기대감을 갖고 나아가보려 한다.
2021년은 2020년에 못지않게 바쁘게 살아왔고 결실까지 얻어냈으니 낭비하지는 않았다고 결론을 내릴 수 있겠다. 2022년에도 2021년에 뒤지지 않도록 내가 해야할 일을 명확히 정의하면서 살아보겠다.

+ Recent posts