예산 절감형 이벤트 기반 아키텍처: Kotlin Coroutines + Redis Streams

발행: (2026년 3월 7일 오후 08:59 GMT+9)
6 분 소요
원문: Dev.to

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.isEmpty is 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보다 비용과 복잡성이 훨씬 낮은 생산‑준비된 이벤트‑드리븐 워크플로를 제공합니다.

0 조회
Back to Blog

관련 글

더 보기 »