[Java to Kotlin] 코틀린에서의 함수형 프로그래밍

bebeis·2025년 11월 11일

코틀린에서 배열과 컬렉션을 다루는 방법

  1. 배열
  2. 코틀린에서의 Collection - List, Set, Map
  3. 컬렉션의 null 가능성, Java와 함께 사용하기

배열

Kotlin

val array = arrayOf(100, 200)

// array.indices는 0부터 마지막 index까지의 Range이다.
for (i in array.indices) {
    println("${i} ${array[i]}")
}

// withIndex() 를 사용하면, 인덱스와 값을 한 번에 가져올 수 있다.
for ((idx, value) in array.withIndex()) {
    println("$idx $value")
}

// 값을 쉽게 넣을 수도 있다. (자바에서는 복사해야 했다)
array.plus(300)
  • (idx, value)는 구조분해라는 문법인데, 다음 챕터에서 다루게 된다.
  • 자바에서 배열에 원소를 추가하기 위해선 직접 복사가 필요했지만, 코틀린에선 .plus() 메서드로 값을 쉽게 추가할 수 있다.

array.indices는 0부터 마지막 index까지의 Range를 반환해준다.

public val <T> Array<out T>.indices: IntRange
    get() = IntRange(0, lastIndex)

컬렉션

  • 코틀린에서는 컬렉션을 만들어줄 때 불변인지, 가변인지를 설정해야 한다.
  • 컬렉션의 종류는 다음과 같다.
    • 가변(Mutable) 컬렉션: 컬렉션에 element를 추가, 삭제할 수 있다.
    • 불변 컬렉션: 컬렉션에 element를 추가, 삭제할 수 없다.
  • 코틀린은 Collection을 만들자마자 Collections.unmodifiableList() 등을 붙여준다.
  • 주의할 점은, 불변 컬렉션이라 하더라도 Reference Type인 Element의 필드는 바꿀 수 있다.
    • 자바랑 비슷하다.

Kotlin

// listOf를 통해 '불변 리스트'를 만든다.
val numbers = listOf(100, 200)

// emptyList<타입>() 으로 빈 리스트를 만들 수 있다.
// 타입 추론이 가능하다면 생략할 수 있다.
val emptyList = emptyList<Int>()

// 하나를 가져오기
println(numbers[0])

// For Each
for (number in numbers) {
    println(number)
}

// 전통적인 For문 느낌
for ((index, number) in numbers.withIndex()) {
    println("$index $number")
}

// 가변(Mutable) 리스트를 만들고 싶다면?
val mutableNumbers = mutableListOf(100, 200)
mutableNumbers.add(300)
  • 기본 구현체는 ArrayList이며, 기타 사용법은 Java와 동일하다.

컬렉션 생성 시 <> 생략이 가능한 경우 예시

printNumbers(emptyList()) // 이런 경우 타입 생략 가능
private fun printNumbers(numbers: List<Int>) { 
}

[!TIP] Tip
우선은 불변 리스트를 만들고, 꼭 필요한 경우 가변 리스트로 바꾸자.

Collection-Set

// 불변 집합
val numbers = setOf(100, 200)

// For Each
for (number in numbers) {
    println(number)
}

// 전통적인 For문 느낌
for ((index, number) in numbers.withIndex()) {
    println("$index $number")
}

// 가변(Mutable) 집합을 만들고 싶다면?
// 기본 구현체는 LinkedHashSet이다.
val mutableNumbers = mutableSetOf(100, 200)

Collection - Map

// 가변 Map
// 타입을 추론할 수 없어, 타입을 지정해주었다.
val map = mutableMapOf<Int, String>()
// Java처럼 put을 쓸 수도 있고, map[key] = value 을 쓸 수도 있다.
map[1] = "MONDAY"
map[2] = "TUESDAY"

// mapOf(key to value) 를 사용해 불변 map을 만들 수 있다.
mapOf(1 to "MONDAY", 2 to "TUESDAY") // 중위 호출

// 순회
for (key in map.keys) {
    println(key)
    println(map[key])
}

for ((key, value) in map.entries) {
    println(key)
    println(value)
}
  • Kotlin에서도 동일하게 MutableMap을 만들어 넣을 수도 있고, 정적 팩토리 메서드를 바로 활용할 수도 있다.

컬렉션의 null 가능성

? 위치에 따라 null 가능성 의미가 달라지므로 차이를 잘 이해해야 한다.

  • List<Int?>: 리스트에 null이 들어갈 수 있지만, 리스트는 절대 null이 아님
  • List<Int>?: 리스트에는 null이 들어갈 수 없지만, 리스트는 null일 수 있음
  • List<Int?>?: 리스트에 null이 들어갈 수도 있고, 리스트가 null일 수도 있음

Java와 함께 사용하기

  1. Java는 읽기 전용 컬렉션과 변경 가능 컬렉션을 구분하지 않는다.

    • Kotlin 쪽의 불변 컬렉션이 Java에서 호출되면 컬렉션 내용이 변할 수 있음을 감안해야 한다.
    • 예를 들어, Java는 Kotlin의 불변 리스트를 가져와 Element를 추가할 수 있다.
    • 이를 방지하려면 코틀린 쪽에서 Collections.unmodifiableXXX()를 활용해 변경 자체를 막을 수 있다.
  2. Java는 nullable 타입과 non-nullable 타입을 구분하지 않는다.

    • Java 코드에서 Kotlin의 non-nullable 컬렉션에 null을 추가할 수 있다.
    • 이로 인해 런타임 에러가 발생할 수 있다.
  3. Kotlin에서 Java 컬렉션을 가져다 사용할 때 플랫폼 타입을 신경써야 한다.

    • Java의 List<Integer>를 Kotlin에서 받을 때, List<Int?>, List<Int>?, List<Int?>? 중 어떤 타입인지 알 수 없다. (플랫폼 타입으로 처리)
    • 이를 해결하려면 Java 코드를 보며 맥락을 확인하고, Java 코드를 가져오는 지점을 wrapping해야 한다.

코틀린에서 다양한 함수를 다루는 방법

확장함수(Extension Function)

어떤 클래스 안에 있는 메소드처럼 호출할 수 있지만, 함수 선언은 클래스 밖에 있는 함수를 확장함수라고 한다.

기존 Java 코드 위에 자연스럽게 코틀린 코드를 추가하거나, Java로 만들어진 라이브러리를 유지보수 및 확장할 때 Kotlin 코드를 덧붙일수 있도록 하기 위해 등장하였다.

기본 문법

fun 확장하려는클래스.함수이름(파라미터): 리턴타입 {
    // this를 이용해 실제 클래스 안의 값(수신 객체)에 접근
}
  • 확장하려는클래스: 수신 객체 타입
  • this: 수신 객체

예시

fun String.lastChar(): Char {
    return this[this.length - 1]
}

확장 함수의 특징
1. 캡슐화 유지

  • 확장함수는 원본 클래스의 private 또는 protected 멤버에 접근할 수 없다.
  1. 멤버함수 우선

    • 멤버함수와 확장함수의 시그니처가 같다면, 멤버함수가 우선적으로 호출된다.
    • 이로 인해, 나중에 원본 클래스에 동일한 시그니처의 멤버함수가 추가되면 기존 확장함수 호출 코드가 예기치 않게 동작할 수 있다.
  2. 정적 타입에 의한 호출

    • 확장함수는 오버라이드 될 수 없다.
    • 호출될 확장함수는 변수의 정적인 타입(선언된 타입)에 의해 결정된다.
      • 다형성과 관련된 말이다. 이해가 가지 않는다면 아래 예시를 보자.
    open class Train(
        val name: String = "새마을기차",
        val price: Int = 5_000,
    )
    
    fun Train.isExpensive(): Boolean {
        println("Train의 확장함수")
        return this.price >= 10000
    }
    class Srt : Train("SRT", 40_000)
    
    fun Srt.isExpensive(): Boolean {
        println("Srt의 확장함수")
        return this.price >= 10000
    }
    fun main() {
      val train: Train = Train()
      train.isExpensive() // Train의 확장함수
    
      val srt1: Train = Srt()
      srt1.isExpensive() // Train의 확장함수 (srt1의 정적 타입이 Train)
    
      val srt2: Srt = Srt()
      srt2.isExpensive() // Srt의 확장함수
    }
    • 인스턴스와 무관하게, 해당 변수의 현재 타입 즉, 정적인 타입에 의해 어떤 확장함수가 호출될지 결정된다.
  3. Java에서의 호출

    • Java에서는 Kotlin 확장함수를 정적 메소드를 호출하는 것처럼 사용할 수 있다.
    // StringUtilsKt.kt 파일에 선언된 확장함수
    StringUtilsKt.lastChar("ABC");
    • 실제로는 public static char lastChar(String $this) { ... } 이렇게 컴파일된다.
    • StringUtilsKt.class 기반 클래스파일이 만들어지므로, StringUtilsKt.lastChar("ABC"); 이렇게 호출하는 것이다.

확장 프로퍼티

  • 확장함수와 유사하게 프로퍼티를 확장할 수 있다. (확장 함수를 프로퍼티처럼 보이게 만든다.)
  • 이는 확장함수 + custom getter의 원리와 동일하다.
fun String.lastChar(): Char {
    return this[this.length - 1]
}
// 위 함수를 프로퍼티로 만들 수도 있다.

val String.lastChar: Char
    get() = this[this.length - 1]

infix 함수(중위 함수)

  • "중위 함수"라고도 하며, 함수를 호출하는 새로운 방법을 제공한다.
  • 변수.함수이름(argument) 대신 변수 함수이름 argument 형식으로 호출할 수 있다.
  • infix 키워드를 사용해 정의하며, 멤버함수에도 붙일 수 있다.
// 일반 확장함수
fun Int.add(other: Int): Int {
    return this + other
}

// infix 확장함수
infix fun Int.add2(other: Int): Int {
    return this + other
}

fun main() {
    3.add(4)
    3.add2(other = 4)
    3 add2 4 // infix 호출
}
  • infix 함수는 연산처럼 자연스럽게 읽히는 DSL 스타일의 코드를 만들 때 유용하다고 한다.
    • ex) score shouldBeGreaterThan 90

inline 함수

  • 함수가 호출되는 대신, 함수를 호출한 지점에 함수 본문을 그대로 복사/붙여넣기 하는 기능이다. (C++에 있는 inline 함수와 동일한 개념이다)
  • 함수를 파라미터로 전달할 때 발생하는 오버헤드를 줄일 수 있다.
  • 성능에 영향을 줄 수 있으므로, 성능 측정과 함께 신중하게 사용해야 한다.
inline fun Int.add(other: Int): Int {
    return this + other
}

fun main() {
    3.add(4)
}
  • Java로 디컴파일 했을 때 함수 호출 없이 연산이 바로 들어간다.
    • int var10000 = 3 + 4;

지역함수

  • 함수 안에 함수를 선언하는 기능이다.
  • 장점: 특정 함수 내에서만 사용되는 로직을 분리하여 코드 중복을 줄일 때 유용하다.
  • 단점: 함수 안에 함수를 선언하기 때문에 depth가 깊어지고 코드가 복잡해보인다.
fun createPerson(firstName: String, lastName: String): Person {
    // 로직이 중복된다.
    if (firstName.isEmpty()) {
        throw IllegalArgumentException("firstName은 비어있을 수 없습니다! 현재 값 : $firstName")
    }
    if (lastName.isEmpty()) {
        throw IllegalArgumentException("lastName은 비어있을 수 없습니다! 현재 값 : $lastName")
    }
    return Person(firstName, lastName, 1)
}

// 지역함수로 리팩토링
fun createPersonRefactored(firstName: String, lastName: String): Person {
    fun validateName(name: String, fieldName: String) {
        if (name.isEmpty()) {
            throw IllegalArgumentException("${fieldName}은 비어있을 수 없습니다! 현재 값 : $name")
        }
    }
    validateName(firstName, "firstName")
    validateName(lastName, "lastName")
    return Person(firstName, lastName, 1)
}
  • 지역 함수를 사용하면 코드가 깔끔하지는 않기 때문에 잘 사용하지 않는 사람이 많다고 한다.
  • 위 예제의 경우 validation을 객체 내부에서 private으로 처리해주는 것이 좋다.

코틀린에서 람다를 다루는 방법

Java에서 람다를 다루기 위한 노력

자바에서 람다를 다루는 방법에 대한 내용은 생략한다.

여기서 다시 짚고 가야할 부분은 Java에서 함수는 2급 시민으로, 변수에 할당되거나 파라미터로 직접 전달될 수 없다는 점이다. (항상 인터페이스를 통해 간접적으로 다뤄진다.)

코틀린에서 람다

  • Kotlin에서는 함수가 1급 시민이다.
  • 함수가 그 자체로 값이 될 수 있어, 변수에 할당하거나 파라미터로 넘길 수 있다.

람다 만들기 - 변수에 할당

// 방법 1
val isApple = fun(fruit: Fruit): Boolean {
    return fruit.name == "사과"
}

// 방법 2 
val isApple2 = { fruit: Fruit -> fruit.name == "사과" }
  • 주로 방법 2를 사용한다.

람다 호출하기

// 방법 1
isApple(Fruit("사과", 1000))

// 방법 2
isApple.invoke(Fruit("사과", 1000))

람다를 파라미터로 사용하기

함수의 타입은 (파라미터 타입...) -> 반환 타입 으로 표현한다.

private fun filterFruits(
    fruits: List<Fruit>, filter: (Fruit) -> Boolean
): List<Fruit> {
    val results = mutableListOf<Fruit>()
    for (fruit in fruits) {
        // if (filter(fruit)) 와 동일
        if (filter.invoke(fruit)) {
            results.add(fruit)
        }
    }
    return results
}

// 사용
filterFruits(fruits, isApple)
filterFruits(fruits, { fruit: Fruit -> fruit.name == "사과" })

람다 사용 시 문법적 편의 기능

  1. 후행 람다(Trailing Lambda)

    • 함수의 마지막 파라미터가 람다인 경우, 소괄호 () 밖에 람다를 작성할 수 있다.
    filterFruits(fruits) { fruit: Fruit -> fruit.name == "사과" }
  2. it

    • 람다의 파라미터가 하나인 경우, it을 사용하여 파라미터를 바로 참조할 수 있다.
    filterFruits(fruits) { it.name == "사과" }
    • 강사님은 it를 사용하는 것 보단 파라미터를 밝혀주는 것을 선호한다고 하신다.
  3. 마지막 줄이 반환 값

    • 람다를 여러 줄로 작성할 수 있으며, 마지막 줄의 결과가 람다의 반환값이 된다.
    filterFruits(fruits) { fruit ->
        println("사과만 받는다..!!")
        fruit.name == "사과" // 이 줄이 반환값이 됨
    }

메서드 참조(Method Reference)

람다식이 이미 존재하는 함수 호출 1줄로만 이루어져 있을 때, 람다를 더 간결하게 표현하기 위한 문법이다.

val numbers = listOf(1, 2, 3)

// 람다식
   numbers.forEach { n -> println(n) }

// 메서드 참조
numbers.forEach(::println)

4가지 유형이 존재한다.
1. 최상위(top-level) 함수 참조

fun isApple(fruit: Fruit) = fruit.name == "사과"

filterFruits(fruits, ::isApple)
  • 람다 없이 함수 이름만 넘기는 형태.
  1. 멤버 함수 참조

    class Person(val name: String) {
        fun printName() = println(name)
    }
    
    val person = Person("홍길동")
    
    listOf(person).forEach(Person::printName)
    • Person::printName는 “Person 타입의 인스턴스의 printName() 호출”을 의미함.
  2. 생성자 참조(Constructor Reference)

    class Fruit(val name: String)
    
    val factory = ::Fruit   // (String) -> Fruit
    val fruit = factory("사과")
    • 생성자를 함수처럼 다루고 싶을 때 사용한다.
  3. 확장 함수 참조

    fun String.lastChar() = this.last()
    
    val ref = String::lastChar
    println(ref("ABC"))  // C
    • 확장 함수도 동일한 방식으로 참조 가능하다.

Closure

  • Java에서는 람다에서 사용할 수 있는 외부 변수가 final 또는 effectively final 이어야 한다는 제약이 있다.
  • 코틀린에서는 이러한 제약이 없다. 람다가 시작하는 지점에서 참조하는 모든 외부 변수를 포획(capture)하여 그 정보를 가지고 있기 때문이다.
  • 이렇게 함수와 함수가 포획한 환경(변수)을 모두 포함하고 있는 데이터 구조를 클로저(Closure) 라고 부른다.
  • 이 덕분에 코틀린의 함수는 진정한 1급 시민으로 간주될 수 있다.
// Kotlin
var targetFruitName = "바나나"
targetFruitName = "수박"
// 외부 변수 targetFruitName이 변경 가능해도 람다 내에서 사용 가능
filterFruits(fruits) { it.name == targetFruitName }

try with resources

  • 코틀린의 use 함수는 람다, 확장함수, inline 함수의 좋은 활용 예시이다.
    public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R
  • useCloseable의 확장함수이다.
  • inline 함수로 성능 오버헤드를 줄였다.
  • 람다를 파라미터로 받아 리소스를 사용하고 자동으로 close 해준다.
fun readFile(path: String) {
    BufferedReader(FileReader(path)).use { reader ->
        println(reader.readLine())
    } // 람다를 실행, 리소스 자동 close
}

코틀린에서 컬렉션을 함수형으로 다루는 방법

코틀린에는 자바 Stream과 동일한 스트림 개념은 없다. Java는 컬렉션에 대해 스트림 API를 따로 만들어서 처리한다.
하지만 Kotlin은 언어 설계가 다르기 때문에 Stream을 별도로 만들지 않았다. 대신 Collection의 확장 함수들이 Stream처럼 동작한다.

다만, 이는 자바 스트림과 작동 방식 구조가 약간 다르다. 즉시 실행(Eager evaluation) 방식이다.
코틀린에서는 lazy 처리 방식이 필요할 때 Sequence를 사용해야 한다. (ex. list.asSequence())

필터와 맵

// 예제용 데이터 클래스
data class Fruit(
    val id: Long,
    val name: String,
    val factoryPrice: Long,
    val currentPrice: Long,
)
  • filter: 주어진 조건을 만족하는 element만 포함하는 새로운 리스트를 반환한다.
    // 사과만 주세요!
    val apples = fruits.filter { fruit -> fruit.name == "사과" }
  • filterIndexed: 인덱스와 함께 필터링한다.
    val apples = fruits.filterIndexed { idx, fruit ->
        println(idx)
        fruit.name == "사과"
    }
  • map: 각 element를 주어진 람다를 적용한 결과로 변환한 새로운 리스트를 반환한다.
    // 사과의 가격들을 알려주세요!
    val applePrices = fruits.filter { it.name == "사과" }
                             .map { it.currentPrice }
  • mapIndexed: 인덱스와 함께 매핑한다.
    val applePrices = fruits.filter { it.name == "사과" }
                             .mapIndexed { idx, fruit ->
                                 println(idx)
                                 fruit.currentPrice
                             }
  • mapNotNull: 매핑 결과가 null이 아닌 것만 가져온다.
    val values = fruits.filter { it.name == "사과" }
                        .mapNotNull { it.nullOrValue() } // null일 수 있는 값을 반환하는 함수

xxxIndexed, mapNotNull 정도를 제외하면 자바와 Syntax sugar가 비슷하다.

다양한 컬렉션 처리 기능

  • all: 조건을 모두 만족하면 true, 그렇지 않으면 false를 반환한다.
    // 모든 과일이 사과인가요?!
    val isAllApple = fruits.all { it.name == "사과" }
  • none: 조건을 모두 불만족하면 true, 그렇지 않으면 false를 반환한다.
    val isNoApple = fruits.none { it.name == "사과" }
  • any: 조건을 하나라도 만족하면 true, 그렇지 않으면 false를 반환한다.
    // 출고가 10,000원 이상의 과일이 하나라도 있나요?!
    val hasExpensiveFruit = fruits.any { it.factoryPrice >= 10_000 }
  • count: 컬렉션의 개수를 센다.
    // 총 과일 개수가 몇 개인가요?!
    val fruitCount = fruits.count()
  • sortedBy: 주어진 값을 기준으로 (오름차순) 정렬한다.
    // 낮은 가격 순으로 보여주세요!
    val sortedFruits = fruits.sortedBy { it.currentPrice }
  • sortedByDescending: 주어진 값을 기준으로 (내림차순) 정렬한다.
    val sortedFruits = fruits.sortedByDescending { it.currentPrice }
  • distinctBy: 주어진 값을 기준으로 중복을 제거한다.
    // 과일이 몇 종류 있죠?!
    val distinctFruitNames = fruits.distinctBy { it.name }.map { it.name }
  • first / firstOrNull: 첫번째 값을 가져온다. (리스트가 비어있으면 first는 예외 발생, firstOrNullnull 반환)
  • last / lastOrNull: 마지막 값을 가져온다. (리스트가 비어있으면 last는 예외 발생, lastOrNullnull 반환)

List를 Map으로

  • groupBy: 주어진 키를 기준으로 그룹화하여 Map<K, List<T>>를 만든다.

    // 과일이름 -> List<과일> Map이 필요해요!
    val map: Map<String, List<Fruit>> = fruits.groupBy { it.name }
    
    // Key와 value를 동시에 처리할 수도 있다.
    // 과일이름 -> List<출고가> Map이 필요해요!
    val map2: Map<String, List<Long>> = fruits.groupBy(
        { it.name }, { it.factoryPrice }
    )
  • associateBy: 주어진 키를 기준으로 Map<K, T>를 만든다. (키가 중복되면 마지막 값만 남는다)

    // id -> 과일 Map이 필요해요!
    val map: Map<Long, Fruit> = fruits.associateBy { it.id }
    
    // Key와 value를 동시에 처리할 수도 있다.
    // id -> 출고가 Map이 필요해요!
    val map2: Map<Long, Long> = fruits.associateBy(
        { it.id }, { it.factoryPrice }
    )
  • 생성된 Map에 대해서도 filter와 같은 함수들을 대부분 사용할 수 있다.

    val map: Map<String, List<Fruit>> = fruits.groupBy { it.name }
    										.filter { (key, value) -> key == "사과" }

중첩된 컬렉션 처리

예제용 데이터

val fruitsInList: List<List<Fruit>> = listOf(
    listOf( ... ), // 사과 리스트
    listOf( ... ), // 바나나 리스트
    listOf( ... )  // 수박 리스트
)
  • flatMap: 중첩된 컬렉션의 각 element를 원하는 기준으로 변환한 후 하나의 리스트로 flatten 한다.

    // 출고가와 현재가가 동일한 과일을 골라주세요!
    val samePriceFruits = fruitsInList.flatMap { list ->
        list.filter { fruit -> fruit.factoryPrice == fruit.currentPrice }
    }
    
    // list 안에 list filter가 있으므로, 다음과 같이 리팩토링할 수 있다.
    fruitsInLIst.flatMap { list -> list.samePriceFilter }
    
    val List<Fruit>.samePriceFilter: List<Fruit> // 확장 함수
    	get() = this.filter(Fruit::isSamePrice) // isSamePrice는 도메인 로직에
    • 각 리스트마다 필터를 적용하고, 그 결과로 나온 리스트 여러 개를 하나의 리스트로 평탄화한다.
  • flatten: 중첩된 컬렉션을 단순히 하나의 리스트로 flatten 한다.

    // List<List<Fruit>>를 List<Fruit>로 그냥 바꾸어주세요!
    val flattenedFruits = fruitsInList.flatten()
profile
단순함은 복잡함을 이긴다.

0개의 댓글