시퀀스로 컬렉션 처리를 지연 실행하기
시퀀스로 컬렉션 처리를 지연 실행하기
컬렉션과 함께 Kotlin 표준 라이브러리에는 또 하나의 타입이 있어요. 바로 **시퀀스(Sequence<T>)**예요. 컬렉션과 달리 시퀀스는 요소를 "담고" 있지 않고, 반복하는 동안 요소를 만들어내요. 같은 함수들을 제공하지만, 여러 단계의 컬렉션 처리를 다른 방식으로 구현해요.
핵심 차이는 실행 시점이에요. Iterable의 다단계 처리는 각 단계가 끝나고 중간 컬렉션을 만드는 즉시(eager) 실행 방식이고, 시퀀스의 다단계 처리는 가능한 한 지연(lazy) 실행돼요. 실제 계산은 전체 처리 체인의 결과를 요청할 때에야 일어나요. 처리 순서도 달라서, 시퀀스는 모든 처리 단계를 각 요소마다 하나씩 차례로 수행해요.
이 페이지에서는 시퀀스를 만드는 여러 방법과, Iterable과 Sequence의 처리 방식이 어떻게 다른지 예시로 살펴볼게요.
시퀀스 만들기
요소로부터
시퀀스를 만들려면 sequenceOf() 함수에 요소를 인자로 나열하면 돼요.
val numbersSequence = sequenceOf("four", "three", "two", "one")
Iterable로부터
이미 Iterable 객체(List나 Set 같은)가 있다면 asSequence()를 호출해 시퀀스로 바꿀 수 있어요.
val numbers = listOf("one", "two", "three", "four")
val numbersSequence = numbers.asSequence()
함수로부터
함수를 써서 시퀀스를 만들 수도 있어요. generateSequence()에 요소를 계산하는 함수를 인자로 넘기면 돼요. 선택적으로 첫 요소를 명시적 값이나 함수 호출 결과로 지정할 수 있고, 제공된 함수가 null을 반환하면 생성을 멈춰요. 그래서 아래 예시의 시퀀스는 무한해요.
fun main() {
val oddNumbers = generateSequence(1) { it + 2 } // `it`은 이전 요소
println(oddNumbers.take(5).toList())
//println(oddNumbers.count()) // 오류: 시퀀스가 무한함
}
generateSequence()로 유한한 시퀀스를 만들려면 필요한 마지막 요소 다음에 null을 반환하는 함수를 제공하면 돼요.
fun main() {
val oddNumbersLessThan10 = generateSequence(1) { if (it < 8) it + 2 else null }
println(oddNumbersLessThan10.count())
}
청크(chunk)로부터
마지막으로 요소를 하나씩 또는 임의 크기의 청크로 만들어내는 함수가 있어요. sequence() 함수예요. 이 함수는 yield()와 yieldAll() 함수 호출을 담은 람다를 인자로 받아요. 이 함수들은 요소를 시퀀스 소비자에게 돌려주고, 소비자가 다음 요소를 요청할 때까지 sequence()의 실행을 일시 중단해요. yield()는 단일 요소를 받고, yieldAll()은 Iterable 객체, Iterator, 또는 다른 Sequence를 받을 수 있어요. yieldAll()의 Sequence 인자는 무한할 수 있지만, 그런 호출은 마지막이어야 해요. 이후의 모든 호출은 실행되지 않으니까요.
fun main() {
val oddNumbers = sequence {
yield(1)
yieldAll(listOf(3, 5))
yieldAll(generateSequence(7) { it + 2 })
}
println(oddNumbers.take(5).toList())
}
시퀀스 연산
시퀀스 연산은 상태 요구량에 따라 분류할 수 있어요.
- 무상태(stateless) 연산은 상태가 필요 없고 각 요소를 독립적으로 처리해요. 예를 들어
map()이나filter()가 있어요. 무상태 연산이 요소를 처리하는 데 작은 상수 크기의 상태를 요구할 수도 있는데,take()나drop()이 그렇죠. - 상태 유지(stateful) 연산은 상당한 상태를 요구하는데, 보통 시퀀스의 요소 수에 비례해요.
시퀀스 연산이 또 다른 시퀀스를 반환하면, 지연해서 만들어진다면 중간(intermediate) 연산이라고 불러요. 그렇지 않으면 종단(terminal) 연산이에요. 종단 연산의 예로는 toList()나 sum()이 있어요. 시퀀스 요소는 종단 연산으로만 얻을 수 있어요.
시퀀스는 여러 번 반복할 수 있어요. 다만 일부 시퀀스 구현은 한 번만 반복되도록 제한할 수 있는데, 그 점은 각 문서에서 명시적으로 언급돼요.
시퀀스 처리 예시
Iterable과 Sequence의 차이를 예시로 확인해 볼게요. 세 글자보다 긴 단어만 거르고, 그런 단어 중 처음 네 개의 길이를 출력한다고 해볼게요.
Iterable 방식
fun main() {
val words = "The quick brown fox jumps over the lazy dog".split(" ")
val lengthsList = words.filter { println("filter: $it"); it.length > 3 }
.map { println("length: ${it.length}"); it.length }
.take(4)
println("Lengths of first 4 words longer than 3 chars:")
println(lengthsList)
}
실행해 보면 filter()와 map()이 코드에 나타난 순서대로 실행돼요. 먼저 모든 요소에 대해 filter:가 찍히고, 필터링 후 남은 요소에 대해 length:가 찍힌 뒤 마지막 두 줄이 출력돼요. 즉 각 단계가 전체 컬렉션에 대해 완료된 뒤 다음 단계로 넘어가는 방식이에요.
Sequence 방식
같은 코드를 시퀀스로 작성해 볼게요.
fun main() {
val words = "The quick brown fox jumps over the lazy dog".split(" ")
// List를 Sequence로 변환
val wordsSequence = words.asSequence()
val lengthsSequence = wordsSequence.filter { println("filter: $it"); it.length > 3 }
.map { println("length: ${it.length}"); it.length }
.take(4)
println("Lengths of first 4 words longer than 3 chars")
// 종단 연산: 결과를 List로 얻기
println(lengthsSequence.toList())
}
출력을 보면 filter()와 map()은 결과 리스트를 만들 때에만 호출돼요. 그래서 먼저 "Lengths of.." 텍스트 줄이 나온 뒤에야 시퀀스 처리가 시작돼요. 필터링 후 남은 요소에 대해서는 다음 요소를 필터링하기 전에 map이 실행되고, 결과 크기가 4에 도달하면 take(4)가 반환할 수 있는 최대 크기이므로 처리가 멈춰요.
이 예시에서 지연 처리와 네 개를 찾은 뒤의 조기 중단 덕분에 리스트 방식보다 연산 횟수가 줄어들어요. 작은 컬렉션이나 단순한 계산에서는 시퀀스의 지연 특성이 오버헤드로 다가올 수 있으니, Sequence와 Iterable을 모두 고려해 상황에 맞는 쪽을 고르는 게 좋아요.