컬렉션은 중요한 개념이며, 안드로이드에서도 일반적으로 RecyclerView, LazyColumn 등을 사용할 때 컬렉션을 사용하게 된다. 현대적인 프로그래밍 언어는 대부분 컬렉션 처리를 굉장히 잘 지원해주며, 코틀린도 강력한 컬렉션 처리를 지원한다.

컬렉션을 처리하는 부분의 최적화는 성능에 큰 영향을 미치므로 굉장히 중요하다. 이번 포스팅에서는 효율적인 컬렉션 처리를 하는 방법에 대해서 알아본다.

하나 이상의 처리단계를 가진 경우에는 시퀀스를 사용하라

많은 사람들이 Iterable과 Sequence의 차이를 모르고 있다.

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

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

모양새는 비슷하나 Iterable과 Sequence는 완전히 다른 목적으로 설계되었다.

  • Iterable : 처리 함수를 사용할 때마다 연산이 이루어져 List가 만들어 진다.
  • Sequence : 지연(lazy) 처리가 특징. 시퀀스 처리 함수들을 사용하면 데코레이터 패턴으로 꾸며진 새로운 시퀀스가 반환된다. 최종적인 계산은 toList 또는 count 등의 최종 연산이 이루어질 때 수행된다.

시퀀스의 지연처리는 다음과 같은 장점을 갖는다.

  • 자연스러운 처리 순서를 유지한다
  • 최소한만 연산한다.
  • 무한 시퀀스 형태로 사용할 수 있다.
  • 각각의 단계에서 컬렉션을 만들어 내지 않는다.

각각의 장점에 대해 하나씩 알아보자

순서의 중요성

iterable은 요소 전체를 대상으로 연산을 차근차근 적용해 나간다. (step by step order 또는 eager order라 함)

sequence는 요소 하나하나에 지정한 연산을 한꺼번에 적용한다. (element-by-element order 또는 lazy order라 함)

최소 연산

Iterable은 중간연산이라는 개념이 없고, Sequence는 중간연산이라는 개념이 있다. 그렇기 때문에 중간 처리 단계를 모든 요소에 적용할 필요가 없는 경우에 시퀀스를 사용하여 연산량을 줄일 수 있다. 다음의 예제를 보자.

sequence의 경우 필요한 만큼만 연산하는 것을 알 수 있다.

무한 시퀀스

generateSequnece 또는 sequence를 사용하여 무한한 시퀀스를 만들고 필요한 부분까지만 값을 추출해보자.

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

표준 컬렉션 처리 함수는 각각의 단계에서 새로운 컬렉션을 만들어 낸다. 대부분 List이며 이를 활용하거나 저장하는 것은 컬렉션의 장점이지만, 메모리를 차지 하는 것은 단점이 될 수 있다.

numbers
    .filter { ... } // 컬렉션 생성1
    .map { ... } // 컬렉션 생성2
    .sum() // 전체적으로 2개의 컬렉션이 생성 되었음.

numbers.asSequence()
    .filter { ... }
    .map { ... }
    .sum()  // 컬렉션이 생성되지 않았음.
     

무거운 컬렉션을 처리할 때는 큰 비용이 발생한다. 큰 파일을 대상으로 일반적인 컬렉션 처리를 하면 OutOfMemory에 직면할 수 있다.

// 큰 파일을 대상으로 컬렉션 처리를 하는 경우
File("Test.txt").readLines()
    .count()
    .let(::println)

// useLines를 사용하면 메모리 절약 및 성능을 향상시킬 수 있다.
File("Test.txt).useLines { lines:Sequence<String> -> 
    .count()
    .let { println(it) }
}

시퀀스가 빠르지 않은 케이스

전체 컬렉션을 대상으로 처리해야 하는 연산은 시퀀스를 사용해도 빠르지 않다. 일례로 코틀린의 sorted 함수는 Sequence를 List로 변환한 뒤에 자바 stdlib의 sort를 사용해서 처리한다. 이러한 변환과정때문에 시퀀스가 컬렉션보다 느려진다.

여기까지가 책 내용이 그렇다는 것이고, 아래 예제를 보면 여전히 시퀀스가 더 빠르다. 예시를 잘 못 들은건지, 왜 인지 모르겠다. (아시는분은 댓글 부탁드립니다.)

참고로 무한 시퀀스처럼 시퀀스의 다음 요소를 lazy하게 계산하는 곳에서 sorted를 호출하면 오류가 발생한다.

자바 스트림의 경우

자바8에서는 컬렉션 처리를 위해 스트림 기능이 추가 되었으며, 코틀린의 시퀀스와 비슷한 형태로 동작한다. 자바8의 스트림도 lazy하게 동작하며 마지막 처리 단계에서만 연산이 일어난다. 다만 코틀린의 시퀀스와는 다음과 같은 차이점을 갖는다.

  • 코틀린의 시퀀스가 더 많은 처리 함수를 갖으며, 사용하기 쉽다.
  • 자바 스트림은 병렬 함수를 사용해서 병렬 모드로 실행할 수 있다. 이는 멀티 코어 환경에서 굉장히 큰 성능 향상을 가져온다.
  • 코틀린의 시퀀스는 다양한 플랫폼에서 동작한다. 하지만 자바 스트림은 코틀린/JVM에서만 동작한다.

코틀린 시퀀스 디버깅

자바 스트림은 ‘Java Stream Debugger’라는 플러그인으로 디버깅을 할 수 있다.

코틀린 시퀀스는 ‘Kotlin Sequence Debugger’라는 플러그인으로 디버깅을 할 수 있다. 참고로 현재 이 플러그인은 ‘Kotlin’ 플러그인에 통합 되어 있다.

컬렉션 단계수를 제한하자

모든 컬렉션 처리 메서드는 비용이 많이 든다. 따라서 적절한 메서드를 활용해서 컬렉션 처리 단계수를 적절하게 제한하는 것이 좋다. 다음 예제를 살펴보자.

class Student(val name: String?)

// this is working
fun List<Student>.getNames(): List<String> = this
    .map { it.name }
    .filter { it != null }
    .map { it!! }

// this is better
fun List<Student>.getNames(): List<String> = this
    .map { it.name }
    .filterNotNull()

// this is the Best
fun List<Student>.getNames(): List<String> = this
    .mapNotNull { it.name }

성능이 중요한 부분에는 기본 자료형 배열을 사용하자

대규모 데이터를 처리할 때 기본 자료형을 사용하면, 상당히 큰 최적화가 이루어진다. 제네릭 타입(예: List, Set)에는 기본자료형을 사용할 수 없으므로, 래핑된 타입을 사용해야 한다. 하지만 성능이 중요한 코드라면 IntArray 및 LongArray와 같이 기본 자료형을 활용하는 배열을 사용하는 것이 좋다.

IntArray와 List<Int>를 비교해보자. 단순히 1,000,000개의 정수를 갖는 컬렉션을 만든다고 해보면, List<Int>는 2,000,006,944바이트를 할당한다. 이는 5배정도의 차이다. 성능적으로도 배열을 사용하는 경우가 25%정도 더 빠르다.

일반적으로는 배열(Array)보다 List와 Set을 사용하는 것이 좋다. 하지만 성능을 높이고, 메모리 사용량을 줄이려면 Array를 사용하는 것이 좋다.

mutable 컬렉션 사용을 고려하자

immutable 컬렉션보다 mutable 컬렉션이 성능적인 측면에서는 더 빠르다. immutable 컬렉션에 요소를 추가하려면 새로운 컬렉션을 만들면서 요소를 추가해야한다. immutable이 상대적으로 느린 이유는 내부에서 컬렉션을 복제하기 때문이다. mutable이 그런 면에서 성능은 좋다.

카테고리: Kotlin

4개의 댓글

최우성 · 2022년 5월 19일 12:57 오후

잘읽었습니다. 찰스님!!

editText가 charSequence로 반환하길래, 뭘까 찾아봤는데 가까운 블로그에 정답이 있었네요!

오로키 · 2022년 8월 5일 10:15 오후

잘 읽었습니다 찰스님!
sort의 경우 collection이 sequence보다 빠른 특이 케이스라고 하셨는데, 위의 결과에서 실행 결과는 collection이 더 오래걸리는 것으로 나와 잘 이해가 되지 않습니다..!

    Charlezz · 2022년 8월 9일 12:03 오후

    죄송합니다 저도 잘 모르겠습니다. 책의 내용대로라면 sequnce에서 sorted호출은 결국 toList()로 변환하여 sort()를 호출 하는 것과 같기 때문에 변환 시간이 추가 되어야 하는데, 오히려 더 빠릅니다… 제가 무엇을 놓치고 있는지 모르겠네요.

dev_sia · 2023년 7월 11일 6:14 오후

안녕하세요. 해결하셨는지 모르겠지만, sequence 연산의 경우 최종 연산이 없는 경우 연산이 아예 수행되지 않습니다. 위의 코드에서 마지막에
val sortedList = numList2.asSequence().sortedDescending().toList()
처럼 둘 다 toList()를 붙이고 실행해 보시면 책의 내용대로 sequence에서의 sort()가 더 느린 것을 확인하실 수 있을 겁니다.
테스트해보니 약 두 배 정도의 시간차이가 있습니다.

최우성 에 답글 남기기 응답 취소

Avatar placeholder

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.