아이템 49 - 하나 이상의 처리 단계를 가진 경우에는 시퀀스를 사용하라
Iterable과 Sequence는 서로 다른 목적으로 설계되어, 완전히 다른 형태로 동작합니다.
- Iterable은 처리 함수를 사용할 때마다 연산이 이루어져 List가 만들어지며, 컬렉션 처리 연산이 호출될 때마다 연산이 이루어집니다.
- Sequence는 지연(lazy) 처리가 되며, 시퀀스 처리 함수 사용 시 데코레이터 패턴으로 꾸며진 새로운 시퀀스가 리턴되고 최종적인 계산은 최종 연산(예: toList, count 등)이 이루어질 때 수행됩니다.
- 즉, 최종 연산이 이루어지기 전까지는 각 단계에서 연산이 이루어지지 않습니다.
시퀀스 지연 처리 장점
시퀀스의 지연 처리는 아래와 같은 장점을 제공합니다.
- 자연스러운 처리 순서를 유지함
- 최소한의 연산을 진행
- 무한 시퀀스 형태로 사용 가능
- 각각의 단계에서 컬렉션 만들지 않음
자연스러운 처리 순서 유지
Iterable과 Sequence는 연산 순서가 달라지면 서로 상이한 결과가 나옵니다. 시퀀스는 요소 하나하나에 지정한 연산을 한꺼번에 수행(element-by-element order, lazy order)하는 반면, 이터러블은 각 연산에 대해 전체 요소를 적용(step-by-step order, eager order)해나갑니다.
sequenceOf(1, 2, 3)
.filter { print("F$it, "); it % 2 == 1 }
.map { print("M$it, "); it * 2 }
.forEach { print("E$it, ") }
listOf(1, 2, 3)
.filter { print("F$it, "); it % 2 == 1 }
.map { print("M$it, "); it * 2 }
.forEach { print("E$it, ") }
앞선 처리를 고전적인 반복문과 조건문 형태로 구성한다면 아래와 같으며, 이는 시퀀스 처리인 element-by-element order와 같습니다.
for ( e in listOf(1, 2, 3)) {
print("F$e, ")
if (e % 2 == 1) {
print("M${e}, ")
print("E${e * 2}, ")
}
}
따라서, 시퀀스 처리에서 사용되는 element-by-element order가 훨씬 자연스러운 처리라고 볼 수 있습니다.
최소 연산 수행
컬렉션 연산에서 최종적으로 나온 결과 중 앞의 N개 요소만 필요한 상황은 쉽게 접할 수 있는 상황입니다. 이때 이터러블을 통해 구현하게 되면, 중간 연산이라는 개념이 없어 N개 요소가 필요하더라도 앞선 연산을 모든 컬렉션 원소에 대해 수행해야 합니다. 반면 시퀀스는 중간 연산이라는 개념이 있어 요소 N개에 대해서만 원하는 처리가 가능합니다.
위 예시처럼, 컬렉션 최종 연산에서 일부 데이터만 가져오는 경우, 이터러블 대신에 시퀀스를 사용하면 연산을 적게 진행할 수 있습니다.
- 일부 원소에 대해서만 적용하는 최종 연산으로는 find, first, take, any, all, none, indexOf가 있습니다.
무한 시퀀스
시퀀스는 최종 연산이 일어나기 전까지는 컬렉션에 어떠한 작업도 하지 않습니다. 따라서, 무한 시퀀스(infinite sequence)를 만들고 필요한 부분까지만 값을 추출하는 것도 가능합니다.
무한 시퀀스는 generateSequence 또는 sequence를 사용해 만들 수 있으며, 무한 시퀀스 사용 시에는 반드시 값을 몇 개 만들어낼지 지정해야 합니다. 그렇지 않으면 무한하게 반복합니다.
- generateSequence는 첫 번째 요소와 그 다음 요소를 계산하는 방법을 지정해서 무한 시퀀스를 생성합니다.
- sequence는 중단 함수(suspend func)로 요소들을 지정하고, 시퀀스 빌더는 중단 함수 내에서 yield 함수를 통해 값을 하나씩 만들어냅니다.
무한 시퀀스를 만들때는 위 예시처럼 최종 연산으로 take()를 사용해 몇 개의 원소를 가져올 지 지정하거나 일부 요소만 선택할 수 있는 first, find, indexOf와 같은 종결 연산을 활용해야 합니다.
- any는 true를 리턴하지 못하면 무한 반복에 빠지며, all이나 none은 false를 리턴하지 못하면 무한 반복에 빠지게 됩니다. 따라서 이러한 연산은 무한 시퀀스에서는 사용하지 않는게 좋습니다.
각 단계에서 컬렉션 만들지 않음
Iterable 컬렉션 처리 함수는 각각의 단계에서 새로운 컬렉션을 만들어냅니다. 각각의 단계에서 만들어진 결과를 활용하거나 저장할 수 있다는 것은 Iterable의 장점이지만, 각 단계의 결과가 메모리 공간을 차지함에 따라 비용이 발생한다는 단점이 있습니다.
반면 Sequence를 사용하면 중간 연산에서 컬렉션을 새로 만들지 않기 때문에 메모리를 절약할 수 있습니다. 처리 단계가 많아질수록 이러한 차이가 커지므로, 큰 컬렉션으로 여러 처리 단계를 거쳐야 한다면 컬렉션 처리보다는 시퀀스 처리를 하는 것이 좋습니다.
다만 코틀린 stdlib의 sorted()처럼 컬렉션 전체를 기반으로 처리해야 하는 연산일 경우 시퀀스를 사용해도 빨라지지 않습니다.
위 사진에서 볼 수 있듯이 코틀린 stdlib의 sorted는 내부적으로 iterable 컬렉션을 만들고 sort() 함수를 호출하기 때문에 오히려 시퀀스가 이터러블 처리보다 느려집니다. (오히려 이터러블로 변환을 해야 하기 때문)
무한 시퀀스처럼 시퀀스의 다음 요소를 lazy하게 구하는 시퀀스에 sorted를 적용하면 무한 반복에 빠지므로 사용하면 안됩니다.
Java Stream과 Kotlin의 Sequence
Java 8부터는 컬렉션 처리를 위해 스트림 기능이 추가되어 있습니다. 스트림은 코틀린의 시퀀스와 유사한 형태로 동작합니다. Java의 stream은 lazy하게 동작하며, 마지막 처리 단계에서 연산이 일어납니다. 다만 자바의 스크림과 코틀린의 스트림은 아래와 같은 세 가지 차이점이 있습니다.
- 코틀린의 Sequence는 자바의 Stream보다 더 많은 처리 함수를 가지고 있습니다.
- 자바 스트림은 병렬 함수를 사용해 병렬 모드로 실행할 수 있습니다. 이는 멀티 코어 환경에서 성능 향상을 보일 수 있습니다. 다만, 병렬 모드에서 사용할 때에는 주의해서 사용이 필요합니다.
- 코틀린의 시퀀스는 코틀린/JVM 외에도 코틀린/JS, 코틀린/네이티브 등에서 모두 사용될 수 있지만, 자바 스트림은 코틀린/JVM에서만 동작합니다.
병렬 모드로 자바 스트림의 성능 이득을 얻을 수 있는 곳에서만 자바 스트림을 사용하고, 이외의 경우에는 코틀린 시퀀스를 사용하는 것이 좋습니다.
코틀린 시퀀스 디버깅
코틀린 시퀀스 디버깅은 intelliJ의 코틀린 플러그인을 통해 할 수 있습니다. 코틀린 플러그인에는 Kotlin Sequence Debugger 기능을 포함하고 있습니다.
코틀린 시퀀스 디버깅을 사용하고자 한다면, 디버깅 중에 Trace Current Stream Chain을 클릭하면 됩니다. 참고로, 너무 많은 연산이 있는 시퀀스에서는 Interal error가 발생하면서 제대로 응답이 오지 않을 수 있습니다.
아이템 50 - 컬렉션 처리 단계 수를 제한하라
모든 컬렉션 처리 메소드 사용 시 비용이 발생합니다. 따라서 적절한 메소드를 활용해서 컬렉션 처리 단계 수를 적절하게 제한하는 것이 좋습니다. 예를 들어 아래와 같이 어떤 메소드를 사용하는지에 따라 컬렉션 처리의 단계 수가 달라집니다.
class Student(val name: String)
// 작동은 함
fun List<Student>.getNames(): List<String> = this
.map { it.name }
.filter { it != null }
.map { it!! }
// 더 좋음
fun List<Student>.getNames(): List<String> = this
.map { it.name }
.filterNotNull()
// 가장 좋음
fun List<Student>.getNames(): List<String> = this
.mapNotNull { it.name }
컬렉션 처리 시, 비효율적 코드를 작성하는 이유는 어떤 메소드를 제공하는지 모르기 때문일때가 더 많습니다. 따라서 어떤 메소드가 있는지 알아보는 것이 중요하며, 전체 컬렉션에 대한 반복과 중간 컬렉션 생성이라는 비용을 줄일 수 있도록 적절한 함수를 사용하는 것이 중요합니다.
아이템 51 - 성능이 중요한 부분에는 기본 자료형 배열을 사용하라
코틀린은 기본 자료형(primitive)을 선언할 수 없지만, 최적화를 위해 내부적으로 사용할 수 있습니다. 따라서 대규모의 데이터를 처리할 때에는 기본 자료형을 사용하도록 유도하는 것이 좋습니다.
아이템 52 - mutable 컬렉션 사용을 고려하라
immutable 컬렉션보다 mutable 컬렉션이 좋은 점은 성능적인 측면에서 더 빠르다는 것입니다. immutable 컬렉션은 요소 추가 시, 새로운 컬렉션을 만들면서 요소를 추가하게 됩니다. 다만, 아이템 1: 가변성을 제한하라에서 언급한 것처럼 immutable 컬렉션은 안전하다는 측면에서 좋습니다.
따라서 지역 변수로 사용할 때에는 mutable 컬렉션을 사용하는 것이 합리적입니다. 표준 라이브러리도 보면, 내부적으로 어떤 처리를 할 때는 mutable 컬렉션을 사용하도록 구현되어 있습니다.
참고 자료
'PROGRAMMING LANGUAGE > KOTLIN' 카테고리의 다른 글
[Kotlin Coroutine] channel & select (0) | 2024.08.16 |
---|---|
[Effective Kotlin] 7장 비용 줄이기 (0) | 2024.08.03 |
[Effective Kotlin] 6장 클래스 설계 (0) | 2024.07.28 |
[Kotlin Coroutine] 코루틴 단위 테스트 (0) | 2024.07.27 |
[Kotlin Coroutine] Asynchronous Flow (5) | 2024.07.20 |