업무 중 우연히 generateSequence라는 Kotlin 함수를 접했습니다. 처음엔 단순히 “값을 반복 생성하는 함수인가?” 정도로 생각했는데, 직접 써보니 예상보다 훨씬 유연하고 흥미로운 기능을 지니고 있더군요. 궁금증이 생겨 문서를 살펴보고, 직접 사용도 해보면서 점점 더 빠져들게 되었습니다.
Kotlin에서는 컬렉션(List, Set)을 다룰 때 대부분 즉시 계산(Eager Evaluation)을 합니다. 즉, 모든 요소를 메모리에 담고 한 번에 처리하죠. 하지만 때로는 필요한 순간에만 값을 계산하고 싶을 때가 있습니다.
이럴 때 사용하는 것이 바로 시퀀스(Sequence)입니다.
그리고 시퀀스 중에서도 generateSequence는 정말 강력하고 유연한 함수 중 하나예요.
말 그대로 “다음 값이 어떻게 만들어질지 직접 정의”해서 무한히 이어지는 데이터 흐름을 만들 수 있습니다.
generateSequence는 기본적으로 다음과 같이 작성합니다.
val numbers = generateSequence(1) { it + 1 }
이 시퀀스는 1부터 시작해서 2, 3, 4... 끝없이 증가합니다.
하지만 시퀀스는 게으르게(lazy) 평가되므로, 실제로 사용하기 전까지는 아무 값도 생성하지 않아요.
println(numbers.take(5).toList())
// [1, 2, 3, 4, 5]
여기서 take(5)가 없었다면 프로그램은 무한히 값을 만들기만하고 멈추지 않았을 겁니다.
generateSequence는 null을 반환하는 순간 멈춥니다.
val limited = generateSequence(1) {
if (it < 10) it + 1 else null
}
println(limited.toList())
// [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
null이 반환되면 시퀀스가 종료되는 원리입니다.
이렇게 하면 “조건이 만족될 때까지만 동작하는 시퀀스”를 간단히 만들 수 있습니다.
일반 컬렉션(List)과 다르게 Sequence는 모든 연산이 lazy하게 동작합니다.
즉, 중간에 여러 변환(map, filter 등)을 거치더라도 실제 연산은 최종 결과(fetch)가 필요할 때만 수행됩니다. 이 방식은 특히 다음과 같은 상황에서 사용하기 좋습니다.
시퀀스는 메모리를 아껴가며 점진적으로 데이터를 처리할 수 있습니다.
generateSequence는 단순히 수열을 만드는 도구에 그치지 않습니다.
“조건을 만족할 때까지 반복 시도한다”는 개념으로 확장하면, 실무에서도 상당히 유용한 패턴이 됩니다.
예를 들어, 어떤 시스템에서 고유한 키나 토큰(API Key, 초대 코드 등)을 생성해야 한다고 해볼까요?
랜덤으로 키를 만들되, 이미 존재하는 값이면 다시 생성해야 하는 경우가 흔히 있습니다.
이때 대부분은 while (true) 루프로 구현하지만, generateSequence를 활용하면 훨씬 깔끔하고 함수형스럽게 처리할 수 있습니다.
fun createResource(entity: ResourceEntity): ResourceModel {
val uniqueKey = generateSequence { keyGenerator.generate() }
.first { candidate ->
try {
repository.findByKey(candidate)
false // 이미 존재하면 다음 키 생성
} catch (_: Exception) {
true // 존재하지 않으면 사용 가능
}
}
val newEntity = entity.copy(key = uniqueKey)
return repository.save(newEntity)
}
위 코드는 “유효한 키가 나올 때까지 무한히 생성하다가, 처음 유효한 키에서 멈추는 로직”을 표현합니다.
동작 방식은 다음과 같습니다.
generateSequence { keyGenerator.generate() }
→ 새로운 키를 무한히 생성합니다..first { ... }
→ “DB에 이미 존재하지 않는 첫 번째 키”를 찾습니다.- 조건을 만족하면 그 시점에서 시퀀스가 멈추고, 해당 값을 반환합니다.
보다 명시적인 메서드를 사용할 수 있다면,
아래처럼 더 간결하게 표현할 수도 있습니다.
val uniqueKey = generateSequence { keyGenerator.generate() }
.first { candidate -> !repository.existsByKey(candidate) }
이 한 줄로 “고유한 키가 나올 때까지 반복 생성”이라는 로직을 완벽히 표현할 수 있습니다.
불필요한 루프 없이, 읽는 순간 의도가 명확히 드러나죠.
저 역시 실무에서 이 방식을 택한 이유가 있습니다.
결과적으로 이 패턴은 초대 코드, URL Slug, API Key, 사용자 토큰 생성 등
“고유한 값을 보장해야 하는 대부분의 상황”에 손쉽게 적용할 수 있었습니다.
val evenNumbers = generateSequence(0) { it + 2 }
println(evenNumbers.take(6).toList())
// [0, 2, 4, 6, 8, 10]
.take(6)로 처음 6개만 가져오기data class Node(val value: Int, val next: Node?)
val node3 = Node(3, null)
val node2 = Node(2, node3)
val node1 = Node(1, node2)
val result = generateSequence(node1) { it.next }
.map { it.value }
.toList()
println(result)
// [1, 2, 3]
fun fetchPage(page: Int): List<String>? {
return if (page <= 3) listOf("Page$page") else null
}
val allData = generateSequence(1) { it + 1 }
.mapNotNull { fetchPage(it) }
.flatten()
.toList()
println(allData)
// [Page1, Page2, Page3]
| 구분 | List | Sequence |
|---|---|---|
| 계산 시점 | 즉시(Eager) | 게으르게(Lazy) |
| 메모리 사용 | 모든 요소 저장 | 필요한 시점에 생성 |
| 적합한 상황 | 작은 데이터 | 큰 데이터, 스트림, 무한 시퀀스 |
| 구현 난이도 | 단순 | 조금 더 구조적 |
generateSequence를 사용하게 된 이유는 이 함수가 단순히 반복문을 대체하는 수준을 넘어서기 때문입니다.
단순히 값을 생성하는 도구가 아니라 조건 기반 반복, 지연(lazy) 계산, 그리고 상태에 따라 달라지는 흐름까지 함수형 스타일로 자연스럽게 표현할 수 있게 해주었습니다.
특히 실무에서는 “조건이 충족될 때까지 반복 시도”라는 패턴에서 그 진가를 제대로 느낄 수 있었습니다.
이 방식을 잘 활용하면 while(true)나 재귀 같은 구조보다 훨씬 선언적이고 예측 가능한 코드를 작성할 수 있습니다.
번외: 프로그래밍에서 ‘게으르다(lazy)’의 의미
여기서 말하는 게으름(lazy)은 성능 최적화를 위한 ‘필요할 때만 계산하는 방식’을 뜻합니다.
즉, “모든 값을 미리 계산하지 않고, 실제로 필요해질 때 계산한다”는 개념이죠.
Kotlin의 시퀀스가 바로 이 원리를 기반으로 동작합니다.