Kotlin Collection: map, filter와 groupBy 실제 예제로 배우기

1. 개요

대부분의 비즈니스 코드는 리스트를 변환하는 작업입니다: 필드를 몇 개 추출하고, 조건에 따라 필터링하고, 합계를 계산하기 위해 그룹화합니다. Kotlin은 표준 라이브러리에 이러한 작업을 위한 확장 함수 map, filter, groupBy를 제공하므로 별도의 라이브러리가 필요하지 않습니다.

이 글에서는 각 함수와 그들의 중요한 변형들을 살펴보고, 이를 완전한 파이프라인으로 조합하는 방법을 설명합니다. 글 끝에는 검증하는 테스트와 자주 발생하는 오류 목록이 있습니다.

2. 샘플 데이터

이 글의 모든 예제는 공통 주문 리스트를 사용합니다:

enum class Status { NEW, PAID, CANCELLED }

data class Order(
    val id: Int,
    val customer: String,
    val status: Status,
    val total: Int,          // 단위: 천원
    val coupon: String? = null
)

val orders = listOf(
    Order(1, "An",   Status.PAID,      120, coupon = "SALE10"),
    Order(2, "Bình", Status.NEW,        80),
    Order(3, "An",   Status.CANCELLED,  50),
    Order(4, "Chi",  Status.PAID,      200, coupon = "VIP"),
    Order(5, "Bình", Status.PAID,       45),
)

orders는 List<Order> 타입이며, 읽기 전용 리스트입니다. 아래의 함수들은 원본 리스트를 수정하지 않습니다. 호출할 때마다 새로운 컬렉션이 반환됩니다.

3. map: 각 요소 변환하기

3.1. 기본 map

map은 람다 (T) -> R을 받아 각 요소에 적용하고 List<R>을 반환합니다:

val ids = orders.map { it.id }
val customers = orders.map { it.customer }

println(ids)
println(customers)
[1, 2, 3, 4, 5]
[An, Bình, An, Chi, Bình]

결과는 항상 원본 리스트와 같은 수의 요소를 가지며 순서는 유지됩니다. 중복 없이 값만 원하면 .distinct()를 추가할 수 있습니다: orders.map { it.customer }.distinct()는 [An, Bình, Chi]를 반환합니다.

단순한 속성의 경우, 람다 대신 프로퍼티 참조를 사용할 수 있습니다: orders.map(Order::id).

3.2. mapNotNull: 변환하고 null 제거하기

람다가 null을 반환할 수 있을 때는 mapNotNull을 사용하여 변환과 동시에 null 값을 제거합니다:

val coupons: List<String> = orders.mapNotNull { it.coupon }
println(coupons)
[SALE10, VIP]

반환 타입은 List<String>이지 List<String?>이 아닙니다. 이는 map { it.coupon }.filterNotNull()보다 더 간결하고 타입 안전합니다.

3.3. mapIndexed: 인덱스가 필요할 때

val lines = orders.mapIndexed { index, order -> "${index + 1}. 주문 #${order.id} - ${order.total}천원" }
lines.forEach(::println)
1. 주문 #1 - 120천원
2. 주문 #2 - 80천원
3. 주문 #3 - 50천원
4. 주문 #4 - 200천원
5. 주문 #5 - 45천원

3.4. Map에서의 map

Map에서 map을 호출하면 List가 반환됩니다. 결과가 여전히 Map이기를 원한다면 mapValues 또는 mapKeys를 사용하세요:

val prices = mapOf("커피" to 30, "차" to 25)

val labels = prices.map { (name, price) -> "$name: ${price}천원" }   // List<String>
val withVat = prices.mapValues { (_, price) -> price * 110 / 100 } // Map<String, Int>

println(labels)
println(withVat)
[커피: 30천원, 차: 25천원]
{커피=33, 차=27}

4. filter: 조건을 만족하는 요소만 유지하기

4.1. filter와 filterNot

filter는 predicate (T) -> Boolean을 받아 predicate이 true를 반환하는 요소만 유지합니다:

val paid = orders.filter { it.status == Status.PAID }
val notCancelled = orders.filterNot { it.status == Status.CANCELLED }

println(paid.map { it.id })
println(notCancelled.map { it.id })
[1, 4, 5]
[2, 4, 5]

잠깐, 두 번째 줄이 잘못되었습니다. 주문 1은 취소되지 않았으므로 포함되어야 합니다. 올바른 결과는:

[1, 4, 5]
[1, 2, 4, 5]

filterNot은 filter { !(...) }의 더 읽기 쉬운 방법일 뿐입니다. 부정 조건이 코드를 어렵게 만들 때 사용하세요.

4.2. partition: 한 번에 두 그룹으로 나누기

조건을 만족하는 요소와 만족하지 않는 요소 모두 필요한 경우, filter를 두 번 호출하지 마세요. partition은 두 리스트를 포함하는 Pair를 반환합니다:

val (bigOrders, smallOrders) = orders.partition { it.total >= 100 }

println(bigOrders.map { it.id })
println(smallOrders.map { it.id })
[1, 4]
[2, 3, 5]

Destructuring val (a, b)를 통해 각 부분에 명확한 이름을 지정할 수 있습니다.

4.3. filterIsInstance: 타입으로 필터링하기

혼합된 타입의 리스트를 다룰 때, filterIsInstance<T>()는 필터링과 스마트 캐스트를 동시에 수행합니다:

sealed interface Event
data class OrderPlaced(val orderId: Int) : Event
data class OrderShipped(val orderId: Int, val carrier: String) : Event

val events: List<Event> = listOf(OrderPlaced(1), OrderShipped(1, "GHN"), OrderPlaced(2))

val shipped: List<OrderShipped> = events.filterIsInstance<OrderShipped>()
println(shipped.map { it.carrier })
[GHN]

수동 타입 캐스팅 as가 필요하지 않습니다. 결과는 이미 List<OrderShipped> 타입입니다.

5. groupBy: 키로 요소 그룹화하기

5.1. 기본 groupBy

groupBy는 키 선택 함수를 받아 Map<K, List<T>>를 반환합니다:

val byCustomer: Map<String, List<Order>> = orders.groupBy { it.customer }

println(byCustomer.mapValues { (_, list) -> list.map { it.id } })
{An=[1, 3], Bình=[2, 5], Chi=[4]}

반환되는 Map은 LinkedHashMap입니다. 키의 순서는 원본 리스트에서의 첫 등장 순서에 따르며, 각 그룹 내 요소의 순서도 유지됩니다.

5.2. groupBy와 valueTransform

두 번째 오버로드는 그룹화할 때 값을 변환할 수 있으므로, 별도의 mapValues가 필요하지 않습니다:

val totalsByCustomer: Map<String, List<Int>> =
    orders.groupBy(keySelector = { it.customer }, valueTransform = { it.total })

println(totalsByCustomer)
{An=[120, 50], Bình=[80, 45], Chi=[200]}

5.3. 그룹화 후 집계하기

가장 자주 사용되는 패턴은 그룹화한 후 각 그룹에 대해 값을 계산하는 것입니다:

val revenueByStatus: Map<Status, Int> = orders
    .groupBy { it.status }
    .mapValues { (_, list) -> list.sumOf { it.total } }

println(revenueByStatus)
{PAID=365, NEW=80, CANCELLED=50}

5.4. groupingBy: 중간 리스트 생성 없이 집계하기

groupBy는 항상 각 그룹에 대해 완전한 List를 생성합니다. 단지 개수를 세거나 누적할 필요가 있다면, groupingBy가 더 효율적입니다. 왜냐하면 직접 누적하기 때문입니다:

val orderCount: Map<String, Int> = orders.groupingBy { it.customer }.eachCount()

val spentByCustomer: Map<String, Int> = orders
    .groupingBy { it.customer }
    .fold(0) { acc, order -> acc + order.total }

println(orderCount)
println(spentByCustomer)
{An=2, Bình=2, Chi=1}
{An=170, Bình=125, Chi=200}

groupingBy는 Grouping 객체를 반환하며, 아직 계산을 수행하지 않습니다. 집계는 eachCount, fold, reduce, 또는 aggregate를 호출할 때만 실행됩니다.

5.5. groupBy와 associateBy의 차이

이 두 함수는 자주 헷갈립니다:

val lastOrderByCustomer: Map<String, Order> = orders.associateBy { it.customer }
println(lastOrderByCustomer.mapValues { it.value.id })
{An=3, Bình=5, Chi=4}

associateBy는 Map<K, T>를 생성합니다. 즉, 각 키는 하나의 요소에 대응됩니다. 키가 중복되면, 이후 요소가 이전 요소를 덮어씁니다. 따라서 associateBy는 키가 고유할 때만 사용하세요. 예를 들어 associateBy { it.id }. 키가 중복될 수 있다면 groupBy를 사용하세요.

6. 파이프라인으로 결합하기

위 함수들은 서로 연결될 수 있습니다. 다음 예제는 지불된 주문의 고객별 수익을 계산한 후 내림차순으로 정렬합니다:

fun paidRevenueRanking(orders: List<Order>): List<Pair<String, Int>> =
    orders
        .filter { it.status == Status.PAID }
        .groupBy { it.customer }
        .mapValues { (_, list) -> list.sumOf { it.total } }
        .toList()
        .sortedByDescending { it.second }

println(paidRevenueRanking(orders))
[(Chi, 200), (An, 120), (Bình, 45)]

파이프라인은 다음 단계를 거칩니다:

  • filter는 주문 1, 4, 5를 유지합니다.
  • groupBy + mapValues는 {An=120, Chi=200, Bình=45}를 생성합니다.
  • toList()는 Map을 List<Pair<String, Int>>로 변환하여 정렬할 수 있게 합니다.
  • sortedByDescending은 수익으로 정렬합니다.

참고: 위의 List의 각 단계는 eager합니다. 전체 데이터를 순회하고 새로운 리스트를 생성합니다. 몇 천 개의 요소가 있다면 무시할 수 있는 수준입니다. 매우 큰 데이터를 다루거나 처음 몇 개의 요소만 필요한 경우(take, first), asSequence()로 전환하여 각 요소를 lazy하게 처리하세요:

val firstTwoPaid = orders.asSequence()
    .filter { it.status == Status.PAID }
    .map { it.id }
    .take(2)
    .toList()

println(firstTwoPaid)
[1, 4]

Sequence를 사용하면, 처리 체인이 충분한 2개의 요소를 얻으면 멈춥니다. 주문 5는 검토되지 않습니다.

7. 파이프라인을 위한 테스트 작성

pom.xml에 JUnit 5와 AssertJ를 추가하세요. Kotlin 프로젝트에는 이미 kotlin-maven-plugin이 있어야 합니다:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.25.3</version>
    <scope>test</scope>
</dependency>
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class OrderStatsTest {

    @Test
    fun `지불된 주문의 고객별 수익 순위`() {
        val result = paidRevenueRanking(orders)

        assertThat(result).containsExactly("Chi" to 200, "An" to 120, "Bình" to 45)
    }

    @Test
    fun `groupingBy eachCount는 고객별 주문 개수를 정확히 센다`() {
        val count = orders.groupingBy { it.customer }.eachCount()

        assertThat(count).containsExactlyEntriesOf(mapOf("An" to 2, "Bình" to 2, "Chi" to 1))
    }

    @Test
    fun `filter는 원본 리스트를 변경하지 않는다`() {
        orders.filter { it.total > 100 }

        assertThat(orders).hasSize(5)
    }
}

containsExactly는 내용과 순서를 모두 확인합니다. 이는 순위 결과가 순서에 따라 달라지므로 중요합니다. containsExactlyEntriesOf도 entry의 순서를 비교하므로, LinkedHashMap의 순서 유지 특성을 검증합니다.

8. 자주 하는 실수

  • 반대 조건으로 filter를 두 번 사용하기. 코드가 리스트를 두 번 순회하고 조건이 맞지 않을 수 있습니다. partition을 사용하세요.
  • 키가 중복될 수 있을 때 associateBy 사용하기. 데이터가 조용히 덮어써지며 오류가 나타나지 않습니다. 이는 발견하기 매우 어려운 버그입니다.
  • 단지 개수를 세거나 누적하기 위해 groupBy 사용하기. 이 방식은 불필요한 중간 리스트를 생성합니다. groupingBy { }.eachCount() 또는 .fold()가 더 간결하고 가볍습니다.
  • map 내에서 부작용 일으키기. 예를 들어 map 내에서 paymentService.charge(it)을 호출하는 것입니다. map은 데이터 변환을 위한 것입니다. 각 요소에 대해 작업을 수행해야 한다면, 의도를 명확하게 하기 위해 forEach를 사용하세요.
  • map { }.filterNotNull() 사용하기. mapNotNull로 대체하세요.
  • 모든 경우에 Sequence 사용하기. 작은 리스트의 경우, Sequence는 iterator와 람다 생성 비용 때문에 더 느릴 수 있습니다. 데이터가 크거나, 파이프라인이 길거나, 조기 종료 작업이 있을 때만 Sequence로 전환하세요.
  • 루프 내에서 같은 데이터에 대해 groupBy를 여러 번 호출하기. 한 번 계산하고 Map을 변수에 저장한 후 사용하세요.

참고: 데이터가 데이터베이스에서 올 때, 필터링과 그룹화는 SQL(WHERE, GROUP BY)로 수행해야 합니다. 모든 데이터를 메모리에 로드한 후 filter/groupBy를 호출하지 마세요. 이 컬렉션 함수들은 메모리에 이미 있고 크기가 적당한 데이터에 적합합니다.

9. 결론

map은 각 요소를 변환하고, filter는 조건을 만족하는 요소를 유지하며, groupBy는 요소를 키별로 Map으로 그룹화합니다. 각 함수에는 특화된 변형들이 있습니다:

  • 변환을 위해 mapNotNull, mapIndexed, mapValues.
  • 필터링을 위해 filterNot, partition, filterIsInstance.
  • 그룹화와 Map 생성을 위해 groupingBy, associateBy.

올바른 변형을 선택하면 코드가 더 짧아지고 버그를 피할 수 있습니다. 파이프라인으로 결합할 때, AssertJ로 테스트를 작성하여 결과의 내용과 순서를 모두 검증하세요. 데이터가 크거나 조기 종료가 필요할 때 Sequence 사용을 고려하세요.

kotlincollectionmapfiltergroupBy