이 글은 인프런 코틀린 고급편 을 참고합니다.
개발을 하다보면 Collection을 조작해서 원하는 데이터를 얻을 수 있도록 많이 사용하게됩니다.
예를 들어 2000000개의 랜덤 과일 중 사과를 골라 10000개의 가격 평균을 계산 해보록 하겠습니다.
fun main() {
val fruits = listOf(
MyFruit("사과", 1000L),
MyFruit("바나나", 3000L),
)
val avg = fruits
.filter { it.name == "사과" }
.map { it.price }
.take(10_000)
.average()
}
data class MyFruit(
val name: String,
val price: Long, // 1,000원부터 20,000원 사이
)
이처럼 우리는 collection을 활용하여 filtering도 하고 원하는 데이터를 추출하기도 하고 다양한 작업들을 할 수 있습니다.
하지만 위의 방법은 생각치 못했던 메모리 낭비를 초래할 수 있습니다.
val avg = fruits
.filter { it.name == "사과" }
.map { it.price }
.take(10_000)
.average()
이 코드에서 filter할 때 임시 List<Fruit>을 한번 만들고
그 다음에 앞에서 만들어진 리스트를 바탕으로 price 뽑아내도록 다시 List를 생성합니다.
마지막으로 10000개만 가져와서 평균을 구합니다.
Iterable은 이러한 큰 단점이 있습니다.
연산의 각 단계마다 중간 Collection이 임시로 생성되는 것입니다.
위의 예시 처럼 데이터가 몇개 없을때에는 문제가 없지만 대용량의 데이터를 처리할 때에는 리스트가 계속 만들어지므로 메모리의 낭비를 초래할 수 있고 또 데이터 처리 속도도 저하될 수 있습니다.
그렇다면 중간 Collection을 만들지 않는 방법은 없을까?
kotlin에는 Sequence 라는 키워드를 제공합니다.
val avg = fruits.asSequence()
.filter { it.name == "사과" }
.map { it.price }
.take(10_000)
.average()
각 단계 (filter, map)가 모든 원소에 적용되지 않을 수 있습니다.
기존 코드는 filter를 하게 되면 리스트의 원소갯수만큼 모두 필터링을 했었습니다.
하지만 Sequence같은 경우에는 모두 필터링 하지 않을 수 있습니다.
한 원소에 대해 모든 연산을 수행하고 다음 원소로 넘어갑니다.
최종연산이 나오기 전까지 계산 자체를 미리 하지 않습니다.
이를 지연연산 이라고합니다.
위의 코드로 예를 들어보자면
JMH를 활용하여 200만건의 데이터를 대상으로 테스트 했습니다.
참고: java 12이상의 버전을 사용하면 JMH plugin이 에러가 발생해서 11로 하는게 정신건강에 좋습니다.

Sequence가 약 60배 빠른 결과를 보여줬습니다. (강의에서는 약 50배 였음)
아래는 테스트 코드 입니다.
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
open class SequenceTest {
private val fruits = mutableListOf<Fruit>()
@Setup
fun init() {
(1..2_000_000).forEach { _ -> fruits.add(Fruit.random()) }
}
@Benchmark
fun kotlinSequence() {
val avg = fruits.asSequence()
.filter { it.name == "사과" }
.map { it.price }
.take(10_000)
.average()
}
@Benchmark
fun kotlinIterator() {
val time = measureTimeMillis {
val avg = fruits
.filter { it.name == "사과" }
.map { it.price }
.take(10_000)
.average()
}
println("소요 시간 : ${time}ms")
}
}
data class Fruit(
val name: String,
val price: Long, // 1,000원부터 20,000원 사이
) {
companion object {
private val NAME_CANDIDATES = listOf("사과", "바나나", "수박", "채리", "오렌지")
fun random(): Fruit {
val randNum1 = Random.nextInt(0, 5)
val randNum2 = Random.nextLong(1000, 20001)
return Fruit(
name = NAME_CANDIDATES[randNum1],
price = randNum2
)
}
}
}
@State : 벤치마크에 사용되는 매개변수의 상태 지정
@BenchmarkMode: 벤치마크 방식
@OutputTimeUnit: 벤치마크 결과 표시 단위
@Setup: 벤치마크 수행 전 호출해야하는 메소드
@Benchmark: 실체 벤치마크 대상함수
이건 아닙니다.
200만건 대신에 100건으로 돌려보면 Iterable이 Sequence보다 8~10% 정도 빠른것을 알 수 있습니다.
연산 순서에 따라 속도에 큰 차이가 날 수 있습니다.
본문에도 써있지만 보충 설명으로 인라인 함수가 어떻게 동작하는지 내부 구현을 알면 이해하기 쉬운데 코틀린에서 사용한 인라인 함수 filter, map 등이 자바 바이트코드로 변환되면 아래와 같이 바뀌기 때문입니다 ㅎ
// 백만개 요소를 포함한 리스트
millionData.filter {
it ! = null
}
Iterator data = millionData.iterator();
List filterList = new ArrayList();
while (data.hasNext()) {
var it = data.next();
if (it != null) {
filterList.add(elem);
}
}
실무에서 Sequence를 몰라서 발생할 수 있는 예시가 백오피스에서 대량 데이터 다운로드 같은 기능 구현할때 인라인 함수로 filter, map 등을 지속사용하는 경우가 많은데 이때 데이터가 백만건씩 되는 경우면 바로 OOME 발생하죠 ㅎㅎ
이때 마법의 Sequence 한방이면 해결되는 경우가 있습니다