이 글은 KOTLINCONF' 23의 Kotlin & Functional Programming: pick the best, skip the rest by Urs Peter를 요약 & 번역한 글입니다. 오역이 있을 수 있습니다.
프로그래밍 스타일은 크게 2가지로 나눌수 있다.
fun findBestDev(lang: String): Developer? {
var devs: List<Developer>
try {
devs = get<Developer>()
} catch (ex: Exception) {
return null
}
var result = mutableListOf<Developer>()
for (dev in devs) {
if (dev.languages.contains(lang))
result.add(dev)
}
if (result.isEmpty())
return null
result.sortByDescending { it.experience }
return result[0]
}
명령형 프로그래밍 스타일은 상태가 변경되는 변수 선언에 의존한다. Kotlin에서는 var
키워드, loop, mutable objects, mutable collection 등의 그 예다.
fun findBestDev(lang: String): Developer? =
try {
get<Developer>()
.filter { it.languages.contains(lang)
.maxByOrNull { it.experience }
} catch (ex: Exception) {
null
}
}
표현 지향 프로그래밍 스타일은 그 자체가 함수형 프로그래밍은 아니지만, 기초가 되는 프로그래밍 스타일이다. 이 방식은 input을 넣으면 output이 나오는 함수적인 사고에 의존한다. Kotlin에서는 data class
, val
키워드, immutable collections 와 같은 불변 기능과, expression consturcts, higher-order functions 등이 해당된다.
명령형 프로그래밍은 데이터의 변경, 부작용(side-effects)에 관해 생각하지만, 표현 지향 프로그래밍은 데이터 변환, input/output에 대하여 집중한다.
발표자(Urs Peter)는 표현 지향 프로그래밍 방식을 사용하고나서 부터, 좀 더 유지보수 가능하고, 안정적인(robust) 코드를 짜는 더 좋은 개발자가 된 것 같다고 한다.
fun devToFile(fileName: String): Stats {
val client = RestClient()
client.username = "xyz"
client.secret = System.getenv("pwd")
client.url = "https://..."
client.initAccessToken()
try {
val file = File(filename)
file.createNewFile()
file.setWritable(true)
val devs = client.getAll<Developer>()
require(devs.isNotEmpty()) {
val msg = "No devs found"
LOG.error(msg)
msg
}
devs.forEach { file.appendText(it.toCSV()) }
return Stats(devs.size, file.length())
}
finally {
client.close()
}
}
현실 세계는 아름답지 않아서, 상태가 변하는 API가 많이 존재한다. 이런 경우는 어떻게 할까? 위 예시에서는 mutable한 rest client, file writing을 사용한다.
fun devsToFile(fileName: String): Result =
RestClient().apply {
username = "reader"
secret = System.getenv("pwd")
url = "https://..."
initAccessToken()
}.use { client ->
client.getAll<Developer>().let { devs ->
require(devs.isNotEmpty()) {
"No devs found".also { LOG.error(it) }
}
File(fileName).run {
createNewFile()
setWritable(true)
devs.forEach { appendText(it.toCSV()) }
Result(devs.size, legnth())
}
}
}
Scope 함수로 mutable한 코드를 고립시키고 접근을 차단한다.
Scoped function은 명령형 세계와 표현 지향 세계를 연결시켜주는 다리(bridge)가 된다.
위 예제에서 toCSV() 대신 toTSV()로만 바꾼 코드를 작성하고 싶다면?
중복된 부분 (generic control structure)과 바뀌는 부분(varing part)을 구분해서 바뀌는 부분에 고차함수를 사용한다.
fun devsToFile(fileName: String, toLine: (Developer) -> String): Stats =
// ... 생략
devs.forEach(appendText(toLine(it))
devsToFile("devs.csv") { it.toCSV() }
devsToFile("devs.tsv") { it.toTSV() }
devsToFile("devs.???") { it.to???() }
이렇게 중복된 부분을 가지면서 특정 부분만 살짝 다른 경우, 고차함수를 사용하면 좋다.
앞의 예제에서 나온 고차 함수는 다른 비슷한 building block들과 합성되어 가치있는 결과를 만들어 낼수 있는 추상화가 부족했다. 즉, 합성 가능하지 않았다.
fun findBestDev(lang: String): Developer? =
try {
get<Developer>()
.filter { it.languages.contains(lang)
.maxByOrNull { it.experience }
} catch (ex: Exception) {
null
}
}
반면 위 예제에서, Collection 추상화는 우리에게 그것의 데이터를 매우 유연하고 강력한 방법들로 조작/변형 가능하게 한다. (filter, maxByOrNull ...)
이것을 가능하게하는 특성을 살펴보면, Collection을 넘어 합성 가능한 우리만의 자료구조를 만들수 있지 않을까?
범주론은 수학적 구조들과 그들의 단계에 관한 이론이다. 물리에서 주기율표에서 원자가 결합되어 어떤 특성을 가진 분자가 되는 것과 비교할 수 있다. 범주론은 그것과 비슷하지만, 원자가 아닌 논리적인 구성요소(building block)들을 특성을 가지도록 합성한다.
Monad는 단순히 endofunctor 카테고리에 속하는 Monoid이다.
위 말이 이해되는가? 학계는 종종 강력한 프로그래밍 패러다임을 너무 어렵고 접근하기 어려운 방식으로 설명한다. 이 것은 개발자들을 겁줘 범주론(또는 모나드)라는 산을 오르기를 포기하거나 돌아가게 만든다.
이 강력한 프로그래밍 패러다임을 모두가 이해하고 활용할 수 있도록 가장 어려운 부분을 해소해야 할 때가 왔다. 가장 적용가능한 분야부터.
우리는 사실 모나드를 매일 사용한다! 모나드는 종류가 다양한 컨테이너다. 안전한 컨테이너, 에어컨이 달린 컨테이너... 등등. 무언가를 담는 공통의 특성을 가지면서도, 컨테이너의 종류는 다양할 수 있다.
Collection도 컨테이너다. 앞의 예제에서 어떤 동작들이 Collection을 유용하게 만들었는가?
Collection은 모나드인데, Collection의 특성을 예시로 들며 모노이드, 펑터, 모나드를 설명한다.
Collection은 비어있을 수 있고, 결합할 수 있다.
listOf(1, 2) + listOf(3) == listOf(1, 2, 3)
이러한 특징을 갖는 것을 모노이드
라고 한다.
inteface Monoid<T> {
fun empty(): Monoid<T>
fun combine(other: Monoid<T>): Monoid<T>
}
Collection은 다른 타입의 Collection으로 변환할 수 있다.
listOf(1, 2).map { it.toString() } == listOf("1", "2")
이러한 특징을 갖는 것을 펑터
라고 한다.
interface Functor<A> {
fun <B> map(transform: (A) -> B): Functor<B>
}
Collection은 결과가 Collection인 변환을 중첩된 Collection을 가지지 않으면서 적용할 수 있다. (flatMap)
data class Developer(val name: String, val languages: List<String>)
// map results in nesting
listOf(dev1, dev2).map { it.languages } == listOf(listOf("Kotlin", "Scala"), listOf("Python"))
// flatMap maps and flatten
listOf(dev1, dev2).flatMap { it.languages } == listOf("Kotlin", "Scala", "Python")
결합 (Monoid), 변환 (Functor) 과 함께, 위 추가적인 중첩 없이 변환 가능한 특성까지 3가지를 모두 가지고 있다면 모나드
이다.
interface Monad<T> {
fun empty(): Monad<T>
fun combine(other: Monad<T>): Monad<T>
fun <V> map(transform: (T) -> V): Monad<V>
fun <V> flatMap(transform: (T) -> Monad<V>): Monad<V>
}
여기서 발표자는 모나드가 모노이드라고 했는데, category of endofunctor에서 모나드를 모노이드로 볼 수는 있지만, 이렇게 combine 함수를 인터페이스에 정의하는 것은 틀리지 않나 생각이 들었다. 뒤에 나오는 모나드 예시들에서도 combine은 빼놓고 제시하고 있다.
발표자는 Monoid
, Functor
, Monad
보다는 Combinable
, Mappable
, Composable
이라는 이름이 더 직관적이고 어울린다고 했다.
class Optional<T> { ... }
Optional.empty()
Optional.of(dev1).map { it.name } == Optional("Jack")
Optional.of(dev1).flatMap {
it.languages.firstOrNull()?.let {
Optional.of(it)
} ?: Optional.empty()
} == Optional.of("Kotlin")
class Mono<T> { ... }
Mono.empty()
getDeveloperByName(dev1.name).map { it.name } == Mono.just("Jack")
getDeveloperByName(dev1.name).flatMap {
selectBest(it.languages)
} == Mono.just("Kotlin")
fun getDeveloperByName(name: String): Mono<Developer> =
// ... some remote API/DB call
fun selectBest(languages: List<String>): Mono<String> =
// ... some remote API/DB call
당신이 과학자가 아니라면 용어는 헷갈리겠지만, 추상화는 가치가 있다. 바로 합성 능력이다. (compose + ability)
fun mostPopularLanguageOf(name: String): Language {
val dev = try {
client.getDevByName(name)
} catch (ex: IOException) {
throw ApplicationException("Oops", ex)
}
return try {
client.getMostPopular(dev.languages)
} catch (ex: Exception) {
Language("Kotlin")
}
}
위 예시에서 try - catch 구문을 조금 더 합성 가능하게 할수 있을까?
Kotlin standard library 에서 Result 라는 모나드를 제공한다.
class Result<T>(...) {
fun getOrNull(): T?
fun exceptionOrNull(): Throwable?
fun map(convert: (value: T) -> R): Result<R>
fun flatMap(convert: (value: T) -> Result<R>): Result<R>
}
fun mostPopularLanguageOf(name: String): Language {
runCatching { client.getDevByName(name) }
.onFailure { throw ApplicationException("Oops", it) }
.map { client.selectBest(it.languages) }
.getOrElse { Language("Kotlin") }
그런데 getDevByName 에서 Exception이 발생할 수 있다는 것을 어떻게 알 수 있을까? 메소드 정의에 없는데..
특히 Kotlin은 checked exception이 없기 때문에 함수의 정의에서 알 수 없다.
fun getDevByName(name: String): Developer
fun selectBest(langs: List<String>): Language
을 다음과 같이 바꾸면 된다.
fun getDevByName(name: String): Result<Developer>
fun selectBest(langs: List<String>): Result<Language>
Result를 반환하도록 바꿈으로써, 이 메소드가 Exception을 반환할 수 있다고 명시할 수 있다. 그러면 위 예제를 다음과 같이 바꿀 수 있다.
fun bestLanguageOf(name: String): Result<Language> =
client.getDevByName(name)
.recoverCatching { throw ApplicationException("Oops", it) }
.flatMap { dev -> client.selectBest(dev.languages)
.mapCatching { Language("Kotlin") }
}
아래 세 API를 순서대로 호출한다고 가정해보자.
fun getDevByName(name: String): Result<Developer>
fun selectBest(langs: List<String>): Result<Language>
fun getStatsFor(lang: Language): Result<LanguageStats>
다음과 같이 합성할 수 있다.
fun statsOfBestLanguageOf(name: String): Result<LanguageStats> =
client.getDevByName(name).flatMap { dev ->
client.selectBest(dev.languages).flatMap { language ->
client.getStatsFor(language)
}
}
합성은 잘 됐지만 flatMap 중첩은 보기 싫다.
Arrow-kt 라이브러리의 도움을 받을 수 있다. Arrow는 범주론 패러다임을 구현하면서, 다양한 모나드를 제공한다.
다음과 같은 모나드들을 제공한다.
Effect
, Option
, Either
, Validated
...
Arrow의 Monad Comprehensions를 사용하면 위의 중첩된 FlatMap 문제를 해결할 수 있다.
Monad Comprehensions는 모나드 계산을 간결하게 표현하기 위한 구문적인 확장이고, Scala와 Haskell에서 개념적으로 가져왔다.
import arrow.core.raise.result
fun statsOfBestLanguageOf(name: String): Result<LanguageStats> =
result { // this: ResultEffectScope
val dev = client.getDevByName(name).bind()
val language = client.selectBest(dev.languages).bind()
val stats = client.getStatsFor(language).bind()
stats
}
bind
함수는 exception 이면 result를 반환하고, 성공인 경우 변수에 값을 할당한다. bind
를 사용해서 보기 싫은 flatMap 중첩을 제거했다.
Kotlin의 context receiver를 사용하면 Result 타입이 아닌 LanguageStats 클래스를 반환하면서 에러 핸들링과 비즈니스 로직의 관심사를 분리할 수 있다.
context(Raise<Throwable>)
fun statsOfBestLanguageOf(name: String): LanguageStats {
val dev = client.getDevByName(name).bind()
val language = client.selectBest(dev.languages).bind()
val stats = client.getStatsFor(language).bind()
return stats
}
영상에 나오지는 않았지만, context receiver은 아직 preview 기능이라서 다음과 같은 옵션 설정이 필요하다.
// build.gradle.kts
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java) {
compilerOptions {
kotlinOptions.freeCompilerArgs = listOf("-Xcontext-receivers")
}
}
flatMap
은 특정 모나드의 중첩을 없애주지만, 다른 타입의 모나들의 중첩은 해결해주지 않는다.
fun getDevByName(name: String): Mono<Result<Option<Developer>>>
fun selectBest(langs: List<Language>): Mono<Result<Option<Language>>>
fun getStatsFor(Lang: Lagnuage): Mono<Result<Option<LanguageStats>>>
fun statsOfBestLanguageOf(name: String): Mono<Result<Option<LanguageStats>>> =
client.getDevByName(name).flatMap { devResOpt ->
devResOpt.map {
it.map { dev ->
client.getMostPopular(dev.languages).flatMap { langResOpt ->
langResOpt.map {
it.map { language ->
client.getStatsForLanguage(language)
}.getOrElse { Mono.just(Result.success(None)) }
}.getOrElse { Mono.just(langResOpt.map { None }) }
}
}.getOrElse { Mono.just(Result.success(None)) }
}.getOrElse { Mono.just(devResOpt.map { None }) }
}
위 코드는 두통을 유발하고 관리가 불가능하다. (실제로 전직장에서 Reactor의 Mono
와 Java의 Optional
을 같이 사용하는 코드가 있었는데 악몽이었다)
특히 고통스러운 점은 map과 flatMap의 중첩뿐 아니라, 그 의미가 모나드마다 달라 가독성을 떨어뜨린다는 것이다.
Monad Comprehension?
Monad Transformers?
fun <A, B> Mono<Result<A>>.mapT(f:(A) -> B): Mono<Result<B>> = ...
fun <A, B> Mono<Result<A>>.flatMapT(f:(A) -> Mono<Result<B>>): Mono<Result<B>> = ...
fun <A, B> Mono<Result<A>>.flatMapTOuter(f:(A) -> Mono<B>): Mono<Result<B>> = ...
fun <A, B> Mono<Result<A>>.flatMapTInner(f:(A) -> Result<B>): Mono<Result<B>> = ...
작성하더라도 며칠이 지나면 이해하지 못할 것이고, 동료들도 고통스러울 것이다.
// Reactor Mono
fun getDevByName(name: String): Mono<Result<Option<Developer>>>
// Kotlin coroutine
suspend fun getDevByName(name: String): Result<Option<Developer>>
suspend fun getDevByName(name: String): Result<Developer?>
위의 끔찍한 코드는 아래처럼 변경할 수 있다.
suspend fun statsOfBestLanguageOf(name: String): Result<LanguageStats?> = result {
client.getDevByName(name).bind()?.let { dev ->
client.selectBest(dev.languages).bind()?.let { lang ->
client.getStatsFor(lang).bind()
}
}
좋은 코드란 속해있는 도메인을 반영한다. 그리고 도메인과 관련없는 추상화는 최소한으로 가져간다. 따라서 좋은 코드는 PO (Product owner)와 같은 도메인 전문가가 읽고 이해할 수 있어야 한다.
모든 곳에서 모나드를 사용하면 함수의 순수함을 가져갈 수는 있겠지만, 그 순수함으로 아무것도 하지 않는다면 사용할 이유가 없다.
// API
class DevController(val devService: DevService) {
@GetMapping("/api/devs")
@ResponseBody
suspend fun getBestLanguageOf(@RequestParam("name") name: String): Language =
devService.getBestLanguageOf(name)
.mapError { throw Application(ErrorType.Server, it) }
.getOrThrow()
}
}
// Service
class DevService(val devDao: DevDao, val langApi: LangApi) {
suspend fun getBestLanguageOf(name: String): Result<Language> = result {
devDao.getDevByName(name).bind()?.let {
langApi.selectBest(it.languages).bind()
} ?: Language("Kotlin")
}
}
// DAO
class DevDao {
suspend fun getDevByName(name: String): Result<Developer?> = ...
}
class LangApi {
suspend fun selectBest(lang: List<Language>): Result<Language?> = ...
}
95%의 경우, 너는 Result 모나드로 넘긴 Exception으로 아무것도 하지 않을 것이다. 그냥 서비스 레이어에서 throw 해도된다. 물론 그 경우 순수하지는 않지만, 더 잘 동작한다 (works better).
class GlobalExceptionHandler {
suspend fun handleUncaught(ex: Throwable) = ...
}
// API
class DevController(val devService: DevService) {
@GetMapping("/api/devs")
@ResponseBody
suspend fun getBestLanguageOf(@RequestParam("name") name: String): Language =
devService.getBestLanguageOf(name)
}
// Service
class DevService(val devDao: DevDao, val langApi: LangApi) {
suspend fun getBestLanguageOf(name: String): Result<Language> = result {
devDao.getDevByName(name).let { dev ->
langApi.selectBest(dev?.languages.orEmpty()).handleErrorWith {
when(it.code) {
ERROR_LANG_NOT_FOUND -> Language("Kotlin").right()
ERROR_TOKEN_EXPIRED -> ...
else -> it.left()
}
}.map { it ?: Language("Kotlin") }
}.getOrHandle { throw ApplicationException(it.code.toString()) }
}
// DAO
class DevDao {
suspend fun getDevByName(name: String): Developer? = ...
}
class LangApi {
suspend fun selectBest(lang: List<Language>): Either<ApiError, Language?> = ...
}
DAO 레이어에서, DB 콜은 Exception에 따른 처리가 필요하지 않아 모나드를 사용하지 않았다. API 콜의 경우에는 ApiError 종류에 따라 다른 처리가 필요하므로, Either 모나드를 사용했다.
Service 레이어에서는 DAO에서 넘겨준 Either의 ApiError에 따른 처리를 했고, 일반적인 Exception으로 바꾸어 던져 ExceptionHandler에게 응답을 맡겼다.
모나드는 선택적으로 사용하면 매우 좋다!
fun createKotlinDeveloper(name: String?, age: Int?, languages: List<Language>): KotlinDeveloper? {
val kotlin = languages.firstOrNull { it.name == "Kotlin" }
return if (name != null && age != null && kotlin == null) { // 보기 싫다.
KotlinDeveloper(
name = name,
age = age,
otherLanguages = languages - kotlin
)
} else null
}
은 Arrow의 nullable comprehension을 사용하면 다음과 같이 바꿀 수 있다.
fun createKotlinDeveloper(name: String?, age: Int?, languages: List<Language>): KotlinDeveloper? =
nullable.eager {
val kotlin = lanugages.firstOrNull { it.name == "Kotlin" }.bind()
KotlinDeveloper(
name = name.bind(),
age = age.bind(),
otherLanguages = languages - kotlin
)
}
val dev = Devleoper(
name = "John",
age = 32,
primaryLanguage = Language("Kotlin", LanguageStats(popularity = 9))
)
vak devChanged = dev.copy(
primaryLanguage = dev.primaryLanguage.copy(
stats.copy(popularity = 10)
)
)
는 Arrow의 optics를 사용하면 다음과 같이 바꿀 수 있다.
val devChanged = Developers.Companion.primaryLanguage.stats.modify(dev) { it.copy(popularity = 10) }
(단 data class 선언에 @Optics
어노테이션을 붙여야한다)
함수형 프로그래밍/모나드는 필요한 경우에만 선택적으로 사용하고, 나머지는 버려라 (pick the best, skip the rest)
감사합니다. 이런 정보를 나눠주셔서 좋아요.