Kotlin 컬렉션: List, Set, Map과 컬렉션 함수

이 글의 핵심

Kotlin의 List는 기본이 읽기 전용이라 add가 없어서 처음에 당황하지만, 이 구분 덕분에 의도하지 않은 수정을 막을 수 있습니다. 컬렉션 연산이 단계마다 새 리스트를 만드는 비용, 큰 데이터에서 Sequence로 바꿔 중간 컬렉션을 없애는 판단 기준을 짚고 실전 집계 예제로 연결합니다.

들어가며

읽기 전용과 가변 컬렉션을 나누어 두면, 의도치 않은 수정을 줄이기 좋습니다. map·filter 등은 새 리스트를 돌려주는 스타일이 기본에 가깝습니다.


List와 MutableList

읽기 전용 List

val fruits = listOf("사과", "바나나", "오렌지")
// 접근
println(fruits[0])        // 사과
println(fruits.first())   // 사과
println(fruits.last())    // 오렌지
println(fruits.size)      // 3
// 검색
println(fruits.contains("사과"))  // true
println(fruits.indexOf("바나나"))  // 1

listOf가 돌려주는 List<String>에는 add나 remove 같은 메서드가 아예 없습니다. 그래서 fruits.add("포도")는 런타임 에러가 아니라 Unresolved reference: add 컴파일 에러가 납니다. 여기서 “읽기 전용”은 불변(immutable)과 같은 뜻이 아니라는 점이 중요합니다. List 인터페이스는 “이 참조로는 수정할 수 없다”는 뜻일 뿐이고, 같은 객체를 MutableList로 가진 다른 코드가 있다면 내용은 바뀔 수 있습니다. 함수 매개변수를 List로 받는 것은 “나는 이 리스트를 수정하지 않겠다”는 약속에 가깝고, 진짜 불변이 필요하면 toList()로 복사본을 만들어 보관하거나 kotlinx.collections.immutable 라이브러리를 씁니다.

fruits[0]은 범위를 벗어나면 IndexOutOfBoundsException을, first()는 빈 리스트에서 NoSuchElementException을 던집니다. 비어 있을 수 있는 리스트라면 getOrNull(0)이나 firstOrNull()처럼 null을 돌려주는 버전을 쓰고 엘비스 연산자(?:)로 기본값을 주는 것이 Kotlin다운 방식입니다.

변경 가능 MutableList

val mutableFruits = mutableListOf("사과", "바나나")
// 추가
mutableFruits.add("오렌지")
mutableFruits.add(0, "포도")  // 인덱스 지정
// 삭제
mutableFruits.remove("사과")
mutableFruits.removeAt(0)
// 수정
mutableFruits[0] = "딸기"
println(mutableFruits)  // [딸기, 오렌지]

결과가 [딸기, 오렌지]인 이유를 한 줄씩 따라가 보면 [사과, 바나나] → add로 [사과, 바나나, 오렌지] → add(0, ...)로 [포도, 사과, 바나나, 오렌지] → remove("사과")로 [포도, 바나나, 오렌지] → removeAt(0)으로 [바나나, 오렌지] → 첫 원소를 딸기로 교체입니다. remove는 값으로, removeAt은 인덱스로 지운다는 차이를 헷갈리기 쉬운데, MutableList<Int>에서 remove(1)은 인덱스 1이 아니라 값 1을 지운다는 점도 기억해 두세요.

val mutableFruits가 val인데 내용을 바꿀 수 있는 것도 처음에 헷갈리는 부분입니다. val은 변수가 다른 리스트를 가리키지 못하게 막을 뿐이고, 가리키는 리스트 객체 자체의 변경 여부는 타입(MutableList)이 결정합니다. 가장 제한적인 조합은 val + List이고, 실무에서는 클래스 내부에 private val _items = mutableListOf<Item>()을 두고 외부에는 val items: List<Item> get() = _items로 읽기 전용 뷰만 노출하는 패턴을 많이 씁니다.

List 생성 방법

// 빈 리스트
val empty = emptyList<String>()
val mutableEmpty = mutableListOf<String>()
// 크기 지정
val zeros = List(5) { 0 }  // [0, 0, 0, 0, 0]
val squares = List(5) { it * it }  // [0, 1, 4, 9, 16]
// 범위로 생성
val numbers = (1..10).toList()

List(5) { ... }의 람다는 인덱스(it)를 받아 각 원소를 만드는 함수라서, 위치에 따라 값이 달라지는 리스트를 한 줄로 만들 수 있습니다. emptyList<String>()처럼 원소가 없을 때는 타입 추론할 근거가 없으므로 타입 인자를 명시해야 하며, 빠뜨리면 Not enough information to infer type variable T 에러가 납니다.


Set과 MutableSet

읽기 전용 Set

val numbers = setOf(1, 2, 3, 2, 1)
println(numbers)  // [1, 2, 3] (중복 제거)
// 집합 연산
val set1 = setOf(1, 2, 3)
val set2 = setOf(2, 3, 4)
println(set1 union set2)        // [1, 2, 3, 4]
println(set1 intersect set2)    // [2, 3]
println(set1 subtract set2)     // [1]

setOf와 mutableSetOf는 기본적으로 LinkedHashSet을 사용해서 삽입 순서를 유지합니다. 그래서 위 출력이 항상 [1, 2, 3] 순서로 나옵니다. Java의 HashSet처럼 순서가 섞이지 않는다는 점은 편하지만, 순서를 유지하기 위한 연결 리스트만큼 메모리를 조금 더 씁니다. 순서가 필요 없고 원소가 아주 많다면 hashSetOf, 정렬된 순서가 필요하면 sortedSetOf를 고를 수 있습니다. union, intersect, subtract는 infix 함수라 연산자처럼 띄어 쓸 수 있고, 모두 새 Set을 반환하며 원본은 바꾸지 않습니다.

Set에 data class가 아닌 일반 클래스 객체를 넣으면 equals/hashCode가 참조 비교로 동작해서, 내용이 같은 두 객체도 서로 다른 원소로 취급됩니다. 중복 제거가 예상대로 안 된다면 클래스가 data class인지부터 확인해 보세요. 반대로 Set에 넣은 뒤 hashCode에 쓰이는 속성을 바꾸면(var 속성을 가진 data class) 그 원소를 contains로 다시 찾지 못하게 됩니다.

변경 가능 MutableSet

val mutableNumbers = mutableSetOf(1, 2, 3)
mutableNumbers.add(4)
mutableNumbers.add(2)  // 중복은 추가 안 됨
mutableNumbers.remove(1)
println(mutableNumbers)  // [2, 3, 4]

Map과 MutableMap

읽기 전용 Map

val ages = mapOf(
    "홍길동" to 25,
    "김철수" to 30,
    "이영희" to 28
)
// 접근
println(ages["홍길동"])        // 25
println(ages.get("홍길동"))    // 25
println(ages.getOrDefault("박민수", 0))  // 0
println(ages["박민수"] ?: 0)             // 0 (Kotlin다운 방식)
// 키/값 확인
println(ages.containsKey("홍길동"))    // true
println(ages.containsValue(25))        // true
// 순회
ages.forEach { (name, age) ->
    println("$name: $age세")
}

Kotlin의 ages[key]는 Java처럼 get을 호출하지만, 반환 타입이 Int?라는 점이 다릅니다. 키가 없을 수 있다는 사실이 타입에 드러나므로, val age: Int = ages["박민수"]처럼 쓰면 Type mismatch: inferred type is Int? but Int was expected 컴파일 에러가 납니다. 없는 키를 에러로 처리하고 싶다면 ages.getValue("박민수")가 NoSuchElementException을 던집니다. forEach { (name, age) -> }의 괄호는 Map.Entry를 구조 분해하는 문법이며, 괄호를 빼고 { name, age -> }로 쓰면 Java 8의 BiConsumer 버전 forEach가 선택되어 Android의 낮은 API 레벨에서 문제가 되기도 합니다.

변경 가능 MutableMap

val mutableAges = mutableMapOf("홍길동" to 25)
// 추가/수정
mutableAges["김철수"] = 30
mutableAges.put("이영희", 28)
// 삭제
mutableAges.remove("홍길동")
// 조건부 추가
mutableAges.putIfAbsent("박민수", 35)
println(mutableAges)  // {김철수=30, 이영희=28, 박민수=35}

mutableAges[key] = value는 키가 없으면 추가하고 있으면 덮어쓰므로, 실수로 기존 값을 덮어쓰지 않으려면 putIfAbsent나 getOrPut을 씁니다. 특히 getOrPut은 “없으면 만들어 넣고, 있으면 기존 값을 돌려준다”는 동작이라 cache.getOrPut(key) { expensiveLoad(key) }처럼 간단한 캐시나 map.getOrPut(city) { mutableListOf() }.add(user) 같은 그룹화 코드에 자주 쓰입니다.


filter, map, fold, groupBy, partition

filter (필터링)

val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
// 짝수만
val evens = numbers.filter { it % 2 == 0 }
println(evens)  // [2, 4, 6, 8, 10]
// 5보다 큰 수
val greaterThan5 = numbers.filter { it > 5 }
println(greaterThan5)  // [6, 7, 8, 9, 10]
// filterNot
val odds = numbers.filterNot { it % 2 == 0 }
println(odds)  // [1, 3, 5, 7, 9]

filter는 원본을 건드리지 않고 조건을 만족하는 원소만 담은 새 리스트를 반환합니다. MutableList에서 원본 자체를 바꾸고 싶다면 removeAll { }이나 retainAll { }을 써야 하는데, filter를 호출하고 결과를 버리는 실수를 하면 아무 일도 일어나지 않은 것처럼 보입니다. IntelliJ는 이런 경우 반환값이 사용되지 않는다는 경고를 띄워 줍니다.

map (변환)

val numbers = listOf(1, 2, 3, 4, 5)
// 2배
val doubled = numbers.map { it * 2 }
println(doubled)  // [2, 4, 6, 8, 10]
// 제곱
val squared = numbers.map { it * it }
println(squared)  // [1, 4, 9, 16, 25]
// 문자열 변환
val strings = numbers.map { "숫자: $it" }
println(strings)  // [숫자: 1, 숫자: 2, ...]

map은 원소 개수를 유지한 채 각 원소를 다른 값으로 바꿉니다. 변환 도중 null이 나올 수 있고 그런 원소는 버리고 싶다면 mapNotNull { it.toIntOrNull() }처럼 쓰면 filter와 map 두 단계를 한 번에 처리할 수 있습니다. 인덱스가 필요하면 mapIndexed { index, value -> }를 씁니다.

reduce와 fold

val numbers = listOf(1, 2, 3, 4, 5)
// reduce (첫 번째 요소가 초기값)
val sum = numbers.reduce { acc, num -> acc + num }
println(sum)  // 15
// fold (초기값 지정)
val sum2 = numbers.fold(0) { acc, num -> acc + num }
println(sum2)  // 15
val product = numbers.fold(1) { acc, num -> acc * num }
println(product)  // 120

reduce는 누적값과 원소의 타입이 같아야 하고, 빈 컬렉션에서는 UnsupportedOperationException: Empty collection can't be reduced를 던집니다. 필터링 결과처럼 비어 있을 수 있는 컬렉션이라면 fold가 안전하고, reduceOrNull도 있습니다. 합계와 곱은 사실 sum(), sumOf { }로 더 짧게 쓸 수 있으므로 fold는 결과 타입이 원소 타입과 다를 때(문자열 이어 붙이기, Map 만들기 등) 진가를 발휘합니다.

groupBy (그룹화)

val words = listOf("apple", "banana", "avocado", "berry", "cherry")
// 첫 글자로 그룹화
val grouped = words.groupBy { it.first() }
println(grouped)
// {a=[apple, avocado], b=[banana, berry], c=[cherry]}
// 길이로 그룹화
val byLength = words.groupBy { it.length }
println(byLength)
// {5=[apple, berry], 6=[banana, cherry], 7=[avocado]}

groupBy는 Map<K, List<V>>를 반환하며, 키 순서는 해당 키가 처음 나온 순서를 따릅니다. 그룹마다 개수나 합계만 필요하다면 리스트를 만들었다가 다시 세는 것보다 뒤의 예제 3에서 쓰는 groupingBy { }.eachCount()나 fold가 중간 리스트를 만들지 않아 효율적입니다.

partition (분할)

val numbers = listOf(1, 2, 3, 4, 5, 6)
val (evens, odds) = numbers.partition { it % 2 == 0 }
println(evens)  // [2, 4, 6]
println(odds)   // [1, 3, 5]

partition은 filter와 filterNot을 따로 호출하는 것과 결과가 같지만 한 번만 순회합니다. 반환값은 Pair<List, List>라서 val (evens, odds) = 구조 분해로 받는 것이 관례입니다.


flatMap, zip, associate

flatMap

val lists = listOf(
    listOf(1, 2, 3),
    listOf(4, 5),
    listOf(6, 7, 8)
)
val flattened = lists.flatMap { it }
println(flattened)  // [1, 2, 3, 4, 5, 6, 7, 8]
// 변환 + 평탄화
val doubled = lists.flatMap { list -> list.map { it * 2 } }
println(doubled)  // [2, 4, 6, 8, 10, 12, 14, 16]

단순히 중첩 리스트를 펼치기만 할 때는 lists.flatten()이 의도를 더 잘 드러냅니다. flatMap은 “각 원소를 여러 개의 원소로 바꾼 다음 한 리스트로 합치는” 연산이라, 주문 목록에서 모든 주문 항목을 뽑아내는 orders.flatMap { it.items } 같은 코드에서 자주 보게 됩니다.

zip

val names = listOf("홍길동", "김철수", "이영희")
val ages = listOf(25, 30, 28)
val pairs = names.zip(ages)
println(pairs)  // [(홍길동, 25), (김철수, 30), (이영희, 28)]
// 커스텀 변환
val users = names.zip(ages) { name, age -> "$name ($age세)" }
println(users)  // [홍길동 (25세), 김철수 (30세), 이영희 (28세)]

zip은 길이가 다른 두 리스트를 받으면 짧은 쪽에 맞춰 남는 원소를 조용히 버립니다. 이름 목록과 나이 목록의 개수가 어긋나는 버그가 있어도 에러 없이 일부 데이터만 사라지므로, 길이가 같아야 하는 데이터라면 require(names.size == ages.size)로 먼저 확인하는 편이 안전합니다.

associate

val fruits = listOf("사과", "바나나", "오렌지")
// 첫 글자를 키로
val indexed = fruits.associateBy { it.first() }
println(indexed)  // {사=사과, 바=바나나, 오=오렌지}
// 커스텀 키-값
val lengths = fruits.associateWith { it.length }
println(lengths)  // {사과=2, 바나나=3, 오렌지=3}

associateBy는 람다 결과를 키로, 원소를 값으로 삼고, associateWith는 원소를 키로, 람다 결과를 값으로 삼습니다. 둘 다 키가 겹치면 에러 없이 나중 원소가 앞의 것을 덮어씁니다. 예를 들어 과일 목록에 “사과”와 “사과주스”가 있으면 associateBy { it.first() } 결과에는 “사과주스”만 남습니다. ID로 조회용 Map을 만들 때 중복 ID가 섞여 있으면 데이터가 조용히 사라지는 것이 이 함수의 전형적인 함정이라, 키가 겹칠 수 있다면 groupBy를 쓰는 편이 맞습니다.


Sequence로 지연 평가하기

val numbers = (1..1000000).asSequence()
    .filter { it % 2 == 0 }
    .map { it * 2 }
    .take(5)
    .toList()
println(numbers)  // [4, 8, 12, 16, 20]

List vs Sequence:

특징ListSequence
평가즉시지연
성능작은 데이터대용량 데이터
메모리모두 저장필요한 것만

List 연산은 단계별로 동작합니다. filter가 전체 원소를 검사해 새 리스트를 만든 다음, map이 그 리스트 전체를 변환해 또 새 리스트를 만듭니다. 반면 Sequence는 원소별로 동작해서, 첫 원소가 filter → map → take를 모두 통과한 뒤에 두 번째 원소를 처리합니다. 위 예제에서 take(5)가 다섯 개를 채우는 순간 나머지 99만여 개는 아예 검사하지 않으므로, List로 같은 코드를 쓰면 50만 개짜리 중간 리스트를 두 번 만드는 것과 비교해 차이가 큽니다.

하지만 “대용량이면 Sequence”라는 표의 요약은 절반만 맞습니다. Sequence는 원소마다 람다를 체인으로 호출하는 오버헤드가 있고, sorted()처럼 전체를 봐야 하는 연산이 끼면 결국 전부 메모리에 올립니다. 연산 단계가 한두 개뿐이거나 결과로 전체 원소가 필요하다면 List 연산이 더 빠른 경우도 많습니다. Sequence가 확실히 유리한 경우는 단계가 여러 개이면서 take, first, any처럼 중간에 끝낼 수 있는 연산이 있을 때, 또는 generateSequence로 무한한 값을 다룰 때입니다. 또 Sequence는 toList() 같은 최종 연산을 호출하기 전까지 아무것도 실행하지 않으므로, 최종 연산을 빠뜨리면 로그를 찍는 onEach조차 실행되지 않는다는 점도 알아 두세요.


사용자 필터링, 점수 통계, 단어 빈도수 예제

예제 1: 사용자 필터링

data class User(val name: String, val age: Int, val city: String)
fun main() {
    val users = listOf(
        User("홍길동", 25, "서울"),
        User("김철수", 30, "부산"),
        User("이영희", 28, "서울"),
        User("박민수", 35, "서울"),
        User("최영수", 22, "대구")
    )
    
    // 서울에 사는 30세 미만
    val result = users
        .filter { it.city == "서울" }
        .filter { it.age < 30 }
        .map { it.name }
    
    println(result)  // [홍길동, 이영희]
    
    // 도시별 그룹화
    val byCity = users.groupBy { it.city }
    byCity.forEach { (city, users) ->
        println("$city: ${users.map { it.name }}")
    }
}

filter를 두 번 이어 쓴 부분은 filter { it.city == "서울" && it.age < 30 } 한 번으로 합칠 수도 있습니다. 두 번 쓰면 중간 리스트가 하나 더 생기지만, 조건이 각각 의미 있는 이름을 가질 만큼 복잡하다면 읽기 쉬운 쪽을 택해도 이 규모에서는 성능 차이가 없습니다. byCity.forEach { (city, users) -> }에서 람다 매개변수 이름을 바깥 변수와 같은 users로 지은 것은 섀도잉이라 컴파일러가 Name shadowed: users 경고를 띄웁니다. 동작에는 문제가 없지만 바깥 users를 쓰려다 안쪽 값을 쓰는 실수로 이어지기 쉬우니 cityUsers처럼 다른 이름을 쓰는 편이 좋습니다.

예제 2: 점수 통계

fun main() {
    val scores = mapOf(
        "홍길동" to 85,
        "김철수" to 92,
        "이영희" to 78,
        "박민수" to 95,
        "최영수" to 88
    )
    
    // 평균
    val average = scores.values.average()
    println("평균: $average")
    
    // 최고점
    val maxScore = scores.maxByOrNull { it.value }
    println("최고점: ${maxScore?.key} (${maxScore?.value}점)")
    
    // 80점 이상
    val passed = scores.filter { it.value >= 80 }
    println("합격: ${passed.keys}")
}

Map에도 filter, maxByOrNull 같은 컬렉션 함수를 바로 쓸 수 있고, 이때 람다는 Map.Entry를 받아 it.key, it.value로 접근합니다. maxByOrNull이 OrNull로 끝나는 것은 빈 Map이면 null을 돌려주기 때문이며, 그래서 출력할 때 maxScore?.key처럼 안전 호출을 씁니다. 동점자가 있으면 먼저 나온 항목이 선택됩니다. values.average()는 빈 컬렉션에서 예외가 아니라 NaN을 반환하므로, 화면에 “평균: NaN”이 찍히는 일을 막으려면 비어 있는지 먼저 확인해야 합니다.

예제 3: 단어 빈도수

fun main() {
    val text = "apple banana apple cherry banana apple"
    val words = text.split(" ")
    
    // 단어 빈도수
    val frequency = words.groupingBy { it }.eachCount()
    println(frequency)
    // {apple=3, banana=2, cherry=1}
    
    // 가장 많이 나온 단어
    val mostFrequent = frequency.maxByOrNull { it.value }
    println("가장 많은 단어: ${mostFrequent?.key} (${mostFrequent?.value}번)")
}

groupingBy { it }.eachCount()는 groupBy { it }.mapValues { it.value.size }와 결과가 같지만, 단어별 리스트를 만들지 않고 개수만 세기 때문에 텍스트가 클수록 유리합니다. 실제 텍스트로 이 코드를 돌려 보면 split(" ")이 가장 먼저 문제를 일으킵니다. 공백이 두 칸 연속이면 빈 문자열이 단어로 세어지고, “Apple”과 “apple,“이 서로 다른 단어가 됩니다. text.lowercase().split(Regex("\\W+")).filter { it.isNotBlank() }처럼 정규화하는 단계를 먼저 넣어야 하며, \W는 ASCII 기준이라 한글 텍스트라면 Regex("[^\\p{L}\\p{N}]+")처럼 유니코드 문자 클래스를 써야 합니다.


컬렉션 요약

  1. List: 순서 있는 컬렉션
  2. Set: 중복 없는 컬렉션
  3. Map: 키-값 쌍
  4. 연산: filter, map, reduce, groupBy
  5. Sequence: 지연 평가

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. reduce와 fold는 무엇이 다른가요?

A. reduce는 첫 번째 요소를 초기값으로 쓰고, fold는 초기값을 직접 받습니다. 그래서 빈 리스트에 reduce를 호출하면 UnsupportedOperationException이 나지만 fold는 초기값을 그대로 돌려줍니다. 또 fold는 초기값의 타입으로 결과 타입을 정할 수 있어, 숫자 리스트를 문자열이나 Map으로 누적하는 것처럼 요소와 결과 타입이 다를 때도 쓸 수 있습니다.