- Published on
Kotlin Sequence vs Flow - 지연평가 함정 정리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 배치 코드에서 컬렉션 파이프라인을 만들다 보면 Sequence 와 Flow 를 비슷한 “스트림” 으로 취급하기 쉽습니다. 둘 다 지연평가를 제공하고, map filter take 같은 연산자도 닮았기 때문입니다.
하지만 실행 시점과 실행 모델이 다릅니다. 이 차이를 모르고 섞어 쓰면 다음 같은 문제가 생깁니다.
- 지연평가가 “메모리 절약” 이 아니라 “지연된 폭탄” 이 되어, 특정 타이밍에 CPU 스파이크나 DB 부하가 한꺼번에 터짐
Flow를Sequence처럼 생각해 블로킹 I/O 를 끼워 넣어 이벤트 루프나 디스패처를 막음Sequence를 “취소 가능한 스트림” 처럼 기대했다가 중간 중단이 안 되거나 리소스가 오래 잡혀 누수처럼 보임
이 글은 Sequence 와 Flow 의 지연평가가 어디까지 같은지, 어디서부터 완전히 다른지, 그리고 실무에서 자주 겪는 함정을 안전한 패턴으로 정리합니다.
1) 핵심 차이: 지연평가의 의미가 다르다
Sequence: 동기 pull 기반 지연평가
- 소비자가
next()를 호출할 때마다 한 요소씩 “당겨오는(pull)” 모델 - 같은 스레드에서 동기적으로 실행되는 것이 기본
- 취소 개념이 사실상 없고(반복 중단은 가능), backpressure 는 소비자가 천천히
next()를 호출하는 것으로 자연스럽게 걸림
즉 Sequence 의 지연평가는 “지금 당장 다 만들지 않고, 필요할 때 한 개씩 계산” 에 가깝습니다.
Flow: 비동기 push 기반 지연평가(콜드 스트림)
- 기본적으로 콜드(cold)라서
collect가 호출되기 전까지 실행되지 않음 - 생산자가
emit으로 “밀어 넣는(push)” 모델이지만, 코루틴 서스펜션과 구조적 동시성 덕분에 backpressure 를 표현할 수 있음 - 취소가 1급이며, 컨텍스트(디스패처)와 예외 전파 규칙이 명확함
즉 Flow 의 지연평가는 “수집(collect) 시점에 코루틴이 실행되며, 취소 가능하고 비동기 경계를 가진 스트림” 입니다.
2) 함정 1: Sequence 는 지연평가지만 리소스를 오래 잡을 수 있다
Sequence 는 “필요할 때만 계산” 이라서 안전해 보이지만, 리소스(파일, DB 커서, 네트워크 스트림)를 시퀀스 생성 시점에 잡아두면 소비가 끝날 때까지 해제되지 않을 수 있습니다.
나쁜 예: 파일 핸들을 시퀀스 생명주기에 묶어버림
import java.io.File
fun linesSequence(path: String): Sequence<String> {
val reader = File(path).bufferedReader() // 여기서 리소스 오픈
return reader.lineSequence() // 소비가 끝날 때까지 닫히지 않음
}
fun main() {
val seq = linesSequence("/var/log/app.log")
// 중간에 예외가 나거나, 일부만 소비하고 버리면?
println(seq.take(10).toList())
// reader.close() 호출이 보장되지 않음
}
위 코드는 “일부만 읽기” 는 되지만, 리더가 제대로 닫히지 않을 수 있습니다. 특히 예외가 발생하거나, Sequence 를 반환한 뒤 다른 코드가 소비를 미루면 파일 핸들이 오래 유지됩니다.
안전한 패턴: use 로 소비 범위를 강제
import java.io.File
fun readFirstNLines(path: String, n: Int): List<String> {
return File(path).bufferedReader().use { br ->
br.lineSequence().take(n).toList()
}
}
Sequence 는 “파이프라인을 반환하는 API” 로 노출할 때 특히 조심해야 합니다. 리소스는 가능하면 “소비하는 함수 내부” 에서 열고 닫는 형태가 안전합니다.
이런 리소스 홀딩이 누적되면 메모리보다 먼저 FD 고갈이나 프로세스 압박으로 이어질 수 있고, 상황에 따라 OOM 이나 강제 종료 분석이 필요해집니다. 관련해서는 리눅스 OOM Killer로 프로세스 죽음 원인 추적 글도 함께 참고하면 좋습니다.
3) 함정 2: Sequence 는 “한 번만” 돈다고 생각했는데, 실제로는 매번 재평가된다
Sequence 는 지연평가라서, 최종 연산을 여러 번 호출하면 동일한 파이프라인이 매번 다시 실행됩니다.
val seq = generateSequence(1) { it + 1 }
.map {
println("map: $it")
it * 2
}
println(seq.take(3).toList())
println(seq.take(3).toList())
출력은 map 로그가 두 번 반복됩니다. 실무에서 이 패턴이 위험한 이유는 다음과 같습니다.
map안에서 DB 조회, HTTP 호출 같은 사이드 이펙트가 있으면 “중복 호출” 이 발생- 디버깅 로그나 메트릭이 중복 집계되어 관측이 왜곡
해결책: 필요하면 명시적으로 materialize
- 중복 실행이 싫으면
toList()toSet()등으로 한 번 물질화 - 또는 “부작용 없는 순수 함수” 로만 파이프라인을 구성
4) 함정 3: Flow 에서 블로킹 I/O 를 그대로 호출해 디스패처를 막는다
Flow 는 코루틴 기반이라서 “비동기니까 안전” 하다고 착각하기 쉽습니다. 하지만 flow {} 내부에서 블로킹 호출을 하면 해당 코루틴이 실행되는 스레드를 그대로 막습니다.
나쁜 예: flow 내부에서 블로킹 호출
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun badFlow(): Flow<String> = flow {
// 예: Thread.sleep, JDBC 블로킹 쿼리, 블로킹 HTTP
Thread.sleep(200)
emit("done")
}
이 코드는 호출 컨텍스트에 따라 Default 디스패처나 심지어 메인 스레드를 막을 수 있습니다.
안전한 패턴 1: flowOn 으로 실행 컨텍스트 분리
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
fun ioFlow(): Flow<String> = flow {
// 블로킹 I/O 라면 IO 디스패처에서 수행되게 분리
Thread.sleep(200)
emit("done")
}.flowOn(Dispatchers.IO)
안전한 패턴 2: 애초에 suspend 친화 API 사용
가능하면 Ktor 같은 suspend 기반 클라이언트, R2DBC 같은 논블로킹 드라이버를 사용해 “블로킹을 옮기는” 수준을 넘어 “블로킹을 제거” 하는 편이 낫습니다.
5) 함정 4: Flow 의 지연평가가 “한 번만 실행” 을 뜻하지 않는다
Flow 는 콜드 스트림이라서 collect 할 때마다 새로 실행됩니다. Sequence 의 “재평가” 와 유사하지만, 비동기/취소/컨텍스트가 얽히며 더 큰 사고로 이어질 수 있습니다.
import kotlinx.coroutines.flow.flow
val f = flow {
println("start")
emit(fetchFromRemote())
}
// collect 를 두 번 하면 fetchFromRemote 도 두 번 호출됨
해결책: 공유가 필요하면 shareIn 또는 stateIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
suspend fun example() {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val state = flow {
emit(fetchFromRemote())
}.stateIn(
scope = scope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = "loading"
)
// 여러 구독자가 붙어도 upstream 호출을 공유할 수 있음
}
이 지점은 “지연평가” 라는 단어만 보고 Sequence 처럼 생각하면 특히 헷갈립니다. Flow 는 기본적으로 “구독 단위 실행” 이라는 점을 항상 의식해야 합니다.
6) 함정 5: Sequence 를 Flow 로 바꿨는데 빨라지기는커녕 느려진다
Sequence 는 단일 스레드에서 매우 낮은 오버헤드로 동작합니다. 반면 Flow 는 코루틴/서스펜션/컨텍스트 전환 비용이 들어갑니다.
- 순수 CPU 연산을 작은 요소 단위로
emit하면 오히려 오버헤드가 커질 수 있음 - 아주 짧은 파이프라인에서
Flow로 바꾸는 것은 성능 최적화가 아니라 “복잡성 추가” 가 될 수 있음
판단 기준
- CPU 집약적이고 동기 데이터라면
Sequence또는 컬렉션 연산이 더 단순하고 빠를 때가 많음 - I/O 바운드, 비동기 소스, 취소/타임아웃/재시도, 스트림 결합이 중요하면
Flow가 적합
7) 함정 6: backpressure 를 오해한다 (buffer, conflate, collectLatest)
Flow 는 기본적으로 “생산자가 너무 빠르면 소비자가 따라갈 때까지 서스펜드” 되며 backpressure 가 걸립니다. 그런데 buffer 나 conflate 같은 연산자를 무심코 붙이면 성격이 바뀝니다.
예: UI 이벤트나 로그 스트림에서 conflate 로 데이터 유실
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flow
val events = flow {
repeat(1_000) { emit(it) }
}
val conflated = events.conflate() // 느린 소비자면 중간 값이 건너뛰어질 수 있음
conflate는 최신 값만 유지하는 전략이라 “모든 이벤트를 처리” 해야 하는 경우 부적합collectLatest는 이전 작업을 취소하므로, 사이드 이펙트 작업(저장, 결제, 전송 등)에 쓰면 위험
이런 실수는 트래픽이 순간적으로 몰릴 때 더 크게 드러납니다. 특히 서버리스나 컨테이너 환경에서 지연이 누적되면 장애로 보일 수 있는데, 콜드스타트 및 지연 튜닝 관점은 GCP Cloud Run 503와 콜드스타트 지연 원인·튜닝 도 참고할 만합니다.
8) 실전 가이드: 어떤 상황에 무엇을 쓰나
Sequence 가 좋은 경우
- 이미 메모리에 있는 컬렉션을 “지연 변환” 하고 싶다
- 동기/단일 스레드에서 충분하고, 취소/타임아웃이 크게 중요하지 않다
- 변환이 순수 함수 중심이며, 부작용이 없다
주의할 점은 다음 두 가지입니다.
- 리소스를 시퀀스 생명주기에 묶지 말 것
- 최종 연산을 여러 번 호출하면 재평가된다는 점을 문서화할 것
Flow 가 좋은 경우
- 비동기 데이터 소스(네트워크, DB change stream, 메시지 큐)
- 취소, 타임아웃, 재시도, 병합, 최신값 처리 같은 스트림 제어가 필요
- 느린 소비자에 대한 backpressure 를 명시적으로 다루고 싶다
주의할 점은 다음입니다.
- 블로킹 I/O 는
Dispatchers.IO로 격리하거나 suspend API 로 전환 - 콜드 스트림 특성상 구독마다 재실행됨을 인지하고 공유가 필요하면
stateInshareIn bufferconflatecollectLatest는 데이터 유실/취소를 의도한 경우에만 사용
9) 변환 패턴: Sequence 와 Flow 를 섞어야 할 때
실무에서는 “대량의 동기 데이터” 를 가공하다가 “중간부터 비동기 처리” 로 넘어가는 경우가 많습니다.
Sequence 에서 Flow 로 전환
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.Dispatchers
val seq = (1..1_000_000).asSequence()
.filter { it % 2 == 0 }
.take(1000)
val flow = seq.asFlow()
.map { expensiveRemoteCall(it) }
.flowOn(Dispatchers.IO)
포인트는 “전환 지점” 을 명확히 두는 것입니다.
Sequence로 충분한 구간은Sequence로 유지- I/O 가 섞이는 순간부터
Flow로 전환하고 컨텍스트를 분리
Flow 를 리스트로 모으기 전에 경계 설정
Flow 를 toList() 로 모으면 결국 메모리에 다 올라옵니다. “지연평가니까 메모리 안전” 이라는 기대가 깨지는 지점입니다.
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
suspend fun safeCollect(f: kotlinx.coroutines.flow.Flow<Int>): List<Int> {
// 반드시 상한을 둔다
return f.take(10_000).toList()
}
상한 없는 수집은 트래픽이나 데이터 크기가 커질 때 OOM 으로 직결될 수 있습니다. OOM 징후 분석은 K8s CrashLoopBackOff·OOMKilled 원인과 해결 도 함께 보면 좋습니다.
10) 체크리스트: 지연평가 함정 회피용
Sequence- 리소스 오픈을 시퀀스 생성 시점에 하지 말고, 가능하면
use범위 안에서 소비까지 끝내기 - 최종 연산을 여러 번 호출하면 매번 재평가됨을 전제하기
- 부작용이 있는 연산(네트워크/DB/파일)을
mapfilter에 넣지 않기
- 리소스 오픈을 시퀀스 생성 시점에 하지 말고, 가능하면
Flow- 블로킹 호출은
flowOn(Dispatchers.IO)또는withContext(Dispatchers.IO)로 격리 - 콜드 스트림이라
collect마다 재실행됨. 공유 필요 시stateInshareIn bufferconflatecollectLatest는 데이터 유실/취소 의미를 정확히 알고 사용toList()같은 물질화는 반드시 상한(take) 또는 윈도잉 전략을 함께 설계
- 블로킹 호출은
마무리
Sequence 와 Flow 는 모두 지연평가처럼 보이지만, 실제로는 “언제, 어디서, 어떤 비용으로 실행되는가” 가 완전히 다릅니다. Sequence 는 동기 pull 기반의 저비용 지연 계산이고, Flow 는 코루틴 기반의 취소 가능 비동기 파이프라인입니다.
지연평가는 만능 최적화가 아니라 “실행을 뒤로 미루는 기술” 입니다. 뒤로 미룬 실행이 어떤 컨텍스트에서, 어떤 리소스를 잡고, 어떤 방식으로 재실행되는지까지 설계해야 함정에 빠지지 않습니다.