하나 이상의 처리 단계를 가진 경우 시퀀스를 써라

많은 사람이 Iterable, Sequence의 차이를 잊어버린다. 사실 정의가 거의 동일해서 충분히 이해할 수 있는 일이다.

interface Iterable<out T> {
    operator fun iterator(): Iterator<T>
}

interface Sequence<out T> {
    operator fun iterator(): Iterator<T>
}

둘은 완전히 다른 목적으로 설계되서 완전히 다른 형태로 동작한다. Sequence는 지연처리된다. 따라서 시퀀스 처리 함수들을 쓰면 데코레이터 패턴으로 꾸며진 새 시퀀스가 리턴된다.

최종 계산은 toList 또는 count 등의 최종 연산이 이뤄질 때 수행된다. 반면 Iterable은 처리 함수를 쓸 때마다 연산이 이뤄져 List가 만들어진다.

정리하면 컬렉션 처리 연산은 호출 시 연산이 이뤄진다. 반면 시퀀스 처리 함수는 최종 연산이 이뤄지기 전까진 각 단계에서 연산이 일어나지 않는다. 예를 들어 시퀀스 처리 함수 filter는 중간 연산이다. 따라서 어떤 연산 처리도 하지 않고 기존 시퀀스를 필터링하는 데코레이터만 설치한다.

실질적인 필터링 처리는 toList 같은 최종 연산 시 이뤄진다.

이런 시퀀스의 지연 처리는 아래와 같은 장점을 갖는다.

자연스런 처리 순서 유지

최소한만 연산

무한 시퀀스 형태로 사용 가능

각 단계에서 컬렉션을 만들어 내지 않음

순서의 중요성

이터러블 처리, 시퀀스 처리는 연산 순서가 달라지면 다른 결과가 나온다. 시퀀스 처리는 요소 하나하나에 지정한 연산을 한꺼번에 적용한다.

이를 element-by-element order 또는 lazy order라고 부른다. 반면 이터러블은 요소 전체를 대상으로 연산을 차근차근 적용해 나간다. 이걸 step-by-step order 또는 eager order라고 부른다.

컬렉션 처리 함수를 쓰지 않고 고전적인 반복문, 조건문을 써서 구현한다면 이는 시퀀스 처리인 element-by-element order와 같다.

for (e in listOf(1, 2, 3)) {
    print("F$e, ")
    if (e % 2 == 1) {
        print("M$e, ")
        val mapped = e * 2
        print("E$mapped, ")
    }
}
// F1, M1, E2, F2, F3, M3, E6,

따라서 시퀀스 처리에서 쓰이는 element-by-element order가 훨씬 자연스러운 처리라고 할 수 있다. |또한 시퀀스 처리는 기본적인 반복문, 조건문을 사용하는 코드와 같으므로 조만간 낮은 레벨 컴파일러 최적화가 처리를 더 빠르게 만들어 줄 수도 있을 것이다.

최소 연산

컬렉션에 어떤 처리를 적용하고 앞의 요소 10개만 필요한 상황은 굉장히 자주 접할 수 있는 상황이다. 이터러블 처리는 기본적으로 중간 연산이란 개념이 없으므로 원하는 처리를 컬렉션 전체에 적용한 뒤, 앞의 요소 10개를 사용해야 한다.

하지만 시퀀스는 중간 연산이란 개념을 갖고 있으므로 앞의 요소 10개에만 원하는 처리를 적용할 수 있다. 이런 이유로 중간 처리단계를 모든 요소에 적용할 필요가 없는 경우 시퀀스를 쓰는 게 좋다.

무한 시퀀스

시퀀스는 실제로 최종 연산이 일어나기 전까진 컬렉션에 어떤 처리도 하지 않는다. 따라서 무한 시퀀스를 만들고 필요한 부분까지만 값을 추출하는 것도 가능하다.

무한 시퀀스를 만드는 일반적인 방법은 generateSequence 또는 sequence를 사용하는 것이다. generateSequence는 첫 번째 요소와 그 다음 요소를 계산하는 방법을 지정해야 한다.

fun main() {
    generateSequence(1) { it + 1 }
        .map { it * 2 }
        .take(10)
        .forEach { print("$it, ") }
    // 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 
}

두 번째로 sequence는 중단 함수로 요소들을 지정한다. 시퀀스 빌더는 중간 함수 안에서 yield로 값을 하나씩 만들어 낸다.

fun main() {
    val fibonacci = sequence {
        yield(1)
        var current = 1
        var prev = 1
        while (true) {
            yield(current)
            val temp = prev
            prev = current
            current += temp
        }
    }

    print(fibonacci.take(10).toList())
    // [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
}

무한 시퀀스를 활용할 때는 값을 몇 개 활용할지 지정해야 한다. 그렇지 않으면 무한 반복한다. 따라서 이전 코드처럼 take를 써서 활용할 값의 수를 지정하거나 first, find, any, all, none, indexOf와 같은 일부 요소만 선택하는 종결 연산을 활용해야 한다.

각 단계에서 컬렉션을 만들지 않음

표준 컬렉션 처리 함수는 각 단계에서 새 컬렉션을 만들어 낸다. 일반적으로 대부분 List다.

각 단계에서 만들어진 결과를 활용하거나 저장할 수 있다는 건 컬렉션의 장점이지만, 각 단계에서 결과가 만들어지면서 공간을 차지하는 비용이 든다는 건 큰 단점이다.

크거나 무거운 컬렉션을 처리할 때는 굉장히 큰 비용이 들어간다. 처리 단계가 하나 정도면 컬렉션, 시퀀스 처리의 차이가 크지 않다. 하지만 처리 단계가 많아질수록 이런 차이가 커지므로 큰 컬렉션으로 여러 처리 단계를 거쳐야 한다면 시퀀스 처리를 쓰는 게 좋다. 큰 컬렉션은 수만 개의 요소를 갖는 정수 리스트, 몇 MB의 긴 문자열이 몇 개 들어 있는 리스트 등 요소를 많이 갖는 무거운 컬렉션을 말한다.

시퀀스가 빠르지 않은 경우

컬렉션 전체를 기반으로 처리해야 하는 연산은 시퀀스를 써도 빨라지지 않는다. 코틀린 stdlib의 sorted()가 있다. 이것은 시퀀스를 리스트로 바꾼 뒤에 자바 stdlib의 sort()를 써서 처리한다.

문제는 이런 변환 처리로 인해 시퀀스가 컬렉션 처리보다 느려진다는 것이다. 따라서 무한 시퀀스에 sorted를 사용할 수 없다는 결함은 따로 기억해야 한다.

자바 스트림의 경우

자바 8부터는 컬렉션 처리를 위해 스트림 기능이 추가됐다. 코틀린 시퀀스와 비슷한 형태로 동작한다. 자바 8의 스트림도 lazy하게 작동하며 마지막 처리 단계에서 연산이 일어난다. 다만 자바 스트림, 코틀린 시퀀스는 3가지 큰 차이가 있다.

코틀린 시퀀스가 더 많은 처리 함수를 갖고 있다. 그리고 더 사용하기 쉽다
자바 스트림은 병렬 함수를 써서 병렬 모드로 실행할 수 있다. 
  이건 멀티코어 환경에서 큰 성능 향상을 가져오지만 몇 가지 결함이 있어서 주의해서 써야 한다
코틀린 시퀀스는 코틀린/JVM, 코틀린/JS 등의 일반적 모듈에서 모두 쓸 수 있다. 
  하지만 자바 스트림은 코틀린/JVM에서만 동작하며 JVM 8 버전 이상에서만 동작한다.

일반적으로 병렬 모드를 쓰지 않으면 자바 스트림, 코틀린 시퀀스 중 어떤 게 더 효율적이라고 단정짓기 어렵다.

Last updated