코틀린 베이직 KB 26 컬렉션 — List 와 MutableList 는 다른 타입이다
코틀린 베이직 · 26/26

KB 26 컬렉션 — List 와 MutableList 는 다른 타입이다

gabury1고친 사람 github-actions[bot]

0. add 가 없는 리스트

자바에서 List.of 로 만든 리스트에 원소를 더하면 어떻게 되는지 기억하시나요. 컴파일은 됩니다. 문제는 실행입니다.

Java
import java.util.List;

public class First {
    public static void main(String[] args) {
        List<String> names = List.of("kim");
        names.add("lee");
        System.out.println(names);
    }
}
터미널
$ javac -d jout First.java
$ java -cp jout First
Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:142)
	at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:147)
	at First.main(First.java:6)

javac 는 아무 말이 없었고, 6번째 줄 add 에서 UnsupportedOperationException 이 났습니다. 「이 리스트는 그 연산을 지원하지 않는다」는 예외입니다. List 인터페이스에는 add 가 있는데, List.of 가 돌려준 객체가 그 호출을 거절한 것입니다.

같은 일을 코틀린으로 썼습니다.

Kotlin
fun main() {
    val names = listOf("kim")
    names.add("lee") // 컴파일 오류
    println(names)
}
  • fun main() — 프로그램이 시작하는 함수입니다. 클래스 없이 파일 바로 아래에 둡니다
  • val — 다시 대입할 수 없는 변수입니다. 타입을 안 적으면 오른쪽 값에서 알아냅니다
  • listOf("kim") — 리스트를 만드는 코틀린 표준 함수입니다. 자바의 List.of 에 해당합니다

코틀린 컴파일러 kotlinc 에 넣었습니다. -d out 은 결과물을 out 폴더에 쓰라는 옵션입니다.

터미널
$ kotlinc First.kt -d out
First.kt:3:11: error: unresolved reference 'add'.
    names.add("lee") // 컴파일 오류
          ^^^

실행까지 가지 못했습니다. unresolved reference 'add' 는 「add 라는 이름을 찾을 수 없다」는 뜻입니다. 거절당한 게 아니라, listOf 가 돌려준 타입에는 add 라는 함수가 아예 없습니다.

KB 05 val 과 var — final 과 무엇이 같고 다른가 에서 mutableListOf 를 listOf 로 바꾸면 add 가 에러가 된다고 짧게 짚었습니다. 이 편은 그 이야기의 본편입니다. 1절이 리스트 타입이 둘로 갈리는 까닭과 그 함정, 2절이 만드는 법, 3절이 자주 쓰는 연산, 4절이 자바와 오갈 때입니다. 5절에서 연산을 잇는 체인의 비용을 셉니다. 출력은 전부 Kotlin 2.4.10, JDK 21 에서 직접 돌린 결과입니다.

1. 읽기 전용과 변경 가능 — List 와 MutableList

코틀린의 컬렉션(여러 값을 담는 리스트·셋·맵을 묶어 부르는 말)은 인터페이스가 두 벌입니다. List 는 읽는 함수(get·size·contains)만 가진 읽기 전용(read-only) 타입입니다. MutableList 는 List 를 물려받고 add·remove·set 을 더한 변경 가능(mutable) 타입입니다(공식 문서).

listOf 는 List 를 돌려주고, mutableListOf 는 MutableList 를 돌려줍니다. 0절의 names 가 List 라서 add 가 안 보였던 것입니다.

flowchart TD
    C["Collection · 읽기: size · contains"] --> L["List · 읽기: get · indexOf"]
    C --> MC["MutableCollection · add · remove"]
    L --> ML["MutableList · add · remove · set"]
    MC --> ML

셋·맵도 같은 모양입니다. Set 과 MutableSet, Map 과 MutableMap 이 짝을 이룹니다. 자바는 java.util.List 하나에 읽기와 쓰기를 다 넣어 두고, 쓰기를 막고 싶으면 실행 중에 예외를 던지는 객체를 돌려줍니다. 코틀린은 그 구분을 타입으로 옮겨서 컴파일 때 막습니다.

대입은 한 방향만 된다

MutableList 가 List 를 물려받았으니, MutableList 를 List 변수에 담는 것은 됩니다. 반대는 어떨까요.

Kotlin
fun main() {
    val m: MutableList<Int> = mutableListOf(1)
    val r: List<Int> = m
    // ↓ 컴파일 오류
    val back: MutableList<Int> = r
}
  • val m: MutableList<Int> — 이름 뒤 콜론 다음이 타입입니다. <Int> 는 원소 타입으로, 자바의 List<Integer> 의 <Integer> 와 같은 쓰임입니다
터미널
$ kotlinc Types.kt -d out
Types.kt:5:32: error: initializer type mismatch: expected 'MutableList<Int>', actual 'List<Int>'.
    val back: MutableList<Int> = r
                               ^

3번째 줄은 통과하고 5번째 줄만 막혔습니다. r 이 가리키는 객체는 방금 mutableListOf 로 만든 것이지만, 컴파일러는 변수의 타입 List<Int> 만 봅니다. 읽기 전용 타입으로 한 번 넘긴 값을 변경 가능 타입으로 되돌려 받을 수는 없습니다.

읽기 전용은 불변이 아니다

이름 때문에 가장 많이 헷갈리는 대목입니다. List 는 「이 변수로는 바꿀 수 없다」는 뜻이지, 「이 리스트는 절대 안 바뀐다」는 뜻이 아닙니다. 한 번 만들면 누구도 내용을 못 바꾸는 성질은 불변(immutable)이라고 따로 부릅니다.

실무에서 흔한 모양으로 확인했습니다. 장바구니 클래스가 안에서는 MutableList 로 원소를 더하고, 밖에는 List 로만 내보냅니다.

Kotlin
class Cart {
    private val _items = mutableListOf<String>()
    val items: List<String> get() = _items
    fun add(item: String) { _items.add(item) }
}

fun main() {
    val cart = Cart()
    cart.add("apple")
    val seen = cart.items
    val snapshot = cart.items.toList()
    cart.add("pear")
    println(seen)       // [apple, pear]
    println(snapshot)   // [apple]
    (seen as MutableList<String>).add("hack")
    // ↓ [apple, pear, hack]
    println(cart.items)
}
  • mutableListOf<String>() — 원소가 없으니 원소 타입을 <String> 으로 직접 적었습니다
  • val items: List<String> get() = _items — 읽을 때마다 _items 를 돌려주는 프로퍼티입니다. 프로퍼티는 필드와 게터를 한 이름으로 묶은 선언이고, get() 은 그 게터를 직접 정의한 것입니다(KB 05 6절)
  • toList() — 지금 원소를 담은 새 리스트를 만들어 돌려줍니다
  • seen as MutableList<String> — as 는 자바의 캐스트 (MutableList<String>) seen 에 해당합니다
터미널
$ kotlinc Shared.kt -d out
$ kotlin -cp out SharedKt

seen 은 apple 만 있을 때 받아 둔 변수인데, 나중에 더한 pear 까지 보였습니다. seen 과 _items 가 같은 리스트 객체를 가리키기 때문입니다. List 타입은 「이 변수로는 읽기만 한다」는 표시일 뿐이고, 객체 자체는 _items 쪽에서 계속 바뀝니다.

toList() 로 받은 snapshot 은 [apple] 에 머물렀습니다. 새 리스트로 복사해 둔 것이라 원본이 바뀌어도 따라가지 않습니다.

마지막 두 줄이 더 불편한 사실을 보여 줍니다. as 로 MutableList 라고 우기자 add 가 컴파일되고, 돌아가고, cart 안의 리스트까지 바뀌었습니다. 속의 객체가 원래 변경 가능한 리스트였기 때문입니다. 읽기 전용은 컴파일러가 변수 타입으로 거는 약속이지, 객체를 잠그는 장치가 아닙니다.

그래서 쓰는 법이 갈립니다. 자기 코드끼리 「이 함수는 리스트를 안 바꾼다」를 알리는 데는 List 로 충분합니다. 받아 간 쪽이 나중에 바뀐 내용을 보면 안 되는 경우, 곧 그때의 값을 떠 두어야 하는 경우라면 toList() 로 복사해 넘깁니다.

정리하면, List 와 MutableList 는 다른 타입이고 MutableList → List 로만 대입됩니다. 다만 List 는 읽기 전용일 뿐 불변이 아니어서, 같은 객체를 MutableList 로 쥔 쪽이 바꾸면 그대로 보입니다.

2. 만들기 — listOf 부터 buildList·mapOf 까지

리스트·맵·셋을 만드는 함수는 이름만 봐도 무엇이 나오는지 알 수 있게 지어져 있습니다. 이 절에서는 자주 쓰는 여섯 가지를 한 파일에서 만들고 찍습니다.

Kotlin
fun main() {
    val a = listOf(1, 2, 3)
    val b = mutableListOf(1, 2, 3)
    b.add(4)
    println(b)        // [1, 2, 3, 4]
    val c = emptyList<String>()
    println(c.size)   // 0
    val d = buildList {
        add(0)
        if (a.size > 2) add(9)
        addAll(a)
    }
    println(d)        // [0, 9, 1, 2, 3]
    val p = "a" to 1
    println(p)        // (a, 1)
    println(p.first)  // a
    println(p.second) // 1
    val m = mapOf("a" to 1, "b" to 2)
    println(m["a"])   // 1
    println(m["z"])   // null
    val mm = mutableMapOf("a" to 1)
    mm["b"] = 2
    println(mm)       // {a=1, b=2}
    println(setOf(1, 2, 2)) // [1, 2]
}
  • emptyList<String>() — 빈 읽기 전용 리스트입니다. 원소가 없어 타입을 알아낼 수 없으니 <String> 을 적습니다
  • buildList { ... } — 중괄호 안에서 add 로 채우고, 다 채운 결과를 읽기 전용 List 로 돌려줍니다. 중괄호는 코틀린의 람다(이름 없이 그 대목에 바로 적는 함수)입니다
  • m["a"] — 맵에서 키 "a" 의 값을 꺼냅니다. 자바의 m.get("a") 입니다. 키가 없으면 null 입니다
  • mm["b"] = 2 — 자바의 mm.put("b", 2) 입니다
터미널
$ kotlinc Make.kt -d out
$ kotlin -cp out MakeKt

값은 전부 줄 옆에 적었습니다. 셋만 짚겠습니다.

buildList 는 「조건에 따라 원소를 넣었다 뺐다 하며 만든 다음, 다 만든 뒤에는 읽기 전용으로 쓰고 싶을 때」를 위한 함수입니다. 자바에서 ArrayList 를 만들어 채운 뒤 List 타입으로 내보내던 두 단계를 한 식으로 줄입니다. 돌려준 타입이 정말 List 인지는 d.add(2) 를 한 줄 더해 확인했습니다. 0절과 같은 unresolved reference 'add'. 에러가 났습니다.

"a" to 1 이 맵을 만드는 핵심입니다. to 는 두 값을 묶어 Pair 를 만드는 함수이고, 점과 괄호 없이 a to b 로 쓰게 선언된 중위 함수(infix function)입니다(공식 문서). Pair 는 값 둘을 first·second 로 담는 작은 클래스입니다. mapOf 는 이 Pair 들을 받아 키와 값으로 씁니다.

setOf(1, 2, 2) 는 겹친 2 를 하나로 줄여 [1, 2] 를 찍었습니다. mapOf 로 만든 맵은 넣은 순서대로 찍힙니다(공식 문서).

만드는 함수를 모으면 이렇습니다.

만들 것 읽기 전용 변경 가능 자바에서 흔히 쓰던 것
리스트 listOf(...) · emptyList() · buildList { } mutableListOf(...) List.of · new ArrayList<>()
셋 setOf(...) mutableSetOf(...) Set.of · new HashSet<>()
맵 mapOf("a" to 1) mutableMapOf("a" to 1) Map.of("a", 1) · new HashMap<>()

정리하면, 이름에 mutable 이 붙으면 변경 가능 타입, 안 붙으면 읽기 전용 타입이 나옵니다. 맵은 키 to 값 으로 만든 Pair 를 넘깁니다.

3. 자주 쓰는 연산 — stream() 도 collect 도 없다

자바에서 리스트를 거르고 바꾸려면 stream() 을 열고 collect 나 toList() 로 닫습니다. 코틀린에서는 리스트에 연산이 바로 붙어 있습니다. 이 절에서는 주문 목록 하나에 일곱 가지 연산을 걸어 봅니다.

Kotlin
data class Order(val user: String, val amount: Int)

fun main() {
    val orders = listOf(
        Order("kim", 300),
        Order("lee", 100),
        Order("kim", 200)
    )
    // ↓ [kim, kim]
    println(orders.filter { it.amount >= 200 }.map { it.user })
    // ↓ null
    println(orders.firstOrNull { it.user == "park" })
    // ↓ true
    println(orders.any { it.amount > 250 })
    // ↓ 600
    println(orders.sumOf { it.amount })
    println(orders.groupBy { it.user })
    println(orders.associateBy { it.user })
}
  • data class Order(...) — 괄호 안 값으로 equals·toString 을 만들어 주는 클래스입니다. 찍으면 Order(user=kim, amount=300) 처럼 나옵니다(KB 23 data class — 한 줄이 만드는 다섯 가지)
  • { it.amount >= 200 } — 람다입니다. 매개변수가 하나면 이름을 안 적고 it 으로 부릅니다
  • firstOrNull { } — 조건에 맞는 첫 원소, 없으면 null 입니다
  • sumOf { } — 원소마다 뽑은 값을 더합니다

뒤의 두 줄은 한 줄이 길어서 옆에 붙이지 않았습니다. 앞 네 줄은 줄 옆의 값과 같으니 ... 로 줄였습니다.

터미널
$ kotlinc Ops.kt -d out
$ kotlin -cp out OpsKt
...
{kim=[Order(user=kim, amount=300), Order(user=kim, amount=200)], lee=[Order(user=lee, amount=100)]}
{kim=Order(user=kim, amount=200), lee=Order(user=lee, amount=100)}

groupBy 는 키마다 원소 리스트를 모읍니다. kim 의 주문 둘이 한 리스트에 들어갔습니다. associateBy 는 키마다 원소 하나를 둡니다. 그런데 kim 이 둘이라 어느 쪽이 남았는지 보면, 뒤에 온 amount=200 입니다.

자바 스트림으로 옮기면 이렇게 짝이 맞습니다. 표의 앞 네 줄은 OpsFull.java 로 옮겨 돌렸고 같은 네 값을 찍었습니다.

하려는 일 자바 스트림 코틀린
거르고 바꾸기 stream().filter(...).map(...).toList() filter { }.map { }
조건에 맞는 첫 원소 filter(...).findFirst().orElse(null) firstOrNull { }
하나라도 맞나 anyMatch(...) any { }
합계 mapToInt(...).sum() sumOf { }
키마다 리스트로 묶기 collect(Collectors.groupingBy(...)) groupBy { }
키마다 하나로 collect(Collectors.toMap(..., o -> o)) associateBy { }

코틀린 쪽에는 stream() 도 끝을 닫는 collect 도 없습니다. filter 가 곧바로 새 List 를 돌려주니, 다음 연산을 그 리스트에 바로 잇습니다. 원래 orders 는 손대지 않고 매번 새 리스트나 맵을 만듭니다.

키가 겹칠 때 자바는 멈추고 코틀린은 덮어쓴다

표의 마지막 줄은 짝이 맞아 보여도 동작이 다릅니다. 같은 주문 목록을 자바로 묶었습니다.

Java
import java.util.List;
import java.util.stream.Collectors;

public class Ops {
    record Order(String user, int amount) {}

    public static void main(String[] args) {
        List<Order> orders = List.of(
            new Order("kim", 300),
            new Order("lee", 100),
            new Order("kim", 200));
        System.out.println(orders.stream()
            .collect(Collectors.groupingBy(Order::user)));
        System.out.println(orders.stream()
            .collect(Collectors.toMap(Order::user, o -> o)));
    }
}

record 는 자바 16 부터 있는 값 클래스로, 코틀린의 data class 에 해당합니다. 스택 트레이스(예외가 지나온 메서드 호출 목록)의 가운데 여덟 줄은 ... 로 줄였습니다.

터미널
$ javac -d jout Ops.java
$ java -cp jout Ops
{lee=[Order[user=lee, amount=100]], kim=[Order[user=kim, amount=300], Order[user=kim, amount=200]]}
Exception in thread "main" java.lang.IllegalStateException: Duplicate key kim (attempted merging values Order[user=kim, amount=300] and Order[user=kim, amount=200])
	at java.base/java.util.stream.Collectors.duplicateKeyException(Collectors.java:135)
...
	at Ops.main(Ops.java:15)

자바의 toMap 은 키 kim 이 두 번 나오자 Duplicate key kim 으로 멈췄습니다. 코틀린의 associateBy 는 멈추지 않고 뒤의 값으로 덮어썼습니다. 공식 문서도 키가 같으면 마지막 원소가 남는다고 적습니다(공식 문서).

자바에서 toMap 이 예외로 알려 주던 중복을 코틀린은 조용히 넘깁니다. 키가 겹칠 수 있는 데이터라면 associateBy 대신 groupBy 로 묶어 개수를 확인하는 편이 안전합니다.

한 가지 더, 첫 줄의 순서도 갈렸습니다. 자바 groupingBy 는 lee 가 먼저, 코틀린 groupBy 는 원래 목록에 나온 순서대로 kim 이 먼저입니다. 자바 groupingBy 는 돌려주는 맵의 종류도 순서도 약속하지 않습니다(공식 문서). 코틀린 groupBy 는 키가 처음 나온 순서를 지킵니다(공식 문서).

정리하면, 코틀린 컬렉션 연산은 리스트에 바로 붙어 새 리스트·맵을 돌려줍니다. 이름은 자바 스트림과 거의 짝이 맞지만, associateBy 는 중복 키를 덮어쓴다는 점이 toMap 과 다릅니다.

4. 자바와 오갈 때 — 읽기 전용은 코틀린 안에서만의 약속

1절에서 읽기 전용은 컴파일러의 약속이라고 했습니다. 그러면 그 약속을 모르는 자바 코드에 넘기면 어떻게 될까요. 이 절에서는 원소를 더하는 메서드와 첫 원소를 바꾸는 메서드를 자바로 만들고, 코틀린 List 를 넘깁니다.

Java
import java.util.List;

public class JavaSide {
    public static void addOne(List<Integer> xs) {
        xs.add(99);
    }

    public static void setFirst(List<Integer> xs) {
        xs.set(0, 99);
    }
}
Kotlin
fun main() {
    val ro = listOf(1, 2, 3)
    // ↓ java.util.Arrays$ArrayList
    println(ro.javaClass.name)
    JavaSide.setFirst(ro)
    println(ro)   // [99, 2, 3]
    val back = mutableListOf(1, 2, 3)
    val view: List<Int> = back
    JavaSide.addOne(view)
    println(view) // [1, 2, 3, 99]
    JavaSide.addOne(ro)
}
  • ro.javaClass.name — 실행 중인 객체의 JVM 클래스 이름입니다. 자바의 ro.getClass().getName() 에 해당합니다

자바 클래스를 먼저 컴파일하고, 그 결과를 -cp(클래스를 찾을 경로)로 넘겨 코틀린을 컴파일했습니다.

터미널
$ javac -d jout JavaSide.java
$ kotlinc Pass.kt -cp jout -d out
$ kotlin -cp out:jout PassKt
java.util.Arrays$ArrayList
[99, 2, 3]
[1, 2, 3, 99]
Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.AbstractList.add(AbstractList.java:155)
	at java.base/java.util.AbstractList.add(AbstractList.java:113)
	at JavaSide.addOne(JavaSide.java:5)
	at PassKt.main(Pass.kt:11)
	at PassKt.main(Pass.kt)

컴파일에서는 아무것도 막히지 않았습니다. 코틀린의 List 와 MutableList 는 JVM 에서 둘 다 java.util.List 로 보입니다(공식 문서). 자바 쪽 매개변수가 java.util.List 이니 add 든 set 이든 컴파일됩니다.

결과는 세 갈래로 갈렸습니다.

넘긴 것 자바가 부른 것 결과
listOf(1, 2, 3) set(0, 99) 바뀌었다 → [99, 2, 3]
mutableListOf 를 List 로 쥔 view add(99) 더해졌다 → [1, 2, 3, 99]
listOf(1, 2, 3) add(99) UnsupportedOperationException

첫 줄이 가장 놀랍습니다. listOf 로 만든 읽기 전용 리스트가 자바 쪽 set 으로 바뀌었습니다. 까닭은 첫 출력의 클래스 이름에 있습니다. 원소가 여럿인 listOf 는 자바의 Arrays.asList 와 같은 Arrays$ArrayList 를 돌려줍니다. 이 리스트는 크기만 고정이라 add 는 거절하고 set 은 받아 줍니다(Arrays.asList 문서).

0절의 자바 List.of 는 스택 트레이스에 보이듯 ImmutableCollections 쪽의 불변 리스트라 add 부터 거절했습니다. 코틀린 listOf 는 그만큼 단단하지 않습니다. 코틀린 안에서 set 을 못 부르게 막을 뿐입니다.

자바가 돌려준 리스트는 둘 다로 받는다

반대 방향도 한 번 봅니다. 자바 메서드 Maker.make() 가 new ArrayList<>(List.of(1)) 을 List<Integer> 로 돌려줄 때, 코틀린은 그것을 무슨 타입으로 볼까요. 일부러 Int 변수에 넣어 에러 문구로 물었습니다.

Kotlin
fun main() {
    val r: List<Int> = Maker.make()
    val m: MutableList<Int> = Maker.make()
    m.add(2)
    println(m)
    val s: Int = Maker.make() // 컴파일 오류
}
터미널
$ javac -d jout Maker.java
$ kotlinc Recv.kt -cp jout -d out
Recv.kt:6:16: error: initializer type mismatch: expected 'Int', actual '(Mutable)List<Int!>!'.
    val s: Int = Maker.make() // 컴파일 오류
               ^

에러는 6번째 줄 하나뿐입니다. 2·3번째 줄에서 같은 값을 List 로도 MutableList 로도 받았는데 둘 다 통과했습니다. 마지막 줄을 뺀 파일은 컴파일되고 [1, 2] 를 찍었습니다.

(Mutable)List<Int!>! 는 「읽기 전용인지 변경 가능인지 모르는 리스트」라는 표시입니다. 자바에는 그 구분이 없으니 코틀린이 어느 쪽으로 받든 허락합니다. 끝의 ! 는 널 검사를 건너뛴 플랫폼 타입 표시로, KB 17 플랫폼 타입 — 자바에서 온 값은 검사를 건너뛴다 에서 다뤘습니다.

실무 규칙

자바 라이브러리에 코틀린 리스트를 넘길 때는 「읽기 전용이니 안전하다」고 기대하지 않습니다. 넘긴 뒤에도 원래 리스트가 바뀌면 안 된다면 toList() 로 복사한 것을 넘깁니다. 자바 쪽의 변경 시도를 예외로 막아야 한다면 자바 표준의 Collections.unmodifiableList 로 감싸 넘기는 방법이 있습니다(공식 문서).

자바에게서 받는 쪽은 쓰려는 대로 고릅니다. 읽기만 할 거면 List 로 받아 코틀린 안에서 실수로 바꾸는 일을 막습니다. MutableList 로 받을 때는 그 객체가 정말 add 를 받아 주는지 자바 쪽 구현을 확인해야 합니다. 타입이 허락해도 0절처럼 실행에서 거절할 수 있습니다.

정리하면, 코틀린의 읽기 전용은 자바 코드에 닿는 순간 사라집니다. listOf 로 만든 리스트도 자바에서 set 으로 바뀝니다. 자바가 돌려준 리스트는 List 로도 MutableList 로도 받을 수 있습니다.

5. 성능 — 체인은 단계마다 중간 리스트를 만든다

3절에서 filter { }.map { } 처럼 연산을 이어 붙였습니다. 이렇게 연산을 이어 붙인 것을 체인이라고 부릅니다. 체인의 각 단계는 새 리스트를 돌려주므로, 단계가 셋이면 리스트도 셋이 생깁니다. 대부분은 문제가 안 되지만, 원소가 많은데 앞에서 몇 개만 쓰고 끊는 경우에는 헛일이 커집니다.

이 절에서는 1부터 1000 까지를 두 배로 만들고(map), 3의 배수만 남기고(filter), 앞의 셋만 가져갑니다(take(3)). map 안에 카운터를 넣어 원소가 몇 번 map 을 지나가나를 셉니다. 같은 체인을 리스트로 한 번, asSequence() 를 붙여 한 번 돌립니다.

Kotlin
fun main() {
    val nums = (1..1_000).toList()

    var listCount = 0
    val a = nums
        .map { listCount++; it * 2 }
        .filter { it % 3 == 0 }
        .take(3)
    println(a)         // [6, 12, 18]
    println(listCount) // 1000

    var seqCount = 0
    val b = nums.asSequence()
        .map { seqCount++; it * 2 }
        .filter { it % 3 == 0 }
        .take(3)
        .toList()
    println(b)         // [6, 12, 18]
    println(seqCount)  // 9
}
  • (1..1_000).toList() — 1..1_000 은 1부터 1000 까지의 범위이고(KB 10 범위와 반복 — for 문에 세미콜론이 없다), toList() 로 리스트로 만듭니다. 밑줄은 자릿수를 끊어 읽는 표시입니다
  • { listCount++; it * 2 } — 세미콜론으로 두 식을 한 줄에 적었습니다. 람다는 마지막 식 it * 2 를 돌려줍니다. 바깥 var 를 람다 안에서 바꿀 수 있습니다
  • asSequence() — 리스트를 Sequence 로 바꿉니다. 아래에서 풉니다
  • take(3) — 앞에서 세 개만 가져갑니다
터미널
$ kotlinc Chain.kt -d out
$ kotlin -cp out ChainKt

결과 리스트는 둘 다 [6, 12, 18] 인데, map 을 지나간 원소 수는 1000 대 9 입니다. 리스트 쪽은 map 이 1000 개를 다 바꿔 1000 칸짜리 리스트를 만들고, filter 가 그것을 다 훑어 또 리스트를 만든 뒤에야 take(3) 이 앞의 셋을 가져갑니다. 시퀀스 쪽은 3, 6, 9 번째 원소에서 답 셋이 채워지자 멈췄습니다.

시퀀스는 원소를 하나씩 끝까지 보낸다

Sequence 는 연산을 걸어 둘 뿐 바로 계산하지 않고, 결과가 필요해질 때 원소를 하나씩 단계 끝까지 흘려보내는 컬렉션 타입입니다(공식 문서). 필요할 때까지 계산을 미루는 방식을 지연 평가(lazy evaluation)라고 부릅니다. 자바 스트림이 같은 방식으로 돕니다.

순서로 확인했습니다. 원소 두 개짜리 리스트에 map 과 filter 를 걸고, 람다마다 찍게 했습니다. 시퀀스 쪽은 체인을 만든 뒤 클래스 이름을 찍고, 그다음에 toList() 를 불렀습니다. 무엇이 어떤 순서로 찍힐까요.

Kotlin
fun main() {
    listOf(1, 2)
        .map { println("map $it"); it }
        .filter { println("filter $it"); true }
    println("--")
    val lazy = listOf(1, 2).asSequence()
        .map { println("map $it"); it }
        .filter { println("filter $it"); true }
    println(lazy.javaClass.name)
    println("--")
    lazy.toList()
}
터미널
$ kotlinc Lazy.kt -d out
$ kotlin -cp out LazyKt
map 1
map 2
filter 1
filter 2
--
kotlin.sequences.FilteringSequence
--
map 1
filter 1
map 2
filter 2

리스트는 단계별로 돕니다. map 이 원소 둘을 다 처리하고 나서 filter 가 돕니다. 시퀀스는 원소별로 돕니다. 1 이 map 과 filter 를 다 지난 뒤에 2 가 들어갑니다. take(3) 이 셋을 채우자 멈출 수 있었던 까닭이 이 순서입니다.

가운데 부분도 봐야 합니다. 시퀀스에 map 과 filter 를 걸어 둔 시점에는 아무것도 안 찍혔습니다. 대신 FilteringSequence 라는 객체가 생겼습니다. 계산은 toList() 를 부를 때 시작됐습니다. toList() 처럼 결과를 실제로 만들어 내는 연산을 최종 연산(terminal operation)이라고 부릅니다. 최종 연산을 안 부르면 시퀀스 체인은 아무 일도 하지 않습니다.

작은 리스트는 리스트가 낫다

그러면 늘 시퀀스를 쓰면 될까요. 그렇지 않습니다. 시퀀스도 공짜가 아닙니다.

위 출력의 FilteringSequence 처럼, 시퀀스는 단계마다 앞 단계를 감싸는 객체를 하나씩 만듭니다. 그리고 리스트의 map·filter 는 inline 함수라 넘긴 람다가 호출부에 펼쳐지지만, 시퀀스의 map·filter 는 inline 이 아니라 람다가 객체로 넘어갑니다(공식 문서). inline 과 람다 객체의 관계는 KB 25 람다와 함수 타입 — 함수를 값으로 넘긴다 에서 봤습니다. 원소마다 이 객체들을 거쳐 한 칸씩 넘기는 비용도 붙습니다.

공식 문서도 시퀀스의 지연 방식에 드는 비용이 작은 컬렉션이나 단순한 계산에서는 클 수 있다고 적습니다(공식 문서). 이 편에서 시간을 재지는 않았습니다. 판단 기준을 조건으로 적으면 이렇습니다.

조건 고를 것 까닭
원소가 적다 리스트 중간 리스트가 작고, 시퀀스가 만드는 객체가 오히려 더 붙는다
단계가 하나 리스트 중간 리스트가 생기지 않는다
원소가 많고, 단계가 여럿이고, take·first 처럼 앞에서 끊는다 시퀀스 끊은 뒤의 원소는 아예 계산하지 않는다
원소가 많고 단계가 여럿이지만 끝까지 다 쓴다 상황에 따라 갈린다(미측정) 계산 횟수는 같다. 중간 리스트가 줄어드는 대신 시퀀스 객체가 붙는다

셋째 줄이 시퀀스가 확실히 이기는 경우입니다. 위 실측이 바로 그 모양이었습니다.

크기와 관련해 하나 더 있습니다. 원소 개수를 미리 알고 ArrayList 를 채운다면 ArrayList<Int>(1_000) 처럼 처음 크기를 넘겨 둘 수 있습니다. 넘기지 않으면 ArrayList 는 원소가 찰 때마다 스스로 속의 배열을 키웁니다(공식 문서). 자바에서 new ArrayList<>(n) 을 쓰던 것과 같은 판단입니다.

정리하면, 리스트 체인은 단계마다 중간 리스트를 만들고, 시퀀스는 원소를 하나씩 끝까지 보내다 필요한 만큼에서 멈춥니다. 앞에서 끊는 긴 체인이면 시퀀스, 작은 리스트면 리스트가 낫습니다.

6. 한 장 요약

코틀린은 리스트를 읽기 전용 List 와 변경 가능 MutableList 두 타입으로 가른다. listOf 는 List 를 돌려주니 add 가 컴파일 에러다. MutableList 는 List 에 담을 수 있지만 거꾸로는 안 된다. 읽기 전용은 불변이 아니다 — 같은 객체를 MutableList 로 쥔 쪽이 바꾸면 보이고, 자바 코드에 넘기면 약속이 사라져 listOf 리스트도 set 으로 바뀐다. 연산은 리스트에 바로 붙어 새 리스트를 돌려주고, 체인은 단계마다 중간 리스트를 만든다. 원소가 많고 앞에서 끊는 긴 체인이면 asSequence() 가 확실히 이기고, 작은 리스트는 리스트가 낫다.

주제 자바 코틀린 확인한 방법
불변 리스트에 add List.of 는 컴파일되고 실행에서 UnsupportedOperationException listOf 는 add 가 없어 컴파일 에러 javac 실행 · kotlinc 에러
읽기·쓰기 구분 java.util.List 하나 List · MutableList 두 타입 kotlinc 에러
List → MutableList 대입 — 컴파일 에러 kotlinc 에러
읽기 전용의 뜻 — 불변 아님. 같은 객체를 다른 쪽이 바꾸면 보인다 실행 출력
그때 값을 복사해 두기 new ArrayList<>(list) toList() 실행 출력
조건부로 채운 뒤 읽기 전용 ArrayList 를 채워 List 로 buildList { } 실행 출력 · kotlinc 에러
맵 만들기 Map.of("a", 1) mapOf("a" to 1), to 가 Pair 를 만든다 실행 출력
거르고 바꾸기 stream().filter().map().toList() filter { }.map { } 양쪽 실행 출력
키가 겹칠 때 toMap 이 IllegalStateException associateBy 가 뒤의 값으로 덮어쓴다 양쪽 실행 출력
자바에 넘긴 listOf set 은 되고 add 는 예외 컴파일은 막지 않는다 실행 출력
자바가 돌려준 List — (Mutable)List<Int!>!, List·MutableList 둘 다로 받는다 kotlinc 에러
체인의 중간 계산 스트림은 원소별 리스트는 단계별 1000 번, 시퀀스는 원소별 9 번 카운터 · 출력 순서

관련 항목

코틀린 컬렉션을 이루는 인터페이스

컬렉션 · List · MutableList · Set · MutableSet · Map · MutableMap · Collection · MutableCollection

읽기 전용과 맞세워지는 성질

읽기 전용 컬렉션 · 불변 컬렉션 · 불변 객체 · 가변 객체 · 방어적 복사 · 얕은 복사

컬렉션을 만드는 표준 함수

listOf · mutableListOf · buildList · emptyList · mapOf · setOf · Pair · 중위 함수

컬렉션에 거는 연산

filter · map · firstOrNull · any · sumOf · groupBy · associateBy · 람다 · 고차 함수

같은 일을 하는 자바 쪽 도구

자바 스트림 · Collectors · List.of · Arrays.asList · Collections.unmodifiableList · UnsupportedOperationException · record

자바와 오가며 바뀌는 타입

매핑 타입 · 플랫폼 타입 · java.util.List · ArrayList · LinkedHashMap · HashMap

체인 비용을 줄이는 계산 방식

Sequence · 지연 평가 · 중간 연산 · 최종 연산 · asSequence · inline 함수 · 객체 할당