본문 바로가기
Kotlin

Kotlin 함수형 프로그래밍 완전 정복

by Ramos 2025. 10. 4.

함수형 프로그래밍(Functional Programming, FP)은 함수를 일급 객체로 다루며, 순수 함수와 불변성을 중심으로 하는 프로그래밍 패러다임이다. Kotlin은 객체지향과 함수형 프로그래밍을 모두 지원하는 멀티 패러다임 언어로, 강력한 함수형 프로그래밍 기능을 제공한다.

이 글에서는 Kotlin에서의 함수형 프로그래밍 기본 개념부터 고급 개념까지 차례대로 살펴보자.

1. 함수형 프로그래밍 기본 개념

1.1 일급 함수 (First-Class Functions)

Kotlin에서 함수는 일급 객체다. 즉, 함수를 변수에 할당하고, 다른 함수의 매개변수로 전달하며, 함수의 반환값으로 사용할 수 있다.

// 함수를 변수에 할당
val add: (Int, Int) -> Int = { a, b -> a + b }

// 함수를 매개변수로 전달
fun calculate(x: Int, y: Int, operation: (Int, Int) -> Int): Int {
    return operation(x, y)
}

fun main() {
    val result = calculate(5, 3, add)
    println(result) // 8

    // 람다 표현식 직접 전달
    val result2 = calculate(10, 2) { a, b -> a - b }
    println(result2) // 8
}

1.2 고차 함수 (Higher-Order Functions)

고차 함수는 다른 함수를 매개변수로 받거나 함수를 반환하는 함수다.

// 함수를 매개변수로 받는 고차 함수
fun applyOperation(numbers: List<Int>, operation: (Int) -> Int): List<Int> {
    return numbers.map(operation)
}

// 함수를 반환하는 고차 함수
fun createMultiplier(factor: Int): (Int) -> Int {
    return { number -> number * factor }
}

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5)

    // 각 숫자를 제곱
    val squared = applyOperation(numbers) { it * it }
    println(squared) // [1, 4, 9, 16, 25]

    // 3배 곱하는 함수 생성
    val triple = createMultiplier(3)
    val tripled = applyOperation(numbers, triple)
    println(tripled) // [3, 6, 9, 12, 15]
}

1.3 순수 함수 (Pure Functions)

순수 함수는 동일한 입력에 대해 항상 동일한 출력을 반환하며, 부수 효과(side effect)가 없는 함수다.

// 순수 함수 - 부수 효과 없음
fun multiply(a: Int, b: Int): Int = a * b

// 순수하지 않은 함수 - 외부 상태에 의존
var counter = 0
fun impureIncrement(): Int {
    counter++  // 부수 효과: 외부 상태 변경
    return counter
}

// 순수한 버전
fun pureIncrement(current: Int): Int = current + 1

fun main() {
    // 순수 함수는 항상 같은 결과
    println(multiply(3, 4)) // 12
    println(multiply(3, 4)) // 12

    // 순수하지 않은 함수는 매번 다른 결과
    println(impureIncrement()) // 1
    println(impureIncrement()) // 2

    // 순수한 버전 사용
    var count = 0
    count = pureIncrement(count)
    println(count) // 1
}

1.4 불변성 (Immutability)

함수형 프로그래밍에서는 데이터의 불변성을 유지하는 것이 중요하다.

// 불변 데이터 클래스
data class Person(val name: String, val age: Int) {
    // 새로운 객체를 반환하는 메서드
    fun withAge(newAge: Int): Person = this.copy(age = newAge)
}

// 불변 리스트 조작
fun addToList(list: List<Int>, element: Int): List<Int> {
    return list + element  // 새로운 리스트 반환
}

fun main() {
    val person = Person("김철수", 25)
    val olderPerson = person.withAge(26)  // 새 객체 생성

    println(person)      // Person(name=김철수, age=25)
    println(olderPerson) // Person(name=김철수, age=26)

    val numbers = listOf(1, 2, 3)
    val newNumbers = addToList(numbers, 4)

    println(numbers)    // [1, 2, 3]
    println(newNumbers) // [1, 2, 3, 4]
}

2. Kotlin의 함수형 프로그래밍 도구들

2.1 람다 표현식과 익명 함수

// 람다 표현식
val square: (Int) -> Int = { x -> x * x }
val squareShort: (Int) -> Int = { it * it }  // 단일 매개변수는 it 사용 가능

// 익명 함수
val cube = fun(x: Int): Int { return x * x * x }

// 여러 매개변수를 가진 람다
val combine: (String, String) -> String = { a, b -> "$a $b" }

fun main() {
    println(square(5))        // 25
    println(cube(3))          // 27
    println(combine("Hello", "World"))  // Hello World
}

2.2 컬렉션 함수형 메서드

Kotlin은 컬렉션에 대한 강력한 함수형 메서드들을 제공한다.

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

    // map: 각 요소를 변환
    val doubled = numbers.map { it * 2 }
    println(doubled) // [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

    // filter: 조건에 맞는 요소만 선택
    val evens = numbers.filter { it % 2 == 0 }
    println(evens) // [2, 4, 6, 8, 10]

    // reduce: 컬렉션을 하나의 값으로 축약
    val sum = numbers.reduce { acc, n -> acc + n }
    println(sum) // 55

    // fold: 초기값과 함께 축약
    val product = numbers.fold(1) { acc, n -> acc * n }
    println(product) // 3628800

    // flatMap: 평면화된 매핑
    val words = listOf("hello", "world")
    val characters = words.flatMap { it.toList() }
    println(characters) // [h, e, l, l, o, w, o, r, l, d]

    // 체이닝: 여러 연산을 연결
    val result = numbers
        .filter { it % 2 == 1 }     // 홀수만
        .map { it * it }            // 제곱
        .filter { it > 10 }         // 10보다 큰 것만
    println(result) // [25, 49, 81]
}

2.3 함수 합성 (Function Composition)

// 함수 합성을 위한 확장 함수
infix fun <A, B, C> ((A) -> B).then(f: (B) -> C): (A) -> C = { f(this(it)) }

fun main() {
    val addOne: (Int) -> Int = { it + 1 }
    val multiplyByTwo: (Int) -> Int = { it * 2 }
    val toString: (Int) -> String = { it.toString() }

    // 함수들을 합성
    val combined = addOne then multiplyByTwo then toString

    println(combined(5)) // "12" (5 + 1 = 6, 6 * 2 = 12, 12.toString())
}

3. 고급 함수형 프로그래밍 개념

3.1 커링 (Currying)

개념

  • 여러 인자를 받는 함수 → 인자 하나만 받는 함수들의 체인으로 분리하는 기법
  • f(a, b, c)f(a)(b)(c)
  • 함수 합성을 더 단순하게 만들 수 있음
// 일반 함수
fun add(a: Int, b: Int): Int = a + b

// 커링된 함수
fun addCurried(a: Int): (Int) -> Int = { b -> a + b }

fun main() {
    val add5 = addCurried(5)  // (Int) -> Int
    println(add5(10))         // 15
}

의의

  • 인자를 고정해놓고 새로운 함수(Partial Application)를 쉽게 만들 수 있음
  • 예: “모든 값에 +5를 더하는 함수” 같은 걸 즉석에서 생성

4. 실전 활용 예제

4.1 데이터 변환 파이프라인

함수형 프로그래밍의 장점을 보여주는 실제 사용 사례다.

data class User(val id: Int, val name: String, val age: Int, val email: String)
data class UserDto(val name: String, val email: String)

fun main() {
    val users = listOf(
        User(1, "김철수", 25, "kim@example.com"),
        User(2, "이영희", 17, "lee@example.com"),
        User(3, "박민수", 30, "park@example.com"),
        User(4, "최지은", 16, "choi@example.com")
    )

    val adultUserDtos = users
        .filter { it.age >= 18 }                    // 성인만 필터링
        .map { UserDto(it.name, it.email) }         // DTO로 변환
        .sortedBy { it.name }                       // 이름순 정렬

    println(adultUserDtos)
    // [UserDto(name=김철수, email=kim@example.com), UserDto(name=박민수, email=park@example.com)]
}

4.2 에러 처리와 함수 합성

sealed class ValidationError : Exception() {
    object EmptyName : ValidationError()
    object InvalidEmail : ValidationError()
    object TooYoung : ValidationError()
}

fun validateName(name: String): Result<String> =
    if (name.isBlank()) Result.failure(ValidationError.EmptyName)
    else Result.success(name)

fun validateEmail(email: String): Result<String> =
    if (email.contains("@")) Result.success(email)
    else Result.failure(ValidationError.InvalidEmail)

fun validateAge(age: Int): Result<Int> =
    if (age >= 18) Result.success(age)
    else Result.failure(ValidationError.TooYoung)

fun createUser(name: String, email: String, age: Int): Result<User> {
    return validateName(name)
        .flatMap { validName ->
            validateEmail(email).flatMap { validEmail ->
                validateAge(age).map { validAge ->
                    User(0, validName, validAge, validEmail)
                }
            }
        }
}

data class User(
    val id: Long,
    val name: String,
    val age: Int,
    val email: String
)

fun main() {
    val validUser = createUser("김철수", "kim@example.com", 25)
    println(validUser) // Success(User(id=0, name=김철수, age=25, email=kim@example.com))

    val invalidUser = createUser("", "invalid-email", 16)
    println(invalidUser) // Failure(ValidationError$EmptyName@...)
}

5. Arrow 라이브러리와 고급 함수형 프로그래밍

Kotlin에서 더 고급 함수형 프로그래밍을 위해서는 Arrow 라이브러리를 사용할 수 있다.

5.1 Option 타입

import arrow.core.*

fun divide(a: Int, b: Int): Option<Double> =
    if (b == 0) None else Some(a.toDouble() / b)

fun main() {
    val result1 = divide(10, 2)
        .map { it * 2 }
        .getOrElse { 0.0 }
    println(result1) // 10.0

    val result2 = divide(10, 0)
        .map { it * 2 }
        .getOrElse { 0.0 }
    println(result2) // 0.0
}

5.2 Either 타입

import arrow.core.*

sealed class DivisionError {
    object DivisionByZero : DivisionError()
}

fun safeDivide(a: Int, b: Int): Either<DivisionError, Double> =
    if (b == 0) DivisionError.DivisionByZero.left()
    else (a.toDouble() / b).right()

fun main() {
    val success = safeDivide(10, 2)
        .map { it * 2 }
    println(success) // Right(10.0)

    val failure = safeDivide(10, 0)
        .map { it * 2 }
    println(failure) // Left(DivisionError$DivisionByZero@...)
}

6. 함수형 프로그래밍의 장단점

장점

  • 예측 가능성: 순수 함수는 같은 입력에 대해 항상 같은 출력
  • 테스트 용이성: 부수 효과가 없어 단위 테스트가 쉬움
  • 병렬 처리: 불변 데이터로 인해 스레드 안전성 확보
  • 코드 재사용성: 고차 함수와 함수 합성으로 코드 재사용 증가
  • 버그 감소: 불변성과 순수 함수로 인한 버그 감소

단점

  • 학습 곡선: 새로운 사고방식에 대한 학습 필요
  • 성능 오버헤드: 불변 객체 생성으로 인한 메모리 사용량 증가 가능
  • 디버깅: 고차 함수와 체이닝으로 인한 디버깅 복잡성

7. 기존 고급 개념들

7.1 모나드 (Monad)

개념

  • 값을 어떤 “컨텍스트”에 담아두고 안전하게 꺼내 쓰는 방법을 추상화한 것
  • FP에서 가장 흔한 모나드는 Optional/Maybe, List, Result, Future

👉 세 가지 속성

  1. 타입 생성자: 값을 담는 껍데기 (예: Result<T>)
  2. unit(=return): 값을 껍데기 안에 넣는 것
  3. bind(=flatMap): 껍데기 안 값을 꺼내서 새로운 껍데기에 넣는 함수 실행

예시 (Kotlin – Result 모나드)

fun parseInt(str: String): Result<Int> =
    runCatching { str.toInt() }

fun reciprocal(x: Int): Result<Double> =
    if (x == 0) Result.failure(ArithmeticException("0으로 나눌 수 없음"))
    else Result.success(1.0 / x)

fun main() {
    val result = parseInt("10")
        .flatMap { reciprocal(it) }
    println(result) // Success(0.1)
}

의의

  • 에러 처리, 비동기 처리, null 안전성을 함수 합성 패턴에 녹여낼 수 있음
  • 문서 속 Result.success / failure → 전형적인 모나드 활용 사례

3. 모나드와 부수효과

  • 순수 함수만 합성 가능하지만, 현실은 I/O, 예외, 상태 변경 등 부수효과가 필수
  • 모나드는 부수효과를 안전하게 컨테이너 안에 가둔 뒤, 합성 가능하게 만들어줌
  • 예시
    • List 모나드 → 여러 값의 연산을 합성
    • Future/Deferred 모나드 → 비동기 연산 합성
  • IO 모나드 (Haskell) → I/O 부수효과 합성

4. 추가 개념 (간단 소개)

🔹 부분 적용 (Partial Application)

  • 커링과 비슷하지만, 함수를 분해하지 않고 일부 인자만 고정하는 기법
fun power(base: Int, exp: Int): Int = Math.pow(base.toDouble(), exp.toDouble()).toInt()

val square: (Int) -> Int = { power(it, 2) }

🔹 Functor

  • map이 가능한 컨테이너
  • List, Optional, Result
Result.success(5).map { it * 2 } // Success(10)

🔹 Applicative

  • Functor보다 강력, 여러 컨테이너 값을 조합 가능
  • Kotlin에서는 Arrow 라이브러리(.ap) 같은 기능 제공

정리

  • 커링: 다인자 함수 → 1인자 함수들의 합성으로 쪼개기 (합성/부분 적용 용이)
  • 모나드: 값을 컨텍스트 안에 안전하게 담고, 합성을 가능하게 하는 추상화 (Result, Future 등)
  • Functors & Applicatives: 모나드로 가기 전 단계, map/ap을 통한 함수 적용

결국 이 모든 것은 “부수효과를 통제하면서도, 순수 함수 합성의 장점을 살리는 방법”