PHpullh

KOTLIN · 심층 가이드

Kotlin 함수형 완전 정리

실패를 예외 대신 값으로 다루고, 중첩된 불변 데이터를 렌즈와 파이프라인으로 갈아 끼우는 Kotlin 함수형 스타일을 12개 주제로 압축했습니다.

주제 12개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

Kotlin은 함수형을 강제하지 않습니다. 순수성도, 전체 함수도, 부수 효과 추적도 컴파일러가 검사해 주지 않습니다. 대신 람다와 고차 함수, 불변 프로퍼티, 대수적 자료형 역할을 하는 sealed 계층을 모두 제공합니다. 즉 규율은 언어가 아니라 설계에서 나옵니다. 그래서 이 분야를 배울 때 중요한 건 낯선 용어가 아니라, 어떤 코드에서 부수 효과를 밀어내고 어떤 경계에서 다시 받아들일지 결정하는 감각입니다.

람다 & 고차함수가 출발점이고, 여기서 바로 Either/Result 타입함수형 에러 처리로 이어집니다. 실패를 반환값으로 표현하기 시작하면 자연히 값을 계속 감싸고 풀게 되는데, 그 반복 구조를 정리한 것이 모나드 패턴입니다. 순서를 뒤집어 모나드부터 읽으면 추상적으로만 들립니다. 렌즈 패턴프리즘 패턴은 중첩 data class를 copy로 갱신하다 지쳐 본 뒤에 읽어야 가치가 보입니다.

표준 Result는 값 클래스라 성공 경로에서 추가 할당이 없다는 장점이 있지만, List<Result<T>>처럼 제네릭 인자로 들어가는 순간 박싱됩니다. 실패를 담아 대량으로 모으는 용도에는 어울리지 않습니다. 또 Result는 실패 원인을 Throwable로만 표현하므로, 도메인 에러를 구분해 처리하려면 sealed 계층이나 Arrow의 Either가 더 맞습니다. 두 방식을 한 코드베이스에 섞어 두면 경계마다 변환 코드가 늘어나니 처음에 하나를 고르는 게 낫습니다.

01람다 &amp; 고차함수

함수를 일급 객체로 다루는 Kotlin의 함수형 프로그래밍 핵심.

Kotlin code

// 함수 타입
val double: (Int) -> Int = { n -> n * 2 }
val greet: (String) -> String = { "Hello, $it!" }  // it: 단일 파라미터
val add: (Int, Int) -> Int = { a, b -> a + b }

// 고차함수 — 함수를 파라미터로 받기
fun transform(list: List<Int>, fn: (Int) -> Int) =
    list.map(fn)

// 고차함수 — 함수를 반환하기
fun multiplier(factor: Int): (Int) -> Int = { it * factor }

// 함수 합성
infix fun <A, B, C> ((A) -> B).then(g: (B) -> C): (A) -> C =
    { g(this(it)) }

// 메서드 참조
fun isPositive(n: Int) = n > 0
val nums = listOf(-3, -1, 0, 2, 5)
val pos = nums.filter(::isPositive)
val strs = nums.map(Int::toString)

fun main() {
    println(transform(listOf(1,2,3), double))   // [2, 4, 6]
    println(transform(listOf(1,2,3)) { it * it }) // trailing lambda

    val triple = multiplier(3)
    println(triple(7))   // 21

    val process = double then { it + 1 } then { "$it!" }
    println(process(5))  // "11!"
}
알아두면 좋은 점

마지막 인자가 람다이면 괄호 밖으로 꺼낼 수 있습니다(trailing lambda). 인자가 람다 하나뿐이면 괄호를 생략할 수 있습니다.

자주 하는 실수

람다 내에서 return은 람다를 포함하는 함수 전체에서 반환합니다(non-local return). 람다만 종료하려면 return@label을 사용하세요.

02SharedFlow vs StateFlow

Hot stream 두 가지의 차이와 실전 사용 패턴

Kotlin code

<span class="cm">// SharedFlow vs StateFlow 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("SharedFlow vs StateFlow") }
알아두면 좋은 점

KOTLIN 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

03모나드 패턴 (Monad Pattern)

모나드는 값을 감싸고 flatMap으로 연쇄 연산을 수행하는 패턴입니다. Kotlin에서 Maybe/Option 모나드를 구현합니다.

Kotlin code

sealed class Maybe<out T> {
    data class Just<T>(val value: T) : Maybe<T>()
    data object None : Maybe<Nothing>()

    fun <R> map(f: (T) -> R): Maybe<R> = when (this) {
        is Just -> Just(f(value))
        is None -> None
    }

    fun <R> flatMap(f: (T) -> Maybe<R>): Maybe<R> = when (this) {
        is Just -> f(value)
        is None -> None
    }

    fun getOrElse(default: @UnsafeVariance T): T = when (this) {
        is Just -> value
        is None -> default
    }
}

fun findUser(id: Int): Maybe<String> =
    if (id > 0) Maybe.Just("User-$id") else Maybe.None

fun findEmail(user: String): Maybe<String> =
    Maybe.Just("$user@example.com")

fun main() {
    val email = findUser(1)
        .flatMap { findEmail(it) }
        .map { it.uppercase() }
    println(email)  // Just(USER-1@EXAMPLE.COM)

    val none = findUser(-1)
        .flatMap { findEmail(it) }
    println(none)   // None
}
알아두면 좋은 점

실무에서는 Kotlin의 nullable 타입이 Maybe/Option의 역할을 대부분 수행합니다. 더 복잡한 모나드가 필요하면 Arrow 라이브러리를 사용하세요.

자주 하는 실수

flatMapmap을 혼동하지 마세요. map은 값을 변환하고, flatMap은 변환 결과가 다시 Maybe인 경우 사용합니다.

04Either/Result 타입

Either 타입으로 성공과 실패를 명시적으로 표현합니다. 예외 대신 반환값으로 에러를 처리하는 함수형 패턴입니다.

Kotlin code

sealed class Either<out L, out R> {
    data class Left<L>(val value: L) : Either<L, Nothing>()
    data class Right<R>(val value: R) : Either<Nothing, R>()

    fun <T> fold(onLeft: (L) -> T, onRight: (R) -> T): T = when (this) {
        is Left -> onLeft(value)
        is Right -> onRight(value)
    }

    fun <T> map(f: (R) -> T): Either<L, T> = when (this) {
        is Left -> this
        is Right -> Right(f(value))
    }

    fun <T> flatMap(f: (R) -> Either<L, T>): Either<L, T> = when (this) {
        is Left -> this
        is Right -> f(value)
    }
}

fun parseInt(s: String): Either<String, Int> =
    s.toIntOrNull()?.let { Either.Right(it) }
        ?: Either.Left("'$s'는 숫자가 아닙니다")

fun divide(a: Int, b: Int): Either<String, Double> =
    if (b == 0) Either.Left("0으로 나눌 수 없습니다")
    else Either.Right(a.toDouble() / b)

fun main() {
    val result = parseInt("10")
        .flatMap { n -> divide(n, 3) }
        .map { "결과: %.2f".format(it) }

    result.fold(
        onLeft = { println("오류: $it") },
        onRight = { println(it) }
    )
}
알아두면 좋은 점

관례적으로 Left는 에러, Right는 성공을 나타냅니다. "Right is right(올바른)"으로 기억하세요.

자주 하는 실수

flatMap 체인에서 첫 번째 Left가 발생하면 나머지 연산이 모두 건너뛰어집니다. 이를 의도적으로 활용하세요.

05함수형 에러 처리

Kotlin의 Result 타입과 runCatching을 활용한 함수형 에러 처리 전략입니다.

Kotlin code

fun fetchData(url: String): Result<String> = runCatching {
    if (url.isEmpty()) throw IllegalArgumentException("빈 URL")
    if (!url.startsWith("http")) throw IllegalArgumentException("잘못된 프로토콜")
    "데이터: $url 응답"
}

fun parseJson(data: String): Result<Map<String, Any>> = runCatching {
    mapOf("content" to data, "status" to "ok")
}

fun main() {
    // Result 체이닝
    val result = fetchData("https://api.example.com")
        .mapCatching { parseJson(it).getOrThrow() }
        .map { it["content"] }
        .getOrElse { "기본값" }
    println(result)

    // recover 패턴
    val recovered = fetchData("")
        .recover { ex ->
            println("복구: ${ex.message}")
            "빈 응답"
        }
    println(recovered.getOrNull())

    // fold로 처리
    fetchData("ftp://wrong").fold(
        onSuccess = { println("성공: $it") },
        onFailure = { println("실패: ${it.message}") }
    )
}
알아두면 좋은 점

mapCatching은 변환 중 발생하는 예외도 Result.failure로 감싸줍니다. map은 예외가 전파됩니다.

자주 하는 실수

Result를 함수 반환 타입으로 직접 사용하는 것은 현재 제한됩니다. 래퍼 클래스를 사용하거나 runCatching 호출부에서 처리하세요.

06렌즈 패턴 (Lens)

불변 데이터 구조의 중첩된 필드를 안전하게 읽고 수정하는 함수형 패턴입니다.

Kotlin code

data class Lens<S, A>(
    val get: (S) -> A,
    val set: (S, A) -> S
) {
    fun modify(source: S, f: (A) -> A): S =
        set(source, f(get(source)))

    infix fun <B> compose(other: Lens<A, B>): Lens<S, B> = Lens(
        get = { s -> other.get(get(s)) },
        set = { s, b -> set(s, other.set(get(s), b)) }
    )
}

data class Address(val city: String, val zip: String)
data class Person(val name: String, val address: Address)

// 렌즈 정의
val personAddress = Lens<Person, Address>(
    get = { it.address },
    set = { person, addr -> person.copy(address = addr) }
)
val addressCity = Lens<Address, String>(
    get = { it.city },
    set = { addr, city -> addr.copy(city = city) }
)

fun main() {
    val person = Person("김철수", Address("서울", "06000"))

    // 렌즈 합성으로 중첩 필드 접근
    val personCity = personAddress compose addressCity
    println(personCity.get(person))  // 서울

    val moved = personCity.set(person, "부산")
    println(moved)
    // Person(name=김철수, address=Address(city=부산, zip=06000))
}
알아두면 좋은 점

렌즈는 깊게 중첩된 불변 데이터를 수정할 때 copy() 체인보다 훨씬 깔끔합니다.

자주 하는 실수

렌즈를 매번 수동으로 작성하는 것은 번거롭습니다. Arrow Optics 라이브러리를 사용하면 KSP로 자동 생성됩니다.

07불변 데이터 구조 (Persistent Data)

효율적인 불변 자료구조를 구현합니다. 구조 공유를 통해 수정 시 전체 복사를 피합니다.

Kotlin code

// 불변 연결 리스트 (구조 공유)
sealed class PList<out T> {
    data object Nil : PList<Nothing>()
    data class Cons<T>(val head: T, val tail: PList<T>) : PList<T>()

    fun prepend(value: @UnsafeVariance T): PList<T> = Cons(value, this)

    fun <R> map(f: (T) -> R): PList<R> = when (this) {
        is Nil -> Nil
        is Cons -> Cons(f(head), tail.map(f))
    }

    fun toList(): List<T> = buildList {
        var current: PList<T> = this@PList
        while (current is Cons) {
            add(current.head)
            current = current.tail
        }
    }
}

fun <T> plistOf(vararg items: T): PList<T> =
    items.foldRight(PList.Nil as PList<T>) { item, acc -> PList.Cons(item, acc) }

fun main() {
    val list1 = plistOf(1, 2, 3)
    val list2 = list1.prepend(0)  // list1은 그대로

    println(list1.toList())  // [1, 2, 3]
    println(list2.toList())  // [0, 1, 2, 3]

    val doubled = list1.map { it * 2 }
    println(doubled.toList())  // [2, 4, 6]
}
알아두면 좋은 점

불변 연결 리스트에서 prepend는 O(1)이지만 append는 O(n)입니다. 앞쪽 삽입이 빈번한 경우에 유리합니다.

자주 하는 실수

깊은 재귀 순회 시 스택 오버플로가 발생할 수 있습니다. 대용량 데이터에는 tailrec이나 반복문으로 변환하세요.

08순수 함수와 부수 효과 관리

순수 함수를 작성하고 부수 효과를 격리하는 전략입니다. 비즈니스 로직의 테스트 용이성을 크게 높입니다.

Kotlin code

// 순수 함수: 동일 입력 → 동일 출력, 부수 효과 없음
fun calculateDiscount(price: Int, memberLevel: Int): Int = when {
    memberLevel >= 3 -> (price * 0.20).toInt()
    memberLevel >= 2 -> (price * 0.10).toInt()
    memberLevel >= 1 -> (price * 0.05).toInt()
    else -> 0
}

fun calculateTotal(items: List<Pair<String, Int>>, discount: Int): Int =
    items.sumOf { it.second } - discount

// 부수 효과를 경계에서 처리
data class OrderResult(val total: Int, val discount: Int, val items: List<String>)

fun processOrder(items: List<Pair<String, Int>>, level: Int): OrderResult {
    val subtotal = items.sumOf { it.second }
    val discount = calculateDiscount(subtotal, level)
    val total = calculateTotal(items, discount)
    return OrderResult(total, discount, items.map { it.first })
}

// I/O는 경계에서만
fun main() {
    val items = listOf("노트북" to 1500000, "마우스" to 50000)
    val result = processOrder(items, memberLevel = 3)

    // 부수 효과(출력)는 최외곽에서만
    println("주문 결과:")
    println("  항목: ${result.items}")
    println("  할인: ${result.discount}원")
    println("  합계: ${result.total}원")
}
알아두면 좋은 점

비즈니스 로직을 순수 함수로 작성하면 별도의 모킹 없이 입력-출력만으로 테스트할 수 있습니다.

자주 하는 실수

순수 함수 내에서 로깅, DB 접근, 네트워크 호출 등을 하면 더 이상 순수하지 않습니다. 이런 부수 효과는 호출부로 분리하세요.

09함수형 파이프라인

데이터 변환을 파이프라인으로 구성하여 선언적으로 처리합니다. 각 단계가 독립적이어서 테스트와 재사용이 용이합니다.

Kotlin code

data class RawRecord(val name: String, val score: String, val dept: String)
data class ValidRecord(val name: String, val score: Int, val dept: String)
data class Report(val dept: String, val avg: Double, val count: Int)

// 파이프라인 단계들 (각각 순수 함수)
val parse: (List<String>) -> List<RawRecord> = { lines ->
    lines.map { line ->
        val (n, s, d) = line.split(",")
        RawRecord(n.trim(), s.trim(), d.trim())
    }
}

val validate: (List<RawRecord>) -> List<ValidRecord> = { records ->
    records.mapNotNull { r ->
        r.score.toIntOrNull()?.let { ValidRecord(r.name, it, r.dept) }
    }
}

val aggregate: (List<ValidRecord>) -> List<Report> = { records ->
    records.groupBy { it.dept }.map { (dept, recs) ->
        Report(dept, recs.map { it.score }.average(), recs.size)
    }
}

val format: (List<Report>) -> String = { reports ->
    reports.sortedByDescending { it.avg }
        .joinToString("
") { "${it.dept}: 평균 %.1f (${it.count}명)".format(it.avg) }
}

fun main() {
    val data = listOf("김철수,85,개발", "이영희,92,마케팅", "박지성,88,개발", "최유리,invalid,인사")

    // 파이프라인 실행
    val result = data.let(parse).let(validate).let(aggregate).let(format)
    println(result)
}
알아두면 좋은 점

각 단계를 독립 함수로 분리하면 단위 테스트가 쉽고, 단계를 교체하거나 재배치할 수 있습니다.

자주 하는 실수

파이프라인이 길어지면 중간 타입을 추적하기 어렵습니다. 각 단계의 입출력 타입을 명시적으로 선언하여 가독성을 유지하세요.

10Arrow 라이브러리 소개

Arrow는 Kotlin을 위한 함수형 프로그래밍 라이브러리입니다. Either, Validated, 렌즈 등 핵심 타입을 제공합니다.

Kotlin code

// Arrow 라이브러리 사용 예시 (개념 코드)
// build.gradle.kts: implementation("io.arrow-kt:arrow-core:1.2.1")

// Either를 활용한 에러 처리
sealed class AppError {
    data class NotFound(val id: String) : AppError()
    data class ValidationError(val msg: String) : AppError()
}

// Arrow 스타일의 Either 활용 (간략화)
fun validateAge(age: Int): Result<Int> =
    if (age in 1..150) Result.success(age)
    else Result.failure(IllegalArgumentException("유효하지 않은 나이"))

fun validateName(name: String): Result<String> =
    if (name.isNotBlank()) Result.success(name.trim())
    else Result.failure(IllegalArgumentException("이름이 비어있음"))

data class Person(val name: String, val age: Int)

fun createPerson(name: String, age: Int): Result<Person> =
    runCatching {
        Person(
            validateName(name).getOrThrow(),
            validateAge(age).getOrThrow()
        )
    }

fun main() {
    createPerson("김철수", 25).fold(
        onSuccess = { println("생성: $it") },
        onFailure = { println("오류: ${it.message}") }
    )
    createPerson("", -1).fold(
        onSuccess = { println("생성: $it") },
        onFailure = { println("오류: ${it.message}") }
    )
}
알아두면 좋은 점

Arrow 2.0은 Kotlin 코루틴과 깊이 통합되어 있으며, context receivers를 활용한 효과 시스템을 제공합니다.

자주 하는 실수

Arrow를 도입할 때 팀 전체가 함수형 개념을 이해해야 합니다. 학습 곡선을 고려하여 점진적으로 도입하세요.

11프리즘 패턴 (Prism)

프리즘은 합(sum) 타입의 특정 케이스에 초점을 맞추는 옵틱입니다. sealed class의 특정 분기를 안전하게 접근합니다.

Kotlin code

data class Prism<S, A>(
    val getOrNull: (S) -> A?,
    val reverseGet: (A) -> S
) {
    fun modify(source: S, f: (A) -> A): S =
        getOrNull(source)?.let { reverseGet(f(it)) } ?: source
}

sealed class Json {
    data class JString(val value: String) : Json()
    data class JNumber(val value: Double) : Json()
    data class JArray(val items: List<Json>) : Json()
    data object JNull : Json()
}

// 프리즘 정의
val jsonString = Prism<Json, String>(
    getOrNull = { (it as? Json.JString)?.value },
    reverseGet = { Json.JString(it) }
)

val jsonNumber = Prism<Json, Double>(
    getOrNull = { (it as? Json.JNumber)?.value },
    reverseGet = { Json.JNumber(it) }
)

fun main() {
    val str: Json = Json.JString("hello")
    val num: Json = Json.JNumber(42.0)

    println(jsonString.getOrNull(str))  // hello
    println(jsonString.getOrNull(num))  // null (안전)

    val upper = jsonString.modify(str) { it.uppercase() }
    println(upper)  // JString(value=HELLO)

    val doubled = jsonNumber.modify(num) { it * 2 }
    println(doubled)  // JNumber(value=84.0)
}
알아두면 좋은 점

프리즘은 sealed class 계층을 다룰 때 패턴 매칭의 함수형 대안입니다. 렌즈와 합성하면 복잡한 데이터 접근이 가능합니다.

자주 하는 실수

프리즘의 getOrNull이 실패하면 modify는 원본을 그대로 반환합니다. 이는 의도된 동작이지만 실수로 무시될 수 있으니 반환값을 확인하세요.

12부수 효과 격리 (Effect Isolation)

부수 효과를 값으로 표현하여 실행을 지연시키는 패턴입니다. 테스트 용이성과 합성 가능성을 높입니다.

Kotlin code

// 부수 효과를 값으로 표현
sealed class IO<out A> {
    data class Pure<A>(val value: A) : IO<A>()
    data class Suspend<A>(val thunk: () -> A) : IO<A>()
    data class FlatMap<A, B>(val io: IO<A>, val f: (A) -> IO<B>) : IO<B>()

    fun <B> map(f: (A) -> B): IO<B> = flatMap { Pure(f(it)) }
    fun <B> flatMap(f: (A) -> IO<B>): IO<B> = FlatMap(this, f)

    @Suppress("UNCHECKED_CAST")
    fun run(): A = when (this) {
        is Pure -> value
        is Suspend -> thunk()
        is FlatMap<*, *> -> {
            val result = (io as IO<Any?>).run()
            (f as (Any?) -> IO<A>)(result).run()
        }
    }
}

fun readLine(): IO<String> = IO.Suspend { kotlin.io.readlnOrNull() ?: "" }
fun printLine(msg: String): IO<Unit> = IO.Suspend { println(msg) }

fun program(): IO<Unit> =
    printLine("이름을 입력하세요:")
        .flatMap { readLine() }
        .flatMap { name -> printLine("안녕하세요, $name!") }

fun main() {
    // 프로그램은 값일 뿐, run()할 때만 실행
    val prog = printLine("Hello").flatMap { printLine("World") }
    println("아직 실행 안됨")
    prog.run() // 이때 실행
}
알아두면 좋은 점

IO 모나드는 효과를 기술(describe)하지만 실행하지 않습니다. 프로그램의 진입점에서만 run()을 호출하세요.

자주 하는 실수

IO 내부에서 직접 println을 호출하면 격리가 깨집니다. 모든 부수 효과를 IO.Suspend로 래핑하세요.

정리하며

  • 부수 효과는 없애는 게 아니라 경계 한 곳으로 밀어내는 것이 목표입니다
  • Result는 값 클래스지만 제네릭 인자로 쓰이면 박싱되므로 대량 수집에는 부적합합니다
  • 도메인 에러를 구분해야 하면 Throwable 대신 sealed 계층이나 Either를 씁니다
  • 중첩 불변 데이터 갱신이 반복되면 렌즈로 접근 경로 자체를 값으로 만듭니다

더 깊이 들어가고 싶다면 Kotlin 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.