| name | async-processing |
| description | Use when working with Redis Streams for asynchronous task dispatch โ place search job producers/consumers, coroutine scopes, retry logic, dead-letter handling, OpenTelemetry Context propagation across dispatcher switches. Trigger when touching `RedisStream*`, `*Dispatcher`, `*Consumer`, job queue code, or when designing any async/background workflow. |
Async Processing (Redis Streams)
๊ฐ์
์ฅ์ ์ถ์ฒ ๊ฒ์์ ๋น์ฉ์ด ํฐ ์์
์ด๋ฏ๋ก Redis Streams๋ก ๋น๋๊ธฐ ์ฒ๋ฆฌํ๋ค.
ํ๋ฆ:
- ๋ฏธํ
์ฐธ์์๊ฐ ์ค๋ฌธ์ ์๋ฃํ๋ฉด
meeting_calculation_stream์ ์ด๋ฒคํธ ๋ฐํ
PlaceSearchConsumer๊ฐ ์คํธ๋ฆผ์ ์๋นํ์ฌ Google Places API ํธ์ถ + ๊ฒฐ๊ณผ ์บ์ฑ
- ์ฒ๋ฆฌ ์คํจ ์ ACK ์์ด PEL์ ๋จ๊น โ
PendingMessageScheduler๊ฐ ์ต๋ 3ํ ์ฌ์๋
์คํธ๋ฆผ ์์
object RedisStreamConstants {
const val MEETING_CALCULATION_STREAM = "meeting_calculation"
const val MEETING_CALCULATION_GROUP = "meeting_calculation_group"
const val MEETING_NOTIFICATION_STREAM = "meeting_notification"
const val MEETING_NOTIFICATION_GROUP = "meeting_notification_group"
}
Consumer ๊ตฌํ ํจํด
@Component
class SomeConsumer(
private val someService: SomeService,
private val stringRedisTemplate: StringRedisTemplate,
) : StreamListener<String, MapRecord<String, String, String>> {
private val scope = CoroutineScope(Dispatchers.Default)
override fun onMessage(message: MapRecord<String, String, String>) {
val id = message.value["someId"]?.toLongOrNull() ?: return
scope.launch(Dispatchers.IO) {
try {
someService.execute(id)
stringRedisTemplate.opsForStream<String, String>()
.acknowledge(RedisStreamConstants.SOME_GROUP, message)
} catch (e: Exception) {
Sentry.captureException(e)
}
}
}
}
Stream Config ํจํด
@Configuration
@Profile("!test")
class SomeStreamsConfig(
private val someConsumer: SomeConsumer,
private val stringRedisTemplate: StringRedisTemplate,
) {
@Bean
fun someStreamSubscription(
redisConnectionFactory: RedisConnectionFactory,
): Subscription {
val streamKey = RedisStreamConstants.SOME_STREAM
val groupName = RedisStreamConstants.SOME_GROUP
val consumerName = "app_server_${UUID.randomUUID()}"
try {
stringRedisTemplate.opsForStream<String, String>().createGroup(streamKey, groupName)
} catch (e: Exception) {
if (e.message?.contains("BUSYGROUP") != true) {
logger.warn("Failed to create consumer group: ${e.message}")
}
}
val options = StreamMessageListenerContainer.StreamMessageListenerContainerOptions
.builder()
.pollTimeout(Duration.ofSeconds(1))
.build()
val container = StreamMessageListenerContainer.create(redisConnectionFactory, options)
val subscription = container.receive(
Consumer.from(groupName, consumerName),
StreamOffset.create(streamKey, ReadOffset.lastConsumed()),
someConsumer,
)
container.start()
return subscription
}
}
์ค๋ณต ์ฒ๋ฆฌ ๋ฐฉ์ง (๋ฉฑ๋ฑ์ฑ)
๊ฐ์ ๋ฉ์์ง๊ฐ ์ฌ๋ฌ ๋ฒ ์ฒ๋ฆฌ๋์ง ์๋๋ก Redis setIfAbsent๋ก ๋ฝ์ ๊ฑด๋ค.
val lockKey = "processing:$meetingId"
val acquired = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofMinutes(5))
if (acquired != true) return
์ฌ์๋ ์ค์ผ์ค๋ฌ ํจํด
@Component
class PendingMessageScheduler(
private val stringRedisTemplate: StringRedisTemplate,
private val someService: SomeService,
) {
private val MAX_RETRY_COUNT = 3L
@Scheduled(fixedDelay = 60000)
fun retryPendingMessages() {
val pending = stringRedisTemplate.opsForStream<String, String>()
.pending(
RedisStreamConstants.SOME_STREAM,
Consumer.from(RedisStreamConstants.SOME_GROUP, "*"),
Range.unbounded(),
10L,
)
pending?.forEach { entry ->
if (entry.totalDeliveryCount >= MAX_RETRY_COUNT) {
stringRedisTemplate.opsForStream<String, String>()
.acknowledge(RedisStreamConstants.SOME_GROUP, entry)
Sentry.captureMessage("Max retry exceeded for message: ${entry.id}")
}
}
}
}
์ด๋ฒคํธ ๋ฐํ
stringRedisTemplate.opsForStream<String, String>().add(
StreamRecords.newRecord()
.`in`(RedisStreamConstants.MEETING_CALCULATION_STREAM)
.ofMap(mapOf("meetingId" to meetingId.toString()))
)
์ฃผ์์ฌํญ
- Stream Config์ ๋ฐ๋์
@Profile("!test") ์ ์ฉ โ ํ
์คํธ ํ๊ฒฝ์์๋ Redis ์ฐ๊ฒฐ ์์ด ์คํ
- ์ปจ์๋จธ ์ด๋ฆ์ UUID ์ฌ์ฉ โ ๋์ผ ์คํธ๋ฆผ์ ์ฌ๋ฌ ์ธ์คํด์ค๊ฐ ์๋นํ ๋ ์์
๋ถ์ฐ
- ์ธ๋ถ API ํธ์ถ(Google Places) ์คํจ๋ ์์ธ๋ก ์ฒ๋ฆฌํ๊ณ ์ฌ์๋์ ๋งก๊น
- ์์ธํ ์ถ์ฒ ์๊ณ ๋ฆฌ์ฆ:
docs/place-recommendation-logic.md ์ฐธ๊ณ