[번역] Kotlin Functional Programming II: Monad Transformers


이 글은 Jorge Castillo 가 Medium에 올린 Kotlin Functional Programming II: Monad Transformers 의 번역 글입니다. 다소 오역이 많을 수 있습니다.

This post is Korean translated post of Kotlin Functional Programming II: Monad Transformers, author is Jorge Castillo. Thanks for great post.


 

감동이지 않나요? 분명 Monad 스택을 하나의 타입으로 합성할 때 얻는 기분과 같을 것입니다. 그러니까, 왜 안하겠어요?

만약 이 시리즈의 첫번째 글을 아직 읽지 않았다면 먼저 읽어주세요. 이 글을 이해하는 데에 큰 도움이 될 것입니다. 또한 이 글은 저 접근법에 대한 추가적인 반복이 될 것입니다.


문제점

함수형 패러다임 세계에서 자주 언급되는 말이 있습니다. : Monad 는 합성할 수 없습니다. 적어도 우아하지는 않은 것 같네요. 단지 호출 체인에 모든 같은 Monad 를  올렸을 때 flatMapflatten을 사용해 스택을 합성할 수 있지만 가능성이 매우 낮습니다.

일반적인 상황에서, 이 함수는 다른 레벨의 다른 Monad를 반환할 것이고, 스택은 심하게 중첩될 것입니다.

스택에 많은 중첩 레벨을 얻을 때 마치 그것은 피라미드처럼 보이기 시작합니다. 다음 3개의 레벨의 중첩된 Monad가 있는 코드를 보세요. 여기서는 가장 깊은 수준의 엑세스 권한을 얻고자 합니다.

이 접근법은 기하급수적으로 무서운 상황에 이끌어낼 수 있습니다.

Monad comprehensions

지난 코드를 보면 모든 레벨에 Either를 사용하고 있습니다. 단일된 유형이니, 우리는 Monad Comprehension 라는 멋진 것을 적용할 수 있습니다!

만약 여러분이 Haskell 에 대해 알고 있다면, 아마 “do notation” 라는 것을 들어보셨을 것 입니다. Monad Comprehension는 순차적으로 정렬된 순서대로 순차적 코드를 작성할 수 있는 방법입니다.문법을 위해 결과를 ‘산출’할 수도 있습니다.

KΛTEGORYbinding라고 불리는 Monad Context의 밑에 있는 기능을 제공합니다. Monad 인스턴스를 사용할 수 있는 모든 데이터 유형에 대해 Monad Comprehensions 를 제공합니다.

그래서 binding를 이용하여 지난 코드를 작성해봅시다.

Bindings 는 Monad Context에서 구동되게 되는데  여기서는 MonadError 인스턴스입니다. Binding 블록은 일련의 순차적 flatMappeable 작업이 포함될 수 있으며, 이를 순차적으로 선언할 수 있습니다. 각 작업의 결과는 Monad Context로 들어올려지고, 다음의 bind()호출을 향해 flatmapped작업을 시행합니다.

마지막으로 Monad Context가 사용된 호출 결과를 자동으로 들어올린 것을 산출할 수 있습니다.

이 Syntactic Sugar (역주: 프로그래밍 언어에서 읽기나 표현을 좀 더 쉽게 만들어주는 문법) 는 많은 언어에서 찾을 수 있습니다. 매번 같은 Monad 유형을 반환하고 같은 Context로 결과가 들어올려지는 대량의 순차적 작업을 가질 때 마다 binding를 사용할 수 있습니다.

하지만 기억하세요: 우리는 Either라는 싱글 유형을 다루고 있기에 binding를 사용할 수 있습니다. 하지만 각기 다른 중첩 유형이 있으면 어떨까요?

이 예제는 kategory.io documented datatypes: EitherT. 의 한 부분에 속합니다. KΛTEGORY문서는 코드와 차후 설명을 제공하고 있어 매우 교훈적입니다.

실제 문제

이전 글에서 모든 Android 앱에 대한 주요 관심사를 해결하기 위해 단계적으로 구축한 것이 이 상황을 깊게 들여다볼 수 있는 예제가 될 수 있습니다. 마지막으로 해당 스택으로 종료되었습니다.

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

우리는 필요한 의존성을 제공하는 Context를 기대할 수 있는 정규화된 Reader 계산을 수행했습니다. 의존성이 제공되면 IO 에 대한 안전하지 않은 작업이 수행되며 이는 오류 또는 유효한 히어로 목록을 반환합니다.

이 스택은 복잡하여 코드의 가독성을 낮춥니다.

하지만 이 Monad 스택은 완전한 것이 아닙니다. 함수형 패러다임 개발자는 이에 머무르지 않고 Monad Transformers 를 사용하여 이 문제를 해결하려고 합니다.

그래서, Monad Transformer가 무엇인지요?

일반적으로 Monad와 유사하지만 독립 실행형 엔터티가 아니라 기본적인 Monad를 다룹니다.

대부분의 KΛTEGORY 가 제공하는 Monad는 Transformer 등가물이 있습니다.

관례상, Monad의 Transformer 는 동일한 이름에 T를 붙인 이름을 가지고 있습니다. 예를 들어, State의 Transformer 등가물의 이름은 StateT입니다.

만일 Either<L, R> 와 같은 다른 모나드 기능을 얻기 위해 기존 IO<A>과 같은 Monad가 필요하다면, Transformer 를 사용할 수 있습니다.

취할 수 있는 이득은 무엇인가요?

Transformer 를 사용하여 중첩된 유형의 필요성을 제거하고 이전에 있는 것과 동일한 기능을 가진 단일 장치로 모든 것을 축소합니다.

즉, 중첩된 Monad에 의해 야기된 복잡성을 제거하여 스택에 대한 매핑을 만들어 스택에 엑세스 할 수 있습니다. 또, 깊은 스택에서의 작업을 사용할 수 없게 합니다.

따라서 여러분의 코드는 Transformers의 도움으로 간결화 될 수 있습니다.

스택 수정하기

가장 깊은 중첩 유형을 골라봅니다.

IO<Either<CharacterError, List<SuperHero>>>

부작용을 포함하는 IO 계산이 있습니다. 양쪽 모두 이중성의 힘으로 전달하기 위해 우리는 Either Transformer를 사용할 수 있습니다.

Either Transformer의 유형은 EitherT<F, L, A>입니다. F는 기본적인 Monad를, L은 Either의 Left Side, A는 최종 결과 유형입니다.

히어로 목록에 대입해본다면 다음과 같습니다.

EitherT<IOHK, CharacterError, List<SuperHero>>

여기서 IOHK이 일반 유형 슬롯의 IO를 참조하는 방법에 대해 이해할 필요성이 있습니다. 따라서 여기서 IO는 기본적인 Monad가 됩니다.

IOHK는 KΛTEGORY 의 더 높은 종류(higher kind)를 위해 컴파일때 자동으로 생성되는 boilerplate의 일종입니다.

EitherT덕분에 IO는 존재하는 문제를 해결할 수 있게 되었습니다 🎉. 하지만 스택은 조금 더 커졌습니다.

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

따라서 우리는 이미 변형된 IO에게 Reader 의 힘을 공급하여 시간에 따른 IO 지연 계산 및 오류 처리 기능 뿐만이 아니라 Dependency injection 기능을 제공하도록 합니다.

우리는 Reader Transformer 를 사용하여 이 문제를 달성할 수 있습니다. : ReaderT<F, D, A>

F는 변형될 기본적인 Monad이고, D는 모든 의존성을 가지고 있는 Reader Context, A는 최종 결과 유형입니다.

변형된 유형은 다른 반환된 유형을 통해 작업할 수 있기 때문에 A에 대해 불가지론적 (역주 = 어떤 것에도 상관없이 소프트웨어 프로그램이나 시스템을 사용 가능한)이 되어야 합니다. 즉 시스템에서 임의의 오류가 발생할 수 있다는 것을 알고 있고, 시스템의 오류가 CharacterError의 클래스 요소 중 하나에 매핑될 것이란 것을 알 수 있습니다. (이전 글에서 자세한 상황을 알아보세요.)

이번에는 기본적인 Monad는 EitherT<IOHK, CharacterError, List<SuperHero>>로 변형될 것입니다. 우리는 우리의 Transformer 타입인 ReaderT<F, D, A> 에 맞게 F의 위치를 알맞게 맞출 필요가 있습니다.

이 의미는 IOHK와 CharacterError 유형을 고치기 위해 EitherT를 일부적으로 적용하여 A를 generic로서 남겨야 된다는 것을 의미합니다. EitherT의 유형이 EitherT<F, L, A>인 것에 기억하세요.

ReaderT<EitherTKindPartial<IOHK, CharacterError>, D, A>

이것으로 우리는 변형된 유형의 작업을 할 준비가 되어있습니다. 비록 그것이 여전히 중첩된 형태로 보일지라도, 실제로 필요한 모든 기능을 가진 단일 유형입니다.

여기서 ReaderTKleisli로도 불리고 있습니다. Kleisli 는 KATEGORY 의 type alias 의 일종입니다. 샘플에도 Kleisli로 사용되고 있습니다.

typealias ReaderT<F, D, A>  = Kleisli<F, D, A>

Kleisli<F, D, A>D => F<A>의 wrapper 입니다. 바로 우리가 샘플 저장소의 아키텍쳐에 있는 계산 수준이나 레이어를 가지고 있습니다.

여러분은 변형된 형태를 Result 로 의미하는 것으로 잘못 인식하고 있다는 것을 깨달았을 것입니다. 결국 이 착각은 다른 계층을 가로질러 움직이는 행동 결과로 보여질 것 입니다.

typealias Result<D, A> = 
Kleisli<EitherTKindPartial<IOHK, CharacterError>, D, A>

또한 나는 AsyncResult로 불리는 Kleisli의 모든 기능을 대표하지만 동시에 AsyncResult를 합성하고 bindings를 이용하기 위해 Monad 인스턴스를 동일하게 제공하는 데이터 타입으로 Result를 Wrap 했습니다. 이는 아키텍쳐 구현에 있어 매우 유용하게 쓰일 것입니다.

이제 AsyncResult 유형을 사용해서 아키텍쳐가 적용된 앱을 어떻게 완전하게 하는지 살펴봅시다.

Architecture using AsyncResult

우리는 이제 프레임워크가 되야 하는 Clean 이라는 가장 바깥쪽의 레이어를 사용해 시작할 것입니다. 여기서는 데이터의 원본을 보고 구현할 수 있습니다. 자, 이제 네트워크 데이터 소스 구현체를 살펴보겠습니다.

AsyncResult덕분에 합성되고 변형된 유형을 가질 수 있어, binding 를 사용해 대량의 순차적 작업을 선언할 수 있습니다.

여기서 나는 모든 순차적 작업을 합성했습니다. flatMap, map를 이용하여 실제로 합성된 작업을 실행하지는 않지만 합성을 되돌려놓을 수 있습니다. 이것이 바로 Key이며, Key는 Transformers 로 활성화됩니다.

binding 안에서 시간 내에 실행되는 순차적 작동 블록은 다음과 같은 기능 체인을 실행할 때에 실행됩니다.

  • 히어로를 불러오는 Query 작성
  • Reader를 들어올리는 의존성에 대한 엑세스 권한을 부여하고(Transformers를 사용한 AsyncResult 자체) Reader Context에 접근
  • IO 계산 수행

이러한 작업 중 하나는 선택적으로bind() 를 사용하여 해결할 수 있습니다. 이 경우에는 AsyncResultMonadError인스턴스를 사용하여 결과를 수동으로 묶습니다. 따라서 다음 단계에서는 이전 단계에서의 해결책을 기대할 수 있으므로 결과적으로 결과가 계산될 수 있습니다.

runOnAsyncContext에 대해 자세한 사항을 알고 싶다면 이전 글샘플 저장소를 살펴보세요. 주로 f 람다 코드를 coroutine로 실행하는 IO 계산을 반환하고 성공과 오류 사례 모두에 대한 Lambda 를 제공합니다.

그런 다음 캐시 정책을 구현할 수 있는 일종의 Stub저장소 레이어가 존재합니다.

개인적으로 이 호출 체인에 추가할 로직이 없다면 당연하게도 추가하지 않을 것입니다. 하지만 이것은 단순히 교휸적인 목적이 될 수 있는데, 이 접근법과 객체지향 프로그래밍에 기반한 Clean Architecture 를 비교해보세요.

flatMapping를 사용하여 캐시 정책을 구현하거나 데이터 소스로부터 반환된 합성된 계산을 매핑하는 작업을 쉽게 수행할 수 있습니다.

여기서 나는 모든 기능을 package level 에 정의했고, 모든 의존성이 있는 경우에만 사용할 수 있으며, 모든 의존성이 파라미터로 전달되거나 Reader 에 의해 제공됨을 알립니다. 여기서는 기능에 대한 상태를 보관할 필요가 없기 때문에 이 기능을 담은 적합 인스턴스를 가질 필요가 없습니다. 개인적 의견으로는 객체지향 프로그래밍 패러다임과 함수형 프로그래밍 패러다임은 매우 다르다는 것을 이해하는 것이 중요하다고 봅니다.

저장소에 대한 호출자는 비지니스 로직이 포함된 Use Case가 될 것입니다.

단지 나는 데이터 소스로부터 반환된 계산 결과에서 유효하지 않은 히어로들을 필터링하길 원합니다. AsyncResult를 반환하고 있기에 모든 레벨에서 하나의 싱글 유형으로 쉽게 합성할 수 있습니다.

Presentaion 코드는 다음과 같게 됩니다.

mapflatMap를 사용하여 Use Case의 결과를 매핑하고 효과를 적용할 수 있습니다. 또한 handleErrorWith함수를 통하여 뷰에 에러에 관한 효과를 적용할 수 있습니다.

getSuperHeroes()와 효과를 유발하는 drawHeroes()또는 displayGetHeroesError()들은 최종적으로 풀어지고 효과를 실행할 때 그들은 뷰에 부작용을 적용할 수 있게 AsyncResult<GetHeroesContext, Unit>를 반환합니다.

이 작업에는 아무것도 반환될 필요가 없기 때문에 Unit를 반환합니다. 당연히 GetHeroesContext는 의존 바인딩이 정의된 Reader Context를 고치는 단순 유형입니다.

작업을 수행하기 위해 바인딩 할 수 있는 기능을 사용하는 기능을 적용하는 양쪽의 부작용을 살펴보세요!

나는 주로 View Contract가 의존성으로 정의된 Reader Context에 접근하기 위하여 binding를 사용하고, 뷰에 효과를 적용합니다. 그래서 제가 보기에 여러가지 다른 효과를 적용해야 한다면 다른 식으로 글을 쓸 수 있었습니다.

다시 말해, 모든 연산은 UseCase을 기반으로 하여 이루어집니다. 이는 효과는 여전히 실행되지 않고 그들이 모든 호출 체인을 실행하고 모든 계산과 효과를 발휘하는 것이 옳다고 판단할 때 까지 계속해서 그것들을 계속 미루고 있습니다.

뷰 구현체는 프리젠테이션 레이어에 의하여 합성된 계산을 실행하는 것을 결정합니다. 약간의 복잡성은 우리가 구현한 Higher Kinds가 여전히 보이고 있기 때문입니다. (그 증거로 ev() 라는 함수가 있습니다. 하지만 KEEP-87 이 거의 성공 단계에 도달했습니다! upvote 하는 것을 잊지 마세요 🙂 그렇지 않을 때 HK와 Typeclasses 에 대한 구현체를 유지해야 되고, 사용하기 쉽게 만들기 위하여 반복해야 되기 때문입니다.

테스트는 어떻게 진행되는가요?

Reader 를 대신할 때 (즉 Reader Transformer) 테스트는 사소한 문제로 변합니다. 여러분은 단순히 필요한 의존성에 대한 Mocks를 제공하는 각기 다른 Context 구현체를 제공하면 됩니다.

따라서 실질적인 생산 코드 베이스를 계속 연습할 수 있고 전체 시스템 실행에 대해 주장(assert)하기 위해 다른 Context을 그냥 지나쳐 버릴 수 있습니다.. 블랙 박스 테스트는 이 시나리오에서 꽤나 설득력이 있습니다.

자, 이제 Reader context를 어떻게 선언하는지 빠르게 살펴보고 끝낼 것입니다.

이 방법은 제가 안드로이드 앱을 Dagger의 애플리케이션, 액티비티의 스코프를 이용해 구축할 때 사용하고 있는 것을 흉내낸 것입니다.

마지막에는 스코프에 따른 다른 구현 방법을 가진 sealed hierachy가 될 수 있습니다. 이 접근법은 Android Context, 네비게이터, api 클라이언트 또는 스레드 구현체 같은 전역 의존성에 대한 상속 여부를 정의하는 데에 도움이 됩니다. 아마도 이들은 애플리케이션 스코프 내에 있을 것입니다.

하지만 그들 모두는 아래에 선언된 ‘sub-scopes’ 에 대해 완전히 상속 가능합니다. 따라서 여러분은 sealed class로부터 메서드와 속성을 상속하기 위해 mocks를 제공하는 고유의 sealed class 구현체를 추가할 수 있습니다🎉.

액티비티 스코프에서는 현재 보여지는 액티비티의 context를 상속할 수 있으며, 다른 view contract의 인스턴스를 제공할 수 있습니다. 그래프 및 구성요소가 뷰 구현체에서 생성되기 때문에 이 모든 것을 쉽게 제공할 수 있습니다. Dagger로 하는 것과 같이 말입니다.


마무리

샘플 저장소에 있는 monad-transformer 모듈을 살펴보는 것을 잊지 마세요! 이 모듈은 히어로 목록과 상세 화면액티비티를 이 접근법을 사용하여 구현하는 것을 보여줍니다.

이 주제와 더 많은 것을 알고 싶다면, 트위터 @JorgeCastilloPr 를 팔로우 하는 것을 잊지 마세요!

아직 시리즈의 이전 글을 살펴보지 않았다면 살펴보세요!

다음 포스트에서는 KATEGORY 로 달성 가능한 ‘Tagless Final’ 라는 좀 더 심화된 함수형 패러다임 스타일에 대해 살펴볼 것입니다. Typeclasses로 정의된 추상적인 행동 위에 있는 완전한 게산 트리가 어떻게 정의되는 지 알 수 있고, 나중에 구체적인 인스턴스를 전달할 때 구현 세부 사항을 제공하는 방법을 알 수 있을 것입니다.

아마 매우 흥미로울 것입니다. 계속 지켜보세요!

옵티머스 프라임이 이 글을 승인합니다 🙂

[번역] Kotlin Functional Programming I: Monad Stack


이 글은 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.


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

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

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

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

함수형 패러다임으로 코딩을 할 때에 대부분의 문제들은 평범한 해결책 하나로 해결됩니다. 그 해결책들은 구현 세부 사항이나 의미론에 한정되지 않아야 합니다. 예를 들어보자면 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를 사용하여 발생할 수 있는 예외들을 정의하려고 합니다. 그 후에는 외부 소스에서 일어날 수 있는 모든 가능성을 정의하는 것입니다. DataSource또는 Repository에서 발생하는 어떠한 예외는 위에 선언된 모델에게 매핑되어야 합니다.

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

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

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

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

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

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

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

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

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

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

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

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

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


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

Async + Threading

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

여기에서는 지난번에 했던 것과 비슷하나 이번에는 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 라는 것을 사용할 것 입니다. 새로운 방법에 대해 알기 전에 이 글을 먼저 읽어보는 것을 추천합니다.

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

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

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

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

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

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

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

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

아마도 여러분은 전에 있던 계산이 이미 있다는 것을 눈치챘을 것입니다. 다만 이제는 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를 줄이기 위해 반환 유형을 제거했지만 가능한 추가해주세요. 반환 유형을 명확히 명시하는 것은 좋은 일 입니다.

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

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

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

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

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

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

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

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


정리

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

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

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

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

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

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

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


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

 

Functional Programming – Memoization (메모이제이션)

지난 글에서도 설명했듯이 함수형 프로그래밍 패러다임으로 바꾸는 이유 하나는 지루한 일은 런타임이 하도록 떠맡겨서 개발자로 하여금 중요한 문제에 집중할 있게 한다는 것이다.

이번 글에서는 개발자가 기능을 구현할 실수를 많이 저지를 있는 부분인캐시 대하여, 함수형 언어가 어떤 식으로 쉽게 해결할 있도록 도와주는지 알아보려고 한다.

시간이 많이 걸리는 연산을 반복적으로 사용해야 한다고 가정해보자. 굳이 연산이 아니더라도 좋다, 안드로이드에서 사용자 지정 글꼴을 불러올 사용해야 하는 Typeface Cache 등을 생각해보자.

보편적인 해결 방법은 내장 캐시를 설정하는 것이다. 주어진 파라미터를 사용하여 행동을 수행할 마다 맵에 저장해놓고, 차후 같은 파라미터로 요청이 들어왔을 경우에는 맵에 있는 데이터를 가져온다.

물론, 캐시는 메모리를 차지하게 되므로 기본 전제 조건은메모리가 충분한 경우이다.

캐싱 방법이 제대로 작동하려면 함수가 순수해야 하는데, 간단히 설명하자면 같은 파라미터로 넣으면 항상 똑같은 값이 나와야 한다 것이다. 처음에 12 넣었는데 값이 14 나오고, 다음에 12 넣었을 16 나오는 그때그때마다 변하면 안된다.

캐시를 구현하는 방법은 가지가 있는데, 원시적으로 개발자가 맵을 구현하는 외부 캐싱 방법 반복되는 함수의 리턴 값을 자동으로 캐시해주는 메모이제이션 방법 있다.

예제 코드

글에서는 어떤 자연수를 넣었을 자연수의 모든 약수의 값을 더해주는 코드로 예시를 것이다.

기본적으로 어떤 자연수(B) 어떤 자연수(A) 대하여 약수이려면, A / B 했을 나머지가 0이면 된다. 여기서 B A보다 같거나 작아야 한다.

코드로 표현하자면 아래와 같을 것이다. 리턴 타입은 Boolean 이나 여기서는 생략했다.

fun isFactor(number: Int, potential: Int) = number % potential == 0

그리고 모든 약수를 구해야 하니, 1 부터 A 까지 isFactor 작업을 반복해서 true 것만 걸러내자.

역시 마찬가지로 리턴 타입은 List<Int> 이나 여기서는 생략했다.

fun factorsOf(number: Int) = (1..number).filter { isFactor(number, it) }.toList()

마지막으로 모든 약수의 합을 구하자.

안해도 알겠지만, 리턴 타입은 Int이다.

fun sumFactors(number: Int) = factorsOf(number).sum()

합산 결과를 캐시하기 (외부 캐싱)

val sumMap = mutableMapOf<Int, Int>()

private fun cachedSumFactors(number: Int): Int {
    val value = sumMap[number]
    return if (value == null) {
        val answer = sumFactors(number)
        sumMap.put(number, answer)
        answer
    } else {
        value
    }
}

먼저 클래스 초기화 당시 sumMap 라는 해시를 만든다. cahceSumFactors 메서드 내부에선 해당 sumMap 파라미터로 자연수의 약수의 합이 들어있으면 바로 값을 반환, 없으면 계산 맵에 넣고서 반환한다.

어디서나 흔히 있는 캐시 방법이니 설명할 부분이 없다고 생각된다.

벤치마크 결과는 아래와 같다.

  • 시행: 15.09254ms
  • 두번째 시행: 0.45838ms
  • 10 평균: 0.2975ms
  • 캐시 시행: 14.41842ms
  • 캐시 두번째 시행: 0.0116ms
  • 10 평균: 0.0029ms

시행 때에는 10 반복과 캐시의 결과가 그렇게 차이나지는 않는다. 오히려 캐시 시행의 시간이 적게 걸렸는데 이는 환경적 요인에 의한 것으로 보통이라면 맵에 넣는 과정이 있어서 캐시 시행이 시간이 걸린다.

하지만 두번째 시행 부터는 차이가 많이 나는데, 캐시를 했을 경우 두번째 부터는 해시를 얼마나 빠르게 읽을 있는지를 측정하는 셈이 되는 것이다.

합을 캐시하게 되면 성능은 좋아지지만 단점 역시 존재하는데 내부 캐시가 상태를 표시하기 때문에 캐시를 사용하는 모든 메서드를 인스턴스 메서드로 만들어야 한다. Singleton 만들 수도 있으나 이럴 경우에는 코드가 복잡해지고 테스트 문제를 일으킬 있다. 개발자가 캐시 변수를 제어하기 때문에 역시 직접 정확성을 테스트나 다른 방법으로 사용하여 확인해야 한다. 방법은 성능을 향상시키는 대신 코드의 복잡성 증가 유지보수 난이도 상승이란 결과를 가지고 온다.

또한 캐시의 범위가 커지면 그만큼 적은 값을 캐시하기 때문에 정확함과 함께 실행 조건도 신경써야 한다. 것이 지난 글에서 페더스가 말한움직이는 부분 해당하게 된다.

메모이제이션

메모이제이션 이란 단어는 Donald Michie 라는 인공지능 연구학자가연속해서 사용되는 연산 값을 함수 레벨에서 캐시하는 지칭하기 위해 사용한 단어이다.

흔히 메모이제이션을 설명하기 위해 사용되는 예제는 피보나치 수열인데, f(1) f(1) + f(0), f(2) f(2) + f(1) + f(0) 이런 식이다. 만일 f(4) 구한다고 해보면 f(3) + f(2) + f(1) + f(0) 인데 각각의 f(n) 조차 하위 계산을 요구하니 시간복잡성은 매우 커지게 된다. 메모이제이션 방법을 사용해 함수 레벨 자체를 캐시하게 되면 하위 계산을 하지 않게 되어 시간복잡성이 O(n) 수준으로 줄어든다.

전에도 말했지만 함수형 프로그래밍은 재사용 가능한 메서드를 만들어서움직이는 부분 최소화하는 데에 주력한다. 이는 캐시도 제외되지 않는데, 많은 언어들이 메모이제이션을 언어 단에서 제공하기도 한다.

메모이제이션과 외부 캐싱은 서로 다른데, 외부 캐싱은 합산한 결과, 연산 결과를 저장하지만 메모이제이션은 코드 블럭, 연산하는 부분 자체에서 캐시를 한다는 점이다. 어떻게 보면 메타함수를 적용하는 것이라고도 있다.

Groovy 등에서는 코드 블럭(클로져) .memoize() 라는 메소드를 사용함으로서 메모이제이션 기능을 활성화할 있는데, 안타깝게도 Kotlin 내장하고 있지 않다.

하지만 우리가 누군가, 개발자다. 없으면 만들면 되는 것이다.

class MemoizeHelper<in T, out R>(private val function: (T) -> R) : (T) -> R {
    private val valuesMap = mutableMapOf<T, R>()
    override fun invoke(key: T): R = valuesMap.getOrPut(key, { function(key) })
}

fun <T, R> ((T) -> R).memoize(): (T) -> R = MemoizeHelper(this)

기본적으로 외부 캐시와 비슷한 구조인데, 클래스 초기화시 valuesMap 라는 맵을 초기화하고 memoize() 메서드가 실행되면 MemoizeHelper invoke 메서드를 통하여 valuesMap 값이 있으면 바로 반환, 없으면 연산하고 맵에 넣고 반환하는 기능을 수행한다. (getOrPut 라는 메서드가 기능을 해준다.)

그리고 주어진 자연수의 약수의 합을 구하는 코드 자체를 블럭으로 감싸서 이를 메모아이즈 해보자.

val memoizeSumFactors = { number: Int -> sumFactors(number) }.memoize()

시행 결과는 아래와 같다.

  • 시행: 15.09254ms
  • 두번째 시행: 0.45838ms
  • 10 평균: 0.2975ms
  • 캐시 시행: 14.41842ms
  • 캐시 두번째 시행: 0.0116ms
  • 10 평균: 0.0029ms
  • 메모이제이션 시행: 17.60542ms
  • 메모이제이션 두번째 시행: 0.00778ms
  • 10 평균: 0.0026ms

두번째 실행부터 외부 캐싱 결과보다 속도가 빨라졌는데, sumFactors 자체를 코드 블록을 만들고, memoizeSumFactors 코드 블럭의 메모아이즈된 인스턴스를 가리키게 것이다.

결론

명령형 버전(외부 캐싱)에서는 개발자가 코드를 소유하고 책임을 진다. 함수형 언어는 가끔 특별한 상황을 제외하고는 주로 표준에 맞는 구조에 적합한 일반적인 도구를 만든다. 함수가 언어의 기초적인 요소이기 때문에, 레벨에서 최적화가 고급 기능을 공짜로 얻는 셈이다. 언어 설계자들은 자기 자신이 정한 규칙도 어길 있다. 다시 말하자면 일반 개발자들이 접근할 없는 부분까지 접근할 있기 때문에 최적화를 진행할 있어 이미 만들어진 보다 효율적인 캐시를 만드는 것은 거의 불가능하다고 있다.

, 언어가 캐싱을 효율적으로 지원하기도 하지만 개발자는 그런 지루한 일을 런타임에게 떠맡기고, 높은 수준에서 추상화된 문제들에 고민해야 한다.

외부 캐싱 같은 수작업으로 캐시를 만드는 것은 간단하다. 하지만 코드에 내부 상태를 더하고 복잡하게 만들어움직이는 부분 증가시키는데 비해 함수형 언어의 메모이제이션 기능을 사용하면 함수 레벨에서 캐시를 더할 있어서, 코드를 거의 바꾸지 않고도 좋은 성능과 간결한 코드를 얻을 있다.

외부 캐싱이든 메모이제이션이든 중요한 전제조건이 있는데, 함수가 순수해야 한다. 외부 캐싱된 결과나 메모아이즈된 함수의 결과가 주어진 파라미터 이외의 어떤 것에라도 의존하면 기대하는 결과는 항상 얻을 없다.

부록

전체 테스트 코드는 아래와 같다.

import kotlin.system.measureNanoTime

// region Factors
fun isFactor(number: Int, potential: Int) = number % potential == 0

fun factorsOf(number: Int) = (1..number).filter { isFactor(number, it) }.toList()
fun sumFactors(number: Int) = factorsOf(number).sum()

/**
 * formatting methods for showing measureNanoTimes effectively
 */
fun Long.format() = String.format("%.5f", (this.toDouble() / 1000000.toDouble()))
// endregion

// region Memoize implement part
class MemoizeHelper<in T, out R>(private val function: (T) -> R) : (T) -> R {
    private val valuesMap = mutableMapOf<T, R>()

    override fun invoke(key: T): R = valuesMap.getOrPut(key, { function(key) })

}

fun <T, R> ((T) -> R).memoize(): (T) -> R = MemoizeHelper(this)
val memoizeSumFactors = { number: Int -> sumFactors(number) }.memoize()
// endregion

// region Cache implement part
val sumMap = mutableMapOf<Int, Int>()

private fun cachedSumFactors(number: Int): Int {
    val value = sumMap[number]
    return if (value == null) {
        val answer = sumFactors(number)
        sumMap.put(number, answer)
        answer
    } else {
        value
    }
}

// endregion
fun Boolean.toInt() = if (this) 1 else 0

val isMemoize = true
val isApply = true
val examplenumber = 2016
val loopCount = 10

fun main(args: Array<String>) {
    val value = isApply.toInt() + isMemoize.toInt()
    println("Measuring start, methods is $value")

    val applyMethod = {
        when (value) {
            2 -> {
                memoizeSumFactors(examplenumber)
            }
            1 -> {
                cachedSumFactors(examplenumber)
            }
            else -> {
                sumFactors(examplenumber)
            }
        }
    }

    for (i in 1..loopCount) {
        println("Step $i, Measured Time is ${(measureNanoTime { applyMethod.invoke() }).format()}ms")
    }
}