이전 편을 먼저 읽는 것을 권장드립니다.
이 글에서는 Sequence의 이해를 돕는 것을 목표로 합니다.
아래 사항 중 하나라도 해당된다면, 이 글이 도움이 될 것입니다.
🤷♂️ Collection은 아는데, Sequence는 모르겠다.
🤷♂️ Sequence가 어떻게 구현되어 있는지 알고 싶다.
🤷 Collection도 있는데, Sequence를 언제 / 왜 사용해야 하는지 알고 싶다.
🤷♀️ Sequence로 작성된 코드를 읽는 방법을 알고 싶다.
Sequence는 Lazy evaluation 원리를 바탕으로, 많은 양의 데이터를 처리할 때 불필요한 계산을 피하고 메모리 낭비를 줄일 수 있다는 점에서 컬렉션과 비교됩니다.
즉, Sequence는 필요한 순간까지 연산을 미루고, 실제로 결과가 필요할 때만 데이터를 처리합니다.
시퀀스의 사용 흐름을 이해하기 위해서는 값을 만들어내는 측인 생산자와 값을 사용하는 측인 소비자의 구성을 이해해야 합니다.
생산자(Producer): 데이터를 만들어내는 역할
중간 연산자(Intermediate): 생산자와 소비자 사이를 연결하지만, 데이터를 소비하지 않음
소비자(Consumer): 데이터를 실제로 사용하는 역할
Sequence는 보통 컬렉션을 시퀀스로 변환하거나 직접 시퀀스 빌더 함수를 활용하여 생성할 수 있습니다.
asSequence()를 통해 이미 결정된 형태의 리스트를 시퀀스로 변환할 수 있습니다.
val sequence = listOf(1, 2, 3).asSequence()
sequence {}는 yield를 통해 값을 하나씩 생산하는 코루틴 기반 시퀀스입니다. yield()는 sequence 빌더 함수 내부에서 값을 하나씩 생산할 때 사용합니다.
val sequence = sequence {
yield(1)
yield(2)
yield(3)
}
sequenceOf 함수를 활용하면, 정해진 여러 개의 값을 직접 시퀀스에 넣어 정의할 수 있습니다.
val fruits = sequenceOf("apple", "banana", "cherry")
generateSequence를 사용하면 조건이 없는 한 무한히 값을 생성할 수 있습니다.
val numbers = generateSequence(1) { it + 1 }
val evenNumbers = naturals.filter { it % 2 == 0 }.take(10)
take(10)과 같은 종단 연산자를 사용해 원하는 만큼만 소비할 수 있습니다.
중간 연산자는 새로운 시퀀스를 반환하며, 실제 실행은 종단 연산자가 호출될 때까지 미뤄집니다.
중간 연산자는 시퀀스를 반환하기 때문에, 각 원소에 여러 연산들을 연쇄적으로 처리하기 위해 체이닝 호출을 활용할 수 있습니다.
val result = (1..10).asSequence()
.map { it * 2 }
.filter { it % 3 == 0 }
.take(2)
대표적인 연산자들은 다음과 같습니다.
| 함수 | 설명 |
|---|---|
map | 원소를 변환 |
filter | 조건을 만족하는 원소만 유지 |
onEach | 중간에 보여주기만 하고 값은 유지 |
take(n) | 처음 n개의 원소만 사용 |
drop(n) | 처음 n개를 빼고 사용 |
takeWhile | 조건을 만족하는 동안 시퀀스 원소 사용 |
핵심은 시퀀스의 각 요소는 파이프라인 처리 방식으로 처리된다는 것입니다.
각 요소가 단계별로 흐르며, 전체 연산을 끝낸 후 다음 요소로 넘어가기 때문입니다.
Sequence는 일반 컬렉션과 달리, 다음과 같은 방식으로 결과를 소비합니다.
종단 연산자는 시퀀스를 실제 실행한다는 측면에서 중요한 요소입니다.
특정 연산자는 시퀀스를 순회하며 결과 값을 반환하지 않고 특정 동작만 수행하도록 하는 반면, 어떤 연산자들은 결과를 반환하기도 합니다.
| 함수 | 설명 |
|---|---|
toList() | List로 변환하여 반환 |
toSet() | Set로 변환하여 반환 |
count() | 시퀀스의 원소 개수 계산하여 반환 |
first() / last() | 처음 / 마지막 값 반환 |
forEach | 각 원소를 활용해 적용할 동작 정의 |
val seq = sequenceOf(1, 2, 3)
val list = seq.toList() // 리스트 형태로 반환
println(list)
[1, 2, 3]
val seq = sequenceOf(1, 2, 3)
seq.forEach { println("consume $it") } // 각 원소에 적용될 동작 정의
consume 1
consume 2
consume 3
주의해야 할 점은, 시퀀스는 한 번만 소비할 수 있다는 것입니다.
예를 들어 다음 코드에서 toList() 이후의 모든 종단 연산자들은 동작하지 않습니다.
val seq = sequenceOf(1, 2, 3)
// 최초 시퀀스 소비
val list = seq.toList() // [1, 2, 3]
// 이하 코드들은 동작하지 않음!
val count = seq.count()
val first = seq.first()
val last = seq.last()
seq.forEach { println(it) }
핵심은 시퀀스의 한 원소가 전체 연산을 끝낸 후에야 다음 원소로 넘어간다는 것입니다.
val result = (1..10).asSequence()
.map { it * 2 }
.filter { it % 3 == 0 }
.take(2)
.toList()
(1..10).asSequence() 측에서 숫자 1부터 10까지를 시퀀스로 만듭니다.map): 각 요소를 2배로 만듭니다.filter): 3의 배수만 남깁니다.take): 위 과정을 통과한 처음 2개만 가져옵니다.toList): 결과를 리스트로 변환하면서 시퀀스를 평가합니다.코드의 흐름을 시각화 하기 위해 IDE의 디버그 기능을 활용하거나, 중간 연산자들 중 map과 filter 내부에 로깅을 위한 코드를 추가해볼 수 있습니다.
val result = (1..10).asSequence()
.map {
println("Mapping $it")
it * 2
}
.filter {
println("Filtering $it")
it % 3 == 0
}
.take(2)
.toList()
Mapping 1
Filtering 2
Mapping 2
Filtering 4
Mapping 3
Filtering 6
Mapping 4
Filtering 8
Mapping 5
Filtering 10
결론적으로 해당 코드의 흐름을 아래와 같이 표현해볼 수 있습니다.
asSequence()로 생성!
1
↓ map 실행 결과 → 2
↓ filter 실행 결과 → 버림
↓ take 실행 결과 → 계속
2
↓ map 실행 결과 → 4
↓ filter 실행 결과 → 버림
↓ take 실행 결과 → 계속
3
↓ map 실행 결과 → 6
↓ filter 실행 결과 → **OK!**
↓ take 실행 결과 → 계속
4
↓ map 실행 결과 → 8
↓ filter 실행 결과 → 버림
↓ take 실행 결과 → 계속
5
↓ map 실행 결과 → 10
↓ filter 실행 결과 → 버림
↓ take 실행 결과 → 계속
6
↓ map 실행 결과 → 12
↓ filter 실행 결과 → **OK!**
↓ take 실행 결과 → 2개라 모두 채워졌으므로 종료!
toList()로 실행!
이번 글에서 살펴봤던 내용은 시퀀스 그 자체의 이해에도 그 목적이 있지만, Flow 등 유사한 데이터 스트림을 이해하기 위해 중요한 기초 지식이라고 생각합니다. 다음 시리즈에서는 Flow의 원리와 활용을 알아보도록 하겠습니다.