예산 절감형 이벤트 기반 아키텍처: Kotlin Coroutines + Redis Streams
Source: Dev.to
해당 링크 외에 번역할 텍스트가 제공되지 않았습니다. 번역이 필요한 본문을 알려주시면 한국어로 번역해 드리겠습니다.
우리가 만들게 될 것
오늘 우리는 Kotlin 코루틴과 Redis Streams를 사용한 가벼운 이벤트‑드리븐 파이프라인을 구축합니다. 끝까지 진행하면, 이벤트를 버퍼링하고 배치하는 작동하는 프로듀서, Kotlin Flows 기반의 컨슈머, 그리고 나중에 Kafka로 교체할 수 있는 깔끔한 추상화 레이어를 갖게 될 것입니다.
사전 요구 사항
- Kotlin 1.9+와 코루틴 (
kotlinx-coroutines-core) - 실행 중인 Redis 6.2+ 인스턴스 (Docker 사용 가능:
docker run -p 6379:6379 redis:7) - 코루틴 지원이 포함된 Lettuce Redis 클라이언트 (
lettuce-core) - Kotlin 코루틴 및
Flow에 대한 기본적인 이해
단계 1: 버퍼링된 프로듀서 만들기
프로듀서는 로컬 백프레셔를 위해 Kotlin Channel을 사용하고, 이후 이벤트를 배치 단위로 Redis Streams에 플러시합니다.
class EventProducer(
private val redis: RedisCoroutinesCommands,
private val stream: String,
bufferSize: Int = 1024
) {
private val channel = Channel(bufferSize)
suspend fun emit(event: Map) {
channel.send(event)
}
fun startFlusher(scope: CoroutineScope, batchSize: Int = 100) {
scope.launch {
val batch = mutableListOf()
for (event in channel) {
batch.add(event)
if (batch.size >= batchSize || channel.isEmpty) {
batch.forEach { redis.xadd(stream, it) }
batch.clear()
}
}
}
}
}Channel은 버퍼가 가득 찼을 때 이벤트를 버리는 대신 호출자를 일시 중단합니다. 이러한 백프레셔 전파 덕분에 설정이 프로덕션 환경에서도 안전하게 동작합니다.
2단계: Flow를 사용하여 Consumer 구축
각 consumer는 XREADGROUP을 통해 Redis consumer group에서 읽고, 메시지를 Kotlin Flow에 방출합니다.
fun CoroutineScope.consumeStream(
redis: RedisCoroutinesCommands,
stream: String,
group: String,
consumer: String
): Flow = flow {
runCatching { redis.xgroupCreate(stream, group, "0") }
while (currentCoroutineContext().isActive) {
val messages = redis.xreadgroup(
Consumer.from(group, consumer),
XReadArgs.Builder.count(50).block(Duration.ofSeconds(2)),
XReadArgs.StreamOffset.lastConsumed(stream)
)
messages.forEach { emit(it) }
}
}consumer 코루틴은 대략 200 바이트 정도의 힙 메모리를 사용합니다. 실제로는 512 MB 컨테이너 하나에서 500개의 동시 consumer가 실행될 수 있으며, 초당 약 12 000개의 이벤트를 처리하고 p99 지연 시간은 15 ms 이하입니다.
3단계: 이벤트 버스 추상화
처음부터 모든 것을 인터페이스 뒤에 감싸서 나중에 비용이 많이 드는 재작성 작업을 방지하세요.
interface EventBus {
suspend fun publish(topic: String, event: Map)
fun subscribe(topic: String, group: String): Flow
}귀하의 RedisEventBus는 이 인터페이스를 구현합니다. Redis Streams를 더 이상 사용하기 어려워지면 KafkaEventBus를 작성하고 최소한의 변경으로 교체할 수 있습니다.
Gotchas
- Retention is memory‑bound. Redis Streams는 RAM에 존재합니다. 보존 기간이 48–72시간을 초과하거나 스트림 깊이가 사용 가능한 메모리를 초과하는 경우 Kafka가 더 경제적입니다. 메모리 사용량을 제한하려면
XADD와 함께MAXLEN을 사용하세요. - No schema registry. 스키마 진화를 직접 관리하세요 (예: 이벤트 맵에 버전 필드
"v"를 추가). - Cross‑datacenter replication is fragile. Redis 복제는 단일 지역 설정에 적합합니다; 다중 지역 시나리오에는 Kafka가 더 적합합니다.
channel.isEmptyis not atomic. 배치 플러시 검사는 최선 노력(best‑effort) 방식이며, 매우 높은 처리량에서는 예상보다 작은 배치가 플러시될 수 있습니다. 이는 정확성에는 문제가 없습니다.- Consumer group creation is not idempotent by default.
xgroupCreate주변에 있는runCatching은 그룹이 이미 존재할 때 발생하는 오류를 방지합니다.
Kafka로 마이그레이션 시점
두 가지 지표를 모니터링하세요: 지속적인 이벤트 속도와 보존 깊이. 지속적으로 약 40 K events/sec에 도달하거나 72 시간을 초과하는 보존이 필요할 경우 마이그레이션을 계획하기 시작하세요. 이 임계값 이하에서는 Kafka가 월 약 $400 의 추가 인프라 비용과 운영 오버헤드를 발생시키며, 이는 필요하지 않을 수 있습니다.
Conclusion
초당 50 K 이벤트 미만이라면 Redis Streams부터 시작하세요. 백프레셔를 위해 Kotlin 코루틴 Channels를 사용하고, 소비를 위해 Flows를 사용하세요. 처음부터 인터페이스 뒤에 이벤트 버스를 추상화하십시오. 이 접근 방식은 Kafka보다 비용과 복잡성이 훨씬 낮은 생산‑준비된 이벤트‑드리븐 워크플로를 제공합니다.