[Kotlin Conf 2023] Kotlin & 함수형 프로그래밍: 최고를 선택하고 나머지는 건너뛰세요 - Urs Peter

sdlee·2023년 7월 30일
2

서론

이 글은 KOTLINCONF' 23Kotlin & Functional Programming: pick the best, skip the rest by Urs Peter를 요약 & 번역한 글입니다. 오역이 있을 수 있습니다.

프로그래밍 스타일

프로그래밍 스타일은 크게 2가지로 나눌수 있다.

명령형 프로그래밍 (Imperative Programming)

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 등의 그 예다.

표현 지향 프로그래밍 (Expression Oriented Programming)

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) 코드를 짜는 더 좋은 개발자가 된 것 같다고 한다.

Scope Functions

상태가 변하는 API는 어떻게 다룰까?

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을 사용한다.

Scoped 코드를 사용한다.

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한 코드를 고립시키고 접근을 차단한다.

  1. apply로 RestClient의 상태가 변하는 부분을 고립시켰다.
  2. run으로 File에 text를 쓰는 부분을 고립시켰다. block 바깥에서는 File에 접근이 불가능하다.

Scoped function은 명령형 세계와 표현 지향 세계를 연결시켜주는 다리(bridge)가 된다.

고차함수 (Higher Order Functions)

어떻게 제어 구조(Control Structures)를 재사용할까?

위 예제에서 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???() }

이렇게 중복된 부분을 가지면서 특정 부분만 살짝 다른 경우, 고차함수를 사용하면 좋다.

합성 가능한 함수 (Composable Functions)

앞의 예제에서 나온 고차 함수는 다른 비슷한 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을 넘어 합성 가능한 우리만의 자료구조를 만들수 있지 않을까?

범주론 (Category Theory)

범주론은 수학적 구조들과 그들의 단계에 관한 이론이다. 물리에서 주기율표에서 원자가 결합되어 어떤 특성을 가진 분자가 되는 것과 비교할 수 있다. 범주론은 그것과 비슷하지만, 원자가 아닌 논리적인 구성요소(building block)들을 특성을 가지도록 합성한다.

범주론과 모나드는 어려운가?

Monad는 단순히 endofunctor 카테고리에 속하는 Monoid이다.

위 말이 이해되는가? 학계는 종종 강력한 프로그래밍 패러다임을 너무 어렵고 접근하기 어려운 방식으로 설명한다. 이 것은 개발자들을 겁줘 범주론(또는 모나드)라는 산을 오르기를 포기하거나 돌아가게 만든다.

이 강력한 프로그래밍 패러다임을 모두가 이해하고 활용할 수 있도록 가장 어려운 부분을 해소해야 할 때가 왔다. 가장 적용가능한 분야부터.

컨테이너와 모나드(Monad)

우리는 사실 모나드를 매일 사용한다! 모나드는 종류가 다양한 컨테이너다. 안전한 컨테이너, 에어컨이 달린 컨테이너... 등등. 무언가를 담는 공통의 특성을 가지면서도, 컨테이너의 종류는 다양할 수 있다.

Collection도 컨테이너다. 앞의 예제에서 어떤 동작들이 Collection을 유용하게 만들었는가?

모노이드와 펑터

Collection은 모나드인데, Collection의 특성을 예시로 들며 모노이드, 펑터, 모나드를 설명한다.

모노이드 (Monoid)

Collection은 비어있을 수 있고, 결합할 수 있다.

listOf(1, 2) + listOf(3) == listOf(1, 2, 3)

이러한 특징을 갖는 것을 모노이드라고 한다.

inteface Monoid<T> {
	fun empty(): Monoid<T>
    fun combine(other: Monoid<T>): Monoid<T>
}

펑터 (Functor)

Collection은 다른 타입의 Collection으로 변환할 수 있다.

listOf(1, 2).map { it.toString() } == listOf("1", "2")

이러한 특징을 갖는 것을 펑터라고 한다.

interface Functor<A> {
	fun <B> map(transform: (A) -> B): Functor<B>
}

모나드 (Monad)

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 이라는 이름이 더 직관적이고 어울린다고 했다.

모나드 예시

Optional
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")
Mono
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") }
        }

중첩된 FlatMap 문제

아래 세 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 라이브러리

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?

  • 두 레벨의 모나드에서만 동작한다.
  • 코틀린은 언어 레벨에서의 기능(Higher Kinded Types)이 부족하여 Monad Comprehension에서 사용될 수 없다.
  • 아래 함수들을 모두 작성하고 관리해야 한다.
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>> = ...

작성하더라도 며칠이 지나면 이해하지 못할 것이고, 동료들도 고통스러울 것이다.

현실적인 해결 방법

  1. Reactor의 Mono -> Kotlin coroutine으로 대체한다.
// Reactor Mono
fun getDevByName(name: String): Mono<Result<Option<Developer>>>

// Kotlin coroutine
suspend fun getDevByName(name: String): Result<Option<Developer>>
  1. Option, Optional은 Kotlin의 nullability로 대체한다.
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에게 응답을 맡겼다.

모나드는 선택적으로 사용하면 매우 좋다!

모나드를 사용하면 좋은 예시들

1. 여러 변수의 null check

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
		)
}

2. 중첩된 data object의 copy

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)

profile
backend engineer

1개의 댓글

comment-user-thumbnail
2023년 7월 30일

감사합니다. 이런 정보를 나눠주셔서 좋아요.

답글 달기