PHpullh

KOTLIN · 심층 가이드

Kotlin 컬렉션 완전 정리

filter·map 체인이 만드는 중간 리스트를 Sequence로 걷어내고, groupBy·fold·windowed로 데이터를 선언적으로 변형하는 방법을 19개 주제로 정리했습니다.

주제 19개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

Kotlin의 List는 불변 컬렉션이 아니라 읽기 전용 인터페이스입니다. MutableListList 타입 변수에 담아 넘기면 받은 쪽은 못 바꾸지만 원래 참조를 쥔 쪽은 얼마든지 바꿉니다. 자바 코드에서 넘어온 리스트라면 그마저도 보장되지 않습니다. 이 구분을 흐리게 알고 있으면, 방어적 복사를 해야 할 자리와 하지 않아도 될 자리를 계속 헷갈리게 됩니다.

List, Set, Map 생성과 기초로 자료구조 감각을 잡은 뒤 컬렉션 함수형 API — filter, map, reduce를 익히면 반복문 대부분이 사라집니다. 그 다음이 Sequence — 지연 평가인데, 앞 단계에서 체인이 길어지는 경험을 해 봐야 왜 필요한지가 와닿습니다. 실무에서 손이 가장 자주 가는 건 오히려 groupBy와 associate, partition과 windowed, zipWithNext와 scan 쪽이라 이 세 편은 예제를 직접 고쳐 가며 보길 권합니다.

Sequence가 언제나 빠르다는 오해를 조심해야 합니다. 원소가 수십 개 수준이면 이터레이터 래핑 오버헤드가 중간 리스트 비용보다 큽니다. 게다가 sorted(), distinct() 같은 상태를 가진 연산은 그 지점에서 전체를 구체화하므로 지연 평가의 이점이 끊깁니다. 또 하나, reduce는 빈 컬렉션에서 예외를 던집니다. 입력이 비어 있을 수 있다면 초기값을 주는 foldreduceOrNull을 쓰는 편이 안전합니다.

01List, Set, Map 생성과 기초

불변/가변 컬렉션 생성 함수와 기본 조작 방법을 익힙니다.

Kotlin code

// 불변 컬렉션
val list = listOf(1, 2, 3, 4, 5)
val set  = setOf("a", "b", "c", "b")    // 중복 제거
val map  = mapOf("one" to 1, "two" to 2)

// 가변 컬렉션
val mList = mutableListOf(1, 2, 3)
mList.add(4)
mList.removeAt(0)
mList[0] = 99

val mMap = mutableMapOf("a" to 1)
mMap["b"] = 2
mMap.remove("a")

// 읽기 안전
println(map["one"])          // 1 (Int?)
println(map.getOrDefault("three", 0))  // 0
println(map.getOrElse("three") { -1 }) // -1

// 컬렉션 + 연산
val combined = list + listOf(6, 7)     // 새 리스트 반환
val filtered = list - listOf(2, 4)     // [1, 3, 5]

// 배열
val arr = arrayOf(1, 2, 3)
val intArr = intArrayOf(1, 2, 3)       // primitive 배열
println(intArr.sum())                   // 6
알아두면 좋은 점

listOf()가 반환하는 타입은 List(불변 뷰)입니다. 실제로는 Java ArrayList일 수 있지만 Kotlin 타입 시스템이 변경을 막습니다.

자주 하는 실수

listOf()로 만든 리스트에 add()를 호출하면 컴파일 에러가 아니라 런타임 에러(UnsupportedOperationException)가 날 수 있습니다.

02컬렉션 함수형 API — filter, map, reduce

Kotlin 컬렉션의 강력한 함수형 API로 선언적인 데이터 변환을 구현합니다.

Kotlin code

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

val products = listOf(
    Product("Kotlin Book",  35000, 10),
    Product("Java Book",    30000,  0),
    Product("Go Guide",     28000,  5),
    Product("Python Crash", 25000, 20),
)

// filter
val inStock = products.filter { it.stock > 0 }

// map
val names = products.map { it.name }
val prices = products.map { it.price }

// find / firstOrNull
val cheap = products.firstOrNull { it.price < 30000 }

// groupBy
val byStock = products.groupBy { if (it.stock > 0) "재고있음" else "품절" }

// partition — 두 리스트로 분리
val (available, soldOut) = products.partition { it.stock > 0 }

// flatMap
val nested = listOf(listOf(1,2), listOf(3,4))
val flat = nested.flatMap { it }            // [1, 2, 3, 4]

// reduce / fold
val total = prices.reduce { sum, p -> sum + p }
val avg   = prices.fold(0) { sum, p -> sum + p } / prices.size

// sortedBy / sortedByDescending
val sorted = products.sortedBy { it.price }
val top3   = products.sortedByDescending { it.price }.take(3)

// 체이닝
val result = products
    .filter { it.stock > 0 }
    .sortedBy { it.price }
    .map { "${it.name}: ${it.price}원" }
알아두면 좋은 점

컬렉션 체이닝은 매 단계마다 새 리스트를 생성합니다. 요소가 많다면 .asSequence()로 변환해 지연(lazy) 평가를 사용하세요.

자주 하는 실수

map { }.filter { }보다 mapNotNull { }이 null 제거 + 변환을 한 번에 처리합니다.

03Sequence — 지연 평가

대용량 데이터 처리 시 불필요한 중간 리스트 생성을 피하는 Sequence.

Kotlin code

// 일반 컬렉션 — 각 단계마다 새 리스트 생성
val eagerResult = (1..1_000_000)
    .filter { it % 2 == 0 }    // 500,000개 리스트
    .map { it * it }            // 500,000개 리스트
    .take(5)                    // 5개

// Sequence — 필요한 만큼만 처리 (훨씬 빠름)
val lazyResult = (1..1_000_000).asSequence()
    .filter { it % 2 == 0 }    // 아직 실행 안 됨
    .map { it * it }            // 아직 실행 안 됨
    .take(5)                    // 여기서 실제 실행
    .toList()                   // [4, 16, 36, 64, 100]

// generateSequence — 무한 시퀀스
val fibs = generateSequence(Pair(0, 1)) { (a, b) ->
    Pair(b, a + b)
}.map { it.first }.take(10).toList()
// [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

// sequence 빌더
val naturalNumbers = sequence {
    var n = 1
    while (true) {
        yield(n++)
    }
}
println(naturalNumbers.take(5).toList()) // [1, 2, 3, 4, 5]
알아두면 좋은 점

원소가 수천 개 이상이거나 처리 중간에 take()로 일부만 사용한다면 Sequence가 유리합니다. 소규모 컬렉션은 오히려 오버헤드가 생깁니다.

자주 하는 실수

toList() 같은 terminal 연산을 호출하지 않으면 Sequence의 filter/map은 실행되지 않습니다.

04Coroutine Exception Handler

CoroutineExceptionHandler와 SupervisorJob으로 에러 격리

Kotlin code

<span class="cm">// Coroutine Exception Handler 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Coroutine Exception Handler") }
알아두면 좋은 점

KOTLIN 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

05시퀀스 심화 (Sequence Advanced)

시퀀스의 지연 평가를 활용하여 대용량 데이터를 효율적으로 처리합니다. 중간 컬렉션 생성을 방지합니다.

Kotlin code

fun main() {
    // 무한 시퀀스
    val fibonacci = sequence {
        var a = 0L
        var b = 1L
        while (true) {
            yield(a)
            val next = a + b
            a = b
            b = next
        }
    }
    println(fibonacci.take(10).toList())

    // 체이닝 최적화: 시퀀스는 원소별 처리
    val result = (1..1_000_000).asSequence()
        .filter { it % 3 == 0 }
        .map { it * it }
        .take(5)
        .toList()
    println(result) // [9, 36, 81, 144, 225]

    // generateSequence
    val powersOf2 = generateSequence(1) { it * 2 }
    println(powersOf2.take(8).toList())
    // [1, 2, 4, 8, 16, 32, 64, 128]
}
알아두면 좋은 점

sequence { yield() }는 코루틴 기반이라 복잡한 생성 로직도 순차적으로 작성할 수 있습니다.

자주 하는 실수

시퀀스는 toList() 등 터미널 연산을 호출해야 실행됩니다. 터미널 연산 없이는 아무것도 수행되지 않습니다.

06groupBy와 associate

groupBy로 원소를 그룹화하고, associate로 맵을 생성합니다. 데이터 변환의 핵심 함수입니다.

Kotlin code

data class Employee(val name: String, val dept: String, val salary: Int)

fun main() {
    val employees = listOf(
        Employee("김철수", "개발", 5000),
        Employee("이영희", "개발", 6000),
        Employee("박지성", "마케팅", 4500),
        Employee("최유리", "마케팅", 5500),
        Employee("정민호", "인사", 4000),
    )

    // groupBy: 부서별 그룹화
    val byDept = employees.groupBy { it.dept }
    byDept.forEach { (dept, emps) ->
        println("$dept: ${emps.map { it.name }}")
    }

    // associateBy: 키로 맵 생성
    val byName = employees.associateBy { it.name }
    println(byName["김철수"])

    // associate: 키-값 쌍 맵 생성
    val salaryMap = employees.associate { it.name to it.salary }
    println(salaryMap)

    // groupBy + 집계
    val avgSalary = employees.groupBy { it.dept }
        .mapValues { (_, emps) -> emps.map { it.salary }.average() }
    println("부서별 평균: $avgSalary")
}
알아두면 좋은 점

groupBy는 한 키에 여러 값을, associateBy는 한 키에 하나의 값을 매핑합니다. 중복 키가 있으면 마지막 값이 유지됩니다.

자주 하는 실수

associateBy에서 키가 중복되면 나중 값이 이전 값을 덮어씁니다. 중복이 예상되면 groupBy를 사용하세요.

07partition과 windowed

partition은 조건에 따라 두 그룹으로 분리하고, windowed는 슬라이딩 윈도우로 부분 리스트를 생성합니다.

Kotlin code

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

    // partition: 조건에 따라 두 리스트로 분리
    val (evens, odds) = numbers.partition { it % 2 == 0 }
    println("짝수: $evens")  // [2, 4, 6, 8, 10]
    println("홀수: $odds")   // [1, 3, 5, 7, 9]

    // windowed: 슬라이딩 윈도우
    val windows = numbers.windowed(3)
    println("윈도우: $windows")
    // [[1,2,3], [2,3,4], [3,4,5], ...]

    // 이동 평균 계산
    val movingAvg = numbers.windowed(3) { it.average() }
    println("이동평균: $movingAvg")

    // windowed with step
    val stepped = numbers.windowed(3, step = 2, partialWindows = true)
    println("스텝2: $stepped")

    // chunked: 고정 크기 분할
    val chunks = numbers.chunked(3)
    println("청크: $chunks")  // [[1,2,3], [4,5,6], [7,8,9], [10]]
}
알아두면 좋은 점

windowed에 변환 함수를 전달하면 중간 리스트 생성 없이 바로 결과를 계산합니다. 이동 평균 등에 효율적입니다.

자주 하는 실수

partialWindows = false(기본값)이면 마지막 불완전한 윈도우는 버려집니다. 모든 원소를 포함하려면 true로 설정하세요.

08zip과 flatten

zip은 두 컬렉션을 쌍으로 결합하고, flatten은 중첩 컬렉션을 평탄화합니다.

Kotlin code

fun main() {
    val names = listOf("김철수", "이영희", "박지성")
    val ages = listOf(25, 30, 28)
    val cities = listOf("서울", "부산")

    // zip: 쌍으로 결합
    val pairs = names.zip(ages)
    println(pairs) // [(김철수,25), (이영희,30), (박지성,28)]

    // zip with transform
    val infos = names.zip(ages) { name, age -> "$name($age세)" }
    println(infos) // [김철수(25세), 이영희(30세), 박지성(28세)]

    // 짧은 쪽에 맞춰짐
    val zipped = names.zip(cities)
    println(zipped) // [(김철수,서울), (이영희,부산)]

    // unzip
    val (unNames, unAges) = pairs.unzip()
    println("이름: $unNames, 나이: $unAges")

    // flatten: 중첩 리스트 평탄화
    val nested = listOf(listOf(1, 2), listOf(3, 4), listOf(5))
    println(nested.flatten()) // [1, 2, 3, 4, 5]

    // flatMap: map + flatten
    val words = listOf("Hello World", "Kotlin Fun")
    println(words.flatMap { it.split(" ") })
}
알아두면 좋은 점

zip은 짧은 컬렉션의 길이에 맞춰집니다. 길이가 다를 수 있다면 zipWithNext()나 인덱스 기반 접근을 고려하세요.

자주 하는 실수

flatten()은 한 레벨만 평탄화합니다. 깊은 중첩을 완전히 평탄화하려면 재귀적으로 적용해야 합니다.

09fold/reduce 심화

foldreduce로 컬렉션을 단일 값으로 누적합니다. runningFold로 중간 결과도 추적합니다.

Kotlin code

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

    // fold: 초기값 + 누적
    val sum = numbers.fold(0) { acc, n -> acc + n }
    println("합계: $sum")  // 15

    // reduce: 첫 원소가 초기값
    val product = numbers.reduce { acc, n -> acc * n }
    println("곱: $product")  // 120

    // foldRight: 오른쪽부터 누적
    val reversed = numbers.foldRight("") { n, acc -> "$acc$n" }
    println("역순: $reversed")  // 54321

    // runningFold: 중간 결과 리스트
    val running = numbers.runningFold(0) { acc, n -> acc + n }
    println("누적합: $running")  // [0, 1, 3, 6, 10, 15]

    // 실용 예: 문자열 통계
    val text = "hello kotlin world"
    val charFreq = text.filter { it != ' ' }
        .fold(mutableMapOf<Char, Int>()) { map, c ->
            map.apply { merge(c, 1, Int::plus) }
        }
    println("빈도: $charFreq")
}
알아두면 좋은 점

runningFold는 누적 합계, 이동 통계 등 중간 상태 추적이 필요한 경우에 유용합니다.

자주 하는 실수

reduce는 빈 컬렉션에서 UnsupportedOperationException을 던집니다. 빈 컬렉션이 가능하면 foldreduceOrNull을 사용하세요.

10커스텀 이터레이터 (Custom Iterator)

Iterator 인터페이스를 구현하여 커스텀 순회 로직을 작성합니다. operator fun iterator()로 for-in 루프를 지원합니다.

Kotlin code

class DateRange(
    private val start: java.time.LocalDate,
    private val end: java.time.LocalDate
) : Iterable<java.time.LocalDate> {

    override fun iterator() = object : Iterator<java.time.LocalDate> {
        var current = start
        override fun hasNext() = !current.isAfter(end)
        override fun next(): java.time.LocalDate {
            val result = current
            current = current.plusDays(1)
            return result
        }
    }
}

class FibonacciSequence(private val limit: Int) : Iterable<Long> {
    override fun iterator() = object : Iterator<Long> {
        var a = 0L; var b = 1L; var count = 0
        override fun hasNext() = count < limit
        override fun next(): Long {
            count++
            val result = a
            val next = a + b; a = b; b = next
            return result
        }
    }
}

fun main() {
    val start = java.time.LocalDate.of(2024, 1, 1)
    val end = java.time.LocalDate.of(2024, 1, 5)
    for (date in DateRange(start, end)) print("$date ")
    println()

    FibonacciSequence(8).forEach { print("$it ") }
}
알아두면 좋은 점

Iterable을 구현하면 for 루프, map, filter 등 모든 컬렉션 함수를 사용할 수 있습니다.

자주 하는 실수

next()에서 hasNext()를 확인하지 않고 호출하면 NoSuchElementException이 발생합니다. 항상 사전 검사를 하세요.

11불변 컬렉션 활용 (Immutable Collections)

Kotlin은 읽기 전용 컬렉션을 기본으로 제공합니다. kotlinx.collections.immutable로 진정한 불변 컬렉션을 사용합니다.

Kotlin code

fun main() {
    // 읽기 전용 (인터페이스만 제한)
    val readOnly: List<Int> = listOf(1, 2, 3)
    // readOnly.add(4) // 컴파일 오류

    // mutable 뷰로 변경 가능 (주의!)
    val mutable = mutableListOf(1, 2, 3)
    val view: List<Int> = mutable
    mutable.add(4)
    println(view)  // [1, 2, 3, 4] - 영향 받음!

    // 안전한 방어적 복사
    fun getItems(): List<Int> {
        val internal = mutableListOf(1, 2, 3)
        return internal.toList()  // 복사본 반환
    }

    // buildList로 불변 리스트 생성
    val immutable = buildList {
        add(1)
        addAll(listOf(2, 3, 4))
        removeIf { it % 2 == 0 }
    }
    println(immutable)  // [1, 3]

    // buildMap
    val config = buildMap {
        put("host", "localhost")
        put("port", "8080")
    }
    println(config)
}
알아두면 좋은 점

buildList, buildMap, buildSet은 빌더 내에서만 변경 가능하고, 결과는 읽기 전용입니다.

자주 하는 실수

읽기 전용 List와 진정한 불변 리스트는 다릅니다. 원본 MutableList가 변경되면 뷰도 영향을 받습니다. toList()로 복사하세요.

12맵 조작 심화 (Map Operations)

맵의 변환, 병합, 필터링 등 고급 연산을 다룹니다. getOrDefault, merge, mapValues를 활용합니다.

Kotlin code

fun main() {
    val scores = mutableMapOf("국어" to 85, "수학" to 92, "영어" to 78)

    // getOrDefault / getOrPut
    println(scores.getOrDefault("과학", 0))  // 0
    scores.getOrPut("과학") { 88 }
    println(scores["과학"])  // 88

    // mapValues / mapKeys
    val grades = scores.mapValues { (_, score) ->
        when {
            score >= 90 -> "A"
            score >= 80 -> "B"
            else -> "C"
        }
    }
    println(grades)

    // 맵 병합
    val map1 = mapOf("a" to 1, "b" to 2)
    val map2 = mapOf("b" to 3, "c" to 4)
    val merged = map1 + map2  // map2가 우선
    println(merged)  // {a=1, b=3, c=4}

    // filterKeys / filterValues
    val passed = scores.filterValues { it >= 80 }
    println("합격: $passed")

    // 단어 빈도 수 세기
    val words = "the cat sat on the mat the cat".split(" ")
    val freq = words.groupingBy { it }.eachCount()
    println(freq)
}
알아두면 좋은 점

groupingBy { }.eachCount()는 빈도수 세기의 관용적 패턴입니다. fold보다 간결합니다.

자주 하는 실수

맵의 + 연산은 키가 중복되면 오른쪽 값이 우선합니다. 값을 병합하려면 merge 함수를 사용하세요.

13정렬 심화 (Advanced Sorting)

다양한 정렬 기준과 커스텀 비교자를 활용합니다. sortedBy, sortedWith, compareBy를 다룹니다.

Kotlin code

data class Student(val name: String, val grade: Int, val score: Double)

fun main() {
    val students = listOf(
        Student("김철수", 3, 88.5),
        Student("이영희", 2, 92.0),
        Student("박지성", 3, 88.5),
        Student("최유리", 1, 95.0),
        Student("정민호", 2, 87.3),
    )

    // 단일 기준 정렬
    println(students.sortedBy { it.score })

    // 다중 기준: 학년 오름차순 → 점수 내림차순
    val sorted = students.sortedWith(
        compareBy<Student> { it.grade }
            .thenByDescending { it.score }
    )
    sorted.forEach { println("${it.grade}학년 ${it.name}: ${it.score}") }

    // 커스텀 Comparator
    val byNameLength = Comparator<Student> { a, b ->
        a.name.length - b.name.length
    }
    println(students.sortedWith(byNameLength).map { it.name })

    // 안정 정렬 확인
    val stable = students.sortedBy { it.score }
    println(stable.filter { it.score == 88.5 }.map { it.name })
}
알아두면 좋은 점

Kotlin의 정렬은 안정 정렬(stable sort)입니다. 같은 값의 원소 순서가 보존되므로 다단계 정렬을 순차적으로 적용해도 안전합니다.

자주 하는 실수

sortedBy는 새 리스트를 반환하고, sortBy는 MutableList를 제자리 정렬합니다. 불변 리스트에 sortBy를 쓸 수 없습니다.

14이진 검색 (Binary Search)

정렬된 리스트에서 binarySearch로 효율적으로 원소를 검색합니다. O(log n) 시간 복잡도를 보장합니다.

Kotlin code

fun main() {
    val sorted = listOf(2, 5, 8, 12, 16, 23, 38, 56, 72, 91)

    // 기본 이진 검색
    val index = sorted.binarySearch(23)
    println("23의 위치: $index")  // 5

    // 찾지 못한 경우: -(삽입 지점) - 1
    val notFound = sorted.binarySearch(20)
    println("20 검색 결과: $notFound")        // 음수
    val insertionPoint = -(notFound + 1)
    println("20 삽입 지점: $insertionPoint")  // 5

    // 객체 이진 검색
    data class Product(val name: String, val price: Int)
    val products = listOf(
        Product("사과", 1000),
        Product("바나나", 2000),
        Product("체리", 5000),
    )
    val found = products.binarySearch {
        it.price.compareTo(2000)
    }
    println("가격 2000: ${products[found].name}")

    // 범위 검색
    val range = sorted.binarySearch(10)
    println("10 이상 시작 인덱스: ${-(range + 1)}")
}
알아두면 좋은 점

이진 검색의 반환값이 음수면 -(result + 1)이 삽입 지점입니다. 이를 활용하여 범위 검색도 가능합니다.

자주 하는 실수

binarySearch는 리스트가 정렬되어 있어야 합니다. 정렬되지 않은 리스트에서 호출하면 잘못된 결과를 반환합니다.

15트리 구조 (Tree Structure)

제네릭 트리 자료구조를 구현하고 재귀적으로 순회합니다. DFS와 BFS를 모두 지원합니다.

Kotlin code

data class TreeNode<T>(
    val value: T,
    val children: MutableList<TreeNode<T>> = mutableListOf()
) {
    fun addChild(value: T): TreeNode<T> {
        val child = TreeNode(value)
        children.add(child)
        return child
    }

    // DFS 순회
    fun dfs(action: (T, Int) -> Unit, depth: Int = 0) {
        action(value, depth)
        children.forEach { it.dfs(action, depth + 1) }
    }

    // BFS 순회
    fun bfs(action: (T) -> Unit) {
        val queue = ArrayDeque<TreeNode<T>>()
        queue.add(this)
        while (queue.isNotEmpty()) {
            val node = queue.removeFirst()
            action(node.value)
            queue.addAll(node.children)
        }
    }
}

fun main() {
    val root = TreeNode("회사")
    val dev = root.addChild("개발부")
    val sales = root.addChild("영업부")
    dev.addChild("프론트엔드")
    dev.addChild("백엔드")
    sales.addChild("국내")

    println("=== DFS ===")
    root.dfs { value, depth -> println("${"  ".repeat(depth)}$value") }

    println("=== BFS ===")
    root.bfs { print("$it ") }
}
알아두면 좋은 점

ArrayDeque를 큐로 사용하면 LinkedList보다 캐시 친화적이어서 BFS 성능이 더 좋습니다.

자주 하는 실수

트리에 순환 참조가 있으면 DFS/BFS가 무한 루프에 빠집니다. 방문 집합(visited set)을 사용하여 순환을 감지하세요.

16chunked 활용 (Batch Processing)

chunked로 컬렉션을 고정 크기 배치로 나눕니다. 대량 데이터의 배치 처리에 유용합니다.

Kotlin code

fun main() {
    val items = (1..23).toList()

    // 기본 chunked
    val batches = items.chunked(5)
    println("배치: $batches")
    // [[1..5], [6..10], [11..15], [16..20], [21..23]]

    // 변환 함수 결합
    val batchSums = items.chunked(5) { chunk ->
        "${chunk.first()}-${chunk.last()}: 합=${chunk.sum()}"
    }
    batchSums.forEach { println(it) }

    // 배치 API 호출 시뮬레이션
    val userIds = (1..12).map { "user-$it" }
    userIds.chunked(4).forEachIndexed { i, batch ->
        println("배치 ${i + 1}: ${batch.joinToString()}")
        // 실제: apiClient.batchFetch(batch)
    }
}
알아두면 좋은 점

chunked에 변환 함수를 전달하면 중간 리스트 생성 없이 바로 결과를 계산합니다.

자주 하는 실수

마지막 청크는 지정한 크기보다 작을 수 있습니다. 크기에 의존하는 로직이 있다면 마지막 청크를 별도로 처리하세요.

17associate 변환 심화

associateWith, associateBy 등 다양한 맵 변환 함수를 활용합니다.

Kotlin code

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

fun main() {
    val products = listOf(
        Product(1, "노트북", "전자기기", 1500000),
        Product(2, "마우스", "전자기기", 30000),
        Product(3, "펜", "문구", 3000),
        Product(4, "노트", "문구", 5000),
    )

    // associateBy: 특정 키로 맵 생성
    val byId = products.associateBy { it.id }
    println("ID 2: ${byId[2]?.name}")

    // associateWith: 값을 변환
    val names = listOf("kotlin", "java", "python")
    val lengths = names.associateWith { it.length }
    println(lengths) // {kotlin=6, java=4, python=6}

    // associate: 키-값 쌍 직접 지정
    val priceMap = products.associate { it.name to it.price }
    println(priceMap)

    // groupBy + mapValues 조합
    val categoryAvg = products
        .groupBy { it.category }
        .mapValues { (_, prods) -> prods.map { it.price }.average() }
    println("카테고리별 평균: $categoryAvg")
}
알아두면 좋은 점

associateWith는 원소 자체가 키가 되고 변환 결과가 값이 됩니다. 역방향인 associateBy와 구분하세요.

자주 하는 실수

associateBy에서 키가 중복되면 마지막 원소만 남습니다. 중복 가능성이 있으면 groupBy를 사용하세요.

18zipWithNext와 scan

zipWithNext로 연속 원소를 쌍으로 묶고, scan으로 누적 결과를 추적합니다.

Kotlin code

fun main() {
    val temps = listOf(20, 22, 19, 25, 23, 28, 30, 27)

    // zipWithNext: 연속 쌍
    val changes = temps.zipWithNext { a, b -> b - a }
    println("온도 변화: $changes")

    // 연속 상승 감지
    val rising = temps.zipWithNext().filter { (a, b) -> b > a }
    println("상승 구간: $rising")

    // scan (runningFold): 누적 결과
    val cumSum = temps.scan(0) { acc, t -> acc + t }
    println("누적합: $cumSum")

    // 이동 평균 (windowed + average)
    val movingAvg = temps.windowed(3) { it.average() }
    println("이동평균: ${movingAvg.map { "%.1f".format(it) }}")

    // 연속 동일값 그룹
    val data = listOf(1, 1, 2, 2, 2, 3, 1, 1)
    val groups = buildList {
        var current = mutableListOf(data[0])
        for (i in 1 until data.size) {
            if (data[i] == data[i - 1]) current.add(data[i])
            else { add(current.toList()); current = mutableListOf(data[i]) }
        }
        add(current.toList())
    }
    println("런 그룹: $groups")
}
알아두면 좋은 점

zipWithNext는 시계열 데이터의 변화량, 차분, 추세 감지에 매우 유용합니다.

자주 하는 실수

scan의 결과 리스트는 원본보다 하나 더 길어집니다(초기값 포함). 인덱스 매핑 시 주의하세요.

19커스텀 컬렉션 확장

확장 함수로 도메인 특화 컬렉션 연산을 추가합니다. 재사용 가능한 유틸리티를 구성합니다.

Kotlin code

// 통계 확장
fun List<Double>.standardDeviation(): Double {
    val mean = average()
    val variance = map { (it - mean) * (it - mean) }.average()
    return kotlin.math.sqrt(variance)
}

fun <T> List<T>.mostFrequent(): T? =
    groupingBy { it }.eachCount().maxByOrNull { it.value }?.key

fun <T> List<T>.distinctBy2(selector1: (T) -> Any?, selector2: (T) -> Any?): List<T> {
    val seen = mutableSetOf<Pair<Any?, Any?>>()
    return filter { seen.add(selector1(it) to selector2(it)) }
}

// 페이지네이션 확장
fun <T> List<T>.page(pageNum: Int, pageSize: Int): List<T> {
    val start = (pageNum - 1) * pageSize
    return if (start >= size) emptyList()
    else subList(start, minOf(start + pageSize, size))
}

fun main() {
    val scores = listOf(85.0, 92.0, 78.0, 95.0, 88.0, 72.0)
    println("표준편차: ${"%.2f".format(scores.standardDeviation())}")

    val words = listOf("cat", "dog", "cat", "bird", "cat", "dog")
    println("최빈값: ${words.mostFrequent()}")

    val items = (1..50).toList()
    println("2페이지: ${items.page(2, 10)}")
    println("6페이지: ${items.page(6, 10)}")
}
알아두면 좋은 점

도메인별 확장 함수를 별도 파일(예: CollectionExtensions.kt)에 모아두면 팀 전체가 재사용할 수 있습니다.

자주 하는 실수

확장 함수는 가상 디스패치를 지원하지 않습니다. 런타임 타입이 아닌 컴파일 타입 기준으로 호출되므로 상속 계층에서 주의하세요.

정리하며

  • 읽기 전용 List는 불변 보장이 아니므로, 외부에 노출할 때는 복사 여부를 따로 판단합니다
  • 체인이 길고 원소가 많을 때만 asSequence가 이득이고, 작은 컬렉션에서는 손해입니다
  • sorted·distinct처럼 상태를 가진 연산은 그 지점에서 지연 평가를 끊습니다
  • 빈 입력 가능성이 있으면 reduce 대신 fold나 reduceOrNull을 씁니다

더 깊이 들어가고 싶다면 Kotlin 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.