컬렉션 연산의 문제와 Sequence의 지연 계산을 통한 해결
컬렉션 연산의 문제
컬렉션을 사용해 람다 연산을 하게 되면 비효율적으로 동작한다. 예를 들어 컬렉션(리스트)에서 가장 먼저 나오는 짝수값을 찾고 싶다고 해보자. 이런 코드는 다음과 같이 작성될 수 있다.
fun main() {
val collection = listOf(1, 2, 3, 4, 5, 6, 7, 8)
val result = collection.filter {
println("filter >> $it 은 짝수인가? >> ${it % 2 == 0}")
it % 2 == 0 // 짝수만 필터링
}.first()
println(result)
}
이렇게 작성된 코드가 어떻게 동작하는지 코드를 실행해 확인해 보자. 그러면 다음과 같은 결과를 확인할 수 있다.
filter >> 1 은 짝수인가? >> false
filter >> 2 은 짝수인가? >> true
filter >> 3 은 짝수인가? >> false
filter >> 4 은 짝수인가? >> true
filter >> 5 은 짝수인가? >> false
filter >> 6 은 짝수인가? >> true
filter >> 7 은 짝수인가? >> false
filter >> 8 은 짝수인가? >> true
2
Process finished with exit code 0
우리가 필요한 것은 짝수인 하나의 값 뿐인데, 리스트의 모든 값에 짝수인지 필터링하는 연산이 들어갔다. 이 때문에 두 번이면 충분할 연산이 불필요하게 여덟 번이나 일어났다.
Sequence 사용해 컬렉션 연산 최적화 하기
위의 문제는 sequence를 사용하면 단 번에 해결된다. 위의 코드를 List 대신 Sequence를 사용하도록 다음과 같이 바꿔보자.
fun main() {
val sequence = sequenceOf(1, 2, 3, 4, 5, 6, 7, 8)
val result = sequence.filter {
println("filter >> $it 은 짝수인가? >> ${it % 2 == 0}")
it % 2 == 0 // 짝수만 필터링
}.first()
println(result)
}
코드를 실행하면 다음과 같은 결과를 확인할 수 있다.
filter >> 1 은 짝수인가? >> false
filter >> 2 은 짝수인가? >> true
2
Process finished with exit code 0
단 두 번의 연산만 일어나는 것을 볼 수 있다.
여기에서 볼 수 있듯이 Sequence는 원소를 순차적으로 처리함으로써, 필요한 값이 나올 때까지 필요한 연산만 실행한다. 이를 가리켜 Sequence는 지연 계산(Lazy Evaluation)을 실행한다고도 한다. 지금부터 Sequence를 사용하는 방법에 대해 더욱 자세히 알아보자.
Sequence 사용법
Sequence 생성하기
Sequence를 생성하기 위해서는 다양한 방법을 사용할 수 있다.
sequenceOf 함수를 사용한 Sequence 생성
위의 코드에서 본 sequenceOf 함수를 사용해 임의의 원소로 Sequence를 만들어 낼 수 있다.
val sequence = sequenceOf(1, 2, 3, 4, 5, 6, 7, 8)
asSequence 함수를 사용해 컬렉션을 Sequence로 변환
Sequence는 컬렉션을 최적화 하기 위해 사용될 수 있기 때문에, 컬렉션은 모두 asSequence 확장 함수를 통해 Sequence로 변환될 수 있다. 엄밀히 말하면, Collection이 Iterable 인터페이스를 상속받는데, asSequence 함수는 Iterable 인터페이스에 대한 확장 함수로 선언돼 있다.
public interface Collection<out E> : Iterable<E>
public fun <T> Iterable<T>.asSequence(): Sequence<T> {
return Sequence { this.iterator() }
}
예를 들어 다음과 같이 List를 Sequence로 변환할 수 있다.
val sequence = listOf(1, 2, 3, 4, 5, 6, 7, 8).asSequence()
Sequence의 연산
Sequence의 지연 계산
Sequence의 연산은 기본적으로 지연 계산 방식으로 이뤄진다. 따라서 계산을 할 필요가 없다면, 아예 계산이 이뤄지지 않는다. 예를 들어 다음과 같은 코드를 살펴보자.
fun main() {
val sequence = listOf(1, 2, 3, 4, 5, 6, 7, 8).asSequence()
sequence.filter {
println("filter >> $it 은 짝수인가? >> ${it % 2 == 0}")
it % 2 == 0 // 짝수만 필터링
}
}
위 코드를 실행했을 때 어떤 결과가 나올 것으로 예상되는가? 아마도 이전과 같이 다음 결과가 나올 것으로 예상할 수 있다.
filter >> 1 은 짝수인가? >> false
filter >> 2 은 짝수인가? >> true
2
Process finished with exit code 0
하지만 실제 위 코드를 실행해 보면, 다음과 같이 아무런 결과가 없이 프로세스가 종료되는 것을 볼 수 있다.
이유는 바로 '지연계산' 특성 때문이다. Sequence는 터미널 연산(Terminal Operation)이라 불리는 결괏값을 얻고자 하는 연산이 있을 때만 각 원소에 대해 연산이 일어나기 시작하며, 위의 코드에서 filter은 중간 연산(Intermediate Operation)이기 때문에 연산이 일어나지 않는다.
터미널 연산자
터미널 연산(Terminal Operation)이란 결과값을 얻는 연산자를 뜻하며, 대표적으로 앞서 사용한 가장 먼저 오는 값을 얻기 위한 first가 있다. 그 외에도 모든 결과값을 List로 변환하기 위한 toList, 모든 결과값을 Set으로 변환하기 위한 toSet 같은 함수들도 터미널 연산자이다. 한 번 위의 코드의 filter 연산 뒤에 toSet()을 붙여 다음과 같이 만들어보자.
fun main() {
val sequence = listOf(1, 2, 3, 4, 5, 6, 7, 8).asSequence()
sequence.filter {
println("filter >> $it 은 짝수인가? >> ${it % 2 == 0}")
it % 2 == 0 // 짝수만 필터링
}.toSet()
}
그러면 다음과 같이 결과가 나오는 것을 볼 수 있다.
정리
- Sequence는 sequenceOf 함수나 asSequence 함수를 통해 만들어질 수 있다.
- Sequence는 지연 계산을 통해 연산을 최적화한다.
- 지연 계산 특성 때문에 터미널 연산자가 없으면 연산이 이뤄지지 않는다.