asSequence() 무조건 써야하는 거 아니야?

Jaychy·2021년 6월 16일
4

알아가는 것

목록 보기
1/11
post-thumbnail

Github 코틀린으로 개발하면서 궁금했던 점에 대해 고찰하는 곳.

개요

자바에서 Stream API를 사용하기 위해서는
stream() 메소드를 이용해서 스트림으로 변환해야 합니다.
하지만 코틀린에서는 따로 스트림으로 변경하지 않아도 Stream API를 사용할 수 있도록
Iterable의 확장함수로 구현되어 있습니다.

그래서 보통 다음과 같이 그냥 사용하는 방법을 사용합니다.

listOf(1, 2, 3)
    .map { it * it }
    .filter { it % 2 == 0 }
    .forEach { println(it) }

하지만 컬렉션에 데이터가 많을 경우 asSequence()를 이용하여 시퀀스로 변경하라고 합니다.
그 이유로는 시퀀스의 Stream API는 지연 계산을 통해 각 Stream API
바로 실행하지 않고 나중에 한 원소에 일련의 연산들을 한 번에 하기 때문이라고 합니다.
그리고 실제로도 빠른 속도를 보이고 있습니다.

그렇다면 무조건 시퀀스로 변경하여 사용하면 되지
왜 데이터가 많을 때만 시퀀스로 변경하라는 걸까요?

그럼 알아봐야지!

일단 컬렉션의 Stream API와 시퀀스의 Stream API가 뭐가 다른지 알아보도록 하겠습니다.

// _Collections.map
public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
    return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}

// _Collections.mapTo
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(destination: C, transform: (T) -> R): C {
    for (item in this)
        destination.add(transform(item))
    return destination
}

// _Sequences.map
public fun <T, R> Sequence<T>.map(transform: (T) -> R): Sequence<R> {
    return TransformingSequence(this, transform)
}

// Sequences.TransformingSequence
internal class TransformingSequence<T, R>
constructor(private val sequence: Sequence<T>, private val transformer: (T) -> R) : Sequence<R> {
    override fun iterator(): Iterator<R> = object : Iterator<R> {
        val iterator = sequence.iterator()
        override fun next(): R {
            return transformer(iterator.next())
        }

        override fun hasNext(): Boolean {
            return iterator.hasNext()
        }
    }

    internal fun <E> flatten(iterator: (R) -> Iterator<E>): Sequence<E> {
        return FlatteningSequence<T, R, E>(sequence, transformer, iterator)
    }
}

컬렉션의 Stream API는 인라인 함수이고
람다를 바로 실행 한다는 특징을 가지고 있고,
시퀀스의 Stream API는 일반 함수이고
람다를 실행하지 않고 저장한다는 특징을 가지고 있습니다.
여기서 더 정확히 알기 위해서는 inline function에 대해 이해하여야 합니다.

inline function

인라인 함수는 JIT 컴파일 방식을 강제하도록하는 함수로
람다, 익명 함수와 깊은 연관이 있습니다.

매개변수로 SAM Interface를 받는 함수를 익명 함수를 이용해 호출하게 되면
함수 호출마다 객체를 생성하게 되므로 오버헤드가 발생하게 됩니다.
하지만 캡처링 하지 않은 람다식으로 호출하게 되면 람다를 따로 저장해두었다가
계속 사용하는 방식으로 바이트코드가 컴파일됩니다.

하지만 캡처링 한 람다식의 경우에는 람다가 가지고 있는 변수가 각각 다르므로
저장해두었다가 사용하는 방법을 사용할 수 없으므로 계속 객체를 생성하게 됩니다.

여기서 인라인 함수는 바이트코드로 컴파일할 때 함수의 본문을 함수를 호출한 곳으로
이동하여 바이트코드를 생성하도록 강제합니다.

원래 JIT 컴파일러가 많이 사용되는 부분을 native 코드로 변경하여
재사용하도록 하게 하지만 JIT 컴파일러가 캡처링 하지 않은 람다식을 구별하여
결정할 정도로 똑똑하지 않아서 개발자가 inline function을 지정해주어야합니다.
더 정확한 정보는 이 글을 참조해주세요.

함수의 본문이 그리 크지 않다면 바이트코드가 많아져서 무거워질 일도 없으니
캡처링하지 않은 람다를 받는 함수를 인라인 함수로 만들면 속도의 향상이 있을 것입니다.

컬렉션의 Stream API는 캡처링하지 않은 람다를 받으므로 인라인 함수로 구현하였고
시퀀스의 Stream API는 람다를 실행하지 않고 새로운 클래스에 저장하게 되므로
람다가 상태를 가지게 되어 인라인 함수로 구현할 수 없었습니다.

따라서 시퀀스를 사용하게 되면 람다를 실행할 때마다
새로운 (익명) 객체가 생성되는 오버헤드가 있고
컬렉션을 사용하게 되면 람다 실행 후 무조건 스트림을 생성해야 한다는 오버헤드가 있습니다.

이렇게 람다를 만드는 객체를 생성하는 것의 오버헤드가 큰가,
매번 스트림을 생성하는 오버헤드가 큰지를 따져서 asSequence()를 사용해야 합니다.

보통 원소의 수가 적을 때는 람다 객체를 만드는 것이 오버헤드가 더 크고
원소의 수가 많을 때는 스트림을 매번 생성하는 것이 오버헤드가 더 크기 때문에
컬렉션의 크기가 클 때만 asSequence()를 이용하여 시퀀스를 사용하라고 하는 것입니다.

결론

컬렉션의 크기가 클 때만 asSequence()를 이용해서 시퀀스를 사용하자!!

profile
아름다운 코드를 꿈꾸는 백엔드 주니어 개발자입니다.

0개의 댓글