[kotlin] Iterable과 Sequence

모지리 개발자·2023년 10월 5일

이 글은 인프런 코틀린 고급편 을 참고합니다.

Intro

개발을 하다보면 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이 임시로 생성되는 것입니다.

위의 예시 처럼 데이터가 몇개 없을때에는 문제가 없지만 대용량의 데이터를 처리할 때에는 리스트가 계속 만들어지므로 메모리의 낭비를 초래할 수 있고 또 데이터 처리 속도도 저하될 수 있습니다.

Sequence

그렇다면 중간 Collection을 만들지 않는 방법은 없을까?
kotlin에는 Sequence 라는 키워드를 제공합니다.

  val avg = fruits.asSequence()
    .filter { it.name == "사과" }
    .map { it.price }
    .take(10_000)
    .average()

동작원리

  1. 각 단계 (filter, map)가 모든 원소에 적용되지 않을 수 있습니다.
    기존 코드는 filter를 하게 되면 리스트의 원소갯수만큼 모두 필터링을 했었습니다.
    하지만 Sequence같은 경우에는 모두 필터링 하지 않을 수 있습니다.

  2. 한 원소에 대해 모든 연산을 수행하고 다음 원소로 넘어갑니다.

  3. 최종연산이 나오기 전까지 계산 자체를 미리 하지 않습니다.

이를 지연연산 이라고합니다.

위의 코드로 예를 들어보자면

  1. 만약 name이 사과가 아니라면 map을 타지 않습니다.
  2. 만약 name이 사과라면 price를 만들고 take에 태웁니다.
  3. 10000개가 채워졌을 때 average를 계산하고 나머지 데이터들은 연산하지 않습니다.

그럼 Sequence가 Iterable보다 빠를까

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
      )
    }
  }
}

JMH 주요 어노테이션

@State : 벤치마크에 사용되는 매개변수의 상태 지정
@BenchmarkMode: 벤치마크 방식
@OutputTimeUnit: 벤치마크 결과 표시 단위
@Setup: 벤치마크 수행 전 호출해야하는 메소드
@Benchmark: 실체 벤치마크 대상함수

그럼 항상 Sequence가 옳을까?

이건 아닙니다.
200만건 대신에 100건으로 돌려보면 Iterable이 Sequence보다 8~10% 정도 빠른것을 알 수 있습니다.

Sequence사용시 주의할 점

연산 순서에 따라 속도에 큰 차이가 날 수 있습니다.

profile
항상 부족하다 생각하며 발전하겠습니다.

2개의 댓글

comment-user-thumbnail
2023년 11월 28일

실무에서 Sequence를 몰라서 발생할 수 있는 예시가 백오피스에서 대량 데이터 다운로드 같은 기능 구현할때 인라인 함수로 filter, map 등을 지속사용하는 경우가 많은데 이때 데이터가 백만건씩 되는 경우면 바로 OOME 발생하죠 ㅎㅎ
이때 마법의 Sequence 한방이면 해결되는 경우가 있습니다

답글 달기
comment-user-thumbnail
2023년 11월 28일

본문에도 써있지만 보충 설명으로 인라인 함수가 어떻게 동작하는지 내부 구현을 알면 이해하기 쉬운데 코틀린에서 사용한 인라인 함수 filter, map 등이 자바 바이트코드로 변환되면 아래와 같이 바뀌기 때문입니다 ㅎ

코틀린 인라인 함수 filter 사용

// 백만개 요소를 포함한 리스트 
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);
     }
}
답글 달기