선생님, 개발을 잘하고 싶어요.

왜 Kotlin Coroutine 인가? suspend 함수, withContext 본문

강좌/kotlin 강좌

왜 Kotlin Coroutine 인가? suspend 함수, withContext

알고싶은 승민 2021. 9. 29. 08:00

흔한 개발 시나리오

Coroutine이 필요할까요? 이를 쉽게 이해하기 위해서 회원가입을 해야 하는 상황을 생각해봅시다. 편의를 위해서 회원가입 완료할 때 로그인 처리까지 하고 싶다고 합시다.

  1. 이메일, 비밀번호를 입력받습니다.
  2. 이메일, 비밀번호를 기반으로 회원가입을 요청합니다.
  3. 회원가입이 성공적으로 완수되면, 해당 이메일과 비밀번호를 사용해서 로그인을 요청합니다.
  4. 성공 메시지를 노출시킵니다.

이걸 코드로 옮겨보겠습니다.

 

// 1. 이메일, 비밀번호를 입력 받습니다.
val email: String = "greedy0110@gmail.com"
val password: String = "0110"

// 2. 이메일, 비밀번호를 기반으로 회원가입을 요청합니다.
server.requestSignUp(email, password)

// 3. 회원가입이 성공적으로 완수되면, 해당 이메일과 비밀번호를 사용해 로그인을 요청합니다.
server.requestLogIn(email, password)

// 4. 성공 메시지를 노출시킵니다.
successMessage.isVisible = true

 

이 코드는 콘솔 프로그램을 만드는 경우에는 문제없이 동작합니다. 하지만 우리는 GUI 프로그래밍을 하고 있고 이 코드는 처참히 잘못된 코드입니다.

GUI 프로그래밍에선 UI를 그리고, 사용자의 입력을 받아오기 위한 MainThread를 별도로 두고 있습니다. 1번과 4번은 그렇다고 해도 2번과 3번의 서버 요청을 MainThread에서 기다린다면 어떻게 될까요? 만약 사용자가 네트워크가 좋지 않은 환경에 있어서 5초는 기다려야 한다면요?

그러면 앱은 사용자의 터치를 하나도 받지도 못하고 가만히 서버 응답만 기다리게 됩니다. 사용자 입장에서는 앱이 죽었다고 생각하게 되겠죠. (실제로 안드로이드에선 이런 경우에 ANR 에러를 발생시켜 사용자가 앱을 종료시킬 수 있도록 하고있습니다. 아니 애초에 서버 콜을 MainThread에서 호출하면 죽어버립니다.)

그러면 어떻게하면 해결될까요? 전체 로직을 별도의 thread로 실행하면 될 것 같습니다.

thread {
	// 2. 이메일, 비밀번호를 기반으로 회원가입을 요청합니다.
	server.requestSignUp(email, password)
		
	// 3. 회원가입이 성공적으로 완수되면, 해당 이메일과 비밀번호를 사용해 로그인을 요청합니다.
	server.requestLogIn(email, password)

	// 4. 성공 메시지를 노출시킵니다.
	successMessage.isVisible = true
}

실행하면 어떻게 될까요? 이 코드는 동작하지 않습니다. 바로 에러를 뿜고 죽게 됩니다. 4번 행동이 UI를 변경시키는 동작, 즉 MainThread에서 실행되어야 하는 동작이라서 동작되지 않습니다. 4번을 빼보면 어떨까요?

 

thread {
		// 2. 이메일, 비밀번호를 기반으로 회원가입을 요청합니다.
		server.requestSignUp(email, password)
		
		// 3. 회원가입이 성공적으로 완수되면, 해당 이메일과 비밀번호를 사용해 로그인을 요청합니다.
		server.requestLogIn(email, password)

}

// 4. 성공 메시지를 노출시킵니다.
successMessage.isVisible = true

 

실행하면 어떻게 될까요? 회원가입이 실패하던, 성공하던, 로그인이 성공하던, 실패하던 4번은 실행될 겁니다. 잘못되었죠? 왜일까요? thread 실행의 동작(2,3)과, MainThread(4)의 동작이 별도의 스레드에서 병렬적으로 실행되기 때문입니다.

2, 3, 4는 순차적 실행이 보장되어야 합니다. 즉 동시성이 보장되어야합니다.

콜백 모델

이 문제를 해결해봅시다. 이를 해결하기 위해서 우리는

  1. 회원가입이 성공할 때 로그인 요청을 해야 하고
  2. 로그인이 성공할 때 성공 메시지를 노출시키면 됩니다.

그렇죠? 그러면 우리는 각 서버 요청을 별도의 스레드에서 동작시키도록 변경시킬 수 있습니다. 그리고 서버 요청이 종료될 때 서버의 응답과 함께 등록된 Callback 함수를 실행하는 것이죠. 이것이 그 유명한 Callback 모델입니다.

하지만 겁먹지 마세요. Callback은 그냥 함수입니다. 네트워크 동작을 다 수행한 다음에 이 함수를 호출하라고 알리는 것이죠. 그니까, 필요한 작업은 다 하고 결과를 받아온 상황을 가정하고 코드를 짜도 된다는 말씀.

함수를 건네는 방법은 많지만 우리는 코틀린을 사용하고 있으니 코틀린 람다를 이용해서 손쉽게 건네도록 바꿔봅시다.

 

// 1. 이메일, 비밀번호를 입력 받습니다.
val email: String = "greedy0110@gmail.com"
val password: String = "0110"

// 2. 이메일, 비밀번호를 기반으로 회원가입을 요청합니다.
server.requestSignUp(email, password) {
	// 3. 회원가입이 성공적으로 완수되면, 해당 이메일과 비밀번호를 사용해 로그인을 요청합니다.
	server.requestLogIn(email, password) {
		// 4. 성공 메시지를 노출시킵니다.
		successMessage.isVisible = true
	}
}

 

이제 깔끔하게 동작합니다. 그렇죠? 사실 이 코드는 그리 깔끔하지 않습니다. 동시성을 처리해야 하는 요청이 2개인 상황에서는 견딜만합니다만 4개 5개만 되어도 가독성이 떨어지고 코드를 읽기가 부담스러워집니다. 이걸 그 악명 높은 callback hell이라고 합니다.

Coroutine will save us

코루틴은 이 상황을 바꾸어 놓았습니다. 두말 않고 코루틴을 사용했을 때 코드를 보죠.

 

uiScope.launch {
	// 1. 이메일, 비밀번호를 입력 받습니다.
	val email: String = "greedy0110@gmail.com"
	val password: String = "0110"
		
	// 2. 이메일, 비밀번호를 기반으로 회원가입을 요청합니다.
	server.requestSignUp(email, password)
		
	// 3. 회원가입이 성공적으로 완수되면, 해당 이메일과 비밀번호를 사용해 로그인을 요청합니다.
	server.requestLogIn(email, password)
		
	// 4. 성공 메시지를 노출시킵니다.
	successMessage.isVisible = true
}

 

어라? 처음에 만들었던 순차적 코드와 똑같습니다! 이게 IO Thread를 통한 서버 요청, MainThread를 사용한 화면 갱신, 모든 비동기 코드의 동시성 처리가 되어버린 코드입니다. 아니... 말이 되나요? 코루틴은 이걸 가능하게 만듭니다.

동작을 이해하기 위해서는 숨겨놓은 requestSignUp의 정의 부분을 봐야 합니다.

suspend fun requestSignUp(email: String, password: String) {
	withContext(Dispatchers.IO) {
		// ... make network request
	}
}

 

아, 이제 뭔지 모르겠는 게 나옵니다. 여기서 중점적으로 볼 것은 크게 3가지입니다.

  1. suspend fun 키워드
  2. withContext + Dispatchers.IO
  3. uiScope

하나씩 같이 털어볼까요?

suspend

코루틴 함수를 정의하는 키워드입니다. 앞으로 이 키워드를 사용해서 정의한 함수를 suspend 함수라고 하겠습니다.

 

기존 스레드 작업과 코루틴은 무엇이 다를까요? 어떻게 하면 비동기 작업을 순차적으로 진행할 수 있을까요?

비동기 작업이 실행되는 동안, 기존 코드가 비동기 작업을 기다리면 그리고 그 비동기 작업이 끝났을 때 기존 코드가 다시 그 상태에서 시작되면 비동기 작업을 순차적으로 진행할 수 있지 않나요?

이게 모든 비동기 작업의 기초입니다. 이를 달성하기 위해서 콜백을 사용해 비동기 작업이 끝났을 때 시작할 작업을 넣어줬었던 거죠.

 

코루틴은 조금 다른 선택을 합니다. (물론 내부적으로는 콜백입니다만, 코루틴의 맨 얼굴에 관심있으신 분은 재밌는 영상을 아래에 링크하겠습니다.) 바로 말 그대로 기다리고(suspend), 다시 그 지점 부터 재개(resume)하는 것입니다. 그래서 코루틴 코드는 우리가 직관적으로 짠 코드와 같은 구조를 가지면서도 우리가 생각했던 걸 달성할 수 있었던 것입니다.

 

좀 더 얘기를 해볼게요. 바로 suspend 키워드에 대해서입니다. suspend 함수는 말 그대로 "기다릴 수 있는" 함수입니다. 누가 기다릴까요? 바로 코루틴 실행 흐름이 기다립니다. 이 얘기는 뒤에서 더 할 기회가 있을 겁니다.

이 함수가 호출될 때, 기존 진행 코드는 멈춥니다(suspend). 그리고 이 함수가 끝날 때 기존 진행 코드가 실행됩니다(resume). 멋지죠?

 

이제 질문이 하나 나올 것 같은데요. "그러면 그놈의 코루틴은 어떤 쓰레드에서 실행되서 어떤 쓰레드가 멈추고 대기하는데? 결국 코드 진행을 멈춰야하잖아?" 어떤 쓰레드에서 멈추는 지는 나중에 얘기해보도록 하고 어떤 쓰레드에서 실행되는가에 대해서 얘기를 해야 할 것 같습니다.

 

바로 코루틴을 시작한 CoroutineContext가 지정한 쓰레드에서 동작합니다. 위에 코드에서는 uiScope이고 이름답게 MainThread 위에서 동작하게 동작합니다. 그리고 모든 suspend fun은 자신을 호출한 코루틴의 Context에서 동작하게 됩니다. 명료하죠.

 

어라? 그러면 진짜 서버 콜을 하는 requestSignUp함수는 어떻게 IO Thread를 사용해서 함수를 실행할까요? 분명 부모 코루틴의 Context를 받아온다면 MainThread에서 실행되는 것이고 이건 문제 아닐까요?

 

네 문제입니다. 그리고 이 문제를 해결하는 아주 좋은 방법이 있습니다. 바로 withContext입니다.

withContext

withContext는 CoroutineContext를 실행 인자와 suspend 함수를 인자로 받고 그 CoroutineContext에서 그 suspend 함수를 실행시킵니다. 그리고 부모 CoroutineContext는 이 전체 실행이 끝나고 결과를 반환할 때까지 기다립니다.

 

네 이 함수가 바로 우리가 찾던 함수입니다. 바로 코틀린 실행 중에 내가 원하는 CoroutineContext로 변경하는 방법입니다. 이게 없다면 우리는 Main Thread에서 실행한 코루틴을 다른 쓰레드로 돌아다니며 실행할 방법이 없겠죠.

 

다시 requestSignUp를 봅시다. 조금 다르게 보입니다.

 

suspend fun requestSignUp(email: String, password: String) {
	withContext(Dispatchers.IO) {
		// ... make network request
	}
}

 

이 함수 자체는 MainThread 코루틴에서 실행됩니다. 하지만 그 MainThread는 withContext 함수를 기다리기만 할 뿐입니다.

실제 네트워크 통신은 withContext의 인자로 주어진 Dispatchers.IO 에서 이루어집니다. 이름만 봐도 IO Thread에서 동작할 것 같이 생긴 이 객체는 코루틴에서 기본 제공하는 CoroutineContext 중 하나입니다. 실제로 IO 작업을 할 때 해당 Dispatcher를 이용해서 작업하시면 됩니다.

 

이제 무슨 일이 일어났는지 한번 확인해보면 놀랍습니다.

uiScope.launch {
	// 이 부분은 내부적으로 IO 를 사용해 동작하지만 MainThread 에서 실행해도 문제되지 않습니다!
	server.requestSignUp(email, password)
		
	// 이 부분은 UI 갱신인데 이미 MainThread 에서 실행 중이니 무슨 문제가 있겠습니까?
	successMessage.isVisible = true
}

와 이게 뭐죠? MainThread에서 호출하는 함수가 내부적으로 IO Thread 에서 동작도 됩니다. 그니까 MainThread에서 마음 편하게 어떤 suspend 함수를 호출해도 무방한 겁니다! 이 함수가 어떤 쓰레드에서 동작해야 하냐는 UI에서 신경 쓸 게 아니라는 뜻입니다.

이걸 이제 그 유명한 코루틴의 Main-Safety라고 합니다.

uiScope

이 부분은 안드로이드 Activity, ViewModel 등 Scope의 필요성이 확 강조되는 부분과 함께 설명하도록 하겠습니다. (to be continued...)

 

참고 링크

코틀린 내부 동작 컨퍼런스: 내용이 좀 충격적이었는데, suspend 함수를 호출하는 것 기준으로 label을 나누고, 모든 데이터를 하나의 객체에 담아서 콜백 함수 호출, 해당 함수가 끝나면 다음 label로 재실행을 반복하는, 재귀적으로 동작하는 코루틴의 민낯을 확인할 수 있습니다.

Main-Safety: 제가 코루틴에서 가장 아름답다고 생각하는 부분입니다. 개발자가 생각을 덜하고 정확하게 코드를 만들게 도와주면서도 알아야 할 건 별로 없는, 그런 속성입니다. 아마 코틀린의 Null-Safety와 비슷한 개념으로 생각하고 구상한 것 같습니다.

Comments