![[Kotlin] Collections: 시퀀스(Sequences)는 무엇이고 언제 사용해야할까](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUFH5Y%2FbtsMgL3svfW%2F80HT8BB6E2vxJ3nVkX0wy1%2Fimg.png)
안녕하세요. 저는 2년차 백엔드 개발자입니다.
현재 JS(TS)로 개발하고 있고, Java는 코테용 언어로 사용하고 있습니다.
코틀린을 사용하기 위해 현재 제가 알고 있는 지식 대비 필요 부분들만 학습하여 정리하는 Kotlin 시리즈입니다.
피드백은 언제나 감사합니다!!
코틀린 기본 문법을 공부하고 있는 와중에, 컬렉션의 Lazy Evaluation을 지원하는 Sequence라는 것이 있다는 것을 알게 되었다. 기본 문법 포스팅에 내용을 남겼다가, 분리해야할 것 같아서 포스팅을 따로 작성했다.
Sequence
Kotlin 1.0에 추가된 Sequence 인터페이스는 Kotlin의 의 내장 라이브러리로, Collections의 다른 유형을 제공하기 위해 등장했다. 기본적으로 Sequence는 Iterator을 통해 값을 반환하는 기능을 수행한다. 쉽게 말해 Collections의 처리 기능 중 하나라고 생각하면 되겠다.
(kotlin-stdlib/kotlin.sequences/Sequence)
package kotlin.sequences
/**
* A sequence that returns values through its iterator. The values are evaluated lazily, and the sequence
* is potentially infinite.
*
* Sequences can be iterated multiple times, however some sequence implementations might constrain themselves
* to be iterated only once. That is mentioned specifically in their documentation (e.g. [generateSequence] overload).
* The latter sequences throw an exception on an attempt to iterate them the second time.
*
* Sequence operations, like [Sequence.map], [Sequence.filter] etc, generally preserve that property of a sequence, and
* again it's documented for an operation if it doesn't.
*
* @param T the type of elements in the sequence.
*/
public interface Sequence<out T> {
/**
* Returns an [Iterator] that returns the values from the sequence.
*
* Throws an exception if the sequence is constrained to be iterated once and `iterator` is invoked the second time.
*/
public operator fun iterator(): Iterator<T>
}
Sequence는 컬렉션과 달리 요소들을 포함하지 않고 반복하면서 생성한다. 순회하며 일련의 연산을 하는 것이 Iterable과 동일한 기능을 제공하지만 여러 연산에서 컬렉션 처리에 대해 다른 방식을 사용한다.
Collections의 인터페이스인 Iterable은 여러 연산을 처리할 때, 각 처리 단계를 순차적으로 완료하고, 하나의 연산이 종료될 때 마다 중간 결과를 임시 컬렉션에 저장한다. 다음 단계는, 초기에 선언했던 컬렉션이 아닌 생성한 임시 컬렉션을 통해 진행된다.
val numbers = listOf(1, 2, 3, 4, 5)
val result = numbers.map {
println("Mapping $it")
it * 2
}.filter {
println("Filtering $it")
it > 5
}.find { true }
print("Result $result")
위 연산에서 가장 처음 생성됐던 numbers 컬렉션에서, map을 수행하면 map의 결과 컬렉션이 생성되고, filter또한 마찬가지이다.
반대로 Sequence는 함수를 수행할 때 지연 실행을 지원하는데 공식문서에서는 이를 evaluated lazily라고 표현하고 있다. 즉 Collections을 통한 다양한 연산을 수행할 때 Lazy Evaluation이 가능하게 해주는 인터페이스가 Sequence이다.
Sequence 생성하기
Sequence는컬렉션과 동일하게 생성하거나, 컬렉션에서 변환할 수 있다.
val sequenceExam = sequenceOf('1', '2', '3')
val numbers = listOf(1, 2, 3, 4, 5)
val numbersSequence = numbers.asSequence()
컬렉션이나 sequenceOf 외에도 함수를 사용하여 동적으로 생성할 수도 있다. 이 때 기본적으로 무한대의 시퀀스를 생성하기 때문에 반드시 특정 종료 조건을 명시해야한다.
val oddNumbers = generateSequence(1) { it + 2 } // 1, 3, 5, ........
println(oddNumbers.take(5).toList()) //take(5)를 통해 5개만 가져오게끔 설정
// [1, 3, 5, 7, 9]
// ⚠️ 무한 루프
val oddNumbers = generateSequence(1) { it + 2 } // 1, 3, 5, ........
println(oddNumbers.count())
// 종료 조건을 명시
val oddNumbersLessThan10 = generateSequence(1) { if (it < 8) it + 2 else null }
마지막으로, sequence 블록({ })을 활용하여 시퀀스를 하나 또는 여러개를 동시에 생성할 수도 있다.
val oddNumbers = sequence {
yield(1) // 개별 요소 반환
yieldAll(listOf(3, 5)) // 리스트의 모든 요소 반환
yieldAll(generateSequence(7) { it + 2 }) // 무한 시퀀스 반환
}
println(oddNumbers.take(5).toList())
// 출력: [1, 3, 5, 7, 9]
위 코드에서 yield(1)은 단일 값을, yieldAll(listOf(3, 5))는 리스트의 모든 값을 반한다. 또한 yieldAll(generateSequence(7) { it + 2 })를 통해 무한 시퀀스를 마지막에 추가할 수도 있다.
Iterable vs Sequence
위에서, 기본 컬렉션의 연산 처리 방식은, 하나의 함수가 끝나면 임시 컬렉션을 생성한다고 했다. 반대로 Sequence의 경우 지연 연산을 통해 데이터를 임시 컬렉션에 저장하지 않고 처리하는 방식이라고 설명했다.
말로 와닿지 않아서, 직접 코드로 작성해보았다. 아래는 간단한 Iterable VS Sequence 예제이다.
val numbers = listOf(1, 2, 3, 4, 5)
// 즉시 실행
val result = numbers.map {
println("Mapping $it")
it * 2
}.filter {
println("Filtering $it")
it > 5
}.find { true }
print("Result $result")
// 지연 실행
val result = numbers.asSequence().map {
println("Mapping $it")
it * 2
}.filter {
println("Filtering $it")
it > 5
}.find { true }
print("Result $result")
// 즉시 실행
Mapping 1
Mapping 2
Mapping 3
Mapping 4
Mapping 5
Filtering 2
Filtering 4
Filtering 6
Filtering 8
Filtering 10
Result 6
// 지연 실행
Mapping 1
Filtering 2
Mapping 2
Filtering 4
Mapping 3
Filtering 6
6
즉시 실행 방식은 numbers 컬렉션에 대해 하나의 메서드가 끝난 후 다른 메서드를 실행하는 방식이다. 위 예제에서는 map의 연산이 끝난 후 filter을, filter 연산이 끝난 후 find가 수행된다. 이 과정에서 numbers 배열에 연산의 결과가 덮어씌워지는 것이 아니라, 임시 컬렉션이 생성되면서 연산 결과를 가지고있게 된다.
하지만 sequence를 사용하면 임시 컬렉션이 생성되지 않고, 최종 연산이 호출되어 실행될 때 까지 연기된다.
(Java의 Stream 객체의 동작과 같다고 하는데 이 부분은 잘 모르겠다.)
중간 결과를 따로 저장할 임시 컬렉션이 생성되지 않고, 원소에 연산을 차례대로 적용하다가, 결과가 얻어진 후의 연산은 수행하지 않는다. 그래서 위 결과에서 filtering 6 이후의 연산은 수행되지 않는 것이다.
그렇다면 언제 사용해야할까? 사실 잘 와닿지가 않아서 극단적인 상황을 가정해봤다.
class IteratorVsSequenceTest {
@Test
fun `즉시 실행과 지연 실행의 성능 차이`() {
try {
val numbers = (1..10_000_000).toList()
val iteratorMemory = measureMemoryUsage {
val iteratorTime = measureTimeMillis { iteratorFunction(numbers) }
println("Iterator 실행 시간: ${iteratorTime}ms")
}
val sequenceMemory = measureMemoryUsage {
val sequenceTime = measureTimeMillis { sequenceFunction(numbers) }
println("Sequence 실행 시간: ${sequenceTime}ms")
}
println("Iterator 메모리 사용량: ${iteratorMemory} KB")
println("Sequence 메모리 사용량: ${sequenceMemory} KB")
} catch (e: Exception) {
println("테스트 실패: ${e.message}")
}
}
private fun iteratorFunction(lists: List<Int>): Int? {
return lists
.filter { it % 2 == 0 }
.filter { it % 3 == 0 }
.map { it * it }
.map { it / 2 }
.filter { it > 1_000_000 }
.firstOrNull { it % 123456 == 0 }
}
private fun sequenceFunction(lists: List<Int>): Int? {
return lists.asSequence()
.filter { it % 2 == 0 }
.filter { it % 3 == 0 }
.map { it * it }
.map { it / 2 }
.filter { it > 1_000_000 }
.firstOrNull { it % 123456 == 0 }
}
private fun measureMemoryUsage(block: () -> Unit): Long {
val runtime = Runtime.getRuntime()
System.gc()
val before = runtime.totalMemory() - runtime.freeMemory()
block()
val after = runtime.totalMemory() - runtime.freeMemory()
return (after - before) / 1024
}
}
천 만개의 numbers List를 사용해서, 시퀀스함수에서는 최대한 빨리 조건을 만족해서 빠져나올 수 있도록 구성해봤다. 레퍼런스들을 찾아보고 메모리 사용량을 볼 수 있는 코드도 추가해보았다. 과연 결과는 어떻게 나왔을까?
Iterator 실행 시간: 249ms
Sequence 실행 시간: 6ms
Iterator 메모리 사용량: 184529 KB
Sequence 메모리 사용량: 512 KB
실행 시간 측면에서는 다소 차이가 있었지만 메모리 사용량에서 차이가 엄청나게 많았다. 위에서 설명했듯이 함수 하나하나에 임시 컬렉션을 사용하기 때문에 메모리 사용량에서 엄청난 차이가 있다. 위처럼 극단적인 상황은 없겠지만, 유추해볼 때 파일을 포함한 대용량 데이터를 핸들링할 때 사용해야할 것으로 보인다.
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!