ActivityResultContract 안전하게 사용하기



This content originally appeared on DEV Community and was authored by Sewon Ann

안드로이드는 intent 를 이용해 다른 앱의 기능을 유연하게 호출하고, 결과를 받아올 수 있다. 이렇게 결과를 받아오는 데 사용하는 함수인 startActivityForResultonActivityResult는 편리해 보이지만, 여러 가지 함정을 가지고 있었다. 이 글에선 새로운 표준이 된 Activity Result APIActivityResultContract에 대해 깊이 파헤쳐 보고, 사용 시 주의할 점까지 함께 알아보겠다.

ActivityResultContract 소개

ActivityResultContract 는 다른 액티비티를 실행하고, 그로부터 결과를 받아오는 작업을 추상화한 클래스다. 이 클래스는 두 가지 타입, 즉 입력(Input)출력(Output)을 제네릭으로 받는다. createIntent 메서드를 통해 입력을 바탕으로 인텐트를 생성하고, parseResult 메서드를 통해 onActivityResult 콜백으로부터 받은 결과를 출력 타입으로 변환하는 역할을 한다.

과거의 유산: onActivityResult

ActivityResultContract가 등장하기 전에는 다른 액티비티로부터 결과를 받기 위해 startActivityForResultonActivityResult를 사용했다.

Activity에서 onActivityResult 사용하기

class OldActivity : AppCompatActivity() {

    val REQUEST_CODE = 123

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ...
        val intent = Intent(this, OtherActivity::class.java)
        startActivityForResult(intent, REQUEST_CODE)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == REQUEST_CODE && resultCode == Activity.RESULT_OK) {
            // 결과 처리
        }
    }
}

이 방식은 requestCode를 사용해 여러 결과를 구분해야 했고, onActivityResult 메서드 내부에 복잡한 조건문이 생길 수 있다는 단점이 있었다. 또한, Activity 클래스에 직접 의존하기 때문에 유닛 테스트가 어려워지는 문제도 있었다.

Fragment에서 onActivityResult 사용하기

Fragment 역시 onActivityResult를 사용했다.

class OldFragment : Fragment() {

    override fun onStart() {
        super.onStart()
        val intent = Intent(requireContext(), OtherActivity::class.java)
        startActivityForResult(intent, REQUEST_CODE)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == REQUEST_CODE && resultCode == Activity.RESULT_OK) {
            // 결과 처리
        }
    }
}

Fragment 역시 Activity와 마찬가지로 onActivityResult를 오버라이드해야 했으며, 이는 비슷한 단점들을 야기했다.

ActivityResultContract의 내부 동작: Registry를 통한 추상화

많은 개발자가 ActivityResultContract가 완전히 새로운 방식으로 동작한다고 생각하지만, 사실 내부적으로는 여전히 startActivityForResultonActivityResult를 사용한다. ActivityResultContract는 이 두 메서드를 개발자가 직접 호출하는 대신, ActivityResultRegistry 를 통해 이를 추상화하고 관리한다.

ActivityResultRegistry는 생명주기에 안전한 방식으로 결과를 전달하기 위해 내부적으로 리스너와 키를 사용한다. registerForActivityResult를 호출하면 다음 세 가지 과정이 진행된다.

  1. 키 생성: ActivityResultRegistry는 내부적으로 고유한 키(예: "activity_rq#1")를 생성한다. 참고: ComponentActivity#registerForActivityResult
  2. 리스너 등록: 이 고유한 키와 함께, 개발자가 제공한 콜백(ActivityResultCallback)이 ActivityResultRegistry에 등록된다. 참고: ActivityResultRegistry#register

이제 ActivityResultLauncherlaunch 메서드가 호출되면, ActivityResultRegistry는 준비된 인텐트를 startActivityForResult를 이용해 실행한다. ( 참고 : ComponentActivity.activityResultRegistry) 액티비티가 종료되어 결과가 반환되면, onActivityResult를 통해 전달된 결과를 ActivityResultRegistry가 가로챈다. (참고: ComponentActivity#onActivityResult)

마지막으로, ActivityResultRegistry처음 등록했던 고유한 키를 이용해 등록된 콜백을 찾아 onResult를 실행하는 방식으로 결과를 전달한다. 이 모든 과정이 ActivityResultContract 뒤에 숨겨져 있어 개발자는 복잡한 콜백 처리를 직접 할 필요가 없다.

이 과정을 sequence diagram 으로 표현하면 다음과 같다.

ActivityResultContract 사용 시 주의할 점

ActivityResultContract는 분명 강력한 도구이지만, 잘못 사용하면 예상치 못한 버그를 유발할 수 있다. 가장 중요한 원칙은 registerForActivityResult() 는 항상 같은 순서로 호출되어야 한다는 점이다.

Activity에서 사용할 때

Activity에서 registerForActivityResult를 호출할 때는 클래스 인스턴스가 생성되는 시점에 멤버 변수로 등록하는 것을 권장한다.

class NewActivity : AppCompatActivity() {

    // 인스턴스가 생성될 때 초기화
    private val someActivityResultLauncher = registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        // 결과 처리
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ...
        someActivityResultLauncher.launch(intent)
    }
}

만약 경우에 따라 호출 순서가 달라진다면 어떤 일이 일어날까? 이런 식으로 짜면 안되지만 하여간 다음 코드를 상상해보자.

class NewActivity : AppCompatActivity() {

    lateinit var someActivityResultLauncher : ActivityResultLauncher<Intent>
    lateinit var otherActivityResultLauncher : ActivityResultLauncher<Intent>

     init {
         val isAm = ZonedDateTime.now().hour < 12

         if( isAm) {
             someActivityResultLauncher = registerForActivityResult(..) { }
             otherActivityResultLauncher = registerForActivityResult(...) { }
         } else {
             otherActivityResultLauncher = registerForActivityResult(...) { }
             someActivityResultLauncher = registerForActivityResult(...) { }
         }
     }
}

오전에 액티비티가 생성되어 some > other 순으로 register 되었다. 이 경우 some 의 내부 key는 activity_req#0, other 는 activity_req#1 이 된다. some 을 launch 해서 다른 OtherActivity 가 호출되었다. 그 사이에 오후가 되었다. 그 사이에 사용자가 화면을 회전해서 NewActivity 는 재생성되어야 한다. 이제 OtherActivity가 재생성되면서 결과값이 전달된다. 그런데 이번엔 other 가 #0, some 이 #1 이 되어버렸다. 결국 some 의 callback 이 호출되어야 하는데, 엉뚱하게 other 의 callback 이 호출되어 버린다. 만약 두 callback 의 인자 타입이 다를 경우엔 class cast exception 이 날 수도 있고, 아니면 가져온 결과값으로 엉뚱한 일이 벌어질 수 있다.

여기서 의문이 한가지 들 수 있다. startActivityForResult() 에 전달되는 request code 는 랜덤하게 생성되는데, 액티비티가 재생성되었을 때 onActivityResult 에서 이전 request code 를 어떻게 알고 callback 을 호출하는걸까? 이 부분은 ActivityResultRegistry 가 재생성 시 복구되면서 기존 내부 key 와 request code 간 매핑을 복구하기 때문에 가능핟.

ActivityResultRegistry 는 내부적으로 launcher 들에 부여된 key 를 Int 타입인 request code 로 반환하는 테이블을 갖고 있으며 ( 참고: ActivityResultRegistiry#bindRcKey) , 액티비티가 재생성될 때 이 테이블을 복구한다. 따라서 액티비티가 재생성되어도 이전에 호출했던 request code 를 가지고 어떤 콜백을 호출해야 할 지 잘 알아낼 수 있다. ( 참고 : ActivityResultRegistry#onRestoreInstanceState)

Fragment에서 사용할 때

Fragment 역시 Activity와 동일한 문제가 발생할 수 있다. 추가적으로 Fragmentdestroy된 후 다시 create될 때, 이전에 등록했던 콜백 정보가 사라져 콜백이 제대로 전달되지 않을 수 있다. 이는 Fragmentdestroy될 때 ActivityResultRegistry에 등록된 정보가 제거되기 때문이다.

lifecycleOwner 가 DESTROY 될 때 등록된 contract, callback 을 unregister 하는 동작은 어차피 ActivityResultRegistry가 수행하기 때문에 ActivityFragment 나 동일하다. 하지만 나는 한번 destroy 된 Activity 인스턴스가 다시 create 되는 경우를 보지 못했는데, Fragment 에선 이런 경우를 종종 봤다. 따라서, Activity 에선 객체 초기화 시 register 를 권장했지만 Fragment 에선 오히려 onCreate 에서 register 를 권장한다. 이래야 destroy 후 re-create 시 다시 ActivityResultRegistryLauncher가 등록되기 때문이다.

따라서 Fragment 인스턴스 초기화 시점보다는 onCreate 콜백 내에서 registerForActivityResult를 호출하는 것이 더 안전하다.

class NewFragment : Fragment() {

    private lateinit var someActivityResultLauncher: ActivityResultLauncher<Intent>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Fragment가 재생성될 때마다 콜백이 다시 등록되도록 보장
        someActivityResultLauncher = registerForActivityResult(
            ActivityResultContracts.StartActivityForResult()
        ) { result ->
            // 결과 처리
        }
    }

    fun someButtonClick() {
        someActivityResultLauncher.launch(Intent(requireContext(), OtherActivity::class.java))
    }
}

Composable에서 사용할 때

Jetpack Compose에서는 rememberLauncherForActivityResult 를 사용한다. 이 함수 역시 내부적으로 activityResultRegistry.register를 사용하므로, ActivityFragment 와 크게 다르지 않다.

@Composable
fun MyScreen() {
    val someActivityResultLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.StartActivityForResult(),
        onResult = { result ->
            // 결과 처리
        }
    )

    Button(onClick = {
        someActivityResultLauncher.launch(Intent(context, OtherActivity::class.java))
    }) {
        Text("Launch Activity")
    }
}

다만 내부 key 가 UUID.randomUUID().toString() 라서, Activity 에서 activity_req#1 와 같은 형태로 등록 순서에 영향을 받는게 아닌, 랜덤한 고유값이 부여되는 부분이 다르다.

rememberLauncherForActivityResult도 마찬가지로 조건문 안에 등록하면 안 된다. remember가 있는 함수는 컴포저블의 재구성(recomposition) 과정에서 remember의 키가 변경되거나 조건에 따라 호출되지 않을 경우, 인스턴스가 새롭게 생성되거나 아예 생성되지 않을 수 있기 때문이다.

또한, rememberLauncherForActivityResult는 가급적 최상위 컴포저블에서 사용하는 것을 권장한다. 하위 컴포저블에서 사용할 경우, 상위 컴포저블의 상태 변화나 조건문에 따라 컴포저블의 위치 등이 바뀔 경우 액티비티 재생성 시 다른 composable 로 인식되어 이전 composable 의 복구가 이뤄지지 않는다면 결과 콜백이 호출되지 않기 때문이다.

캡슐화 관점에서 기능을 사용하고자 하는 composable 내부에 rememberLauncherForActivityResult 를 사용하면 참 편리하겠지만, 해당 composable 이 어떤 맥락에서, 어떤 경우에 호출되는지 통재하기가 어렵기 때문에 이렇게 구현하는 건 위험할 수 있다. 예를들어 게시판을 만드는데 댓글 composable 에 이미지 첨부를 위해 갤러리를 호출하고, 결과를 받아오는 기능까지 넣어두면 참 멋질 것 같다. 하지만 이 댓글 composable 이 어떤 식으로 호출될지 composable 작성자가 컨트롤 할 수 없고, 이러다보면 액티비티 재생성 시 호출 위치가 바뀌어버릴 경우 찾기 어려운 오류가 발생할 것이다. 반대로 composable 을 사용하는 개발자 입장에서도 이 composable을 사용하는데 어떤 주의사항이 있는지 일일이 파악하기가 쉽지 않아, 이런 경우 애초에 rememberLauncherForActivityResult 를 안쓰는게 낫다.

따라서 가급적 최상위 composable 에만 사용하는게 유지보수 관점에서 더 나은 선택이 될 것이라 생각한다.

맺음말

ActivityResultContract는 코드를 간결하게 만들고, 타입 안정성을 제공하며, 테스트 용이성을 높여준다. 하지만 주의깊게 사용하지 않을 경우 찾기 어려운 이상동작이 발생할 수 있으므로 잘 알고 사용해야 한다.


This content originally appeared on DEV Community and was authored by Sewon Ann