(Kotlin/코틀린) Collection 확장함수 실전 모음 - 그룹핑/집계/검색/변환/Set·Map 연산

개요


1. 그룹핑 & 집계

표준 groupBy / groupingBy

data class Order(val userId: Int, val amount: Int, val status: String)

val orders = listOf(
    Order(1, 3000, "완료"),
    Order(2, 5000, "완료"),
    Order(1, 2000, "취소"),
    Order(3, 8000, "완료"),
    Order(2, 1000, "취소"),
)

// 유저별 주문 목록
val byUser: Map<Int, List<Order>> = orders.groupBy { it.userId }
// {1=[Order(1,3000,완료), Order(1,2000,취소)], ...}

// 유저별 주문 건수
val countByUser: Map<Int, Int> = orders.groupingBy { it.userId }.eachCount()
// {1=2, 2=2, 3=1}

// 유저별 총 결제 금액
val sumByUser: Map<Int, Int> = orders.groupBy { it.userId }
    .mapValues { (_, list) -> list.sumOf { it.amount } }
// {1=5000, 2=6000, 3=8000}

커스텀 그룹핑 확장함수

/** 키 기준 그룹핑 후 각 그룹 합산 */
inline fun <T, K> Iterable<T>.groupSumBy(
    keySelector: (T) -> K,
    valueSelector: (T) -> Int
): Map<K, Int> = groupBy(keySelector).mapValues { (_, v) -> v.sumOf(valueSelector) }

/** 키 기준 그룹핑 후 각 그룹 평균 */
inline fun <T, K> Iterable<T>.groupAverageBy(
    keySelector: (T) -> K,
    valueSelector: (T) -> Double
): Map<K, Double> = groupBy(keySelector).mapValues { (_, v) -> v.map(valueSelector).average() }

/** 상위 N개 그룹만 반환 (합산 기준 내림차순) */
inline fun <T, K> Iterable<T>.topNGroupsBy(
    n: Int,
    keySelector: (T) -> K,
    valueSelector: (T) -> Int
): Map<K, Int> = groupSumBy(keySelector, valueSelector)
    .entries.sortedByDescending { it.value }.take(n)
    .associate { it.key to it.value }

사용 예시

val amountByUser = orders.groupSumBy({ it.userId }, { it.amount })
println(amountByUser) // {1=5000, 2=6000, 3=8000}

val top2 = orders.topNGroupsBy(2, { it.userId }, { it.amount })
println(top2) // {3=8000, 2=6000}

2. Zip / Combine

표준 zip

val names = listOf("Alice", "Bob", "Charlie")
val scores = listOf(90, 85, 92)

// 두 리스트를 묶어 Pair 리스트로
val zipped: List<Pair<String, Int>> = names.zip(scores)
// [(Alice, 90), (Bob, 85), (Charlie, 92)]

// 변환 함수 적용
val result = names.zip(scores) { name, score -> "$name: $score점" }
// ["Alice: 90점", "Bob: 85점", "Charlie: 92점"]

// 인접 요소 비교 (zipWithNext)
val diffs = scores.zipWithNext { a, b -> b - a }
// [-5, 7]

커스텀 Zip 확장함수

/** 3개 리스트를 Triple로 묶기 */
fun <A, B, C> zip3(a: List<A>, b: List<B>, c: List<C>): List<Triple<A, B, C>> =
    (0 until minOf(a.size, b.size, c.size)).map { Triple(a[it], b[it], c[it]) }

/** 인덱스 포함하여 zip */
fun <T, R> List<T>.zipWithIndex(transform: (index: Int, T) -> R): List<R> =
    mapIndexed { i, t -> transform(i, t) }

/** 두 리스트 길이가 달라도 안전하게 zip (짧은 쪽 기준, 없으면 null) */
fun <A, B> List<A>.zipOrNull(other: List<B>): List<Pair<A?, B?>> {
    val size = maxOf(this.size, other.size)
    return (0 until size).map { i -> getOrNull(i) to other.getOrNull(i) }
}

/** Pair 리스트를 두 리스트로 분리 */
fun <A, B> List<Pair<A, B>>.unzipToLists(): Pair<List<A>, List<B>> = unzip()

사용 예시

val labels = listOf("1위", "2위", "3위")
val items = listOf("Gold", "Silver")
println(labels.zipOrNull(items))
// [(1위, Gold), (2위, Silver), (3위, null)]

val triples = zip3(listOf(1, 2), listOf("a", "b"), listOf(true, false))
println(triples)
// [(1, a, true), (2, b, false)]

val (first, second) = listOf("A" to 1, "B" to 2, "C" to 3).unzipToLists()
println(first)  // [A, B, C]
println(second) // [1, 2, 3]

3. Flatten / FlatMap

/** 중첩 리스트 1단계 펼치기 */
val nested = listOf(listOf(1, 2), listOf(3, 4), listOf(5))
val flat = nested.flatten()
println(flat) // [1, 2, 3, 4, 5]

/** 변환 + 펼치기 */
data class Team(val name: String, val members: List<String>)
val teams = listOf(
    Team("Android", listOf("Alice", "Bob")),
    Team("iOS", listOf("Charlie", "Dave"))
)
val allMembers = teams.flatMap { it.members }
println(allMembers) // [Alice, Bob, Charlie, Dave]

커스텀 Flatten 확장함수

/** null을 제외하고 flatMap */
inline fun <T, R : Any> Iterable<T>.flatMapNotNull(transform: (T) -> List<R?>): List<R> =
    flatMap(transform).filterNotNull()

/** 조건을 만족하는 요소만 flatMap */
inline fun <T, R> Iterable<T>.flatMapIf(
    predicate: (T) -> Boolean,
    transform: (T) -> List<R>
): List<R> = filter(predicate).flatMap(transform)

/** 중첩 맵의 모든 값을 하나의 리스트로 */
fun <K, V> Map<K, List<V>>.flattenValues(): List<V> = values.flatten()

/** 그룹별 대표값만 추출 (첫 번째 요소) */
inline fun <T, K> Iterable<T>.flatGroupFirstBy(keySelector: (T) -> K): List<T> =
    groupBy(keySelector).values.mapNotNull { it.firstOrNull() }

사용 예시

val teamMembersMap = mapOf(
    "Android" to listOf("Alice", "Bob"),
    "iOS" to listOf("Charlie")
)
println(teamMembersMap.flattenValues()) // [Alice, Bob, Charlie]

val data = listOf(
    listOf("a", null, "b"),
    listOf(null, "c")
)
println(data.flatMapNotNull { it }) // [a, b, c]

4. Set 집합 연산

val setA = setOf(1, 2, 3, 4, 5)
val setB = setOf(3, 4, 5, 6, 7)

// 합집합
val union = setA union setB
println(union) // [1, 2, 3, 4, 5, 6, 7]

// 교집합
val intersect = setA intersect setB
println(intersect) // [3, 4, 5]

// 차집합 (A - B)
val subtract = setA subtract setB
println(subtract) // [1, 2]

커스텀 Set 확장함수

/** 대칭 차집합 (A에만 있거나 B에만 있는 것) */
fun <T> Set<T>.symmetricDifference(other: Set<T>): Set<T> =
    (this - other) + (other - this)

/** 두 Set이 겹치는 부분이 있는지 */
fun <T> Set<T>.overlaps(other: Set<T>): Boolean =
    (this intersect other).isNotEmpty()

/** 특정 기준으로 중복 제거 후 Set 반환 */
inline fun <T, K> Iterable<T>.toSetBy(keySelector: (T) -> K): Set<T> =
    associateBy(keySelector).values.toSet()

/** List → Set 변환 후 포함 여부 빠르게 확인 */
fun <T> Iterable<T>.toFastLookupSet(): Set<T> = toHashSet()

사용 예시

println(setA.symmetricDifference(setB)) // [1, 2, 6, 7]
println(setA.overlaps(setB))             // true
println(setOf(1, 2).overlaps(setOf(3, 4))) // false

data class User(val id: Int, val name: String)
val users = listOf(User(1, "A"), User(2, "B"), User(1, "A_dup"))
println(users.toSetBy { it.id }.map { it.name }) // [A, B]

5. Map 조작

표준 Map 연산

val map1 = mapOf("a" to 1, "b" to 2)
val map2 = mapOf("b" to 3, "c" to 4)

// 키/값만 변환
val upperKeys = map1.mapKeys { (k, _) -> k.uppercase() }  // {A=1, B=2}
val doubledValues = map1.mapValues { (_, v) -> v * 2 }     // {a=2, b=4}

// 조건으로 필터
val filtered = map1.filterValues { it > 1 } // {b=2}
val filteredK = map1.filterKeys { it != "a" } // {b=2}

커스텀 Map 확장함수

/** 두 Map 병합 (충돌 시 병합 함수 적용) */
fun <K, V> Map<K, V>.mergeWith(other: Map<K, V>, resolve: (V, V) -> V): Map<K, V> =
    (keys + other.keys).associateWith { key ->
        val v1 = this[key]
        val v2 = other[key]
        when {
            v1 == null -> v2!!
            v2 == null -> v1
            else -> resolve(v1, v2)
        }
    }

/** Map 뒤집기 (value → key) */
fun <K, V> Map<K, V>.inverted(): Map<V, K> =
    entries.associate { (k, v) -> v to k }

/** 특정 키들만 추출한 Map */
fun <K, V> Map<K, V>.pick(vararg keys: K): Map<K, V> =
    filterKeys { it in keys }

/** 특정 키들을 제외한 Map */
fun <K, V> Map<K, V>.omit(vararg keys: K): Map<K, V> =
    filterKeys { it !in keys }

/** null 값 제거 */
fun <K, V : Any> Map<K, V?>.filterNotNullValues(): Map<K, V> =
    entries.mapNotNull { (k, v) -> v?.let { k to it } }.toMap()

/** Map을 정렬된 리스트로 변환 */
fun <K, V : Comparable<V>> Map<K, V>.toSortedListByValue(descending: Boolean = false): List<Pair<K, V>> =
    entries.map { it.toPair() }
        .let { if (descending) it.sortedByDescending { p -> p.second } else it.sortedBy { p -> p.second } }

/** 값 기준 상위 N개 */
fun <K, V : Comparable<V>> Map<K, V>.topN(n: Int): Map<K, V> =
    toSortedListByValue(descending = true).take(n).toMap()

사용 예시

val scores1 = mapOf("Alice" to 80, "Bob" to 70)
val scores2 = mapOf("Bob" to 90, "Charlie" to 85)

// 점수 합산으로 병합
val merged = scores1.mergeWith(scores2) { a, b -> a + b }
println(merged) // {Alice=80, Bob=160, Charlie=85}

val codeMap = mapOf(200 to "OK", 404 to "Not Found", 500 to "Error")
println(codeMap.inverted()) // {OK=200, Not Found=404, Error=500}
println(codeMap.pick(200, 404)) // {200=OK, 404=Not Found}
println(codeMap.omit(500))      // {200=OK, 404=Not Found}

val ranking = mapOf("Alice" to 90, "Bob" to 70, "Charlie" to 85)
println(ranking.topN(2)) // {Alice=90, Charlie=85}

6. 검색 / 탐색

/** 조건을 만족하는 첫 번째 인덱스와 값 쌍 반환 */
inline fun <T> List<T>.findIndexed(predicate: (T) -> Boolean): Pair<Int, T>? =
    asSequence().withIndex().firstOrNull { predicate(it.value) }?.let { it.index to it.value }

/** 조건을 만족하는 모든 인덱스 반환 */
inline fun <T> List<T>.indicesWhere(predicate: (T) -> Boolean): List<Int> =
    indices.filter { predicate(this[it]) }

/** 이진 탐색 (정렬된 리스트에서) */
fun <T : Comparable<T>> List<T>.binarySearch(target: T): Int {
    var low = 0; var high = size - 1
    while (low <= high) {
        val mid = (low + high) ushr 1
        when {
            this[mid] < target -> low = mid + 1
            this[mid] > target -> high = mid - 1
            else -> return mid
        }
    }
    return -1
}

/** 특정 값이 몇 번 등장하는지 */
fun <T> Iterable<T>.countOf(target: T): Int = count { it == target }

/** 조건별 카운트 */
inline fun <T> Iterable<T>.countWhere(predicate: (T) -> Boolean): Int = count(predicate)

/** 특정 조건의 요소가 몇 % 인지 */
inline fun <T> List<T>.percentageWhere(predicate: (T) -> Boolean): Double =
    if (isEmpty()) 0.0 else count(predicate).toDouble() / size * 100.0

/** none/any/all 조합 유틸 */
inline fun <T> Iterable<T>.noneOrAll(predicate: (T) -> Boolean): Boolean =
    none(predicate) || all(predicate)

사용 예시

val list = listOf(10, 30, 20, 50, 40)

println(list.findIndexed { it > 25 })       // (1, 30)
println(list.indicesWhere { it > 25 })      // [1, 3, 4]
println(list.sorted().binarySearch(40))     // 3
println(list.countOf(30))                   // 1
println(list.countWhere { it >= 30 })       // 3
println(list.percentageWhere { it >= 30 })  // 60.0

val flags = listOf(true, true, true)
println(flags.noneOrAll { it })             // true (모두 true)

7. 누적 합산 / 누적 변환

/** 누적 합 (Running Sum) */
fun List<Int>.runningSum(): List<Int> =
    runningFold(0) { acc, v -> acc + v }.drop(1)

fun List<Double>.runningSumDouble(): List<Double> =
    runningFold(0.0) { acc, v -> acc + v }.drop(1)

/** 누적 최댓값 */
fun <T : Comparable<T>> List<T>.runningMax(): List<T> {
    if (isEmpty()) return emptyList()
    return runningFold(first()) { acc, v -> if (v > acc) v else acc }.drop(1)
}

/** 누적 최솟값 */
fun <T : Comparable<T>> List<T>.runningMin(): List<T> {
    if (isEmpty()) return emptyList()
    return runningFold(first()) { acc, v -> if (v < acc) v else acc }.drop(1)
}

/** 이동 평균 (Moving Average) */
fun List<Double>.movingAverage(windowSize: Int): List<Double> =
    windowed(windowSize) { it.average() }

/** 각 요소의 전체 합 대비 비율 */
fun List<Int>.toRatios(): List<Double> {
    val total = sum().toDouble()
    return if (total == 0.0) map { 0.0 } else map { it / total }
}

사용 예시

val sales = listOf(100, 200, 150, 300, 250)

println(sales.runningSum())        // [100, 300, 450, 750, 1000]
println(sales.runningMax())        // [100, 200, 200, 300, 300]
println(sales.runningMin())        // [100, 100, 100, 100, 100]

val prices = listOf(10.0, 12.0, 11.0, 13.0, 14.0, 12.0)
println(prices.movingAverage(3))   // [11.0, 12.0, 12.67, 13.0]

println(listOf(1, 3, 6).toRatios()) // [0.1, 0.3, 0.6]

8. 다중 기준 정렬

/** 다중 기준 정렬 (여러 Comparator 순서대로 적용) */
fun <T> Iterable<T>.sortedByMultiple(vararg comparators: Comparator<T>): List<T> =
    sortedWith(comparators.reduce { acc, c -> acc.thenComparing(c) })

/** 특정 값이 우선 정렬되도록 */
inline fun <T, K> Iterable<T>.sortedWithPriority(
    prioritySelector: (T) -> K,
    priorityOrder: List<K>,
    crossinline thenBy: (T) -> Comparable<*>? = { null }
): List<T> = sortedWith(
    compareBy(
        { priorityOrder.indexOf(prioritySelector(it)).let { i -> if (i < 0) Int.MAX_VALUE else i } },
        { @Suppress("UNCHECKED_CAST") (thenBy(it) as? Comparable<Any?>) }
    )
)

/** null을 뒤로 보내는 정렬 */
inline fun <T, R : Comparable<R>> Iterable<T>.sortedByNullsLast(
    crossinline selector: (T) -> R?
): List<T> = sortedWith(compareBy(nullsLast(naturalOrder())) { selector(it) })

/** null을 앞으로 보내는 정렬 */
inline fun <T, R : Comparable<R>> Iterable<T>.sortedByNullsFirst(
    crossinline selector: (T) -> R?
): List<T> = sortedWith(compareBy(nullsFirst(naturalOrder())) { selector(it) })

사용 예시

data class Product(val name: String, val category: String, val price: Int)

val products = listOf(
    Product("iPad", "태블릿", 900000),
    Product("Galaxy S", "스마트폰", 1200000),
    Product("iPhone", "스마트폰", 1500000),
    Product("Galaxy Tab", "태블릿", 700000),
)

// 카테고리 오름차순 → 가격 내림차순
val sorted = products.sortedByMultiple(
    compareBy { it.category },
    compareByDescending { it.price }
)
sorted.forEach { println("${it.category} / ${it.name} / ${it.price}") }
// 스마트폰 / iPhone / 1500000
// 스마트폰 / Galaxy S / 1200000
// 태블릿 / iPad / 900000
// 태블릿 / Galaxy Tab / 700000

// 특정 카테고리를 맨 앞에
val prioritized = products.sortedWithPriority(
    prioritySelector = { it.category },
    priorityOrder = listOf("스마트폰", "태블릿"),
    thenBy = { it.price }
)
prioritized.forEach { println("${it.name}") }
// Galaxy S, iPhone, Galaxy Tab, iPad

9. 실전 조합 예제

예제 — 주문 통계 대시보드

data class Order(val userId: Int, val amount: Int, val status: String)

val orders = listOf(
    Order(1, 3000, "완료"), Order(2, 5000, "완료"),
    Order(1, 2000, "취소"), Order(3, 8000, "완료"),
    Order(2, 1000, "취소"), Order(3, 4000, "완료"),
)

// 1) 완료 주문만 필터 후 유저별 합산
val completedByUser = orders
    .filter { it.status == "완료" }
    .groupSumBy({ it.userId }, { it.amount })
println(completedByUser) // {1=3000, 2=5000, 3=12000}

// 2) 상위 2명
println(completedByUser.topN(2)) // {3=12000, 2=5000}

// 3) 전체 완료 금액 누적 합
val dailySales = listOf(3000, 5000, 12000)
println(dailySales.runningSum()) // [3000, 8000, 20000]

// 4) 각 유저 비율
println(listOf(3000, 5000, 12000).toRatios().map { "%.1f%%".format(it * 100) })
// [15.0%, 25.0%, 60.0%]

// 5) 유저 id → 총액 Map 뒤집기 (확인용)
val flipped = completedByUser.inverted()
println(flipped) // {3000=1, 5000=2, 12000=3}

10. 정리

카테고리 주요 함수
그룹핑/집계 groupBy, eachCount, groupSumBy, groupAverageBy, topNGroupsBy
Zip/Combine zip, zipWithNext, zipOrNull, zip3, unzipToLists
Flatten flatten, flatMap, flatMapNotNull, flatMapIf, flattenValues
Set 연산 union, intersect, subtract, symmetricDifference, overlaps
Map 조작 mergeWith, inverted, pick, omit, filterNotNullValues, topN
검색/탐색 findIndexed, indicesWhere, binarySearch, countOf, percentageWhere
누적 합산 runningSum, runningMax, runningMin, movingAverage, toRatios
다중 정렬 sortedByMultiple, sortedWithPriority, sortedByNullsLast

참고



Related Posts