[번역] Kotlin Functional Programming I: Monad Stack

WindSekirun (wind.seo)·2022년 4월 26일
1

이 글은 기존 운영했던 WordPress 블로그인 PyxisPub: Development Life (pyxispub.uzuki.live) 에서 가져온 글 입니다. 모든 글을 가져오지는 않으며, 작성 시점과 현재 시점에는 차이가 많이 존재합니다.

작성 시점: 2017-10-31


이 글은 Jorge Castillo 가 Medium에 올린 Kotlin Function Programming I: Monad Stack 의 번역 글입니다. 첫 번역이라 다소 오역이 많을 수 있습니다.

This post is Korean translated post of Kotlin Function Programming I: Monad Stack , author is Jorge Castillo. Thanks for great post.


"너희는 스스로 성전을 짓고, 마루를 바닥에 쌓아두고, 성전에 있을 법한 문제에 유념하면 하늘을 만질 수 있다. 그러면 그 절에서 영원토록 살 수 있다."

평화롭지 않나요? 그리고 이 문구는 진실이기도 합니다.

아직 이 시리즈의 첫번째 포스트를 읽지 않았다면, 이 게시글을 먼저 읽는 것을 추천합니다. 여기서 언급될 몇몇 개념들이 이 게시글에 쓰여졌기 때문입니다.

Kotlin Functional Programming: Does it make sense?

medium.com

함수형 프로그래밍에 대해 생각해보면, 우리는 한 가지 핵심 개념을 이해하고 있어야 합니다 : 문제를 한 번에 해결하도록 하고, 그 해결책은 영원히 재사용 할 수 있게 해야 한다.

함수형 패러다임으로 코딩을 할 때에 대부분의 문제들은 평범한 해결책 하나로 해결됩니다. 그 해결책들은 구현 세부 사항이나 의미론에 한정되지 않아야 합니다. 예를 들어보자면 Asynchrony, IO, Threading, Dependency Injection 또는 다양한 중첩된 아키텍쳐에 따른 의존성을 대체할 수 있는 전체적 개념에 대해 이야기할 수 있습니다.

따라서 이러한 문제는 모든 시스템에 핵심 요소가 될 수 있습니다. 이상적으로는 해결책에 대한 구현 작업을 한 번만 수행해야 합니다. 이미 해결된 모든 애플리케이션은 사용자가 작성한 모든 앱 또는 백엔드와 같은 다른 플랫폼에서도 사용할 수 있습니다. 물론, Kotlin 으로 작성되었기 때문이죠.

이를 매우 투명하게 반영하기 위해서 우리는 자체 애플리케이션 아키텍쳐(Application architecture) 를 만들어 단게별로 전체 스택을 구성할 수 있습니다.

Error 와 Success에 대한 모델링

거의 모든 시스템에는 외부 서비스에서 데이터를 가져오는 작업이 필요합니다. 예로 데이터베이스, API, 또는 외부 캐시들이 있습니다. 저는 단지 Android 앱에 대해서만 이야기 하는 것은 아닙니다.

이러한 데이터 서비스는 응답에 2가지의 각각 다른 시나리오를 제공합니다. 서비스에 요청해서 데이터를 가져오거나, 또는 예외로 처리되는 오류 사항입니다. 이 결과들은 매우 명확한 이중성을 가지고 있으며, 코드에 명시적으로 반영할 수 있는 방법을 찾아야 합니다.

Clean Architecture 에 대해 생각한다면 ThreadPoolExecutor가 제공하는 외부 DataSource또는 Repositories에 의해 던져진 예외는 취소한 다음 Callback를 사용하여 호출하는 등의 재사용된 스레드에 대한 사용 예제를 알고 있을 것입니다.

이러한 접근 방식은 단순한 애플리케이션에는 유용하지만 일부 내제된 문제가 있을 수 있습니다.

우선 오류 측면에서 예외를 콜백 전파(Callback propagation) 으로 전환해야 합니다. 예외가 Thread limit를 초과할 수 없기 때문에 이러한 현상이 일어납니다.

그리고 실질적인 투명성(referential transparency) 문제도 있습니다. Callback는 반환되는 유형을 살펴서 함수가 반환할 내용을 반영할 수 없기 때문에 Callback는 이와 같은 방식을 깨트리게 됩니다.

하지만 중요한 점은 우리가 두 가지 방법을 통해 결과를 얻을 수 있다는 점입니다. 결국 양쪽 모두 동일한 수행 결과 안에 있는 이중적인 사실의 일부분입니다. 그렇지요? 우리는 이 이중성을 단일 지점(Single possible branch) 으로 줄일 수 있어야 합니다.

RxJava를 이용하여 두 개의 결과를 하나의 통로로 변환시킬 수 있습니다. 이 방법은 객체지향 프로그래밍 패러다임에 있다면 흥미로운 방법인데, 이는 Flow가 항상 단일 통로로 감소되기 때문입니다.

하지만 우리가 KΛTEGORY 를 사용해 함수형 프로그래밍 방법으로 문제를 해결하면 어떨까요?

우리는 Either<A, B>유형으로 결과에 대한 이중성을 쉽게 반영할 수 있습니다. Either는 분리 동맹입니다. 즉, A 또는 B이지 A와 B는 절대로 성립할 수 없습니다.

참고로 Either<A, B>는 sealed class 로 Left<a: A>또는 Right(b: B)의 2개의 구현체를 가지고 있습니다. (역주: sealed class 에 대한 설명은 Kotlin - Sealed class for Restricted Class Hierarchies 글에서 볼 수 있습니다. )

함수형 패러다임의 협약에 따라 예외와 성공에 대한 사례를 Either 를 사용하여 제시할 때 왼쪽은 예외, 오른쪽은 성공에 대한 사례를 제시해야 합니다.

여기서 볼 수 있다싶이, Either는 우리의 목적을 완전히 채울 수 있습니다. 그러므로 Kotlin 으로 넘어가서 Either<CharacterError, List<SuperHero>>를 사용하여 어떻게 이중성을 모델링하여 이득을 볼 수 있는지 살펴봅시다.

sealed class CharacterError {
  object AuthenticationError : CharacterError()
  object NotFoundError : CharacterError()
  object UnknownServerError : CharacterError()
}

첫번째로, sealed class를 사용하여 발생할 수 있는 예외들을 정의하려고 합니다. 그 후에는 외부 소스에서 일어날 수 있는 모든 가능성을 정의하는 것입니다. DataSource또는 Repository에서 발생하는 어떠한 예외는 위에 선언된 모델에게 매핑되어야 합니다.

자, 이제 슈퍼히어로 들의 데이터를 가져오기 위한 DataSource 구현체를 살펴봅시다.

/* data source impl */
fun getAllHeroes(service: HeroesService): Either<CharacterError, List<SuperHero>> =
    try {
      Right(service.getCharacters().map { SuperHero(it.id, it.name, it.thumbnailUrl, it.description) })
    } catch (e: MarvelAuthApiException) {
      Left(AuthenticationError)
    } catch (e: MarvelApiException) {
      if (e.httpCode == HttpURLConnection.HTTP_NOT_FOUND) {
        Left(NotFoundError)
      } else {
        Left(UnknownServerError)
      }
    }

자, 히어로들은 서비스를 통해 가져와서 정의한 모델에 맞게 매핑됩니다. 그리고는 Right(heroes)로 감싸져 반환됩니다. 이는 성공 결과에 대한 오른쪽 부분입니다.

하지만 무언가 잘못되면 예외가 발견되고 모델에 매핑됩니다. 그리고는 Left(error)로 감싸져 반환됩니다.

따라서 데이터 레이어 들은 완전히 명시적인 유형 (Either<CharacterError, List<SuperHero>>)에 의해 반환됩니다. 메서드 구현체를 보면, 요청자는 단순히 오류를 반환하거나 유효한 히어로 리스트를 반환할 것임을 알 수 있습니다. 간단해요. 그렇죠?

// Use case function
fun getHeroes(dataSource: HeroesDataSource, logger: Logger): Either<Error, List<SuperHero>> =
    dataSource.getAllHeroes().fold(
    { logger.log(it); Left(it) },
    { Right(it.filter { it.imageUrl.isEmpty() }) })

이 사례는 매우 직관적인데, Either는 두 개의 가능한 값을 fold 하여  두 개의 다른 결과 유형에 대해 두 개의 람다를 제공합니다.

DataSource에서 돌아온 값에 따라 (Left 이거나, Right거나) 해당 람다가 실행됩니다.

따라서 예외를 사용하여 오류를 기록하고 성공적인 값을 반환한 후에도 여전히 왼쪽에 있는 그대로의 값을 반환합니다. 그렇지 않을 경우에는 유효하지 않은 히어로 리스트를 필터링하여 올바른 리스트를 가져옵니다. (예를 들어 유효한 이미지 URL이 없는 것과 같습니다.)

따라서 전체 코드는 다음과 같을 수 있습니다. 이미 구성된 연산을 통해 각각 다른 케이스에 따라 수행될 수 있습니다.

fun getSuperHeroes(view: SuperHeroesListView, logger: Logger, dataSource: HeroesDataSource) {
  getHeroesUseCase(dataSource, logger).fold(
      { error -> drawError(error, view) },
      { heroes -> drawHeroes(heroes, view) })
}

private fun drawError(error: CharacterError,
    view: HeroesView) {
  when (error) {
    is NotFoundError -> view.showNotFoundError()
    is UnknownServerError -> view.showGenericError()
    is AuthenticationError -> view.showAuthenticationError()
  }
}

private fun drawHeroes(success: List<SuperHero>, view: SuperHeroesListView) {
  view.drawHeroes(success.map {
    RenderableHero(
        it.name,
        it.thumbnailUrl)
  })
}

아시다 싶이, 우리는 함수형 파라미터로서 항상 의존성을 수동으로 전달할 수 있다는 것을 알 수 있습니다.

함수형 프로그래밍에서는 데이터를 변환 / 조작하는 중간 작업에 대해 대부분의 시간을 사용하지 않습니다. 수행 결과에 따른 오류 또는 성공적인 히어로 리스트 만이 있을 뿐입니다.

아직 살펴보진 못했지만 제가 보여드린 모든 기능들이 패키지 레벨에서 정의되어 있습니다. 그들은 어떠한 경우에도 속하지 않습니다. 즉, 부작용 없는 순수한 기능을 가진 순수 함수이기 때문에, 공유 상태가 없고 외부 상태에도 접근할 수 없기 때문에 이러한 '구현 세부 사항' 에는 동봉되지 않습니다. 순수 함수는 다양한 파라미터(종속성)을 제공하고 이를 통해 결과를 제공할 뿐입니다. 이것이 전부입니다.

따라서 편의상 의존성은 함수 파라미터로서 전달됩니다. 네, 이 글을 조금만 더 읽는다면 그 것들을 없앨 방법을 찾을 수 있을 것입니다. (DI. Yay! 🎊)

만약 KΛTEGORY를 사용한 에러 핸들링 전략을 보고 싶다면, 공식 문서 상의 이 섹션을 보세요.


그래서 우리의 웅장한 사원이 눈 앞에 나타나기 시작했습니다. 우리는 이미 기초가 마련되어 있습니다. 어쩌면 벽이 필요할까요? 겨울이 다가오고 있으니 계속 움직여 봅시다.

Async + Threading

아마도 여러분은 Asynchrony 나 Threading 에 대해 무시하고 있다는 것을 알아챘을 것입니다. 하지만 우리는 그것들을 다룰 수 있는 접근법을 찾아야 합니다.

매번 우리가 화면에 어떤 것을 그리거나 외부 데이터 소스에 대해 쿼리를 요청할 때 마다 실제로 수행하는 작업은 I/O 계산입니다. 이러한 계산은 부작용을 낳기 때문에 우리의 함수형 패러다임에서는 좋은 역할은 아닙니다. 우리는 Presentation 레이어와 그 밖에 모든 레이어를 시작하기 위해 순수하게 시작할 필요가 있습니다. 따라서 최소한의 DataSource를 위해서는 무엇인가 해야 합니다.

여기서 IO Monad 가 진가를 발휘합니다. 지금 당장은 'M' 이란 글자를 잊어버리세요. 솔직하게 말하자면 이것은 접근법을 이해하는 데 필요하지 않고, 더 쉬워질 것입니다. 나는 그것이 무엇인지는 확실히 알고 있고, 아마 이 시리즈의 끝이면 여러분도 알 수 있을 것입니다.

IO는 부작용, 계산을 포함하여 순수하게 만듭니다. 아직 실행되고 있지 않기 때문에 우리가 그것을 실행하기로 결정하면서 안전하지 않은 수행을 하기로 결정하는 순간에 달려있기 때문입니다.

예를 들어 IO는 Haskell 에서 잘 알려지고 중요합니다. 어떠한 부작용도 언어에선 허용되지 않기 때문입니다. IO 덕분에 우리는 Monad 스택을 유지할 수 있고, 호출하는 트리의 반환 유형에 대해 명시적으로 계산을 수행할 수 있기 때문입니다.

자, 우리의 DataSource구현체를 좀 더 강화시켜 봅시다.

/* network data source implementation */
fun getAllHeroes(service: HeroesService, logger: Logger): 
IO<Either<CharacterError, List<SuperHero>>> =
    runInAsyncContext(
        f = { queryForHeroes(service) },
        onError = { logger.log(it); it.toCharacterError().left() },
        onSuccess = { mapHeroes(it).right() },
        AC = IO.asyncContext()
    )

runInAsyncContext는 문법을 위해 생성한 함수입니다. 우리는 Coroutine 안에 있는 람다를 실행하고 그 연산을 IO의 Context 로서 들어 올리기 위해 사용하고 있습니다.

각각에 대해 파라미터에 대해 설명해보면...

  • f: coroutine 안에서 실행될 함수
  • onError: 계산 과정에서 예외가 발생될 경우 실행될 람다입니다. throwableCharacterError로 모델링합니다.
  • onSuccess: 계산이 성공했을 때 실행될 람다입니다. 히어로에 대한 리스트를 매핑하고 반환하기 전에 Right로 감쌉니다.
  • AC: AsyncContext, 제한되지 않은 콜백으로부터 'IO' 와 같은 비동기를 지원하는 유형으로 데이터를 이동하는 typeclass 입니다.

보시다싶이, 반환 유형은 실행 결과에 대해 명시적으로 선언되어 있습니다 : IO<Either<CharacterError, List<SuperHero>>>

이 의미는 DataSource가 IO 계산을 반환하는데, IO 계산은 CharacterErrorList<SuperHero>를 얻을 수 있게 해줍니다. 의미론적으로 유형이 스스로 말하는 것입니다.

실제 사용 사례를 살펴보면, 좀 특별할 것입니다.

/* Use case */
fun getHeroesUseCase(service: HeroesService, logger: Logger): 
IO<Either<CharacterError, List<SuperHero>>> =
    getAllHeroesDataSource(service, logger).map { it.map { discardNonValidHeroes(it) } }

이 사용 사례 함수는 map 함수를 두 번 호출하는데, 이렇게 되어야 히어로의 데이터 소스에 대한 2개의 중첩된 Monad 가 있기 때문입니다. 따라서 IO로 인스턴스를 map하여 나중에 Either로 매핑할 수 있게 합니다. 이 의미는 계산이 감싸지지 않고 실행된다는 의미는 아니고, Monad 를 이용하여 선언적으로 연산 스택을 작성합니다. 모든 작업은 최대한 나중으로 연기됩니다.

Monad 스택의 작성 이후에 이 두 번의 매핑을 자연스러운 반복을 제공하는 흥미로운 스타일로 단순화 할 것입니다. 아마도 전체 스택을 한 개의 유형으로 합성해서 자연스럽게 이 문제를 해결할 수 있는지 보게 되겠죠.

그나저나, Either<A, B>는 오른쪽에 치중되었다는 것을 알리고 싶습니다. 이 의미는 mapflatMap는 오른쪽에 값이 적용되고, 왼쪽은 값이 남아있기 때문입니다.

드디어 우리는 우리의 Presentation 레이어에 도달했습니다. 우리는 IO 에게 부작용이 있어서 이상적인 형태는 아니지만 일단은 안전하지 않은 작업을 하도록 합니다. 다만, 단지 반복 과정에 대해서만 입니다.

/* Presentation logic */
fun getSuperHeroes(view: SuperHeroesListView, service: HeroesService, logger: Logger) =
    getHeroesUseCase(service, logger).unsafeRunAsync { it.map { maybeHeroes ->
      maybeHeroes.fold(
          { error -> drawError(error, view) },
          { success -> drawHeroes(success, view) })}
    }

여기에서는 지난번에 했던 것과 비슷하나 이번에는 IO가 작업을 실행합니다. 따라서 unsafeRunAsync는 IO를 결과적으로 풀어서 계산을 실행할 수 있도록 정리합니다. 그리고 결과값을 람다로 넘겨줍니다.

이제 뷰는 결과에 따라서 오류를 표시하거나, 히어로를 표시할 수 있습니다.

하지만 이것은 차후에도 반복할 수 있습니다. 이상적으로 우리는 시스템의 가장자리에 있는 단일 지점에 영향을 미칠 것입니다. Android는 Activity 나 View의 오버라이딩 메소드일 것이며, 나머지 프로그램들은 엔드포인트나 main 메소드가 될 것입니다.

이 곳이 바로 순수성이 안전하지 않은 작업으로 바뀌는 곳인데,  안드로이드에서 공유 상태를 가지고 화면에 렌더링하기 위해 필요하기 때문입니다. 우리는 적어도 이러한 가능성 있는 문제들을 레이어들에 분리하고 전체적인 아키텍쳐 디자인을 순수성에 기반하게 만듭니다.

이 것을 달성하기 위해서는 lazy evaluation 라고 불리는 것을 적용합니다. 우리는 단순히 호출 트리에 모든 기능들을 넣어두면 됩니다. 앞선 글에서도 설명했듯이 이미 계산된 결과 대신 실행하는 함수를 넘길 수 있습니다.

따라서 우리는 우리의 완전한 실행 트리를 아래와 같이 합성할 수 있습니다. (pseudocode)

  • presenter(deps) = { deps -> useCase(deps) }
  • useCase(deps) = { deps -> dataSource(deps) }
  • dataSource(deps) = {deps -> deps.apiClient.fetchHeroes() }

이 단계들은 이미 계산된 결과 대신 함수를 반환합니다. 이 함수들은 값이 넘겨질 때 계산을 수행하지, 미리 수행되지 않습니다. 결과적으로 DataSource가 필요한 의존성 들을 선택해서 작업을 할 수 있게 합니다.

따라서 뷰가 Presenter / ViewModel를 호출할 때 이미 계산된 결과 값이 아닌, 계산 결과에 따른 함수를 반환할 수 있도록 합니다. 그래서 전체 실행 tree를 최종적으로 풀어서 필요한 의존성들을 통과할 시기를 결정하는 것이 View Implementation 입니다.

하지만 의존성을 계속 넘기는건 고통받는데, 이걸 자동으로 할 수 있지 않을까요?

분명하게 그건 Dependency Injection을 말하는 거겠죠.


우리는 이미 벽을 모두 세웠습니다. 아직까지 지붕은 없으나 거의 다 했습니다. 적어도 야생 스님들은 더 이상 밤에 몰래 사원에 들어갈 수 없을 것입니다. 👏👏 그리고 우리는 심한 감기 😓😓 에 걸릴 수도 있지만 일단 사원 안에서 사는 것을 생각할 수 있습니다.

아마 우리는 조금만 더 반복하면 될 것입니다.

Dependency Injection

Dependency Injection 에 대해서는 우리는 조금 이상한 이름인 Render Monad 라는 것을 사용할 것 입니다. 새로운 방법에 대해 알기 전에 이 글을 먼저 읽어보는 것을 추천합니다.

Kotlin Dependency Injection with the Reader Monad

medium.com

Monad 스택을 합성하고 있기에, Reader 를 사용하여 완벽하게 맞을 수 있게 하길 원합니다.

Reader는 (D) -> A 유형으로 계산을 감싸며, 저런 유형으로 계산을 활성화 시킬 수 있도록 합니다. 

D 는 Reader Context 를 가리키는데, 연산이 실행되는 데에 필요한 의존성을 나타냅니다. 이러한 계산들은 현재 우리가 가지고 있는 각각의 아키텍쳐에 있어서 가지고 있는 것과 정확히 같은 것들입니다. 그렇죠?

또한 Reader 들이 모든 것을 계산하는 방식 덕분에 자동적으로 의존성을 약화시킵니다.

따라서 우리에겐 해결해야 될 두 가지 염려할 사항이 있습니다.

  • 연산을 수행하기 위해 모든 레벨에서 연산을 실행합니다. (의존성을 통과하기를 기다리는 함수)
  • 서로 다른 기능의 호출을 통해 의존성을 자동으로 전달하여 의존성을 무시하므로 직접 사용할 필요가 없습니다.

이 아이디어는 Reader 를 사용하여 반환 유형을 감싸는 것입니다. 자, 여기에 DataSource의 다른 구현체가 있습니다.

/* data source could look like this */
fun getHeroes(): 
Reader<GetHeroesContext, IO<Either<CharacterError, List<SuperHero>>>> =
    Reader.ask<GetHeroesContext>().map({ ctx ->
      runInAsyncContext(
          f = { ctx.apiClient.getHeroes() },
          onError = { it.toCharacterError().left() },
          onSuccess = { it.right() },
          AC = ctx.threading
      )
    })

무서워 하지 마세요. 나도 우리가 유형에 대해 조금씩 상실하고 있다는 것을 압니다. 하지만 전에도 말했지만 이 시리즈의 다음 글에서 이 것을 고쳐나갈 것 입니다. 지금은 저를 믿으세요! 🤗

아마도 여러분은 전에 있던 계산이 이미 있다는 것을 눈치챘을 것입니다. 다만 이제는 Reader로 매핑하고 있다는 점입니다. 하지만 Reader는 어디에서 올까요? 언뜻 보면 정적인 것 처럼 보는 것 같은데, 그렇지 않나요?

함수의 시작 부분을 보면 아래와 같은 문장을 찾을 수 있습니다.

Reader.ask<GetHeroesContext>().map { ctx -> ... }

GetHeroesContext 는 Reader Context, 즉 D입니다. GetHeroesContext는 필요한 모든 의존성을 제공하는 데이터 클래스 입니다. Context는 이전에 계산되는 것이 아닌 전체 계산 트리를 실행하는 순간에 인스턴스화 됩니다.

D에 대한 의존도는 완전한 호출 체인을 위해 제가 필요로 하는 것 입니다. 다른 말로 말하자면 D는 Dagger 의존 그래프나 액티비티, 애플리케이션 별로 구축하는 모든 바인딩을 포함하는 구성 요소에 적합합니다.

ask()호출은 Reader 의 companion object 들 중 한 부분인데, { (D) -> D }로 계산을 감싸서 ReaderT 로서 사용할 수 있도록 정적으로 반환합니다. 따라서 우리는 Reader에게 모든 의존성을 포함하고 있는 D에게 접근하여 map할 수 있습니다. 그것이 바로 람다 안에 있는 저것들을 사용할 수 있는 이유입니다.

따라서 반환 유형은 다음과 같습니다.

Reader<GetHeroesContext, IO<Either<Error, List<SuperHero>>>>

이러한 유형의 중첩은 하나의 중첩된 유형으로 축소하기 위한 추가적인 반복이 필요합니다. 그것이 바로 어떤 함수형 패러다임 개발자라도 큰 Monad 스택을 다룰 수 있게 하는 것입니다. 하지만 아직까지는 공개할 수 없습니다. 😉

유형이 어떻게 되어있는지 살펴보세요.

의존성(GetHeroesContext) 를 실행하기 위해 일부 의존성을 기다리고 있는 계산(Reader) 를 지연시킵니다. 이 때서야 오류 또는 유효한 히어로 목록을 반환할 수 있는 IO 계산을 수행할 수 있습니다.

만약 우리가 호출 체인으로 돌아간다면, 우리는 사용 사례가 예전과 같은 방식으로 변화가 없이 진행되고 있는지 알 수 있습니다. Noise를 줄이기 위해 반환 유형을 제거했지만 가능한 추가해주세요. 반환 유형을 명확히 명시하는 것은 좋은 일 입니다.

/* use case */
fun getHeroesUseCase() = fetchAllHeroes().map { io ->
  io.map { maybeHeroes ->
    maybeHeroes.map { discardNonValidHeroes(it) }
  }
}

또한 Presenter 코드는 정말로 비슷하지만 이제 우리는 다른 작업을 하기 전에 Context를 가진 Render를 들어올리기도 합니다. 따라서 의존성이 포함된 Context에 대한 접근 권한을 자동으로 확보할 수 있습니다.

/* presenter code */
fun getSuperHeroes() = Reader.ask<GetHeroesContext>().flatMap(
{ (_, view: SuperHeroesListView) ->
  getHeroesUseCase().map({ io ->
    io.unsafeRunAsync { it.map { maybeHeroes ->
        maybeHeroes.fold(
            { error -> drawError(error, view) },
            { success -> drawHeroes(view, success) })
      }
    }
  })
})

이번엔 흥미로운 일을 하려고 합니다. 왜냐하면 우리는 데이터 클래스이기 때문에 그 Context에 대한 해체를 적용합니다. 다만 Context는 여전히 존재합니다.

(_, view: SuperHeroesListView) -> ...

우리가 의존성들 중 하나에 접근하기 위하여 (아마 MVP view contract가 될 것입니다)  신속히 접근할 수 있도록 해체를 신청할 수 있습니다.

나머지 부분은 예전에 했던 것과 똑같이 계속되고 있습니다. 하지만 이번에는 계산을 마치고 Reader라는 수식어를 반환합니다. 이제 View Implementation 을 통해 Presentation Pure function (=프리젠테이션 순수 기능) 을 호출할 수 있으며, 나중에 의존성이 있는 상태에서 준비되면 선택할 수 있습니다.

/* we perform unsafe effects on view impl now */
override fun onResume() {
  /* presenter call */
  getSuperHeroes().run(heroesContext)
}

자, 이제 우리가 사용하려고 하는 Reader context를 넘길 차례입니다. 👏

이 덕분에, 우리는 Presenter로부터 감지될 수 있는 효과를 이끌어 낼 수 있었고, 완전히 순수한 실행 트리가 완성되었습니다. 부작용도 없고, 상태도 없습니다. 대승리네요.

의존성 트리의 어느 지점에서든 의존성을 테스트할 때 의존성을 바꿔야 하는 경우에는 복잡한 프레임워크나 라이브러리가 필요하지 않습니다. 실제 상황에서 확장할 수 있는 다른 Context의 Instance를 제공할 수 있어야 하며, Mockito 가 수행하는 것과 동일한 인스턴스 내에서 필요한 인스턴스를 제공해야 합니다.


정리

올바르게 구축된 Monad 스택은 수년간 안드로이드 앱이 제공할 수 있는 모든 우려 사항을 해결할 수 있습니다. 이러한 문제를 해결하기 위해 사용되는 방법과 접근 방식은 오류 처리, Dependency Injection, IO 계산 등과 같은 완전한 일반적인 문제입니다.

이러한 전략들은 향후 작성하는 다른 모든 애플리케이션과 공유할 수 있습니다. 애플리케이션이 아니더라도 Kotlin을 실행할 수 있는 플랫폼이라면 어떤 것이든지 공유할 수 있습니다. 실행할 수 없다고 해도 똑같은 접근법을 이용해 함수형 패러다임으로 전략을 도입할 수 있습니다.

아마도 RxJava가 도입되기 시작했을 때와 같이 안드로이드 개발자에게는 잘 쓰이지 않던 새로운 패러다임이다 보니까, 어느정도 익숙하지 않은 건 사실입니다.

그것이 반드시 나쁘거나, 성공할 수 없다는 것은 아닙니다. 이전 글에서 설명했던 것과 같은 매우 흥미로운 혜택을 제공하기 때문에 적어도 시도해보고 조사할 가치는 충분히 있습니다. 저는 여러분이 이 것을 이해할 수 있도록 노력하라고 젱나합니다. 왜냐하면 여러분이 이해하는 것 처럼 더 나은 개발자가 될 수 있기 때문입니다.  😊

다음 장을 기다리세요. Monad Transformers 라는 새롭고 멋진 기능을 사용하여 중첩된 유형을 단순화 시킬 것입니다. 이것이 바로 진정한 함수형 프로그래머가 이 것에 대해 반복하는 것입니다.

샘플 저장소에 다가가는 것을 잊지 마세요! nested-monads 라는 모듈에서 실제 안드로이드 앱에 이 접근 방법이 어떻게 쓰여지고 있는지 보여주고 있습니다.

트위터에서 @JorgeCastilloPr 를 추가하면 이 주제와 다른 것들에 대해 알 수 있습니다.


"마침내 웅장한 사원을 짓게 되었습니다. 공기를 느끼며 하늘을 만져보세요. 그리고 안에서 영원히 평화롭게 사는 것 입니다. "

profile
Android Developer @kakaobank

0개의 댓글