Kotlin 함수형 프로그래밍 — 고차 함수, 시퀀스, 스코프 함수

Kotlin의 함수형 프로그래밍

Kotlin은 객체지향과 함수형 프로그래밍을 모두 지원하는 멀티패러다임 언어입니다. 일급 함수, 고차 함수, 람다, 불변 컬렉션 등 함수형 프로그래밍의 핵심 요소를 언어 수준에서 제공합니다.

이 글에서는 고차 함수, 컬렉션 연산, 시퀀스, 스코프 함수를 실전 패턴과 함께 정리합니다.

고차 함수와 람다

고차 함수(Higher-Order Function)는 함수를 매개변수로 받거나 반환하는 함수입니다. Kotlin에서는 람다 표현식으로 간결하게 작성할 수 있습니다.

// 고차 함수 정의 — 함수를 매개변수로 받음
fun <T> List<T>.customFilter(predicate: (T) -> Boolean): List<T> {
    val result = mutableListOf<T>()
    for (item in this) {
        if (predicate(item)) {
            result.add(item)
        }
    }
    return result
}

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

// 인라인 함수 — 람다 오버헤드 제거
inline fun <T> measureTime(label: String, block: () -> T): T {
    val start = System.nanoTime()
    val result = block()
    val elapsed = (System.nanoTime() - start) / 1_000_000.0
    println("$label: ${String.format("%.2f", elapsed)}ms")
    return result
}

fun main() {
    // 고차 함수 사용
    val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

    val evens = numbers.customFilter { it % 2 == 0 }
    println("짝수: $evens")
    // 실행 결과: 짝수: [2, 4, 6, 8, 10]

    val bigNumbers = numbers.customFilter { it > 5 }
    println("5 초과: $bigNumbers")
    // 실행 결과: 5 초과: [6, 7, 8, 9, 10]

    // 함수 반환
    val double = createMultiplier(2)
    val triple = createMultiplier(3)
    println("double(5) = ${double(5)}")  // 10
    println("triple(5) = ${triple(5)}")  // 15
    // 실행 결과:
    // double(5) = 10
    // triple(5) = 15

    // 함수 합성
    val doubleAndAdd10: (Int) -> Int = { double(it) + 10 }
    println("doubleAndAdd10(7) = ${doubleAndAdd10(7)}")
    // 실행 결과: doubleAndAdd10(7) = 24

    // measureTime 인라인 함수
    val sorted = measureTime("정렬") {
        (1..100_000).toList().shuffled().sorted()
    }
    println("정렬 결과 첫 5개: ${sorted.take(5)}")
    // 실행 결과:
    // 정렬: 45.23ms (시스템마다 다름)
    // 정렬 결과 첫 5개: [1, 2, 3, 4, 5]
}

inline 함수는 람다를 호출 지점에 인라인하여 객체 생성 오버헤드를 제거합니다. 작은 람다를 자주 호출하는 유틸리티 함수에 사용합니다.

컬렉션 함수형 연산

Kotlin 표준 라이브러리는 map, filter, flatMap, groupBy, fold 등 풍부한 컬렉션 연산을 제공합니다.

data class Student(
    val name: String,
    val grade: Int,
    val scores: List<Int>
)

fun main() {
    val students = listOf(
        Student("김영희", 1, listOf(90, 85, 92)),
        Student("이철수", 2, listOf(78, 82, 88)),
        Student("박민수", 1, listOf(95, 91, 87)),
        Student("정수진", 2, listOf(88, 93, 95)),
        Student("한지은", 3, listOf(72, 68, 75))
    )

    // map — 변환
    val names = students.map { it.name }
    println("이름: $names")
    // 실행 결과: 이름: [김영희, 이철수, 박민수, 정수진, 한지은]

    // filter + map 체이닝
    val topStudents = students
        .filter { it.scores.average() >= 85 }
        .map { "${it.name} (평균: ${"%.1f".format(it.scores.average())})" }
    println("우수 학생: $topStudents")
    // 실행 결과: 우수 학생: [김영희 (평균: 89.0), 박민수 (평균: 91.0), 정수진 (평균: 92.0)]

    // groupBy — 그룹핑
    val byGrade = students.groupBy { it.grade }
    byGrade.forEach { (grade, group) ->
        println("${grade}학년: ${group.map { it.name }}")
    }
    // 실행 결과:
    // 1학년: [김영희, 박민수]
    // 2학년: [이철수, 정수진]
    // 3학년: [한지은]

    // flatMap — 중첩 리스트 평탄화
    val allScores = students.flatMap { it.scores }
    println("전체 점수: $allScores")
    // 실행 결과: 전체 점수: [90, 85, 92, 78, 82, 88, 95, 91, 87, 88, 93, 95, 72, 68, 75]

    // fold — 누적 연산
    val totalAverage = students.fold(0.0) { acc, student ->
        acc + student.scores.average()
    } / students.size
    println("전체 평균: ${"%.1f".format(totalAverage)}")
    // 실행 결과: 전체 평균: 84.6

    // associate — Map 생성
    val scoreMap = students.associate { it.name to it.scores.average() }
    println("점수 맵: $scoreMap")
    // 실행 결과: 점수 맵: {김영희=89.0, 이철수=82.67, 박민수=91.0, 정수진=92.0, 한지은=71.67}

    // partition — 조건으로 분할
    val (passed, failed) = students.partition { it.scores.average() >= 80 }
    println("합격: ${passed.map { it.name }}")
    println("불합격: ${failed.map { it.name }}")
    // 실행 결과:
    // 합격: [김영희, 이철수, 박민수, 정수진]
    // 불합격: [한지은]
}

시퀀스 — 지연 평가

컬렉션 연산은 각 단계마다 중간 리스트를 생성합니다. Sequence는 지연 평가(Lazy Evaluation)를 통해 중간 컬렉션 없이 파이프라인을 처리합니다.

fun main() {
    val numbers = (1..1_000_000).toList()

    // 즉시 평가 — 중간 리스트 2개 생성
    val eagerResult = numbers
        .filter { it % 2 == 0 }    // 중간 리스트 1 (50만 개)
        .map { it * 2 }            // 중간 리스트 2 (50만 개)
        .take(5)
    println("즉시 평가: $eagerResult")
    // 실행 결과: 즉시 평가: [4, 8, 12, 16, 20]

    // 지연 평가 — 중간 리스트 없음, 5개만 처리
    val lazyResult = numbers.asSequence()
        .filter { it % 2 == 0 }    // 중간 리스트 없음
        .map { it * 2 }            // 중간 리스트 없음
        .take(5)                   // 5개만 처리하고 종료
        .toList()
    println("지연 평가: $lazyResult")
    // 실행 결과: 지연 평가: [4, 8, 12, 16, 20]

    // 무한 시퀀스 — generateSequence
    val fibonacci = generateSequence(Pair(0L, 1L)) { (a, b) ->
        Pair(b, a + b)
    }.map { it.first }

    val first10 = fibonacci.take(10).toList()
    println("피보나치 10개: $first10")
    // 실행 결과: 피보나치 10개: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

    // 시퀀스 빌더 — yield로 값 방출
    val primes = sequence {
        yield(2)
        var n = 3
        while (true) {
            if ((2 until n).none { n % it == 0 }) {
                yield(n)
            }
            n += 2
        }
    }

    println("소수 15개: ${primes.take(15).toList()}")
    // 실행 결과: 소수 15개: [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]
}

시퀀스는 요소 수가 많고 중간 연산이 여러 단계일 때 효과적입니다. 요소가 적거나 단일 연산이면 일반 컬렉션이 더 빠를 수 있습니다.

스코프 함수 — let, run, apply, also, with

스코프 함수는 객체의 컨텍스트 내에서 코드 블록을 실행하는 함수입니다. 각 함수의 차이는 객체 참조 방식(this/it)과 반환값(컨텍스트 객체/람다 결과)입니다.

data class Config(
    var host: String = "",
    var port: Int = 0,
    var debug: Boolean = false
)

fun main() {
    // let — null 체크 + 변환 (it으로 참조, 람다 결과 반환)
    val name: String? = "Kotlin"
    val length = name?.let {
        println("이름: $it")
        it.length  // 반환값
    }
    println("길이: $length")
    // 실행 결과:
    // 이름: Kotlin
    // 길이: 6

    // run — 객체 초기화 + 결과 계산 (this로 참조, 람다 결과 반환)
    val greeting = Config().run {
        host = "localhost"
        port = 8080
        "서버: $host:$port"  // 반환값
    }
    println(greeting)
    // 실행 결과: 서버: localhost:8080

    // apply — 객체 설정 (this로 참조, 객체 자체 반환)
    val config = Config().apply {
        host = "api.example.com"
        port = 443
        debug = false
    }
    println("설정: $config")
    // 실행 결과: 설정: Config(host=api.example.com, port=443, debug=false)

    // also — 부수 효과 (it으로 참조, 객체 자체 반환)
    val numbers = mutableListOf(3, 1, 4, 1, 5)
        .also { println("정렬 전: $it") }
        .also { it.sort() }
        .also { println("정렬 후: $it") }
    // 실행 결과:
    // 정렬 전: [3, 1, 4, 1, 5]
    // 정렬 후: [1, 1, 3, 4, 5]

    // with — 이미 존재하는 객체에 여러 작업 (this로 참조, 람다 결과 반환)
    val info = with(config) {
        """
        |호스트: $host
        |포트: $port
        |디버그: $debug
        """.trimMargin()
    }
    println(info)
    // 실행 결과:
    // 호스트: api.example.com
    // 포트: 443
    // 디버그: false
}

스코프 함수 선택 가이드

함수참조반환값주요 용도
letit람다 결과null 체크, 변환
runthis람다 결과초기화 + 결과 계산
applythis객체 자체객체 설정/구성
alsoit객체 자체부수 효과 (로깅, 검증)
withthis람다 결과기존 객체에 여러 작업

실전 팁

  • 불변 컬렉션 기본: listOf, mapOf를 기본으로 사용하고, 변경이 필요할 때만 mutableListOf를 사용합니다.
  • 시퀀스 사용 시점: 요소 10,000개 이상이고 중간 연산이 2단계 이상이면 asSequence()를 고려합니다.
  • 스코프 함수 남용 주의: 중첩 3단계 이상의 스코프 함수는 가독성을 해칩니다. 지역 변수로 풀어쓰는 것이 나을 수 있습니다.
  • inline 함수 활용: 고차 함수를 자주 호출하면 inline으로 람다 객체 생성 오버헤드를 제거합니다.
  • destructuring + 컬렉션: map, filter와 구조 분해 선언을 조합하면 Pair, Map.Entry 처리가 간결해집니다.

이 글이 도움이 되었나요?