1. 들어가기 앞서
현재 재직 중인 회사에서는 Kotlin + Spring을 사용하고 있다. 최근 운영 이슈를 대응하다가 Flow 관련 코드를 수정할 일이 생겼는데, 코드를 읽다 보니 "이거 제대로 이해하고 써야겠다"는 생각이 들었다. 그렇게 Flow를 본격적으로 공부하게 되었다.
그런데 공부하면서 흥미로운 점을 발견했다. 이전에 Node.js 개발을 하면서 즐겨 사용했던 fxts의 pipe와 구조가 상당히 비슷한 것이다. pipe(source, map(fn), filter(fn), toArray) 형태의 함수형 파이프라인을 알고 있다면, Flow의 90%는 이미 이해한 것이나 다름없었다.
이 글에서는 공부한 내용을 정리하면서, fxts와 비교하여 설명해보려 한다. 같은 길을 걷고 있는 분들에게 도움이 되었으면 한다.
2. 가장 익숙한 그림: fxts pipe vs Flow
2.1. 기본 파이프 비교
fxts 코드와 Flow 코드를 나란히 놓으면, 파이프 구조가 동일하다는 걸 바로 알 수 있다.
// fxts
pipe(
userIds,
map(id => userMap.get(id)),
zipWithIndex,
map(([index, user]) => ({ ...user, rank: index })),
toArray,
);
TypeScript
복사
// Kotlin Flow
userIds.asFlow()
.map { id -> userMap[id] }
.withIndex()
.map { (index, user) -> user.copy(rank = index) }
.toList()
Kotlin
복사
구조를 한 줄로 정리하면 이렇다.
fxts: pipe(source, map(), zipWithIndex, map(), toArray)
Flow: source.asFlow(). map(). withIndex(). map(). toList()
Plain Text
복사
2.2. 동시성 파이프 비교
동시성 제어도 1:1로 대응된다. fxts의 concurrent(n)이 Flow의 flatMapMerge(n)이다.
// fxts
await pipe(
notifications,
toAsync,
concurrent(10),
each(noti => sendPush(noti)),
);
TypeScript
복사
// Kotlin Flow
notifications.asFlow()
.flatMapMerge(10) { noti ->
flow { emit(sendPush(noti)) }
}
.collect()
Kotlin
복사
2.3. 주요 연산자 대응표
대부분 이름만 다를 뿐 역할은 동일하다. 마지막 flowOn만 fxts에 없는 개념인데, 이건 뒤에서 다룬다.
fxts | Kotlin Flow | 역할 |
pipe(source, ...) | source.asFlow() | 파이프 시작 |
map(fn) | .map { } | 변환 |
filter(fn) | .filter { } | 필터링 |
toArray | .toList() | 수집 (Terminal) |
each(fn) | .collect { } | 소비 (Terminal) |
concurrent(n) | .flatMapMerge(n) | 동시성 제한 병렬 |
reduce(fn, init) | .fold(init) { } | 누적 |
(없음) | .flowOn(Dispatchers.IO) | 실행 스레드 풀 지정 |
3. Flow의 3단계: Builder - Operator - Terminal
fxts의 pipe가 소스 -> 변환들 -> 최종 수집 구조를 가지는 것처럼, Flow도 동일한 3단계로 구성된다.
+---------------------------+
| Builder (생성) |
| |
| flowOf(1,2,3) |
| .asFlow() |
| flow { emit() } |
+---------------------------+
|
v
+---------------------------+
| Operator (변환) |
| |
| .map { } |
| .filter { } |
| .flatMapMerge { } |
+---------------------------+
|
v
+---------------------------+
| Terminal (소비) |
| |
| .collect { } |
| .toList() |
| .first() |
+---------------------------+
Plain Text
복사
3.1. Cold Stream - fxts와의 중요한 차이
fxts도 toArray 없이 pipe(items, map(fn))만 쓰면 lazy iterable(generator)을 반환한다. 하지만 Flow의 Cold Stream은 단순한 lazy와 다르다. Flow는 collect할 때마다 처음부터 다시 실행되는 "레시피"다.
// fxts: lazy iterable이지만, 한 번 소비하면 끝
const lazy = pipe([1, 2, 3], map(x => { console.log('변환:', x); return x * 2; }));
[...lazy]; // "변환: 1", "변환: 2", "변환: 3" 출력
[...lazy]; // 아무것도 출력되지 않음. generator는 이미 소비됨.
TypeScript
복사
// Flow: collect할 때마다 처음부터 다시 실행됨
val myFlow = flowOf(1, 2, 3)
.map { println("변환: $it"); it * 2 }
myFlow.toList() // "변환: 1", "변환: 2", "변환: 3" 출력
myFlow.toList() // "변환: 1", "변환: 2", "변환: 3" 또 출력. 처음부터 다시 실행.
Kotlin
복사
flow { }는 함수 정의이고, collect/toList가 함수 호출이다. 함수를 두 번 호출하면 두 번 실행되는 것과 같다. 이것이 Cold Stream의 의미다.
3.2. Cold의 실무적 의미
Cold Stream이라는 것은 곧 Flow가 상태를 갖지 않는다는 뜻이기도 하다. 매번 새로 실행되므로 캐싱이나 결과 공유가 필요하면 별도 처리가 필요하다.
만약 하나의 데이터를 여러 구독자에게 공유하고 싶다면 Hot Stream이 필요하다.
•
SharedFlow = 구독자에게 값을 방출하고, 구독자가 없으면 유실
•
StateFlow = 항상 최신 값을 보유 (React의 useState와 유사)
4. flatMap 삼형제: 비동기 파이프의 꽃
실무에서 가장 많이 쓰이면서 가장 중요한 Operator다. 셋 다 "각 항목을 비동기 처리하고 결과를 합치는" 역할인데, 합치는 방식이 다르다.
4.1. flatMapConcat - 순차 처리
입력: A -> B -> C
처리: [A 완료] -> [B 완료] -> [C 완료]
결과: a1, a2, b1, b2, c1, c2 (순서 보장)
Plain Text
복사
fxts에서 concurrent 없이 toAsync + map으로 순차 처리하는 것과 동일하다.
4.2. flatMapMerge - 동시성 제한 병렬
fxts의 concurrent(n)과 동일한 역할이다.
입력: A, B, C, D, E (concurrency = 3)
시점1: [A 처리중] [B 처리중] [C 처리중] [D 대기] [E 대기]
시점2: [A 완료] [B 처리중] [C 처리중] [D 시작] [E 대기]
시점3: [B 완료] [C 완료] [D 처리중] [E 시작]
결과: 순서 보장 안 됨 (완료 순서대로 emit)
Plain Text
복사
// fxts
await pipe(items, toAsync, map(fn), concurrent(3), toArray);
TypeScript
복사
// Kotlin Flow
items.asFlow()
.flatMapMerge(3) { item -> flow { emit(fn(item)) } }
.toList()
Kotlin
복사
이름을 분해하면 역할이 보인다.
•
flat: 각 항목이 Flow(여러 값)를 반환 -> 평탄화
•
Map: 각 항목을 변환
•
Merge: 동시 실행, 완료 순서대로 합침
4.3. flatMapLatest - 최신 것만, 이전 자동 취소
이건 fxts에 없는 개념이다. 새 값이 들어오면 이전 처리를 자동으로 취소한다.
입력: A -> B -> C
처리: [A 처리--취소!] [B 처리--취소!] [C 처리----완료]
결과: C의 결과만
Plain Text
복사
검색 자동완성을 생각하면 된다. 사용자가 "abc"를 입력할 때의 흐름이다.
•
"a" 입력 -> "a" 검색 시작
•
"ab" 입력 -> "a" 검색 자동 취소, "ab" 검색 시작
•
"abc" 입력 -> "ab" 검색 자동 취소, "abc" 검색 시작 -> 완료
Node.js에서는 AbortController로 수동으로 이전 요청을 취소해야 했는데, Flow는 이걸 파이프 안에서 자동으로 처리해준다.
5. 멀티스레드에서 Flow가 안전한 이유
Node.js 개발자가 Flow를 처음 접할 때 가장 낯선 부분이다. Node.js는 단일 스레드이므로 concurrent(10)을 써도 두 작업이 변수를 물리적으로 동시에 건드리는 순간이 없다.
Node.js (단일 스레드):
스레드1: [A의 count++] ... [B의 count++] ... [C의 count++]
이벤트루프가 번갈아 실행. 절대 겹치지 않음.
JVM (멀티 스레드):
스레드1: [A의 count++]
스레드2: [B의 count++] <- 동시 접근! 값 유실 가능
스레드3: [C의 count++]
Plain Text
복사
그런데 Flow는 이 문제를 설계적으로 해결해놨다.
5.1. 기본은 순차 실행
flatMapMerge 없이 map, filter, collect만 쓰면 전부 하나의 코루틴에서 순차 실행된다. 동시성 이슈가 원천적으로 없다.
5.2. flatMapMerge는 Channel로 격리
flatMapMerge를 쓰면 동시성이 생기지만, 각 코루틴은 독립 실행되고 결과는 thread-safe한 Channel을 통해 수집된다.
+--코루틴1: apiCall(A) -> emit(resultA)--+
orders asFlow -> +--코루틴2: apiCall(B) -> emit(resultB)--+-> Channel -> toList
+--코루틴3: apiCall(C) -> emit(resultC)--+
Plain Text
복사
비유하자면, 3명이 1개 화이트보드에 동시에 쓰는 것(위험)이 아니라, 각자 종이에 써서 수거함(Channel)에 넣는 것(안전)이다.
5.3. Context Preservation - 위험한 코드를 문법 수준에서 차단
Flow는 flow {} 빌더 내부에서 context 전환을 금지한다. 내부에서 withContext로 context를 바꾸면 런타임 에러가 발생한다.
// 에러! IllegalStateException
flow {
withContext(Dispatchers.IO) {
emit(value) // 다른 context에서 emit 불가
}
}
// 올바른 방법: flowOn으로 외부에서 지정
flow { emit(value) }
.flowOn(Dispatchers.IO)
Kotlin
복사
왜 이런 제약을 뒀을까? emit이 여러 스레드에서 동시에 호출되면 동시성 이슈가 생기기 때문이다. 이걸 문법 수준에서 차단해버린 것이다.
규칙 하나만 기억하면 된다: Flow 안에서 외부 가변 상태를 건드리지 않고, emit으로만 값을 내보내면 동시성 이슈가 없다. Node.js에서 이미 하고 있는 패턴(함수형, 불변, 반환값만 사용)을 그대로 하면 된다.
6. flowOn - 실행 스레드 풀 지정
Node.js에는 없는 개념이다. Node.js는 단일 스레드이므로 "어떤 스레드에서 실행할지" 고민할 필요가 없다. JVM은 멀티스레드이므로 "이 파이프의 이 구간은 어떤 스레드 풀에서 돌려라"를 지정해야 한다.
6.1. 동작 방식
flowOn은 자기 위에 있는 모든 연산의 실행 스레드 풀을 지정한다.
flow { emit(heavyCpuWork()) } // (1) Default 풀에서 실행
.flowOn(Dispatchers.Default) // <- 여기가 경계선
.map { saveToDb(it) } // (2) IO 풀에서 실행
.flowOn(Dispatchers.IO) // <- 여기가 경계선
.collect { updateUi(it) } // (3) 호출자 스레드에서 실행
Kotlin
복사
6.2. 시각화
레이어별로 어떤 스레드 풀에서 실행되는지 보면 직관적이다.
+-------------------------------+
| flow { heavyCpuWork() } | Dispatchers.Default (CPU 코어 수만큼)
+-------------------------------+
flowOn(Default) <-- 경계선. 내부적으로 Channel로 안전하게 전환.
+-------------------------------+
| .map { saveToDb(it) } | Dispatchers.IO (기본 최대 64개 스레드)
+-------------------------------+
flowOn(IO) <-- 경계선
+-------------------------------+
| .collect { updateUi(it) } | 호출자의 스레드
+-------------------------------+
Plain Text
복사
flowOn의 경계에서는 내부적으로 Channel이 생성되어 context 전환이 안전하게 처리된다.
flow { emit(1) } flowOn(IO) collect { }
| | |
IO 스레드에서 Channel 호출자 스레드에서
값 생산 (thread-safe 큐) 값 소비
| | |
send(1) -------------> [1] ---------> receive(1)
Plain Text
복사
생산자 스레드와 소비자 스레드가 다르더라도, Channel이 중간에서 안전하게 값을 전달해준다.
6.3. Dispatchers 종류
•
Dispatchers.IO
◦
I/O 작업용 스레드 풀 (기본값 64개)
◦
DB, HTTP 호출
•
Dispatchers.Default
◦
CPU 작업용 스레드 풀 (코어 수)
◦
계산, 파싱
•
Dispatchers.Main
◦
UI 스레드 (Android용)
7. fxts에는 없는 Flow만의 무기
7.1. 선언적 에러 처리
fxts에서는 에러 처리가 파이프 밖으로 나가야 했다. 각 map 안에서 try-catch를 직접 써야 했다.
// fxts: 에러 처리가 map 안에서 수동
pipe(
items, toAsync,
map(async (item) => {
try {
return await pRetry(() => riskyApiCall(item), { retries: 3 });
} catch {
return fallbackData;
}
}),
toArray,
);
TypeScript
복사
// Flow: retry, catch가 파이프의 일부
flow { emit(riskyApiCall()) }
.retry(3) { it is IOException } // 3번 재시도
.catch { emit(fallbackData) } // 실패시 대체 값
.collect { save(it) }
Kotlin
복사
retry와 catch가 map, filter처럼 파이프의 한 단계로 들어간다. 에러 처리 로직이 파이프 흐름 안에서 선언적으로 읽힌다.
단, catch는 상류(upstream) 의 에러만 잡는다는 점에 주의해야 한다. collect 안에서 발생한 에러는 별도의 try-catch가 필요하다.
7.2. Lazy 평가의 진가
Flow는 Lazy하기 때문에 take(50)을 쓰면 50개를 채운 시점에 나머지 연산이 자동 취소된다. API 페이지네이션에서 이 특성이 빛난다.
fun fetchAllPages(): Flow<Item> = flow {
var page = 0
var hasMore = true
while (hasMore) {
val response = api.getItems(page = page, size = 100)
response.items.forEach { emit(it) }
hasMore = response.hasNext
page++
}
}
// 50개만 필요하면 나머지 페이지의 API를 호출하지 않는다
fetchAllPages()
.filter { it.isActive }
.take(50)
.toList()
Kotlin
복사
fxts의 lazy iterable로도 비슷한 구조를 만들 수 있지만, generator는 한 번 소비하면 끝이라 Flow처럼 재실행이 되지 않는다.
8. 실무 패턴
8.1. 에러 격리
외부 API를 병렬 호출할 때, 하나가 실패해도 나머지는 계속 처리해야 한다. 에러를 어디서 잡느냐에 따라 동작이 달라진다.
방법 1: Flow 레벨 .catch (하나 실패 → 전체 중단)
orders.asFlow()
.flatMapMerge(5) { order ->
flow { emit(externalApi.getItems(order.id)) }
}
.catch { emit(emptyList()) } // 하나라도 실패하면 여기서 중단
.toList()
Kotlin
복사
방법 2: 항목별 runCatching (실패한 항목만 대체, 나머지 계속)
orders.asFlow()
.flatMapMerge(5) { order ->
flow {
val items = runCatching {
externalApi.getItems(order.id)
}.getOrElse { emptyList() }
emit(order.copy(items = items))
}
}
.toList()
Kotlin
복사
fxts에서도 map 안에 try-catch를 넣어 개별 항목의 에러를 격리하던 것과 같다.
// fxts: 항목별 에러 격리
const results = await pipe(
orders,
toAsync,
map(async (order) => {
try {
const items = await externalApi.getItems(order.id);
return { ...order, items };
} catch {
return { ...order, items: [] };
}
}),
concurrent(5),
toArray,
);
TypeScript
복사
실무에서는 대부분 방법 2(runCatching)가 필요하다. .catch는 Flow 전체 에러 시 대체 값이고, runCatching은 개별 항목 실패 시 격리다.
9. 마무리
fxts의 pipe를 이미 쓰고 있다면, Flow를 이해하는 데 90%는 끝난 것이다.
•
같은 것
◦
데이터가 흐르는 파이프 구조 (pipe = asFlow()...collect)
◦
중간 변환 (map, filter)
◦
동시성 제어 (concurrent(n) = flatMapMerge(n))
•
다른 것 (JVM 멀티스레드 환경 때문에 추가된 것)
◦
flowOn - 실행 스레드 풀 지정
◦
Channel 기반 thread-safe한 결과 수집
◦
Context Preservation - emit과 collect의 context 일치 강제
•
fxts 대비 Flow만의 이점
◦
retry, catch가 파이프의 일부 (선언적 에러 처리)
◦
Lazy 평가 (take(50)하면 나머지 자동 취소)
◦
flatMapLatest (이전 작업 자동 취소)
•
역할 분리
suspend -> 논블로킹 (스레드 반환). Node.js의 await와 동일.
Flow -> suspend 위에서 선언적 파이프 스타일 코드 작성. fxts의 pipe와 동일.
flatMapMerge -> 파이프 안에서 동시성 제어. fxts의 concurrent(n)와 동일.
Plain Text
복사
Flow를 공부하면서 가장 의외였던 점은, fxts에서 익힌 파이프라인 사고방식이 거의 그대로 통했다는 것이다. flowOn이나 Context Preservation처럼 JVM 멀티스레드 환경에서 비롯한 새로운 개념들도 있었지만, 핵심 멘탈 모델은 크게 달라지지 않았다.
아직 SharedFlow/StateFlow 같은 Hot Stream 영역과 Spring WebFlux에서의 실전 적용은 더 파봐야 할 부분이다. 한 줄로 요약하면, Flow = suspend가 가능한 Lazy 파이프다.
Appendix A. suspend vs Flow - "둘 다 논블로킹인데 뭐가 다른가?"
이 구분이 가장 혼란스러운 부분이었다. 정확히 정리한다.
A.1. suspend = 논블로킹의 핵심
suspend 지점에서 스레드가 풀로 반환되어 다른 요청을 처리할 수 있다. Node.js의 await와 동일한 역할이다.
@GetMapping("/delivery")
suspend fun getDelivery(): DeliveryInfo {
return webClient.get().retrieve().awaitBody()
// ^^^^ 여기서 suspend -> 스레드 반환 -> 응답 오면 resume
}
Kotlin
복사
스레드의 움직임을 보면 이렇다.
스레드1: [요청 받음] [외부 API 호출 -> suspend -> 스레드1 반환!]
스레드1: [다른 요청 처리] <- 반환된 스레드가 즉시 다른 일을 함
스레드2: [외부 API 응답 도착 -> resume -> 반환]
Plain Text
복사
동시 요청 100개가 들어올 때의 차이다.
suspend 없이 (블로킹):
스레드1: [요청1 - 외부API 대기중.........응답]
스레드2: [요청2 - 외부API 대기중.........응답]
...
스레드200: [요청200 - 외부API 대기중.......응답]
요청201: 스레드 없음! 대기! <- 병목
suspend 있을 때
스레드1: [요청1 시작 -> suspend] [요청53 시작 -> suspend] [요청99 시작 -> suspend]
스레드2: [요청2 시작 -> suspend] [요청54 시작 -> suspend] [요청100 시작 -> suspend]
...
200개 스레드가 돌아가면서 수천 개 요청을 처리 가능
Plain Text
복사
단건 호출이어도, 동시 사용자가 많으면 suspend는 의미 있다. 스레드 하나가 IO 대기하지 않고 다른 요청을 처리할 수 있기 때문이다.
A.2. Flow = suspend 위에서의 선언적 파이프
그러면 Flow는 뭘 추가하는가? 코드 스타일(선언적 파이프)과 동시성 제어(flatMapMerge)이다.
// 방법 1: for + suspend (명령형)
suspend fun updateDeliveries(orderIds: List<String>) {
for (orderId in orderIds) {
val delivery = externalApi.get(orderId) // suspend -> 스레드 반환
db.update(delivery) // suspend -> 스레드 반환
}
}
// 방법 2: Flow (선언적 파이프)
suspend fun updateDeliveries(orderIds: List<String>) {
orderIds.asFlow()
.map { externalApi.get(it) } // suspend -> 스레드 반환
.map { db.update(it) } // suspend -> 스레드 반환
.collect()
}
Kotlin
복사
두 코드의 스레드 반환 타이밍은 동일하다.
방법 1 (for + suspend):
스레드1: [API호출 -> suspend] [반환] ... [resume -> DB저장 -> suspend] [반환]
방법 2 (Flow):
스레드1: [API호출 -> suspend] [반환] ... [resume -> DB저장 -> suspend] [반환]
동일함!
Plain Text
복사
논블로킹의 이점은 suspend에서 오는 것이지, Flow에서 오는 것이 아니다. Flow로 감싸든 for문으로 돌리든 suspend 지점에서 스레드가 반환되는 것은 같다.
A.3. Flow가 for문보다 나은 순간: flatMapMerge
// for문: 순차 처리만 가능
for (orderId in orderIds) {
val delivery = externalApi.get(orderId) // 하나 끝나야 다음
db.update(delivery)
}
// Flow + flatMapMerge: 동시성 제어 병렬
orderIds.asFlow()
.flatMapMerge(10) { orderId ->
flow {
val delivery = externalApi.get(orderId)
emit(db.update(delivery))
}
}
.collect()
Kotlin
복사
fxts에서 pipe만 쓸 때와 concurrent(n)을 붙일 때의 차이와 동일하다.
// fxts: concurrent 없으면 for문과 성능 같음
pipe(items, toAsync, map(fn), toArray);
// fxts: concurrent 붙으면 병렬 처리
pipe(items, toAsync, map(fn), concurrent(10), toArray);
TypeScript
복사
A.4. 핵심 정리
Flow 순차 (map -> collect) = for + suspend -> 같은 성능. 가독성 차이만.
Flow 병렬 (flatMapMerge -> collect) = concurrent(n) -> 이게 Flow의 진짜 성능 이점.
Plain Text
복사
Appendix B. suspend vs Flow vs coroutineScope: 언제 뭘 쓰는가
Kotlin의 비동기 도구는 세 가지가 있고, 각각 fxts/Node.js의 대응 개념이 있다.
B.1. suspend fun - 단건 비동기 호출
suspend fun getDelivery(orderId: String): DeliveryInfo {
return externalApi.get(orderId) // suspend 지점 -> 스레드 반환
}
Kotlin
복사
fxts 대응: async function
B.2. Flow - 동종 N건 파이프 처리
orderIds.asFlow()
.flatMapMerge(3) { id -> flow { emit(externalApi.get(id)) } }
.filter { it.hasChanged }
.toList()
Kotlin
복사
fxts 대응: pipe(items, toAsync, map(fn), concurrent(3), filter(fn), toArray)
B.3. coroutineScope + async - 이종 작업 동시 실행 후 합치기
Flow는 같은 타입의 데이터를 파이프로 처리하는 데 최적화되어 있다. 서로 다른 타입의 작업을 동시에 실행하고 합치는 것은 Flow의 영역이 아니다.
// Flow로 억지로 하면... 어색함
val results = flowOf("order", "user", "products")
.flatMapMerge(3) { type ->
flow {
when (type) {
"order" -> emit(fetchOrder(orderId)) // Order 타입
"user" -> emit(fetchUser(userId)) // User 타입
"products" -> emit(fetchProducts(ids)) // List<Product> 타입
}
}
}
.toList()
// results[0]이 order인지 user인지 보장 안 됨!
// 타입도 전부 Any로 퉁쳐야 함
Kotlin
복사
// coroutineScope + async: 자연스러움
coroutineScope {
val order = async { fetchOrder(orderId) } // Order 타입
val user = async { fetchUser(userId) } // User 타입
val products = async { fetchProducts(ids) } // List<Product> 타입
OrderDetail(
order = order.await(), // 타입 안전
user = user.await(), // 타입 안전
products = products.await() // 타입 안전
)
}
Kotlin
복사
fxts 대응: Promise.all([fetchOrder(), fetchUser(), fetchProducts()])
B.4. 선택 기준
단건 비동기 호출 -> suspend fun
같은 작업 N건 병렬 + 파이프 변환 -> Flow + flatMapMerge
다른 작업 동시 실행 후 합치기 -> coroutineScope + async
실시간 스트리밍 (SSE 등) -> Flow 필수
Plain Text
복사
핵심은 "모든 문제가 파이프 모양은 아니다"라는 것이다.
Appendix C. Spring 서버 레이어와의 관계
C.1. Flow와 WebFlux는 레이어가 다르다
Flow는 "비즈니스 로직을 파이프로 처리하는 도구"이고, WebFlux는 "HTTP 서버 자체를 논블로킹으로 만드는 프레임워크"이다.
[HTTP 서버 레이어] -> 요청을 어떻게 받는가? (Tomcat vs Netty)
[비즈니스 로직 레이어] -> 받은 요청을 어떻게 처리하는가? (블로킹 vs suspend/Flow)
Plain Text
복사
Node.js는 두 레이어 모두 태생적으로 논블로킹이라 구분할 필요가 없었다. JVM은 레이어별로 선택해야 한다.
C.2. 비교표
HTTP 서버 (입구) | 비즈니스 로직 (내부) | 비고 | |
Spring MVC (전통) | 블로킹 (스레드 점유) | 블로킹 | |
Spring MVC + suspend | 스레드 풀, suspend시 스레드 반환 | 논블로킹 | 대부분 충분 |
Spring WebFlux | 이벤트루프 (Netty) | 논블로킹 | 극한 성능 |
Node.js Express | 이벤트루프 (libuv) | 논블로킹 |
Node.js | Spring MVC + suspend | Spring WebFlux | |
서버 엔진 | libuv (이벤트루프) | Tomcat (스레드풀) | Netty (이벤트루프) |
동시 연결 | 수만 | ~수천 (suspend 반환) | 수만 |
로직 처리 | async/await | suspend + Flow | suspend + Flow |
C.3. 식당으로 비유하면
Spring MVC (전통):
웨이터 200명. 손님 1명당 웨이터 1명 배정.
웨이터가 주방에 주문 넣고 음식 나올 때까지 테이블 옆에서 서서 대기.
손님 201명째 -> 웨이터 없어서 입구에서 대기.
Spring MVC + suspend:
웨이터 200명. 주문 넣고 다른 테이블로 이동.
음식 나오면 아무 웨이터나 가져다줌.
200명 웨이터로 수천 명 처리 가능.
Spring WebFlux:
웨이터 4명. 주문 메모만 하고 바로 다음 손님.
주방에서 음식 나오면 아무 웨이터나 가져다줌.
손님 10000명이어도 처리 가능.
Node.js Express와 유사한 모델.
Plain Text
복사
WebFlux + Flow를 같이 쓰면 입구부터 출구까지 전부 논블로킹이 되어 Node.js와 유사한 논블로킹 모델이 된다.