diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8137e12..08a3973 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -21,7 +21,10 @@ "Bash(gh pr list:*)", "Bash(ls:*)", "Bash(wc:*)", - "Bash(brew list:*)" + "Bash(brew list:*)", + "WebFetch(domain:hey-mi.oopy.io)", + "Bash(mdfind:*)", + "Bash(done)" ] } } diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f8d1cea --- /dev/null +++ b/.editorconfig @@ -0,0 +1,30 @@ +root = true + +[*.{kt,kts,java,js,jsx,ts,tsx}] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +max_line_length = 120 +trim_trailing_whitespace = true + +[*.{js,jsx,ts,tsx,json,md,yml,yaml}] +indent_size = 2 + +[*.md] +max_line_length = off + +[*.{kt,kts}] +ij_kotlin_name_count_to_use_star_import = 999 +ij_kotlin_name_count_to_use_star_import_for_members = 999 +ktlint_standard_trailing-comma-on-declaration-site = disabled +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_function-expression-body = disabled +ktlint_standard_multiline-expression-wrapping = disabled +ktlint_standard_chain-method-continuation = disabled +ktlint_standard_function-signature = disabled +ktlint_standard_argument-list-wrapping = disabled +ktlint_standard_function-literal = disabled +ktlint_standard_if-else-wrapping = disabled +ktlint_standard_max-line-length = disabled diff --git a/behavior-consumer/build.gradle.kts b/behavior-consumer/build.gradle.kts index bd8260c..968e511 100644 --- a/behavior-consumer/build.gradle.kts +++ b/behavior-consumer/build.gradle.kts @@ -19,8 +19,8 @@ dependencies { // Spring Boot implementation("org.springframework.boot:spring-boot-starter") implementation("org.springframework.boot:spring-boot-starter-actuator") - implementation("org.springframework.boot:spring-boot-starter-validation") // Bean Validation - implementation("org.springframework.boot:spring-boot-starter-webflux") // WebClient for Embedding Service + implementation("org.springframework.boot:spring-boot-starter-validation") // Bean Validation + implementation("org.springframework.boot:spring-boot-starter-webflux") // WebClient for Embedding Service implementation("org.springframework.kafka:spring-kafka") // Kotlin @@ -44,7 +44,7 @@ dependencies { // Logging implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") - implementation("net.logstash.logback:logstash-logback-encoder:8.0") // Phase 5: JSON 로깅 + implementation("net.logstash.logback:logstash-logback-encoder:8.0") // Phase 5: JSON 로깅 // Micrometer for metrics implementation("io.micrometer:micrometer-registry-prometheus") diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/client/EmbeddingClient.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/client/EmbeddingClient.kt index 4e5adde..53ce27b 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/client/EmbeddingClient.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/client/EmbeddingClient.kt @@ -1,6 +1,5 @@ package com.rep.consumer.client -import com.fasterxml.jackson.annotation.JsonProperty import kotlinx.coroutines.reactor.awaitSingleOrNull import mu.KotlinLogging import org.springframework.stereotype.Component @@ -19,11 +18,11 @@ private val log = KotlinLogging.logger {} */ @Component class EmbeddingClient( - private val embeddingWebClient: WebClient + private val embeddingWebClient: WebClient, ) { companion object { - const val QUERY_PREFIX = "query: " // 검색 쿼리용 (유저 취향) - const val PASSAGE_PREFIX = "passage: " // 문서용 (상품 정보) + const val QUERY_PREFIX = "query: " // 검색 쿼리용 (유저 취향) + const val PASSAGE_PREFIX = "passage: " // 문서용 (상품 정보) } /** @@ -33,16 +32,21 @@ class EmbeddingClient( * @param prefix e5 모델용 prefix (query: 또는 passage:) * @return 벡터 목록 */ - suspend fun embed(texts: List, prefix: String = QUERY_PREFIX): List? { + suspend fun embed( + texts: List, + prefix: String = QUERY_PREFIX, + ): List? { if (texts.isEmpty()) return emptyList() return try { - val response = embeddingWebClient.post() - .uri("/embed") - .bodyValue(EmbedRequest(texts = texts, prefix = prefix)) - .retrieve() - .bodyToMono() - .awaitSingleOrNull() + val response = + embeddingWebClient + .post() + .uri("/embed") + .bodyValue(EmbedRequest(texts = texts, prefix = prefix)) + .retrieve() + .bodyToMono() + .awaitSingleOrNull() response?.embeddings?.map { it.toFloatArray() } } catch (e: Exception) { @@ -54,41 +58,43 @@ class EmbeddingClient( /** * 단일 텍스트를 벡터로 변환합니다. */ - suspend fun embedSingle(text: String, prefix: String = QUERY_PREFIX): FloatArray? { - return embed(listOf(text), prefix)?.firstOrNull() - } + suspend fun embedSingle( + text: String, + prefix: String = QUERY_PREFIX, + ): FloatArray? = embed(listOf(text), prefix)?.firstOrNull() /** * 헬스체크 */ - suspend fun healthCheck(): Boolean { - return try { - val response = embeddingWebClient.get() - .uri("/health") - .retrieve() - .bodyToMono() - .awaitSingleOrNull() + suspend fun healthCheck(): Boolean = + try { + val response = + embeddingWebClient + .get() + .uri("/health") + .retrieve() + .bodyToMono() + .awaitSingleOrNull() response?.status == "ok" } catch (e: Exception) { log.warn(e) { "Embedding service health check failed" } false } - } } data class EmbedRequest( val texts: List, - val prefix: String = "query: " + val prefix: String = "query: ", ) data class EmbedResponse( val embeddings: List>, - val dims: Int = 768 + val dims: Int = 768, ) data class HealthResponse( val status: String, val model: String, - val dims: Int + val dims: Int, ) diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/ConsumerProperties.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/ConsumerProperties.kt index 1db69d6..2126f3d 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/ConsumerProperties.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/ConsumerProperties.kt @@ -10,30 +10,22 @@ import org.springframework.validation.annotation.Validated data class ConsumerProperties( @field:NotBlank(message = "topic must not be blank") val topic: String = "user.action.v1", - @field:NotBlank(message = "dlqTopic must not be blank") val dlqTopic: String = "user.action.v1.dlq", - @field:Positive(message = "bulkSize must be positive") val bulkSize: Int = 500, - @field:Positive(message = "concurrency must be positive") val concurrency: Int = 3, - @field:Positive(message = "maxRetries must be positive") val maxRetries: Int = 3, - @field:Positive(message = "retryDelayMs must be positive") val retryDelayMs: Long = 1000, - // DLQ 파일 백업 설정 @field:Positive(message = "dlqFileMaxSizeBytes must be positive") - val dlqFileMaxSizeBytes: Long = 10 * 1024 * 1024L, // 10MB - + val dlqFileMaxSizeBytes: Long = 10 * 1024 * 1024L, // 10MB @field:NotBlank(message = "dlqLogsDir must not be blank") val dlqLogsDir: String = "logs", - // 벡터 설정 (multilingual-e5-base) @field:Positive(message = "vectorDimensions must be positive") - val vectorDimensions: Int = 768 + val vectorDimensions: Int = 768, ) diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/DispatcherConfig.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/DispatcherConfig.kt index e73c26f..036844a 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/DispatcherConfig.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/DispatcherConfig.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.CloseableCoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher import mu.KotlinLogging -import org.springframework.beans.factory.annotation.Qualifier import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import java.util.concurrent.Executors @@ -23,7 +22,6 @@ private val log = KotlinLogging.logger {} @Configuration @OptIn(ExperimentalCoroutinesApi::class) class DispatcherConfig { - private var dispatcher: CloseableCoroutineDispatcher? = null /** @@ -32,11 +30,11 @@ class DispatcherConfig { * Java 25 Virtual Threads를 활용하여 blocking I/O 호출 시에도 * 시스템 처리량을 유지합니다. */ - @Bean - @Qualifier("virtualThreadDispatcher") + @Bean("virtualThreadDispatcher") fun virtualThreadDispatcher(): CloseableCoroutineDispatcher { log.info { "Creating Virtual Thread Coroutine Dispatcher" } - return Executors.newVirtualThreadPerTaskExecutor() + return Executors + .newVirtualThreadPerTaskExecutor() .asCoroutineDispatcher() .also { dispatcher = it } } diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/ElasticsearchConfig.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/ElasticsearchConfig.kt index 00d6ac0..2f0bb75 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/ElasticsearchConfig.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/ElasticsearchConfig.kt @@ -15,7 +15,6 @@ private val log = KotlinLogging.logger {} @Configuration class ElasticsearchConfig { - @Value("\${elasticsearch.host}") private lateinit var host: String @@ -29,11 +28,12 @@ class ElasticsearchConfig { private var transport: RestClientTransport? = null @Bean - fun restClient(): RestClient { - return RestClient.builder( - HttpHost(host, port, scheme) - ).build().also { restClient = it } - } + fun restClient(): RestClient = + RestClient + .builder( + HttpHost(host, port, scheme), + ).build() + .also { restClient = it } @Bean fun elasticsearchClient(restClient: RestClient): ElasticsearchClient { diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/EmbeddingProperties.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/EmbeddingProperties.kt index 8ff4444..104c9ba 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/EmbeddingProperties.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/EmbeddingProperties.kt @@ -15,7 +15,6 @@ import org.springframework.validation.annotation.Validated data class EmbeddingProperties( @field:NotBlank(message = "url must not be blank") val url: String = "http://localhost:8000", - @field:Positive(message = "timeoutMs must be positive") - val timeoutMs: Long = 5000 + val timeoutMs: Long = 5000, ) diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/KafkaConsumerConfig.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/KafkaConsumerConfig.kt index 4fbc22a..d2a4c9a 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/KafkaConsumerConfig.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/KafkaConsumerConfig.kt @@ -15,9 +15,8 @@ import org.springframework.kafka.listener.ContainerProperties @Configuration class KafkaConsumerConfig( - private val consumerProperties: ConsumerProperties + private val consumerProperties: ConsumerProperties, ) { - @Value("\${spring.kafka.bootstrap-servers}") private lateinit var bootstrapServers: String @@ -26,31 +25,29 @@ class KafkaConsumerConfig( @Bean fun consumerFactory(): ConsumerFactory { - val props = mapOf( - ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, - // Group ID는 application.yml 또는 @KafkaListener에서 설정 - ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, - ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to KafkaAvroDeserializer::class.java, - - // 성능 튜닝 - Phase 2 문서 기준 - ConsumerConfig.MAX_POLL_RECORDS_CONFIG to consumerProperties.bulkSize, // Bulk 크기와 일치 - ConsumerConfig.FETCH_MIN_BYTES_CONFIG to 1024, // 최소 fetch 크기 - ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG to 500, // 최대 대기 시간 - - // 안정성 - 수동 커밋으로 메시지 유실 방지 - ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false, - ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest", - - // Avro Schema Registry - KafkaAvroDeserializerConfig.SCHEMA_REGISTRY_URL_CONFIG to schemaRegistryUrl, - KafkaAvroDeserializerConfig.SPECIFIC_AVRO_READER_CONFIG to true - ) + val props = + mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + // Group ID는 application.yml 또는 @KafkaListener에서 설정 + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to KafkaAvroDeserializer::class.java, + // 성능 튜닝 - Phase 2 문서 기준 + ConsumerConfig.MAX_POLL_RECORDS_CONFIG to consumerProperties.bulkSize, // Bulk 크기와 일치 + ConsumerConfig.FETCH_MIN_BYTES_CONFIG to 1024, // 최소 fetch 크기 + ConsumerConfig.FETCH_MAX_WAIT_MS_CONFIG to 500, // 최대 대기 시간 + // 안정성 - 수동 커밋으로 메시지 유실 방지 + ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest", + // Avro Schema Registry + KafkaAvroDeserializerConfig.SCHEMA_REGISTRY_URL_CONFIG to schemaRegistryUrl, + KafkaAvroDeserializerConfig.SPECIFIC_AVRO_READER_CONFIG to true, + ) return DefaultKafkaConsumerFactory(props) } @Bean - fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory { - return ConcurrentKafkaListenerContainerFactory().apply { + fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory().apply { consumerFactory = consumerFactory() // 수동 커밋 - 배치 처리 완료 즉시 커밋 containerProperties.ackMode = ContainerProperties.AckMode.MANUAL_IMMEDIATE @@ -62,5 +59,4 @@ class KafkaConsumerConfig( // 분산 트레이싱 Observation 활성화 containerProperties.isObservationEnabled = true } - } } diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/KafkaProducerConfig.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/KafkaProducerConfig.kt index e4e27b2..64a1af8 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/KafkaProducerConfig.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/KafkaProducerConfig.kt @@ -17,7 +17,6 @@ import org.springframework.kafka.core.ProducerFactory */ @Configuration class KafkaProducerConfig { - @Value("\${spring.kafka.bootstrap-servers}") private lateinit var bootstrapServers: String @@ -26,26 +25,24 @@ class KafkaProducerConfig { @Bean fun producerFactory(): ProducerFactory { - val configProps = mapOf( - ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, - ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, - ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to KafkaAvroSerializer::class.java, - - // Reliability settings - ProducerConfig.ACKS_CONFIG to "all", - ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to true, - ProducerConfig.RETRIES_CONFIG to 3, - - // Schema Registry - KafkaAvroSerializerConfig.SCHEMA_REGISTRY_URL_CONFIG to schemaRegistryUrl - ) + val configProps = + mapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to KafkaAvroSerializer::class.java, + // Reliability settings + ProducerConfig.ACKS_CONFIG to "all", + ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to true, + ProducerConfig.RETRIES_CONFIG to 3, + // Schema Registry + KafkaAvroSerializerConfig.SCHEMA_REGISTRY_URL_CONFIG to schemaRegistryUrl, + ) return DefaultKafkaProducerFactory(configProps) } @Bean - fun kafkaTemplate(): KafkaTemplate { - return KafkaTemplate(producerFactory()).apply { + fun kafkaTemplate(): KafkaTemplate = + KafkaTemplate(producerFactory()).apply { setObservationEnabled(true) } - } } diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/RedisConfig.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/RedisConfig.kt index ad188e7..6094a88 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/RedisConfig.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/RedisConfig.kt @@ -17,21 +17,21 @@ import org.springframework.data.redis.serializer.StringRedisSerializer */ @Configuration class RedisConfig { - @Bean @Primary fun reactiveRedisTemplate( - connectionFactory: ReactiveRedisConnectionFactory + connectionFactory: ReactiveRedisConnectionFactory, ): ReactiveRedisTemplate { val serializer = StringRedisSerializer() - val context = RedisSerializationContext - .newSerializationContext(serializer) - .key(serializer) - .value(serializer) - .hashKey(serializer) - .hashValue(serializer) - .build() + val context = + RedisSerializationContext + .newSerializationContext(serializer) + .key(serializer) + .value(serializer) + .hashKey(serializer) + .hashValue(serializer) + .build() return ReactiveRedisTemplate(connectionFactory, context) } diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/SchemaRegistryHealthIndicator.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/SchemaRegistryHealthIndicator.kt index 353639f..ac9668c 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/SchemaRegistryHealthIndicator.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/SchemaRegistryHealthIndicator.kt @@ -17,44 +17,49 @@ private val log = KotlinLogging.logger {} * Actuator /health 엔드포인트에서 Schema Registry 연결 상태를 확인합니다. * Schema Registry URL이 설정된 경우에만 활성화됩니다. * - * @see docs/phase%205.md + * @see docs/phase 5.md */ @Component @ConditionalOnProperty(name = ["spring.kafka.consumer.properties.schema.registry.url"]) class SchemaRegistryHealthIndicator( @Value("\${spring.kafka.consumer.properties.schema.registry.url}") - private val schemaRegistryUrl: String + private val schemaRegistryUrl: String, ) : HealthIndicator { + private val webClient = + WebClient + .builder() + .baseUrl(schemaRegistryUrl) + .build() - private val webClient = WebClient.builder() - .baseUrl(schemaRegistryUrl) - .build() - - override fun health(): Health { - return try { - val response = webClient.get() - .uri("/subjects") - .retrieve() - .bodyToMono(String::class.java) - .block(Duration.ofSeconds(5)) + override fun health(): Health = + try { + val response = + webClient + .get() + .uri("/subjects") + .retrieve() + .bodyToMono(String::class.java) + .block(Duration.ofSeconds(5)) if (response != null) { - Health.up() + Health + .up() .withDetail("url", schemaRegistryUrl) .withDetail("status", "connected") .build() } else { - Health.down() + Health + .down() .withDetail("url", schemaRegistryUrl) .withDetail("status", "empty response") .build() } } catch (e: Exception) { log.warn { "Schema Registry health check failed: ${e.message}" } - Health.down() + Health + .down() .withDetail("url", schemaRegistryUrl) .withDetail("error", e.message ?: "unknown error") .build() } - } } diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/WebClientConfig.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/WebClientConfig.kt index 414bd26..dcbe7c1 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/config/WebClientConfig.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/config/WebClientConfig.kt @@ -18,18 +18,19 @@ import java.util.concurrent.TimeUnit */ @Configuration class WebClientConfig( - private val embeddingProperties: EmbeddingProperties + private val embeddingProperties: EmbeddingProperties, ) { - @Bean fun embeddingWebClient(builder: WebClient.Builder): WebClient { - val httpClient = HttpClient.create() - .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, embeddingProperties.timeoutMs.toInt()) - .responseTimeout(Duration.ofMillis(embeddingProperties.timeoutMs)) - .doOnConnected { conn -> - conn.addHandlerLast(ReadTimeoutHandler(embeddingProperties.timeoutMs, TimeUnit.MILLISECONDS)) - conn.addHandlerLast(WriteTimeoutHandler(embeddingProperties.timeoutMs, TimeUnit.MILLISECONDS)) - } + val httpClient = + HttpClient + .create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, embeddingProperties.timeoutMs.toInt()) + .responseTimeout(Duration.ofMillis(embeddingProperties.timeoutMs)) + .doOnConnected { conn -> + conn.addHandlerLast(ReadTimeoutHandler(embeddingProperties.timeoutMs, TimeUnit.MILLISECONDS)) + conn.addHandlerLast(WriteTimeoutHandler(embeddingProperties.timeoutMs, TimeUnit.MILLISECONDS)) + } return builder .baseUrl(embeddingProperties.url) diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/listener/BehaviorEventListener.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/listener/BehaviorEventListener.kt index 7fd17ad..1349305 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/listener/BehaviorEventListener.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/listener/BehaviorEventListener.kt @@ -7,7 +7,6 @@ import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.DistributionSummary import io.micrometer.core.instrument.MeterRegistry import io.micrometer.core.instrument.Timer -import java.util.concurrent.TimeUnit import kotlinx.coroutines.CloseableCoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -17,6 +16,9 @@ import org.springframework.beans.factory.annotation.Qualifier import org.springframework.kafka.annotation.KafkaListener import org.springframework.kafka.support.Acknowledgment import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +private val log = KotlinLogging.logger {} /** * Kafka Behavior Event Listener @@ -27,8 +29,7 @@ import org.springframework.stereotype.Component * 중요: ES 저장이 완료된 후에만 오프셋을 커밋하여 메시지 유실을 방지합니다. * 취향 벡터 갱신은 best-effort로 처리됩니다 (실패해도 오프셋 커밋). * - * @see Phase 2: Kafka Listener - * @see Phase 2: Preference Update + * @see docs/phase 2.md */ @Component @OptIn(ExperimentalCoroutinesApi::class) @@ -36,34 +37,41 @@ class BehaviorEventListener( private val bulkIndexer: BulkIndexer, private val preferenceUpdater: PreferenceUpdater, private val meterRegistry: MeterRegistry, - @Qualifier("virtualThreadDispatcher") private val virtualThreadDispatcher: CloseableCoroutineDispatcher + @param:Qualifier("virtualThreadDispatcher") + private val virtualThreadDispatcher: CloseableCoroutineDispatcher, ) { - companion object { - private val log = KotlinLogging.logger {} - } - - private val processedCounter: Counter = Counter.builder("kafka.consumer.processed") - .tag("topic", "user.action.v1") - .register(meterRegistry) - - private val indexedCounter: Counter = Counter.builder("kafka.consumer.indexed") - .tag("topic", "user.action.v1") - .register(meterRegistry) - - private val errorCounter: Counter = Counter.builder("kafka.consumer.errors") - .tag("topic", "user.action.v1") - .register(meterRegistry) - - private val consumeTimer: Timer = Timer.builder("kafka.consumer.batch.duration") - .tag("topic", "user.action.v1") - .description("Time spent processing a batch of records") - .register(meterRegistry) - - private val batchSizeSummary: DistributionSummary = DistributionSummary.builder("kafka.consumer.batch.size") - .tag("topic", "user.action.v1") - .description("Distribution of batch sizes") - .publishPercentiles(0.5, 0.9, 0.99) - .register(meterRegistry) + private val processedCounter: Counter = + Counter + .builder("kafka.consumer.processed") + .tag("topic", "user.action.v1") + .register(meterRegistry) + + private val indexedCounter: Counter = + Counter + .builder("kafka.consumer.indexed") + .tag("topic", "user.action.v1") + .register(meterRegistry) + + private val errorCounter: Counter = + Counter + .builder("kafka.consumer.errors") + .tag("topic", "user.action.v1") + .register(meterRegistry) + + private val consumeTimer: Timer = + Timer + .builder("kafka.consumer.batch.duration") + .tag("topic", "user.action.v1") + .description("Time spent processing a batch of records") + .register(meterRegistry) + + private val batchSizeSummary: DistributionSummary = + DistributionSummary + .builder("kafka.consumer.batch.size") + .tag("topic", "user.action.v1") + .description("Distribution of batch sizes") + .publishPercentiles(0.5, 0.9, 0.99) + .register(meterRegistry) /** * 배치 단위로 이벤트를 수신하고 처리합니다. @@ -78,11 +86,11 @@ class BehaviorEventListener( @KafkaListener( topics = ["\${consumer.topic}"], groupId = "\${spring.kafka.consumer.group-id}", - containerFactory = "kafkaListenerContainerFactory" + containerFactory = "kafkaListenerContainerFactory", ) fun consume( records: List>, - acknowledgment: Acknowledgment + acknowledgment: Acknowledgment, ) { if (records.isEmpty()) { acknowledgment.acknowledge() @@ -121,7 +129,6 @@ class BehaviorEventListener( if (records.size >= 100) { log.info { "Processed batch: received=${records.size}, indexed=$indexedCount" } } - } catch (e: Exception) { errorCounter.increment(records.size.toDouble()) log.error(e) { "Failed to process batch of ${records.size} records" } diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/repository/ProductVectorRepository.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/repository/ProductVectorRepository.kt index 811b261..77e1f33 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/repository/ProductVectorRepository.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/repository/ProductVectorRepository.kt @@ -5,6 +5,7 @@ import co.elastic.clients.elasticsearch.core.GetResponse import com.rep.consumer.config.ConsumerProperties import com.rep.model.ProductDocument import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository private val log = KotlinLogging.logger {} @@ -14,17 +15,14 @@ private val log = KotlinLogging.logger {} * * Elasticsearch의 product_index에서 상품 벡터를 조회합니다. * - * @see docs/phase%202.md + * @see docs/phase 2.md */ @Repository class ProductVectorRepository( private val esClient: ElasticsearchClient, - private val consumerProperties: ConsumerProperties + private val consumerProperties: ConsumerProperties, + @Value("\${elasticsearch.index.product:product_index}") private val productIndex: String, ) { - companion object { - private const val INDEX_NAME = "product_index" - } - private val expectedDimensions: Int get() = consumerProperties.vectorDimensions /** @@ -35,17 +33,20 @@ class ProductVectorRepository( */ fun getProductVector(productId: String): FloatArray? { return try { - val response: GetResponse = esClient.get( - { g -> g.index(INDEX_NAME).id(productId) }, - ProductDocument::class.java - ) + val response: GetResponse = + esClient.get( + { g -> g.index(productIndex).id(productId) }, + ProductDocument::class.java, + ) if (response.found()) { val vector = response.source()?.productVector?.toFloatArray() // 벡터 차원 검증 if (vector != null && vector.size != expectedDimensions) { - log.warn { "Product $productId has invalid vector dimension: ${vector.size}, expected: $expectedDimensions" } + log.warn { + "Product $productId has invalid vector dimension: ${vector.size}, expected: $expectedDimensions" + } return null } @@ -70,12 +71,14 @@ class ProductVectorRepository( if (productIds.isEmpty()) return emptyMap() return try { - val response = esClient.mget( - { m -> m.index(INDEX_NAME).ids(productIds) }, - ProductDocument::class.java - ) + val response = + esClient.mget( + { m -> m.index(productIndex).ids(productIds) }, + ProductDocument::class.java, + ) - response.docs() + response + .docs() .filter { it.result()?.found() == true } .mapNotNull { doc -> val id = doc.result()?.id() ?: return@mapNotNull null @@ -84,15 +87,18 @@ class ProductVectorRepository( // 벡터 차원 검증 if (vector != null && vector.size != expectedDimensions) { - log.warn { "Product $id has invalid vector dimension: ${vector.size}, expected: $expectedDimensions" } + log.warn { + "Product $id has invalid vector dimension: ${vector.size}, expected: $expectedDimensions" + } return@mapNotNull null } if (vector != null) { id to vector - } else null - } - .toMap() + } else { + null + } + }.toMap() } catch (e: Exception) { log.error(e) { "Failed to get product vectors for ${productIds.size} products" } emptyMap() diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/repository/UserPreferenceRepository.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/repository/UserPreferenceRepository.kt index efd2179..bdfc915 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/repository/UserPreferenceRepository.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/repository/UserPreferenceRepository.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.data.redis.core.ReactiveRedisTemplate import org.springframework.stereotype.Repository import java.time.Duration @@ -32,11 +33,11 @@ private val log = KotlinLogging.logger {} class UserPreferenceRepository( private val redisTemplate: ReactiveRedisTemplate, private val esClient: ElasticsearchClient, - private val objectMapper: ObjectMapper + private val objectMapper: ObjectMapper, + @Value("\${elasticsearch.index.user-preference:user_preference_index}") private val userPreferenceIndex: String, ) { companion object { private const val KEY_PREFIX = "user:preference:" - private const val ES_INDEX = "user_preference_index" private val TTL = Duration.ofHours(24) } @@ -63,18 +64,24 @@ class UserPreferenceRepository( * @param vector 취향 벡터 * @param actionCount 누적 행동 수 */ - suspend fun save(userId: String, vector: FloatArray, actionCount: Int = 1) { + suspend fun save( + userId: String, + vector: FloatArray, + actionCount: Int = 1, + ) { val key = "$KEY_PREFIX$userId" val updatedAt = System.currentTimeMillis() - val data = UserPreferenceData( - preferenceVector = vector.toList(), - actionCount = actionCount, - updatedAt = updatedAt - ) + val data = + UserPreferenceData( + preferenceVector = vector.toList(), + actionCount = actionCount, + updatedAt = updatedAt, + ) try { // 1. Redis 저장 (Primary) - redisTemplate.opsForValue() + redisTemplate + .opsForValue() .set(key, objectMapper.writeValueAsString(data), TTL) .awaitSingle() @@ -90,7 +97,6 @@ class UserPreferenceRepository( log.warn(e) { "ES backup failed for userId=$userId (best-effort, Redis is primary)" } } } - } catch (e: Exception) { log.error(e) { "Failed to save preference vector for userId=$userId to Redis" } throw e @@ -119,10 +125,11 @@ class UserPreferenceRepository( } else { // 2. Redis 미스 → ES에서 복구 log.debug { "Cache miss for userId=$userId, trying ES fallback" } - getFromEs(userId)?.also { (vector, actionCount) -> - // Redis에 다시 캐싱 (actionCount 보존) - save(userId, vector, actionCount) - }?.first // Pair에서 vector만 반환 + getFromEs(userId) + ?.also { (vector, actionCount) -> + // Redis에 다시 캐싱 (actionCount 보존) + save(userId, vector, actionCount) + }?.first // Pair에서 vector만 반환 } } catch (e: Exception) { log.error(e) { "Failed to get preference vector for userId=$userId" } @@ -151,21 +158,28 @@ class UserPreferenceRepository( * Race Condition 방지: External versioning으로 updatedAt이 더 최신인 경우만 저장. * 구버전 데이터가 뒤늦게 도착해도 신버전을 덮어쓰지 않습니다. */ - private fun saveToEs(userId: String, vector: FloatArray, actionCount: Int, updatedAt: Long) { + private fun saveToEs( + userId: String, + vector: FloatArray, + actionCount: Int, + updatedAt: Long, + ) { try { - val document = mapOf( - "userId" to userId, - "preferenceVector" to vector.toList(), - "actionCount" to actionCount, - "updatedAt" to updatedAt - ) + val document = + mapOf( + "userId" to userId, + "preferenceVector" to vector.toList(), + "actionCount" to actionCount, + "updatedAt" to updatedAt, + ) esClient.index { idx -> - idx.index(ES_INDEX) + idx + .index(userPreferenceIndex) .id(userId) .document(document) .versionType(VersionType.External) - .version(updatedAt) // updatedAt을 version으로 사용 + .version(updatedAt) // updatedAt을 version으로 사용 } log.debug { "Backed up preference vector for userId=$userId to ES (version=$updatedAt)" } @@ -187,12 +201,13 @@ class UserPreferenceRepository( * * @return Pair(vector, actionCount) 또는 null */ - private fun getFromEs(userId: String): Pair? { - return try { - val response = esClient.get( - { g -> g.index(ES_INDEX).id(userId) }, - UserPreferenceDocument::class.java - ) + private fun getFromEs(userId: String): Pair? = + try { + val response = + esClient.get( + { g -> g.index(userPreferenceIndex).id(userId) }, + UserPreferenceDocument::class.java, + ) if (response.found()) { val source = response.source() @@ -207,5 +222,4 @@ class UserPreferenceRepository( log.warn(e) { "Failed to get preference vector from ES for userId=$userId" } null } - } } diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/service/BulkIndexer.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/service/BulkIndexer.kt index f4d2e4f..0c5a432 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/service/BulkIndexer.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/service/BulkIndexer.kt @@ -10,13 +10,13 @@ import io.micrometer.core.instrument.MeterRegistry import io.micrometer.core.instrument.Timer import io.micrometer.observation.Observation import io.micrometer.observation.ObservationRegistry -import java.util.UUID -import java.util.concurrent.TimeUnit import jakarta.annotation.PostConstruct import kotlinx.coroutines.delay import mu.KotlinLogging import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component +import java.util.UUID +import java.util.concurrent.TimeUnit private val log = KotlinLogging.logger {} @@ -32,7 +32,7 @@ private val log = KotlinLogging.logger {} * - 실패 시 DLQ 전송 * - traceId 기반 멱등성 보장 * - * @see Phase 2: Data Pipeline + * @see docs/phase 2.md */ @Component class BulkIndexer( @@ -40,7 +40,7 @@ class BulkIndexer( private val dlqProducer: DlqProducer, private val properties: ConsumerProperties, private val meterRegistry: MeterRegistry, - private val observationRegistry: ObservationRegistry + private val observationRegistry: ObservationRegistry, ) { @Value("\${elasticsearch.index.user-behavior:user_behavior_index}") private lateinit var indexName: String @@ -48,34 +48,44 @@ class BulkIndexer( // Metrics - @PostConstruct에서 초기화 (indexName이 주입된 후) private lateinit var bulkSuccessCounter: Counter private lateinit var bulkFailedCounter: Counter - private lateinit var bulkBatchFailedCounter: Counter // 배치 단위 실패 카운터 + private lateinit var bulkBatchFailedCounter: Counter // 배치 단위 실패 카운터 private lateinit var retryCounter: Counter - private lateinit var bulkIndexTimer: Timer // 배치 인덱싱 처리 시간 + private lateinit var bulkIndexTimer: Timer // 배치 인덱싱 처리 시간 @PostConstruct fun initMetrics() { - bulkSuccessCounter = Counter.builder("es.bulk.success") - .tag("index", indexName) - .register(meterRegistry) - - bulkFailedCounter = Counter.builder("es.bulk.failed") - .tag("index", indexName) - .description("Failed documents count") - .register(meterRegistry) - - bulkBatchFailedCounter = Counter.builder("es.bulk.batch.failed") - .tag("index", indexName) - .description("Failed batch count (all retries exhausted)") - .register(meterRegistry) - - retryCounter = Counter.builder("es.bulk.retry") - .tag("index", indexName) - .register(meterRegistry) - - bulkIndexTimer = Timer.builder("es.bulk.duration") - .tag("index", indexName) - .description("Time spent on bulk indexing") - .register(meterRegistry) + bulkSuccessCounter = + Counter + .builder("es.bulk.success") + .tag("index", indexName) + .register(meterRegistry) + + bulkFailedCounter = + Counter + .builder("es.bulk.failed") + .tag("index", indexName) + .description("Failed documents count") + .register(meterRegistry) + + bulkBatchFailedCounter = + Counter + .builder("es.bulk.batch.failed") + .tag("index", indexName) + .description("Failed batch count (all retries exhausted)") + .register(meterRegistry) + + retryCounter = + Counter + .builder("es.bulk.retry") + .tag("index", indexName) + .register(meterRegistry) + + bulkIndexTimer = + Timer + .builder("es.bulk.duration") + .tag("index", indexName) + .description("Time spent on bulk indexing") + .register(meterRegistry) log.info { "BulkIndexer initialized with index: $indexName" } } @@ -95,9 +105,11 @@ class BulkIndexer( suspend fun indexBatchSync(events: List): Int { if (events.isEmpty()) return 0 - val observation = Observation.createNotStarted("es.bulk-index", observationRegistry) - .lowCardinalityKeyValue("index", indexName) - .lowCardinalityKeyValue("batchSize", events.size.toString()) + val observation = + Observation + .createNotStarted("es.bulk-index", observationRegistry) + .lowCardinalityKeyValue("index", indexName) + .lowCardinalityKeyValue("batchSize", events.size.toString()) observation.start() val startTime = System.nanoTime() @@ -120,14 +132,18 @@ class BulkIndexer( if (attempt < properties.maxRetries - 1) { val delayMs = properties.retryDelayMs * (1L shl attempt) - log.warn { "Bulk indexing attempt ${attempt + 1}/${properties.maxRetries} failed, retrying in ${delayMs}ms..." } + log.warn { + "Bulk indexing attempt ${attempt + 1}/${properties.maxRetries} failed, retrying in ${delayMs}ms..." + } delay(delayMs) } } } // 모든 재시도 실패 - log.error(lastException) { "Bulk indexing failed after ${properties.maxRetries} attempts for ${events.size} events" } + log.error( + lastException, + ) { "Bulk indexing failed after ${properties.maxRetries} attempts for ${events.size} events" } bulkBatchFailedCounter.increment() bulkFailedCounter.increment(events.size.toDouble()) sendToDlq(events) @@ -149,17 +165,20 @@ class BulkIndexer( events.forEach { event -> bulkRequest.operations { op -> op.index { idx -> - idx.index(indexName) - .id(event.traceId?.toString() ?: UUID.randomUUID().toString()) // Idempotency 보장 - .document(mapOf( - "traceId" to (event.traceId?.toString() ?: ""), - "userId" to event.userId.toString(), - "productId" to event.productId.toString(), - "category" to event.category.toString(), - "actionType" to event.actionType.toString(), - "metadata" to event.metadata, - "timestamp" to event.timestamp.toEpochMilli() - )) + idx + .index(indexName) + .id(event.traceId?.toString() ?: UUID.randomUUID().toString()) // Idempotency 보장 + .document( + mapOf( + "traceId" to (event.traceId?.toString() ?: ""), + "userId" to event.userId.toString(), + "productId" to event.productId.toString(), + "category" to event.category.toString(), + "actionType" to event.actionType.toString(), + "metadata" to event.metadata, + "timestamp" to event.timestamp.toEpochMilli(), + ), + ) } } } @@ -171,7 +190,10 @@ class BulkIndexer( * Bulk 응답을 처리하고 성공 건수를 반환합니다. * 개별 문서 실패 시 해당 이벤트만 DLQ로 전송합니다. */ - private fun handleBulkResponse(response: BulkResponse, events: List): Int { + private fun handleBulkResponse( + response: BulkResponse, + events: List, + ): Int { var successCount = 0 if (response.errors()) { diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/service/DlqProducer.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/service/DlqProducer.kt index c01b762..215c70b 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/service/DlqProducer.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/service/DlqProducer.kt @@ -21,24 +21,28 @@ private val log = KotlinLogging.logger {} * 처리 실패한 메시지를 DLQ 토픽으로 전송합니다. * DLQ 전송도 실패할 경우 로컬 파일에 기록합니다 (최후의 수단). * - * @see Phase 2: DLQ 처리 + * @see docs/phase 2.md */ @Component class DlqProducer( private val kafkaTemplate: KafkaTemplate, private val properties: ConsumerProperties, - private val meterRegistry: MeterRegistry + private val meterRegistry: MeterRegistry, ) { // 파일 로테이션 동기화를 위한 Lock private val fileLock = ReentrantLock() - private val dlqCounter: Counter = Counter.builder("kafka.dlq.sent") - .tag("topic", properties.dlqTopic) - .register(meterRegistry) + private val dlqCounter: Counter = + Counter + .builder("kafka.dlq.sent") + .tag("topic", properties.dlqTopic) + .register(meterRegistry) - private val dlqFailedCounter: Counter = Counter.builder("kafka.dlq.failed") - .tag("topic", properties.dlqTopic) - .register(meterRegistry) + private val dlqFailedCounter: Counter = + Counter + .builder("kafka.dlq.failed") + .tag("topic", properties.dlqTopic) + .register(meterRegistry) /** * 실패한 이벤트를 DLQ 토픽으로 동기 전송합니다. @@ -53,7 +57,9 @@ class DlqProducer( // 동기 전송 - 완료될 때까지 대기 val result = kafkaTemplate.send(properties.dlqTopic, event.userId.toString(), event).get() dlqCounter.increment() - log.info { "Event sent to DLQ successfully: traceId=${event.traceId}, offset=${result.recordMetadata.offset()}" } + log.info { + "Event sent to DLQ successfully: traceId=${event.traceId}, offset=${result.recordMetadata.offset()}" + } true } catch (e: Exception) { dlqFailedCounter.increment() diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/service/PreferenceUpdater.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/service/PreferenceUpdater.kt index dd58db9..5aa54d9 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/service/PreferenceUpdater.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/service/PreferenceUpdater.kt @@ -1,5 +1,6 @@ package com.rep.consumer.service +import com.github.benmanes.caffeine.cache.Caffeine import com.rep.consumer.repository.ProductVectorRepository import com.rep.consumer.repository.UserPreferenceRepository import com.rep.event.user.UserActionEvent @@ -7,7 +8,6 @@ import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.MeterRegistry import io.micrometer.observation.Observation import io.micrometer.observation.ObservationRegistry -import com.github.benmanes.caffeine.cache.Caffeine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import mu.KotlinLogging @@ -28,7 +28,7 @@ private val log = KotlinLogging.logger {} * 4. EMA로 취향 벡터 갱신 * 5. Redis에 저장 (+ ES 백업) * - * @see docs/phase%202.md + * @see docs/phase 2.md * @see docs/adr-004-vector-storage.md */ @Component @@ -37,32 +37,39 @@ class PreferenceUpdater( private val userPreferenceRepository: UserPreferenceRepository, private val preferenceVectorCalculator: PreferenceVectorCalculator, private val meterRegistry: MeterRegistry, - private val observationRegistry: ObservationRegistry + private val observationRegistry: ObservationRegistry, ) { // 유저별 Mutex - Lost Update 방지 // 같은 유저에 대한 동시 업데이트를 직렬화하여 Lost Update 방지 // Caffeine: 5분간 접근 없는 유저의 Mutex 자동 제거 (메모리 누수 방지) - private val userLocks = Caffeine.newBuilder() - .expireAfterAccess(5, TimeUnit.MINUTES) - .maximumSize(100_000) - .build() - - private val updateSuccessCounter: Counter = Counter.builder("preference.update.success") - .register(meterRegistry) - - private val updateSkippedCounter: Counter = Counter.builder("preference.update.skipped") - .description("Skipped due to missing product vector") - .register(meterRegistry) - - private val updateFailedCounter: Counter = Counter.builder("preference.update.failed") - .register(meterRegistry) + private val userLocks = + Caffeine + .newBuilder() + .expireAfterAccess(5, TimeUnit.MINUTES) + .maximumSize(100_000) + .build() + + private val updateSuccessCounter: Counter = + Counter + .builder("preference.update.success") + .register(meterRegistry) + + private val updateSkippedCounter: Counter = + Counter + .builder("preference.update.skipped") + .description("Skipped due to missing product vector") + .register(meterRegistry) + + private val updateFailedCounter: Counter = + Counter + .builder("preference.update.failed") + .register(meterRegistry) /** * 유저별 Mutex를 반환합니다. * Caffeine Cache의 get(key, loader)로 thread-safe하게 생성 */ - private fun getUserLock(userId: String): Mutex = - userLocks.get(userId) { Mutex() } + private fun getUserLock(userId: String): Mutex = userLocks.get(userId) { Mutex() } /** * 단일 이벤트에 대해 유저 취향 벡터를 갱신합니다. @@ -77,9 +84,11 @@ class PreferenceUpdater( val productId = event.productId.toString() val actionType = event.actionType.toString() - val observation = Observation.createNotStarted("preference.update", observationRegistry) - .lowCardinalityKeyValue("userId", userId) - .lowCardinalityKeyValue("actionType", actionType) + val observation = + Observation + .createNotStarted("preference.update", observationRegistry) + .lowCardinalityKeyValue("userId", userId) + .lowCardinalityKeyValue("actionType", actionType) observation.start() return try { @@ -90,7 +99,7 @@ class PreferenceUpdater( log.debug { "Product vector not found for productId=$productId, skipping preference update" } updateSkippedCounter.increment() observation.stop() - return true // 상품 벡터가 없는 것은 오류가 아님 + return true // 상품 벡터가 없는 것은 오류가 아님 } // 유저별 Mutex로 동시 업데이트 직렬화 @@ -101,17 +110,18 @@ class PreferenceUpdater( val currentActionCount = currentData?.actionCount ?: 0 // 3. EMA로 취향 벡터 갱신 - val updatedVector = preferenceVectorCalculator.update( - currentPreference = currentPreference, - newProductVector = productVector, - actionType = actionType - ) + val updatedVector = + preferenceVectorCalculator.update( + currentPreference = currentPreference, + newProductVector = productVector, + actionType = actionType, + ) // 4. Redis에 저장 (+ ES 백업) userPreferenceRepository.save( userId = userId, vector = updatedVector, - actionCount = currentActionCount + 1 + actionCount = currentActionCount + 1, ) log.debug { @@ -123,7 +133,6 @@ class PreferenceUpdater( updateSuccessCounter.increment() observation.stop() true - } catch (e: Exception) { log.error(e) { "Failed to update preference for userId=$userId" } updateFailedCounter.increment() @@ -163,43 +172,44 @@ class PreferenceUpdater( } // 유저별 Mutex로 동시 업데이트 직렬화 - val userSuccessCount = getUserLock(userId).withLock { - var innerSuccessCount = 0 - - // 현재 취향 벡터 조회 - var currentPreference = userPreferenceRepository.get(userId) - val currentData = userPreferenceRepository.getWithMetadata(userId) - var actionCount = currentData?.actionCount ?: 0 - - // 이벤트 순서대로 취향 벡터 갱신 - for (event in userEvents) { - val productId = event.productId.toString() - val productVector = productVectors[productId] - - if (productVector != null) { - currentPreference = preferenceVectorCalculator.update( - currentPreference = currentPreference, - newProductVector = productVector, - actionType = event.actionType.toString() - ) - actionCount++ - innerSuccessCount++ - updateSuccessCounter.increment() - } else { - updateSkippedCounter.increment() + val userSuccessCount = + getUserLock(userId).withLock { + var innerSuccessCount = 0 + + // 현재 취향 벡터 조회 + var currentPreference = userPreferenceRepository.get(userId) + val currentData = userPreferenceRepository.getWithMetadata(userId) + var actionCount = currentData?.actionCount ?: 0 + + // 이벤트 순서대로 취향 벡터 갱신 + for (event in userEvents) { + val productId = event.productId.toString() + val productVector = productVectors[productId] + + if (productVector != null) { + currentPreference = + preferenceVectorCalculator.update( + currentPreference = currentPreference, + newProductVector = productVector, + actionType = event.actionType.toString(), + ) + actionCount++ + innerSuccessCount++ + updateSuccessCounter.increment() + } else { + updateSkippedCounter.increment() + } } - } - // 최종 결과 저장 - if (currentPreference != null) { - userPreferenceRepository.save(userId, currentPreference, actionCount) - } + // 최종 결과 저장 + if (currentPreference != null) { + userPreferenceRepository.save(userId, currentPreference, actionCount) + } - innerSuccessCount - } + innerSuccessCount + } successCount += userSuccessCount - } catch (e: Exception) { log.error(e) { "Failed to batch update preferences for userId=$userId" } updateFailedCounter.increment(userEvents.size.toDouble()) diff --git a/behavior-consumer/src/main/kotlin/com/rep/consumer/service/PreferenceVectorCalculator.kt b/behavior-consumer/src/main/kotlin/com/rep/consumer/service/PreferenceVectorCalculator.kt index e8687ba..8734929 100644 --- a/behavior-consumer/src/main/kotlin/com/rep/consumer/service/PreferenceVectorCalculator.kt +++ b/behavior-consumer/src/main/kotlin/com/rep/consumer/service/PreferenceVectorCalculator.kt @@ -19,27 +19,27 @@ private val log = KotlinLogging.logger {} * - CLICK: 0.3 (중간 신호) * - PURCHASE: 0.5 (강한 신호) * - * @see docs/phase%202.md + * @see docs/phase 2.md */ @Component class PreferenceVectorCalculator( - private val consumerProperties: ConsumerProperties + private val consumerProperties: ConsumerProperties, ) { companion object { // EMA 가중치 (CLAUDE.md 및 Phase 3 문서 기준) // 강한 신호 - const val ALPHA_PURCHASE = 0.5f // 구매: 가장 강한 관심 신호 + const val ALPHA_PURCHASE = 0.5f // 구매: 가장 강한 관심 신호 // 중간 강도 신호 - const val ALPHA_ADD_TO_CART = 0.3f // 장바구니: 구매 의도가 있는 강한 신호 - const val ALPHA_CLICK = 0.3f // 클릭: 적극적인 관심 + const val ALPHA_ADD_TO_CART = 0.3f // 장바구니: 구매 의도가 있는 강한 신호 + const val ALPHA_CLICK = 0.3f // 클릭: 적극적인 관심 // 중간 신호 - const val ALPHA_SEARCH = 0.2f // 검색: 탐색 의도 + const val ALPHA_SEARCH = 0.2f // 검색: 탐색 의도 // 약한 신호 - const val ALPHA_VIEW = 0.1f // 조회: 수동적 노출 - const val ALPHA_WISHLIST = 0.1f // 위시리스트: 나중을 위한 저장 + const val ALPHA_VIEW = 0.1f // 조회: 수동적 노출 + const val ALPHA_WISHLIST = 0.1f // 위시리스트: 나중을 위한 저장 } private val expectedVectorDimensions: Int get() = consumerProperties.vectorDimensions @@ -55,7 +55,7 @@ class PreferenceVectorCalculator( fun update( currentPreference: FloatArray?, newProductVector: FloatArray, - actionType: String + actionType: String, ): FloatArray { // 벡터 차원 검증 require(newProductVector.size == expectedVectorDimensions) { @@ -80,9 +80,10 @@ class PreferenceVectorCalculator( } // 기존 유저: EMA로 벡터 갱신 - val updated = FloatArray(currentPreference.size) { i -> - currentPreference[i] * (1 - alpha) + newProductVector[i] * alpha - } + val updated = + FloatArray(currentPreference.size) { i -> + currentPreference[i] * (1 - alpha) + newProductVector[i] * alpha + } return updated.normalize() } @@ -97,7 +98,7 @@ class PreferenceVectorCalculator( */ fun updateBatch( currentPreference: FloatArray?, - productVectorsWithActions: List> + productVectorsWithActions: List>, ): FloatArray? { if (productVectorsWithActions.isEmpty()) { return currentPreference @@ -118,8 +119,8 @@ class PreferenceVectorCalculator( * @param actionType 행동 유형 * @return EMA 가중치 (0.0f ~ 0.5f), 알 수 없는 행동은 0.0f */ - private fun getAlpha(actionType: String): Float { - return when (actionType.uppercase()) { + private fun getAlpha(actionType: String): Float = + when (actionType.uppercase()) { "VIEW" -> ALPHA_VIEW "SEARCH" -> ALPHA_SEARCH "CLICK" -> ALPHA_CLICK @@ -131,7 +132,6 @@ class PreferenceVectorCalculator( 0.0f } } - } /** * 벡터를 정규화합니다 (단위 벡터로 변환). diff --git a/behavior-consumer/src/main/resources/application.yml b/behavior-consumer/src/main/resources/application.yml index af542e7..52e78d7 100644 --- a/behavior-consumer/src/main/resources/application.yml +++ b/behavior-consumer/src/main/resources/application.yml @@ -42,6 +42,8 @@ elasticsearch: scheme: ${ELASTICSEARCH_SCHEME:http} index: user-behavior: user_behavior_index + product: product_index + user-preference: user_preference_index # Embedding Service (Phase 3) embedding: @@ -118,6 +120,8 @@ elasticsearch: scheme: http index: user-behavior: user_behavior_index + product: product_index + user-preference: user_preference_index embedding: service: diff --git a/build.gradle.kts b/build.gradle.kts index 510b336..517c6a6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("org.springframework.boot") version "3.5.9" apply false id("io.spring.dependency-management") version "1.1.7" apply false id("com.github.davidmc24.gradle.plugin.avro") version "1.9.1" apply false + id("org.jlleitschuh.gradle.ktlint") version "14.0.1" apply false } allprojects { @@ -17,6 +18,15 @@ allprojects { } subprojects { + plugins.withId("org.jetbrains.kotlin.jvm") { + apply(plugin = "org.jlleitschuh.gradle.ktlint") + configure { + filter { + exclude("**/build/**") + } + } + } + tasks.withType { compilerOptions { freeCompilerArgs.add("-Xjsr305=strict") diff --git a/common-model/src/main/kotlin/com/rep/model/NotificationHistory.kt b/common-model/src/main/kotlin/com/rep/model/NotificationHistory.kt index d0468eb..9ae9390 100644 --- a/common-model/src/main/kotlin/com/rep/model/NotificationHistory.kt +++ b/common-model/src/main/kotlin/com/rep/model/NotificationHistory.kt @@ -20,7 +20,7 @@ data class NotificationHistory( val channels: List? = null, val priority: String? = null, val status: String? = null, - val sentAt: Instant? = null + val sentAt: Instant? = null, ) /** @@ -29,10 +29,13 @@ data class NotificationHistory( enum class SendStatus { /** 발송 완료 */ SENT, + /** 발송 실패 */ FAILED, + /** Rate Limit으로 인한 차단 */ RATE_LIMITED, + /** 유저가 알림 수신 거부 */ - USER_OPTED_OUT + USER_OPTED_OUT, } diff --git a/common-model/src/main/kotlin/com/rep/model/ProductDocument.kt b/common-model/src/main/kotlin/com/rep/model/ProductDocument.kt index 15ddeb6..997568f 100644 --- a/common-model/src/main/kotlin/com/rep/model/ProductDocument.kt +++ b/common-model/src/main/kotlin/com/rep/model/ProductDocument.kt @@ -19,7 +19,6 @@ data class ProductDocument( val productName: String = "", val category: String = "", val price: Float = 0f, - // 선택 필드 (nullable) val subCategory: String? = null, val stock: Int? = null, @@ -28,22 +27,19 @@ data class ProductDocument( val tags: List? = null, val productVector: List? = null, val createdAt: String? = null, - val updatedAt: String? = null + val updatedAt: String? = null, ) { /** * 필수 필드 유효성 검증 */ - fun isValid(): Boolean { - return productId.isNotBlank() && - productName.isNotBlank() && - category.isNotBlank() && - price >= 0 - } + fun isValid(): Boolean = + productId.isNotBlank() && + productName.isNotBlank() && + category.isNotBlank() && + price >= 0 /** * KNN 검색 가능 여부 (벡터 존재 + 768차원 확인) */ - fun hasVector(): Boolean { - return productVector != null && productVector.size == UserPreferenceData.VECTOR_DIMENSIONS - } + fun hasVector(): Boolean = productVector != null && productVector.size == UserPreferenceData.VECTOR_DIMENSIONS } diff --git a/common-model/src/main/kotlin/com/rep/model/UserPreferenceData.kt b/common-model/src/main/kotlin/com/rep/model/UserPreferenceData.kt index 27c49d1..6b10d4e 100644 --- a/common-model/src/main/kotlin/com/rep/model/UserPreferenceData.kt +++ b/common-model/src/main/kotlin/com/rep/model/UserPreferenceData.kt @@ -14,7 +14,7 @@ data class UserPreferenceData( val preferenceVector: List, val actionCount: Int = 1, val updatedAt: Long = System.currentTimeMillis(), - val version: Long = 1 + val version: Long = 1, ) { companion object { const val VECTOR_DIMENSIONS = 768 diff --git a/common-model/src/main/kotlin/com/rep/model/UserPreferenceDocument.kt b/common-model/src/main/kotlin/com/rep/model/UserPreferenceDocument.kt index be717be..068c714 100644 --- a/common-model/src/main/kotlin/com/rep/model/UserPreferenceDocument.kt +++ b/common-model/src/main/kotlin/com/rep/model/UserPreferenceDocument.kt @@ -12,5 +12,5 @@ data class UserPreferenceDocument( val userId: String? = null, val preferenceVector: List? = null, val actionCount: Int? = null, - val updatedAt: Long? = null + val updatedAt: Long? = null, ) diff --git a/docker/docker-compose.lowmem.yml b/docker/docker-compose.lowmem.yml new file mode 100644 index 0000000..ee9b2ca --- /dev/null +++ b/docker/docker-compose.lowmem.yml @@ -0,0 +1,12 @@ +services: + kafka: + environment: + KAFKA_HEAP_OPTS: "-Xms512m -Xmx512m" + + elasticsearch: + environment: + ES_JAVA_OPTS: "-Xms1g -Xmx1g" + + redis: + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy volatile-lru + diff --git a/docker/init-indices.bat b/docker/init-indices.bat index 79663f5..4976905 100644 --- a/docker/init-indices.bat +++ b/docker/init-indices.bat @@ -27,7 +27,7 @@ curl -X PUT "%ES_HOST%/user_preference_index" -H "Content-Type: application/json echo. -curl -X PUT "%ES_HOST%/notification_history_index" -H "Content-Type: application/json" -d "{\"settings\":{\"number_of_shards\":2,\"number_of_replicas\":0,\"refresh_interval\":\"5s\"},\"mappings\":{\"properties\":{\"notificationId\":{\"type\":\"keyword\"},\"userId\":{\"type\":\"keyword\"},\"productId\":{\"type\":\"keyword\"},\"type\":{\"type\":\"keyword\"},\"title\":{\"type\":\"text\"},\"body\":{\"type\":\"text\"},\"data\":{\"type\":\"object\",\"enabled\":false},\"channels\":{\"type\":\"keyword\"},\"priority\":{\"type\":\"keyword\"},\"status\":{\"type\":\"keyword\"},\"sentAt\":{\"type\":\"date\"}}}}" +curl -X PUT "%ES_HOST%/notification_history_index" -H "Content-Type: application/json" -d "{\"settings\":{\"number_of_shards\":2,\"number_of_replicas\":0,\"refresh_interval\":\"5s\"},\"mappings\":{\"properties\":{\"notificationId\":{\"type\":\"keyword\"},\"userId\":{\"type\":\"keyword\"},\"productId\":{\"type\":\"keyword\"},\"type\":{\"type\":\"keyword\"},\"title\":{\"type\":\"text\"},\"body\":{\"type\":\"text\"},\"data\":{\"type\":\"object\",\"enabled\":false},\"channels\":{\"type\":\"keyword\"},\"priority\":{\"type\":\"keyword\"},\"status\":{\"type\":\"keyword\"},\"sentAt\":{\"type\":\"date\"},\"traceId\":{\"type\":\"keyword\"}}}}" echo. diff --git a/docker/init-indices.sh b/docker/init-indices.sh index 546ecbb..0d0620e 100644 --- a/docker/init-indices.sh +++ b/docker/init-indices.sh @@ -124,7 +124,8 @@ curl -X PUT "$ES_HOST/notification_history_index" -H 'Content-Type: application/ "channels": { "type": "keyword" }, "priority": { "type": "keyword" }, "status": { "type": "keyword" }, - "sentAt": { "type": "date" } + "sentAt": { "type": "date" }, + "traceId": { "type": "keyword" } } } }' diff --git a/docs/code-style-guide.ko.md b/docs/code-style-guide.ko.md new file mode 100644 index 0000000..e725d99 --- /dev/null +++ b/docs/code-style-guide.ko.md @@ -0,0 +1,76 @@ +# REP-Engine Kotlin + Spring 코드 스타일 가이드 (v1) + +## 범위 + +이 문서는 이 저장소의 Kotlin + Spring **서버 사이드** 모듈에 대한 코딩 스타일을 정의합니다. + +기본 기준: +1. Kotlin Coding Conventions +2. 이 저장소에 설정된 ktlint 규칙 +3. 이 문서(팀 가독성 중심 규칙) + +## 핵심 원칙 + +1. 가독성 우선 +2. 축약보다 명시성 +3. 모듈 간 예측 가능한 구조 +4. 가능한 규칙은 도구로 강제 + +## Kotlin 기본 규칙 + +1. 와일드카드 import(`*`) 사용 금지 +2. import는 명시적으로 작성하고 정렬 유지 +3. 줄 길이는 읽기 좋은 수준 유지(권장 120자) +4. 축약어보다 설명적인 이름 선호 +5. KDoc 링크는 `@see docs/phase N.md` 형식 사용 + +## Spring 구조 규칙 + +1. 기본은 생성자 주입 사용 +2. 생성자 파라미터에 qualifier가 필요하면 `@param:Qualifier("...")` 사용 +3. 빈 조립은 `config` 패키지, 비즈니스 로직은 `service` 패키지에 배치 +4. Controller에는 비즈니스 로직/영속성 로직을 넣지 않음 +5. Repository 접근은 controller가 아닌 service/repository 레이어에서 처리 + +## Controller 규칙 + +1. HTTP 관심사(요청/응답, 상태코드, 검증 오류)만 처리 +2. 성공 경로는 단순하고 빠르게 읽히도록 유지 +3. 잘못된 요청/미존재 분기는 early return 사용 +4. 경계에서 DTO/파라미터 검증 수행(`@Valid`, 제약 애너테이션) + +## Service 규칙 + +1. 서비스 메서드는 하나의 비즈니스 의도를 표현 +2. 긴 메서드는 기술 단위가 아닌 비즈니스 단계 기준으로 분리 +3. 트랜잭션 경계는 서비스 레이어에 둠 +4. 숨은 부작용을 피하고 외부 호출 흐름을 코드에 명시 + +## 예외 처리와 로깅 + +1. API 경계에서 일관된 오류 응답 정책 사용 +2. 예외를 조용히 무시하지 않음 +3. 로거 스타일 통일: top-level logger 선언(`private val log = KotlinLogging.logger {}`) +4. 로그 메시지에는 비즈니스 컨텍스트 키 포함(예: userId, productId, requestId) +5. 오류 로그에는 예외 객체 포함(`log.error(e) { "..." }`) + +## 영속성과 외부 I/O + +1. 인덱스/토픽/테이블 이름을 비즈니스 코드에 하드코딩하지 않음 +2. 외부 리소스 이름은 설정(`application.yml`/properties)에서 주입 +3. 매핑 로직(entity/document <-> domain model)은 명시적이고 테스트 가능하게 유지 + +## 테스트 규칙 + +1. 단위 테스트는 비즈니스 판단/분기 로직 검증 +2. 통합 테스트는 Spring wiring + 저장소/메시징 경계 검증 +3. 테스트 이름은 구현이 아니라 동작을 설명 +4. 프로덕션 영향 버그 수정 시 회귀 테스트 추가 + +## 도구와 강제 방식 + +1. `.editorconfig`를 기본 포맷 계약으로 사용 +2. 포맷팅: `./gradlew ktlintFormat -q` +3. 스타일 검증: `./gradlew ktlintCheck -q` +4. 동작 회귀 확인: `./gradlew test -q` +5. detekt는 다음 단계(복잡도/설계 스멜 검증)로 도입 권장 diff --git a/docs/code-style-guide.md b/docs/code-style-guide.md new file mode 100644 index 0000000..585b6c6 --- /dev/null +++ b/docs/code-style-guide.md @@ -0,0 +1,76 @@ +# REP-Engine Kotlin + Spring Code Style Guide (v1) + +## Scope + +This document defines the **server-side** coding style for Kotlin + Spring modules in this repository. + +Primary references: +1. Kotlin Coding Conventions +2. ktlint rules configured in this repository +3. This document (team-specific readability rules) + +## Core Principles + +1. Readability first +2. Explicitness over shorthand +3. Predictable structure across modules +4. Enforce by tooling where possible + +## Kotlin Baseline + +1. Do not use wildcard imports (`*`) +2. Keep imports explicit and sorted +3. Keep line length readable (soft limit: 120) +4. Prefer descriptive names over abbreviations +5. KDoc links use `@see docs/phase N.md` form + +## Spring Structure Rules + +1. Use constructor injection by default +2. If qualifier is needed on constructor parameter, use `@param:Qualifier("...")` +3. Keep bean wiring in `config` package, business logic in `service` package +4. Controller must not contain business logic or persistence logic +5. Repository access should stay in service/repository layer, not controller + +## Controller Rules + +1. Keep HTTP concerns in controller (request/response, status, validation errors) +2. Success path should be simple and easy to scan +3. Use early return for invalid/not-found branches +4. Validate request DTO/params at boundary (`@Valid`, constraint annotations) + +## Service Rules + +1. Service methods should express one business intent +2. Long methods should be split by business step, not by technical micro-helpers +3. Transactions are defined at service boundary +4. Avoid hidden side effects; external calls must be explicit in code flow + +## Error Handling and Logging + +1. Use a consistent error response policy at API boundary +2. Do not swallow exceptions silently +3. Logger style is consistent: top-level logger declaration (`private val log = KotlinLogging.logger {}`) +4. Log message must include business context keys (example: userId, productId, requestId) +5. Error logs should include exception object (`log.error(e) { "..." }`) + +## Persistence and External I/O + +1. Do not hardcode infrastructure names (index/topic/table) in business code +2. External resource names come from configuration (`application.yml`/properties) +3. Keep mapping logic (entity/document <-> domain model) explicit and testable + +## Testing Rules + +1. Unit tests validate business decisions and branching logic +2. Integration tests validate wiring with Spring + storage/message boundaries +3. Test names should describe behavior, not implementation detail +4. Add regression tests when fixing production-facing bugs + +## Tooling and Enforcement + +1. `.editorconfig` is the base formatting contract +2. `./gradlew ktlintFormat -q` for formatting +3. `./gradlew ktlintCheck -q` for CI style validation +4. `./gradlew test -q` for behavior regression check +5. detekt adoption is recommended as next phase for complexity/design smell checks diff --git a/docs/github-issues-to-create.md b/docs/github-issues-to-create.md index 004282a..1fcf7ce 100644 --- a/docs/github-issues-to-create.md +++ b/docs/github-issues-to-create.md @@ -173,6 +173,41 @@ Docker 환경에서 frontend 컨테이너의 헬스체크가 제대로 동작하 --- +## Issue #5: design: NotificationRateLimiter fail-close 시 알림 유실 복구 방안 + +### 개요 +NotificationRateLimiter가 Redis 장애 시 fail-close 정책으로 알림을 차단하는데, +차단된 알림이 DLQ로 가지 않고 조용히 유실되는 구조적 한계가 있습니다. + +### 현재 동작 +``` +ProductInventoryEvent (Kafka) → InventoryEventConsumer + → EventDetector.detectPriceDrop() + → rateLimiter.canSend() == false (Redis 장애) + → continue (다음 유저로 건너뜀) + → 예외 아님 → DLQ 안 감 + → 원본 이벤트 offset commit됨 → 복구 불가 +``` + +- `canSend()`는 Redis 예외를 내부에서 catch하고 `false`를 반환 +- Consumer 입장에서는 정상 처리 완료 → Kafka offset commit +- 차단된 알림은 메트릭(`notification.rate.fail_close.blocked`)으로만 추적 가능 +- **어떤 유저에게 어떤 알림이 막혔는지 상세 정보 없음** + +### 개선 방향 (택 1) +1. **Circuit Breaker** — N회 연속 Redis 실패 시 일정 시간 fail-open 전환 +2. **로컬 인메모리 캐시 fallback** — Redis 장애 시 Caffeine 등으로 임시 중복 체크 +3. **차단 이벤트 별도 큐 저장** — Redis 복구 후 재처리할 수 있도록 차단된 (userId, productId, type) 정보를 파일/별도 토픽에 저장 + +### 관련 파일 +- `notification-service/.../service/NotificationRateLimiter.kt` (line 76-81, 117-122) +- `notification-service/.../service/EventDetector.kt` (line 109-111, 193-195) + +### 우선순위 +- **Medium** — 현재 fail-close는 의도된 안전 설계이나, 장시간 Redis 장애 시 알림 유실 위험 + +--- + ## 생성 방법 GitHub CLI가 설치되어 있다면: diff --git a/docs/local-dev-16gb-optimization.ko.md b/docs/local-dev-16gb-optimization.ko.md new file mode 100644 index 0000000..470c54a --- /dev/null +++ b/docs/local-dev-16gb-optimization.ko.md @@ -0,0 +1,127 @@ +# 로컬 16GB 개발 최적화 가이드 + +이 문서는 `MacBook Air 16GB` 같은 제한된 메모리 환경에서 REP-Engine을 안정적으로 실행하기 위한 운영 가이드입니다. + +## 목표 + +1. 필수 인프라만 올려서 스왑을 줄인다. +2. 모니터링 스택은 필요할 때만 켠다. +3. 동일 명령으로 재현 가능한 실행 절차를 만든다. + +## 기본 원칙 + +1. 항상 `필수 서비스 우선`으로 시작한다. +2. `관측/대시보드`는 디버깅 시에만 임시로 켠다. +3. Elasticsearch 힙은 16GB 환경에서 낮춰서 운영한다. + +## 1) 저메모리 모드 파일 + +저장소에 아래 오버라이드 파일이 추가되어 있습니다. + +- `/Users/hun/Desktop/etc/REP-engine/docker/docker-compose.lowmem.yml` + +적용 내용: +1. Kafka heap: `-Xms512m -Xmx512m` +2. Elasticsearch heap: `-Xms1g -Xmx1g` +3. Redis maxmemory: `256mb` + +## 2) 권장 실행 시나리오 + +### A. 기본 개발(권장) + +모니터링 제외, 핵심 인프라만 실행: + +```bash +docker compose \ + -f docker/docker-compose.yml \ + -f docker/docker-compose.lowmem.yml \ + up -d zookeeper kafka schema-registry elasticsearch redis +``` + +추천 API/소비자까지 함께 실행이 필요하면: + +```bash +docker compose \ + -f docker/docker-compose.yml \ + -f docker/docker-compose.lowmem.yml \ + up -d zookeeper kafka schema-registry elasticsearch redis embedding-service +``` + +### B. 트레이싱만 잠깐 확인할 때 + +```bash +docker compose -f docker/docker-compose.yml up -d jaeger +``` + +확인 후 바로 내리기: + +```bash +docker compose -f docker/docker-compose.yml stop jaeger +``` + +### C. 모니터링 스택이 정말 필요할 때만 + +```bash +docker compose -f docker/docker-compose.yml up -d \ + prometheus grafana loki promtail \ + kafka-exporter elasticsearch-exporter redis-exporter +``` + +작업이 끝나면 즉시 중지: + +```bash +docker compose -f docker/docker-compose.yml stop \ + prometheus grafana loki promtail \ + kafka-exporter elasticsearch-exporter redis-exporter +``` + +## 3) 모듈별 최소 인프라 매핑 + +1. simulator: Kafka, Schema Registry +2. behavior-consumer: Kafka, Schema Registry, Elasticsearch, Redis, embedding-service +3. recommendation-api: Elasticsearch, Redis +4. notification-service: Kafka, Elasticsearch, Redis + +필요한 모듈만 실행하면 메모리를 크게 절약할 수 있습니다. + +## 4) 점검 명령 + +현재 컨테이너 메모리 사용량 확인: + +```bash +docker stats --no-stream +``` + +서비스 상태 확인: + +```bash +docker compose -f docker/docker-compose.yml ps +``` + +## 5) 종료/정리 + +컨테이너만 중지: + +```bash +docker compose -f docker/docker-compose.yml stop +``` + +컨테이너와 네트워크 정리: + +```bash +docker compose -f docker/docker-compose.yml down +``` + +볼륨까지 삭제(데이터 초기화, 필요 시에만): + +```bash +docker compose -f docker/docker-compose.yml down -v +``` + +## 6) 16GB 환경 운영 팁 + +1. IntelliJ 프로젝트는 필요한 모듈만 열기 +2. 크롬 탭 수 최소화 +3. `ktlintCheck`/`test`는 동시에 돌리지 말고 순차 실행 +4. 스왑이 심해지면 모니터링 스택부터 내리기 + diff --git a/docs/pr-draft-style-readability-v1.ko.md b/docs/pr-draft-style-readability-v1.ko.md new file mode 100644 index 0000000..f8a4496 --- /dev/null +++ b/docs/pr-draft-style-readability-v1.ko.md @@ -0,0 +1,108 @@ +# PR/커밋 초안: 코드 스타일 통일 + 가독성 리팩토링 + +## 1) 커밋 내역 추천 + +아래 순서로 나누면 리뷰가 가장 쉽습니다. + +1. `docs: Kotlin+Spring 코드 스타일 가이드 v1 수립 및 한글 문서 추가` + - `docs/code-style-guide.md` + - `docs/code-style-guide.ko.md` + - `docs/readability-refactoring-case-study.ko.md` + +2. `chore: 루트 포맷/린트 기준 정비 (.editorconfig, ktlint/detekt 기반)` + - `.editorconfig` + - `build.gradle.kts` (ktlint 플러그인/설정 반영분) + - 모듈별 `build.gradle.kts` 변경분(있으면 포함) + +3. `style: Kotlin import/KDoc/Qualifier/로거 스타일 일관화` + - 전 모듈 Kotlin 스타일 일관화 파일들 + - 와일드카드 import 제거, `@see` 표기 통일, `@param:Qualifier` 통일 등 + +4. `fix: 설정 하드코딩 제거 및 입력 검증 강화` + - ES index/topic/table 등 설정 외부화 관련 파일 + - timezone/property 외부화 + - simulator 입력 검증 강화(`@Validated`, 제약 애너테이션, require) + +5. `refactor: EventDetector/LoadTestService 가독성 중심 리팩토링` + - `notification-service/.../EventDetector.kt` + - `simulator/.../LoadTestService.kt` + +6. `chore(frontend): Node 버전 명시 및 ESLint 규칙 보강` + - `frontend/package.json` + - `frontend/.nvmrc` + - `frontend/eslint.config.js` + - `frontend/src/api/client.ts` + - `frontend/README.md` + +--- + +## 2) PR 본문 초안 (복붙용) + +### 제목 +`fix: 코드 스타일 기준 정립 및 가독성 리팩토링 적용` + +### 개요 +Kotlin + Spring 서버 코드의 스타일 기준을 문서/도구로 고정하고, +핵심 서비스 코드에 가독성 중심 리팩토링을 적용했습니다. + +이번 변경의 목표는 다음 3가지입니다. +1. 팀 공통 스타일 기준 확정(문서 + lint) +2. 하드코딩/환경 의존 요소 설정화 +3. 핵심 로직의 "상위 의도 / 하위 구현" 구조 강화 + +### 주요 변경사항 +1. 코드 스타일 가이드 확정 + - `docs/code-style-guide.md` (영문) + - `docs/code-style-guide.ko.md` (국문) + - `docs/readability-refactoring-case-study.ko.md` (학습용 before/after) + +2. 스타일/포맷 자동화 강화 + - `.editorconfig` 정비 + - ktlint 최신 호환 버전 반영 및 규칙 조정 + - import/KDoc/Qualifier/로깅 스타일 일관화 + +3. 설정 외부화 및 안정성 보완 + - ES index 이름 하드코딩 제거(설정 주입) + - scheduler timezone 설정화 + - simulator 입력 검증 강화 + +4. 가독성 리팩토링 + - `EventDetector`: 조건 해석/배치 발송/알림 생성/관측 로직 분리 + - `LoadTestService`: 시작/중지/수집/시나리오 오케스트레이션 단계 분리 + +5. 프론트 환경/린트 정리 + - Node 엔진 버전 명시(`.nvmrc`, `package.json`) + - ESLint 규칙 강화 및 `console.log` 제거 + +### 테스트 +실행한 검증: +1. `./gradlew ktlintFormat -q` +2. `./gradlew ktlintCheck -q` +3. `./gradlew test -q` +4. `./gradlew :notification-service:compileKotlin :simulator:compileKotlin -q` + +참고: +1. 일부 조합 실행 시 `common-avro`의 Gradle task implicit dependency 경고/실패가 재현될 수 있음 +2. 프론트 빌드는 로컬 Node 버전에 따라 실패 가능(프로젝트는 Node 20.19+ / 22 권장으로 명시) + +### 영향 범위 +1. backend: `behavior-consumer`, `recommendation-api`, `notification-service`, `simulator` +2. docs: 코드 스타일/리팩토링 가이드 문서 추가 +3. frontend: lint/runtime 요구사항 명시 + +### 리뷰 포인트 +1. ktlint 규칙 비활성화 항목이 팀 기준(가독성 우선)에 맞는지 +2. EventDetector/LoadTestService의 분해 수준이 과소/과다 추상화가 아닌지 +3. 설정 외부화 키 네이밍의 장기 일관성 + +### 체크리스트 +- [ ] 문서 기준과 코드 적용 방향이 일치함 +- [ ] 스타일 변경과 동작 변경이 논리적으로 분리됨 +- [ ] 주요 모듈 컴파일/테스트 검증 완료 +- [ ] 운영 영향(설정 키 추가/변경) 확인 완료 + +--- + +## 3) 브랜치명 추천 + +`codex/style-readability-v1` diff --git a/docs/readability-refactoring-case-study.ko.md b/docs/readability-refactoring-case-study.ko.md new file mode 100644 index 0000000..b3957ac --- /dev/null +++ b/docs/readability-refactoring-case-study.ko.md @@ -0,0 +1,211 @@ +# 가독성 리팩토링 비교 노트 (Kotlin + Spring) + +## 목적 + +이 문서는 실제 코드 변경을 기준으로, +`가독성은 높이고 응집도는 유지`하는 리팩토링 방법을 학습용으로 정리한 자료입니다. + +대상 파일: +1. `notification-service/.../EventDetector.kt` +2. `simulator/.../LoadTestService.kt` + +--- + +## 사례 1: EventDetector + +파일: `/Users/hun/Desktop/etc/REP-engine/notification-service/src/main/kotlin/com/rep/notification/service/EventDetector.kt` + +### 문제(기존 코드) +1. `detectPriceDrop`/`detectRestock` 메서드 내부가 너무 길고 중복이 많음 +2. 비즈니스 판단, 배치 발송, 관측(Observation), 알림 객체 생성이 한 메서드에 혼합됨 +3. 같은 패턴(rate limit + chunk + delay)이 두 메서드에 반복됨 + +### 변경 포인트 요약 +1. 입력 해석: `extractPriceDrop`, `extractRestock` 분리 +2. 관측 래퍼: `withObservation` 분리 +3. 배치 발송 공통화: `sendNotificationsInBatches` +4. 알림 생성 분리: `buildPriceDropNotification`, `buildRestockNotification` + +### Before vs After + +#### 1) 메인 흐름 단순화 + +Before: +```kotlin +suspend fun detectPriceDrop(event: ProductInventoryEvent) { + val previousPrice = event.previousPrice ?: return + val currentPrice = event.currentPrice ?: return + if (previousPrice <= 0 || currentPrice < 0) return + + val dropPercentage = ((previousPrice - currentPrice) / previousPrice * 100).toInt() + if (dropPercentage >= properties.priceDropThreshold) { + // 관측 시작 + 대상 조회 + 배치 루프 + 알림 생성 + 발송 + 로그 + } +} +``` + +After: +```kotlin +suspend fun detectPriceDrop(event: ProductInventoryEvent) { + val priceChange = extractPriceDrop(event) ?: return + if (priceChange.dropPercentage < properties.priceDropThreshold) return + + withObservation(...) { + val targetUsers = targetResolver.findInterestedUsers(...) + if (targetUsers.isEmpty()) return@withObservation + + val productName = resolveProductName(priceChange.productId) + val sentCount = sendNotificationsInBatches(...) { userId -> + buildPriceDropNotification(...) + } + log.info { "..., sent=${sentCount.sentCount}, batches=${sentCount.batchCount}" } + } +} +``` + +핵심 효과: +1. 최상위 메서드가 “비즈니스 스토리”만 보여줌 +2. 세부 구현은 private 함수로 내려가서 읽기 부담 감소 + +#### 2) 중복 루프 제거 + +Before: +```kotlin +for ((batchIndex, batch) in batches.withIndex()) { + for (userId in batch) { + if (!rateLimiter.canSend(...)) continue + notificationProducer.send(notification) + } + if (batchIndex < batches.size - 1) delay(...) +} +``` + +After: +```kotlin +private suspend fun sendNotificationsInBatches(...): BatchSendResult { + val batches = targetUsers.chunked(properties.batchSize) + var sentCount = 0 + for ((batchIndex, batch) in batches.withIndex()) { + ... + } + return BatchSendResult(sentCount = sentCount, batchCount = batches.size) +} +``` + +핵심 효과: +1. 중복 제거로 변경 포인트 단일화 +2. 배치 처리 정책이 한 곳에 모여 응집도 유지 + +--- + +## 사례 2: LoadTestService + +파일: `/Users/hun/Desktop/etc/REP-engine/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestService.kt` + +### 문제(기존 코드) +1. `startTest()`에 상태 초기화 + 메트릭 루프 + 시나리오 실행 로직이 몰림 +2. `stopTest()`도 중지/취소/정리 절차가 인라인으로 길게 배치됨 +3. 시작/중지 오케스트레이션의 의도가 코드 구조에서 바로 보이지 않음 + +### 변경 포인트 요약 +1. 시작 절차를 단계 메서드로 분리 +2. 메트릭 수집 루프를 `launchMetricsCollection` + `collectAndStoreMetrics`로 분리 +3. 시나리오 실행을 `launchScenarioOrchestrator` + `runScenario`로 분리 +4. 중지 절차를 `stopGenerators` + `cancelRunningJobs`로 분리 + +### Before vs After + +#### 1) startTest 구조 + +Before: +```kotlin +fun startTest(request: LoadTestStartRequest): LoadTestStatus { + lock.withLock { + // 실행 중 체크 + // 상태 초기화 + // metricsJob launch + // orchestratorJob launch + return getStatus() + } +} +``` + +After: +```kotlin +fun startTest(request: LoadTestStartRequest): LoadTestStatus { + lock.withLock { + ensureNoRunningTest() + val testId = initializeTestState(request) + metricsJob = launchMetricsCollection() + orchestratorJob = launchScenarioOrchestrator(testId, request) + return getStatus() + } +} +``` + +핵심 효과: +1. 읽는 순서가 실행 순서와 동일 +2. 각 단계 책임이 분리되어 디버깅/수정이 쉬움 + +#### 2) stopTest 구조 + +Before: +```kotlin +fun stopTest(): LoadTestStatus { + lock.withLock { + trafficSimulator.stopSimulation() + inventorySimulator.stopSimulation() + recLoadGenerator.stop() + orchestratorJob?.cancel() + metricsJob?.cancel() + saveResult() + ... + } +} +``` + +After: +```kotlin +fun stopTest(): LoadTestStatus { + lock.withLock { + stopGenerators() + cancelRunningJobs() + saveResult() + ... + } +} +``` + +핵심 효과: +1. 중지 절차의 의도가 메서드 이름으로 드러남 +2. 중지 정책 변경 시 수정 위치가 명확함 + +--- + +## 이번 리팩토링이 응집도를 해치지 않은 이유 + +1. 공통화한 함수들은 모두 같은 클래스 내부 `private`로 유지 +2. 기능을 다른 계층/패키지로 빼지 않고, 해당 클래스의 책임 범위 안에서만 분해 +3. “재사용을 위한 과도한 추상화”가 아니라 “읽기 위한 의도 분리” 중심으로 적용 + +--- + +## 실무에서 그대로 쓰는 체크리스트 + +리팩토링 전: +1. 이 메서드가 두 가지 이상 일을 하고 있는가? +2. 같은 루프/조건/로그 패턴이 반복되는가? +3. top-level 흐름을 30초 안에 설명 가능한가? + +리팩토링 후: +1. top-level 메서드가 비즈니스 문장처럼 읽히는가? +2. 세부 구현은 private 함수로 내려갔는가? +3. 변경 포인트가 줄었는가(중복 제거)? + +--- + +## 참고 + +원본 스타일 가이드: +1. `/Users/hun/Desktop/etc/REP-engine/docs/code-style-guide.md` +2. `/Users/hun/Desktop/etc/REP-engine/docs/code-style-guide.ko.md` diff --git a/frontend/.env.example b/frontend/.env.example index 3b03715..b09bc42 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,6 +1,7 @@ -# API URLs (로컬 개발 시 사용, Docker에서는 nginx 프록시 사용) -VITE_RECOMMENDATION_API_URL=http://localhost:8082/api/v1/recommendations -VITE_SIMULATOR_API_URL=http://localhost:8080/api/v1/simulator +# API URLs (로컬 개발 시에는 Vite proxy가 처리하므로 설정 불필요) +# 직접 지정 시 Vite proxy를 우회하고 해당 URL로 직접 요청합니다. +# VITE_RECOMMENDATION_API_URL=http://localhost:8080/api/v1/recommendations +# VITE_SIMULATOR_API_URL=http://localhost:8084/api/v1/simulator VITE_GRAFANA_URL=http://localhost:3000 # Feature Flags (optional) diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 0000000..1d9b783 --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +22.12.0 diff --git a/frontend/README.md b/frontend/README.md index d2e7761..8026ea9 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,5 +1,9 @@ # React + TypeScript + Vite +## Runtime Requirement + +- Node.js `20.19+` or `22.12+` (see `.nvmrc`) + This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Currently, two official plugins are available: diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5e6b472..ba5e65b 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -19,5 +19,11 @@ export default defineConfig([ ecmaVersion: 2020, globals: globals.browser, }, + rules: { + 'no-console': ['error', { allow: ['warn', 'error'] }], + 'quotes': ['error', 'single', { avoidEscape: true }], + 'semi': ['error', 'never'], + '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], + }, }, ]) diff --git a/frontend/package.json b/frontend/package.json index b36ec86..fcb7591 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.0.0", "type": "module", + "engines": { + "node": ">=20.19.0 <23" + }, "scripts": { "dev": "vite", "build": "tsc -b && vite build", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1f6bee9..94a8a4a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -11,10 +11,7 @@ export const apiClient = axios.create({ // Request 인터셉터 (로깅, 인증 토큰 추가 등) apiClient.interceptors.request.use( (config) => { - // 개발 환경에서 요청 로깅 - if (import.meta.env.DEV) { - console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`) - } + // 개발 환경 요청 로그는 브라우저 네트워크 탭으로 확인합니다. return config }, (error) => { diff --git a/frontend/src/features/tracing/TracingPage.tsx b/frontend/src/features/tracing/TracingPage.tsx index 5bab27c..afde69e 100644 --- a/frontend/src/features/tracing/TracingPage.tsx +++ b/frontend/src/features/tracing/TracingPage.tsx @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react' import { useQuery } from '@tanstack/react-query' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Skeleton } from '@/components/ui/skeleton' import { TraceSearch } from './components/TraceSearch' import { TraceList } from './components/TraceList' import { TraceDetail } from './components/TraceDetail' @@ -82,11 +83,24 @@ export function TracingPage() { - + {tracesLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + + +
+ ))} +
+ ) : ( + + )} {selectedTraceId && ( diff --git a/notification-service/build.gradle.kts b/notification-service/build.gradle.kts index 5255a6e..6e30288 100644 --- a/notification-service/build.gradle.kts +++ b/notification-service/build.gradle.kts @@ -47,7 +47,7 @@ dependencies { // Logging implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") - implementation("net.logstash.logback:logstash-logback-encoder:8.0") // Phase 5: JSON 로깅 + implementation("net.logstash.logback:logstash-logback-encoder:8.0") // Phase 5: JSON 로깅 // Micrometer for metrics implementation("io.micrometer:micrometer-registry-prometheus") diff --git a/notification-service/src/main/kotlin/com/rep/notification/client/RecommendationClient.kt b/notification-service/src/main/kotlin/com/rep/notification/client/RecommendationClient.kt index 1320a18..89c4708 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/client/RecommendationClient.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/client/RecommendationClient.kt @@ -4,8 +4,8 @@ import com.rep.notification.config.NotificationProperties import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.MeterRegistry import io.micrometer.core.instrument.Timer -import mu.KotlinLogging import io.netty.channel.ChannelOption +import mu.KotlinLogging import org.springframework.http.client.reactive.ReactorClientHttpConnector import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient @@ -27,32 +27,43 @@ private val log = KotlinLogging.logger {} class RecommendationClient( private val properties: NotificationProperties, meterRegistry: MeterRegistry, - webClientBuilder: WebClient.Builder + webClientBuilder: WebClient.Builder, ) { - private val webClient: WebClient = webClientBuilder - .baseUrl(properties.recommendation.apiUrl) - .clientConnector(ReactorClientHttpConnector( - HttpClient.create() - .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) - .responseTimeout(Duration.ofSeconds(10)) - )) - .build() - - private val requestCounter = Counter.builder("notification.recommendation_client.request") - .description("Recommendation API requests") - .register(meterRegistry) - - private val successCounter = Counter.builder("notification.recommendation_client.success") - .description("Successful recommendation API requests") - .register(meterRegistry) - - private val failureCounter = Counter.builder("notification.recommendation_client.failure") - .description("Failed recommendation API requests") - .register(meterRegistry) - - private val latencyTimer = Timer.builder("notification.recommendation_client.latency") - .description("Recommendation API latency") - .register(meterRegistry) + private val webClient: WebClient = + webClientBuilder + .baseUrl(properties.recommendation.apiUrl) + .clientConnector( + ReactorClientHttpConnector( + HttpClient + .create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) + .responseTimeout(Duration.ofSeconds(10)), + ), + ).build() + + private val requestCounter = + Counter + .builder("notification.recommendation_client.request") + .description("Recommendation API requests") + .register(meterRegistry) + + private val successCounter = + Counter + .builder("notification.recommendation_client.success") + .description("Successful recommendation API requests") + .register(meterRegistry) + + private val failureCounter = + Counter + .builder("notification.recommendation_client.failure") + .description("Failed recommendation API requests") + .register(meterRegistry) + + private val latencyTimer = + Timer + .builder("notification.recommendation_client.latency") + .description("Recommendation API latency") + .register(meterRegistry) /** * 유저별 추천 상품을 조회합니다. @@ -63,17 +74,19 @@ class RecommendationClient( */ suspend fun getRecommendations( userId: String, - limit: Int = properties.recommendation.limit + limit: Int = properties.recommendation.limit, ): RecommendationResponse? { requestCounter.increment() return try { val sample = Timer.start() - val response = webClient.get() - .uri("/api/v1/recommendations/{userId}?limit={limit}", userId, limit) - .retrieve() - .awaitBodyOrNull() + val response = + webClient + .get() + .uri("/api/v1/recommendations/{userId}?limit={limit}", userId, limit) + .retrieve() + .awaitBodyOrNull() sample.stop(latencyTimer) @@ -86,7 +99,6 @@ class RecommendationClient( } response - } catch (e: Exception) { failureCounter.increment() log.warn(e) { "Failed to get recommendations for userId=$userId" } @@ -104,7 +116,7 @@ data class RecommendationResponse( val userId: String, val recommendations: List, val strategy: String, - val latencyMs: Long + val latencyMs: Long, ) /** @@ -115,5 +127,5 @@ data class ProductRecommendation( val productName: String, val category: String, val price: Float, - val score: Double = 0.0 + val score: Double = 0.0, ) diff --git a/notification-service/src/main/kotlin/com/rep/notification/config/DispatcherConfig.kt b/notification-service/src/main/kotlin/com/rep/notification/config/DispatcherConfig.kt index 926bd0d..861447d 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/config/DispatcherConfig.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/config/DispatcherConfig.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.CloseableCoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.asCoroutineDispatcher import mu.KotlinLogging -import org.springframework.beans.factory.annotation.Qualifier import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import java.util.concurrent.Executors @@ -20,14 +19,13 @@ private val log = KotlinLogging.logger {} @Configuration @OptIn(ExperimentalCoroutinesApi::class) class DispatcherConfig { - private var dispatcher: CloseableCoroutineDispatcher? = null - @Bean - @Qualifier("virtualThreadDispatcher") + @Bean("virtualThreadDispatcher") fun virtualThreadDispatcher(): CloseableCoroutineDispatcher { log.info { "Creating Virtual Thread Coroutine Dispatcher for notification-service" } - return Executors.newVirtualThreadPerTaskExecutor() + return Executors + .newVirtualThreadPerTaskExecutor() .asCoroutineDispatcher() .also { dispatcher = it } } diff --git a/notification-service/src/main/kotlin/com/rep/notification/config/ElasticsearchConfig.kt b/notification-service/src/main/kotlin/com/rep/notification/config/ElasticsearchConfig.kt index 3785c99..039b2c8 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/config/ElasticsearchConfig.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/config/ElasticsearchConfig.kt @@ -19,7 +19,6 @@ private val log = KotlinLogging.logger {} */ @Configuration class ElasticsearchConfig { - @Value("\${spring.elasticsearch.uris:http://localhost:9200}") private lateinit var elasticsearchUri: String diff --git a/notification-service/src/main/kotlin/com/rep/notification/config/KafkaConsumerConfig.kt b/notification-service/src/main/kotlin/com/rep/notification/config/KafkaConsumerConfig.kt index 68981ed..3b8a862 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/config/KafkaConsumerConfig.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/config/KafkaConsumerConfig.kt @@ -20,7 +20,10 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory -import org.springframework.kafka.core.* +import org.springframework.kafka.core.ConsumerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.listener.CommonErrorHandler import org.springframework.kafka.listener.ContainerProperties import org.springframework.kafka.listener.DeadLetterPublishingRecoverer @@ -43,9 +46,8 @@ private val log = KotlinLogging.logger {} @Configuration class KafkaConsumerConfig( private val properties: NotificationProperties, - private val meterRegistry: MeterRegistry + private val meterRegistry: MeterRegistry, ) { - @Value("\${spring.kafka.bootstrap-servers}") private lateinit var bootstrapServers: String @@ -54,7 +56,8 @@ class KafkaConsumerConfig( // DLQ 메트릭 private val dlqSentCounter by lazy { - Counter.builder("kafka.dlq.sent") + Counter + .builder("kafka.dlq.sent") .description("Messages sent to DLQ") .register(meterRegistry) } @@ -64,16 +67,17 @@ class KafkaConsumerConfig( */ @Bean fun inventoryConsumerFactory(): ConsumerFactory { - val props = mapOf( - ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, - ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, - ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to KafkaAvroDeserializer::class.java, - ConsumerConfig.MAX_POLL_RECORDS_CONFIG to 100, - ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false, - ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest", - KafkaAvroDeserializerConfig.SCHEMA_REGISTRY_URL_CONFIG to schemaRegistryUrl, - KafkaAvroDeserializerConfig.SPECIFIC_AVRO_READER_CONFIG to true - ) + val props = + mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to KafkaAvroDeserializer::class.java, + ConsumerConfig.MAX_POLL_RECORDS_CONFIG to 100, + ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest", + KafkaAvroDeserializerConfig.SCHEMA_REGISTRY_URL_CONFIG to schemaRegistryUrl, + KafkaAvroDeserializerConfig.SPECIFIC_AVRO_READER_CONFIG to true, + ) return DefaultKafkaConsumerFactory(props) } @@ -82,16 +86,17 @@ class KafkaConsumerConfig( */ @Bean fun notificationConsumerFactory(): ConsumerFactory { - val props = mapOf( - ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, - ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, - ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to KafkaAvroDeserializer::class.java, - ConsumerConfig.MAX_POLL_RECORDS_CONFIG to 100, - ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false, - ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest", - KafkaAvroDeserializerConfig.SCHEMA_REGISTRY_URL_CONFIG to schemaRegistryUrl, - KafkaAvroDeserializerConfig.SPECIFIC_AVRO_READER_CONFIG to true - ) + val props = + mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to KafkaAvroDeserializer::class.java, + ConsumerConfig.MAX_POLL_RECORDS_CONFIG to 100, + ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest", + KafkaAvroDeserializerConfig.SCHEMA_REGISTRY_URL_CONFIG to schemaRegistryUrl, + KafkaAvroDeserializerConfig.SPECIFIC_AVRO_READER_CONFIG to true, + ) return DefaultKafkaConsumerFactory(props) } @@ -101,16 +106,17 @@ class KafkaConsumerConfig( @Bean @ConditionalOnProperty("notification.dlq.enabled", havingValue = "true", matchIfMissing = true) fun dlqKafkaTemplate(): KafkaTemplate { - val props = mapOf( - ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, - ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, - ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to KafkaAvroSerializer::class.java, - ProducerConfig.ACKS_CONFIG to "all", - ProducerConfig.RETRIES_CONFIG to 3, - KafkaAvroSerializerConfig.SCHEMA_REGISTRY_URL_CONFIG to schemaRegistryUrl, - // DLQ에서는 스키마 자동 등록 허용 (같은 스키마지만 토픽이 다름) - KafkaAvroSerializerConfig.AUTO_REGISTER_SCHEMAS to true - ) + val props = + mapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to KafkaAvroSerializer::class.java, + ProducerConfig.ACKS_CONFIG to "all", + ProducerConfig.RETRIES_CONFIG to 3, + KafkaAvroSerializerConfig.SCHEMA_REGISTRY_URL_CONFIG to schemaRegistryUrl, + // DLQ에서는 스키마 자동 등록 허용 (같은 스키마지만 토픽이 다름) + KafkaAvroSerializerConfig.AUTO_REGISTER_SCHEMAS to true, + ) val producerFactory = DefaultKafkaProducerFactory(props) return KafkaTemplate(producerFactory).apply { setObservationEnabled(true) @@ -124,10 +130,8 @@ class KafkaConsumerConfig( */ @Bean @ConditionalOnProperty("notification.dlq.enabled", havingValue = "true", matchIfMissing = true) - fun deadLetterPublishingRecoverer( - dlqKafkaTemplate: KafkaTemplate - ): DeadLetterPublishingRecoverer { - return DeadLetterPublishingRecoverer(dlqKafkaTemplate) { record: ConsumerRecord<*, *>, ex: Exception -> + fun deadLetterPublishingRecoverer(dlqKafkaTemplate: KafkaTemplate): DeadLetterPublishingRecoverer = + DeadLetterPublishingRecoverer(dlqKafkaTemplate) { record: ConsumerRecord<*, *>, ex: Exception -> val dlqTopic = record.topic() + properties.dlq.topicSuffix dlqSentCounter.increment() log.error(ex) { @@ -136,7 +140,6 @@ class KafkaConsumerConfig( } TopicPartition(dlqTopic, record.partition()) } - } /** * DLQ 지원 Error Handler @@ -145,13 +148,12 @@ class KafkaConsumerConfig( */ @Bean @ConditionalOnProperty("notification.dlq.enabled", havingValue = "true", matchIfMissing = true) - fun kafkaErrorHandler( - deadLetterPublishingRecoverer: DeadLetterPublishingRecoverer - ): CommonErrorHandler { - val backOff = FixedBackOff( - properties.dlq.retryBackoffMs, - properties.dlq.maxRetries.toLong() - ) + fun kafkaErrorHandler(deadLetterPublishingRecoverer: DeadLetterPublishingRecoverer): CommonErrorHandler { + val backOff = + FixedBackOff( + properties.dlq.retryBackoffMs, + properties.dlq.maxRetries.toLong(), + ) return DefaultErrorHandler(deadLetterPublishingRecoverer, backOff).apply { // 재시도하지 않을 예외 타입 설정 (필요시) @@ -171,46 +173,43 @@ class KafkaConsumerConfig( */ @Bean @ConditionalOnProperty("notification.dlq.enabled", havingValue = "false") - fun simpleErrorHandler(): CommonErrorHandler { - return DefaultErrorHandler().apply { + fun simpleErrorHandler(): CommonErrorHandler = + DefaultErrorHandler().apply { // 재시도 없이 로깅만 하고 커밋 setBackOffFunction { _, _ -> FixedBackOff(0, 0) } } - } /** * Inventory Event Listener Container Factory */ @Bean fun inventoryListenerContainerFactory( - errorHandler: CommonErrorHandler - ): ConcurrentKafkaListenerContainerFactory { - return ConcurrentKafkaListenerContainerFactory().apply { + errorHandler: CommonErrorHandler, + ): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory().apply { consumerFactory = inventoryConsumerFactory() containerProperties.ackMode = ContainerProperties.AckMode.RECORD setCommonErrorHandler(errorHandler) isBatchListener = false - setConcurrency(3) // product.inventory.v1 파티션 수 + setConcurrency(3) // product.inventory.v1 파티션 수 // 분산 트레이싱 Observation 활성화 containerProperties.isObservationEnabled = true } - } /** * Notification Event Listener Container Factory (Push Sender용) */ @Bean fun notificationListenerContainerFactory( - errorHandler: CommonErrorHandler - ): ConcurrentKafkaListenerContainerFactory { - return ConcurrentKafkaListenerContainerFactory().apply { + errorHandler: CommonErrorHandler, + ): ConcurrentKafkaListenerContainerFactory = + ConcurrentKafkaListenerContainerFactory().apply { consumerFactory = notificationConsumerFactory() containerProperties.ackMode = ContainerProperties.AckMode.RECORD setCommonErrorHandler(errorHandler) isBatchListener = false - setConcurrency(6) // notification.push.v1 파티션 수 + setConcurrency(6) // notification.push.v1 파티션 수 // 분산 트레이싱 Observation 활성화 containerProperties.isObservationEnabled = true } - } } diff --git a/notification-service/src/main/kotlin/com/rep/notification/config/KafkaProducerConfig.kt b/notification-service/src/main/kotlin/com/rep/notification/config/KafkaProducerConfig.kt index 28c5628..aae8c3a 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/config/KafkaProducerConfig.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/config/KafkaProducerConfig.kt @@ -17,7 +17,6 @@ import org.springframework.kafka.core.ProducerFactory */ @Configuration class KafkaProducerConfig { - @Value("\${spring.kafka.bootstrap-servers}") private lateinit var bootstrapServers: String @@ -26,31 +25,28 @@ class KafkaProducerConfig { @Bean fun notificationProducerFactory(): ProducerFactory { - val configProps = mapOf( - ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, - ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, - ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to KafkaAvroSerializer::class.java, - - // Reliability settings - ProducerConfig.ACKS_CONFIG to "all", - ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to true, - ProducerConfig.RETRIES_CONFIG to 3, - ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION to 5, - - // Performance tuning - ProducerConfig.LINGER_MS_CONFIG to 5, - ProducerConfig.BATCH_SIZE_CONFIG to 16384, - - // Schema Registry - KafkaAvroSerializerConfig.SCHEMA_REGISTRY_URL_CONFIG to schemaRegistryUrl - ) + val configProps = + mapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to KafkaAvroSerializer::class.java, + // Reliability settings + ProducerConfig.ACKS_CONFIG to "all", + ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to true, + ProducerConfig.RETRIES_CONFIG to 3, + ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION to 5, + // Performance tuning + ProducerConfig.LINGER_MS_CONFIG to 5, + ProducerConfig.BATCH_SIZE_CONFIG to 16384, + // Schema Registry + KafkaAvroSerializerConfig.SCHEMA_REGISTRY_URL_CONFIG to schemaRegistryUrl, + ) return DefaultKafkaProducerFactory(configProps) } @Bean - fun notificationKafkaTemplate(): KafkaTemplate { - return KafkaTemplate(notificationProducerFactory()).apply { + fun notificationKafkaTemplate(): KafkaTemplate = + KafkaTemplate(notificationProducerFactory()).apply { setObservationEnabled(true) } - } } diff --git a/notification-service/src/main/kotlin/com/rep/notification/config/NotificationProperties.kt b/notification-service/src/main/kotlin/com/rep/notification/config/NotificationProperties.kt index 47c28b0..69bc0cb 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/config/NotificationProperties.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/config/NotificationProperties.kt @@ -9,7 +9,7 @@ import org.springframework.validation.annotation.Validated /** * 알림 서비스 설정 * - * @see docs/phase%204.md + * @see docs/phase 4.md */ @Validated @ConfigurationProperties(prefix = "notification") @@ -17,48 +17,37 @@ data class NotificationProperties( /** 가격 하락 알림 임계값 (%) - 이 비율 이상 하락 시 알림 */ @field:Positive(message = "priceDropThreshold must be positive") val priceDropThreshold: Int = 10, - /** 관심 유저 조회 시 최대 대상 수 */ @field:Positive(message = "targetUserLimit must be positive") val targetUserLimit: Int = 10000, - /** 관심 유저 조회 기간 (일) - VIEW, CLICK, ADD_TO_CART 대상 */ @field:Positive(message = "interestedUserDays must be positive") val interestedUserDays: Int = 30, - /** 장바구니 유저 조회 기간 (일) - 재입고 알림 대상 */ @field:Positive(message = "cartUserDays must be positive") val cartUserDays: Int = 7, - /** 유저별 일일 알림 최대 횟수 */ @field:Positive(message = "dailyLimitPerUser must be positive") val dailyLimitPerUser: Int = 10, - /** 동일 알림 중복 방지 기간 (시간) */ @field:Positive(message = "duplicatePreventionHours must be positive") val duplicatePreventionHours: Long = 1, - /** Kafka 토픽 설정 */ @field:NotBlank(message = "inventoryTopic must not be blank") val inventoryTopic: String = "product.inventory.v1", - @field:NotBlank(message = "notificationTopic must not be blank") val notificationTopic: String = "notification.push.v1", - /** DLQ 설정 */ @field:Valid val dlq: DlqConfig = DlqConfig(), - /** 배치 처리 설정 (Kafka 버스트 방지) */ @field:Positive(message = "batchSize must be positive") val batchSize: Int = 100, - @field:Positive(message = "batchDelayMs must be positive") val batchDelayMs: Long = 50, - /** 추천 알림 설정 (일일 배치) */ @field:Valid - val recommendation: RecommendationConfig = RecommendationConfig() + val recommendation: RecommendationConfig = RecommendationConfig(), ) { /** * DLQ (Dead Letter Queue) 설정 @@ -68,17 +57,14 @@ data class NotificationProperties( data class DlqConfig( /** DLQ 활성화 여부 */ val enabled: Boolean = true, - /** DLQ 토픽 접미사 (원본 토픽 + 접미사) */ val topicSuffix: String = ".dlq", - /** 재시도 횟수 (재시도 후에도 실패 시 DLQ로 이동) */ @field:Positive(message = "maxRetries must be positive") val maxRetries: Int = 3, - /** 재시도 간격 (ms) */ @field:Positive(message = "retryBackoffMs must be positive") - val retryBackoffMs: Long = 1000 + val retryBackoffMs: Long = 1000, ) /** @@ -90,20 +76,19 @@ data class NotificationProperties( data class RecommendationConfig( /** 추천 알림 활성화 여부 */ val enabled: Boolean = true, - /** 실행 주기 (cron 표현식, KST 기준) */ val cron: String = "0 0 9 * * *", - + /** 실행 타임존 */ + @field:NotBlank(message = "zone must not be blank") + val zone: String = "Asia/Seoul", /** 활성 유저 조회 기간 (일) - 이 기간 내 활동한 유저 대상 */ @field:Positive(message = "activeUserDays must be positive") val activeUserDays: Int = 7, - /** 추천 상품 개수 (알림당) */ @field:Positive(message = "limit must be positive") val limit: Int = 3, - /** recommendation-api URL */ @field:NotBlank(message = "apiUrl must not be blank") - val apiUrl: String = "http://recommendation-api:8082" + val apiUrl: String = "http://recommendation-api:8082", ) } diff --git a/notification-service/src/main/kotlin/com/rep/notification/config/RedisConfig.kt b/notification-service/src/main/kotlin/com/rep/notification/config/RedisConfig.kt index 611b87e..383ece4 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/config/RedisConfig.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/config/RedisConfig.kt @@ -13,20 +13,20 @@ import org.springframework.data.redis.serializer.StringRedisSerializer */ @Configuration class RedisConfig { - @Bean @Primary fun reactiveRedisTemplate( - connectionFactory: ReactiveRedisConnectionFactory + connectionFactory: ReactiveRedisConnectionFactory, ): ReactiveRedisTemplate { val serializer = StringRedisSerializer() - val context = RedisSerializationContext - .newSerializationContext(serializer) - .key(serializer) - .value(serializer) - .hashKey(serializer) - .hashValue(serializer) - .build() + val context = + RedisSerializationContext + .newSerializationContext(serializer) + .key(serializer) + .value(serializer) + .hashKey(serializer) + .hashValue(serializer) + .build() return ReactiveRedisTemplate(connectionFactory, context) } diff --git a/notification-service/src/main/kotlin/com/rep/notification/config/ShedLockConfig.kt b/notification-service/src/main/kotlin/com/rep/notification/config/ShedLockConfig.kt index 024a401..1c93aa5 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/config/ShedLockConfig.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/config/ShedLockConfig.kt @@ -19,7 +19,6 @@ import org.springframework.data.redis.connection.RedisConnectionFactory @Configuration @EnableSchedulerLock(defaultLockAtMostFor = "1h") class ShedLockConfig { - /** * Redis 기반 락 제공자 * @@ -27,7 +26,6 @@ class ShedLockConfig { * @return Redis 락 제공자 (환경 이름: rep-notification) */ @Bean - fun lockProvider(connectionFactory: RedisConnectionFactory): LockProvider { - return RedisLockProvider(connectionFactory, "rep-notification") - } + fun lockProvider(connectionFactory: RedisConnectionFactory): LockProvider = + RedisLockProvider(connectionFactory, "rep-notification") } diff --git a/notification-service/src/main/kotlin/com/rep/notification/consumer/InventoryEventConsumer.kt b/notification-service/src/main/kotlin/com/rep/notification/consumer/InventoryEventConsumer.kt index 6e964a7..4a44320 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/consumer/InventoryEventConsumer.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/consumer/InventoryEventConsumer.kt @@ -26,7 +26,7 @@ private val log = KotlinLogging.logger {} * - DefaultErrorHandler가 재시도 및 DLQ 전송 담당 * - 예외 발생 시 에러 핸들러로 전파하여 재시도/DLQ 처리 * - * @see docs/phase%204.md - Inventory Event Consumer + * @see docs/phase 4.md - Inventory Event Consumer * @see KafkaConsumerConfig - DLQ 설정 */ @Component @@ -34,15 +34,19 @@ private val log = KotlinLogging.logger {} class InventoryEventConsumer( private val eventDetector: EventDetector, private val meterRegistry: MeterRegistry, - @param:Qualifier("virtualThreadDispatcher") private val dispatcher: CloseableCoroutineDispatcher + @param:Qualifier("virtualThreadDispatcher") private val dispatcher: CloseableCoroutineDispatcher, ) { - private val processedCounter = Counter.builder("inventory.events.processed") - .description("Inventory events processed") - .register(meterRegistry) + private val processedCounter = + Counter + .builder("inventory.events.processed") + .description("Inventory events processed") + .register(meterRegistry) - private val errorCounter = Counter.builder("inventory.events.error") - .description("Inventory event processing errors") - .register(meterRegistry) + private val errorCounter = + Counter + .builder("inventory.events.error") + .description("Inventory event processing errors") + .register(meterRegistry) /** * 재고/가격 변동 이벤트 처리 @@ -54,7 +58,7 @@ class InventoryEventConsumer( @KafkaListener( topics = ["\${notification.inventory-topic}"], groupId = "notification-consumer-group", - containerFactory = "inventoryListenerContainerFactory" + containerFactory = "inventoryListenerContainerFactory", ) fun consume(record: ConsumerRecord) { val event = record.value() @@ -80,7 +84,6 @@ class InventoryEventConsumer( } processedCounter.increment() - } catch (e: Exception) { errorCounter.increment() log.error(e) { "Failed to process inventory event: ${event.eventId}" } diff --git a/notification-service/src/main/kotlin/com/rep/notification/consumer/PushSenderSimulator.kt b/notification-service/src/main/kotlin/com/rep/notification/consumer/PushSenderSimulator.kt index 5f7081f..626052a 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/consumer/PushSenderSimulator.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/consumer/PushSenderSimulator.kt @@ -22,27 +22,29 @@ private val log = KotlinLogging.logger {} * * 실제 운영에서는 FCM, APNs, SMS Gateway 등을 연동합니다. * - * @see docs/phase%204.md - Push Sender + * @see docs/phase 4.md - Push Sender */ @Component class PushSenderSimulator( private val historyService: NotificationHistoryService, - private val meterRegistry: MeterRegistry + private val meterRegistry: MeterRegistry, ) { - private val sentCounter = Counter.builder("notification.push.sent") - .description("Push notifications sent (simulated)") - .register(meterRegistry) + private val sentCounter = + Counter + .builder("notification.push.sent") + .description("Push notifications sent (simulated)") + .register(meterRegistry) private val channelCounters = java.util.concurrent.ConcurrentHashMap() @KafkaListener( topics = ["\${notification.notification-topic}"], groupId = "push-sender-group", - containerFactory = "notificationListenerContainerFactory" + containerFactory = "notificationListenerContainerFactory", ) fun consume( record: ConsumerRecord, - acknowledgment: Acknowledgment + acknowledgment: Acknowledgment, ) { val notification = record.value() @@ -63,7 +65,6 @@ class PushSenderSimulator( sentCounter.increment() acknowledgment.acknowledge() - } catch (e: Exception) { log.error(e) { "Failed to send notification: ${notification.notificationId}" } historyService.save(notification, SendStatus.FAILED) @@ -71,7 +72,10 @@ class PushSenderSimulator( } } - private fun sendToChannel(channel: Channel, notification: NotificationEvent) { + private fun sendToChannel( + channel: Channel, + notification: NotificationEvent, + ) { when (channel) { Channel.PUSH -> simulatePush(notification) Channel.SMS -> simulateSms(notification) @@ -111,12 +115,12 @@ class PushSenderSimulator( // 실제 구현: WebSocket을 통해 실시간 전송 } - private fun getChannelCounter(channel: String): Counter { - return channelCounters.getOrPut(channel) { - Counter.builder("notification.push.channel") + private fun getChannelCounter(channel: String): Counter = + channelCounters.getOrPut(channel) { + Counter + .builder("notification.push.channel") .tag("channel", channel) .description("Notifications sent per channel") .register(meterRegistry) } - } } diff --git a/notification-service/src/main/kotlin/com/rep/notification/repository/ActiveUserRepository.kt b/notification-service/src/main/kotlin/com/rep/notification/repository/ActiveUserRepository.kt index 6a4b4fd..2f50a00 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/repository/ActiveUserRepository.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/repository/ActiveUserRepository.kt @@ -6,6 +6,7 @@ import com.rep.notification.config.NotificationProperties import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.MeterRegistry import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository private val log = KotlinLogging.logger {} @@ -22,19 +23,20 @@ private val log = KotlinLogging.logger {} class ActiveUserRepository( private val esClient: ElasticsearchClient, private val properties: NotificationProperties, - meterRegistry: MeterRegistry + @Value("\${elasticsearch.index.user-behavior:user_behavior_index}") private val behaviorIndex: String, + meterRegistry: MeterRegistry, ) { - companion object { - private const val BEHAVIOR_INDEX = "user_behavior_index" - } + private val queryCounter = + Counter + .builder("notification.active_user.query") + .description("Active user queries executed") + .register(meterRegistry) - private val queryCounter = Counter.builder("notification.active_user.query") - .description("Active user queries executed") - .register(meterRegistry) - - private val usersFoundCounter = Counter.builder("notification.active_user.found") - .description("Active users found") - .register(meterRegistry) + private val usersFoundCounter = + Counter + .builder("notification.active_user.found") + .description("Active users found") + .register(meterRegistry) /** * 최근 활동한 유저 목록을 조회합니다. @@ -42,28 +44,33 @@ class ActiveUserRepository( * @param withinDays 조회 기간 (일) - 이 기간 내 활동한 유저 추출 * @return 유저 ID 목록 */ - fun getActiveUsers(withinDays: Int = properties.recommendation.activeUserDays): List { - return try { + fun getActiveUsers(withinDays: Int = properties.recommendation.activeUserDays): List = + try { queryCounter.increment() - val response = esClient.search({ s -> - s.index(BEHAVIOR_INDEX) - .size(0) // 집계만 필요 - .query { q -> - q.range { r -> - r.field("timestamp").gte(JsonData.of("now-${withinDays}d")) - } - } - .aggregations("active_users") { agg -> - agg.terms { t -> - t.field("userId").size(properties.targetUserLimit) + val response = + esClient.search({ s -> + s + .index(behaviorIndex) + .size(0) // 집계만 필요 + .query { q -> + q.range { r -> + r.field("timestamp").gte(JsonData.of("now-${withinDays}d")) + } + }.aggregations("active_users") { agg -> + agg.terms { t -> + t.field("userId").size(properties.targetUserLimit) + } } - } - }, Void::class.java) + }, Void::class.java) - val users = response.aggregations()["active_users"] - ?.sterms()?.buckets()?.array() - ?.map { it.key().stringValue() } ?: emptyList() + val users = + response + .aggregations()["active_users"] + ?.sterms() + ?.buckets() + ?.array() + ?.map { it.key().stringValue() } ?: emptyList() usersFoundCounter.increment(users.size.toDouble()) @@ -72,10 +79,8 @@ class ActiveUserRepository( } users - } catch (e: Exception) { log.error(e) { "Failed to get active users" } emptyList() } - } } diff --git a/notification-service/src/main/kotlin/com/rep/notification/repository/ProductRepository.kt b/notification-service/src/main/kotlin/com/rep/notification/repository/ProductRepository.kt index e906722..83702e3 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/repository/ProductRepository.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/repository/ProductRepository.kt @@ -3,6 +3,7 @@ package com.rep.notification.repository import co.elastic.clients.elasticsearch.ElasticsearchClient import com.rep.model.ProductDocument import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository private val log = KotlinLogging.logger {} @@ -15,24 +16,22 @@ private val log = KotlinLogging.logger {} */ @Repository class ProductRepository( - private val esClient: ElasticsearchClient + private val esClient: ElasticsearchClient, + @Value("\${elasticsearch.index.product:product_index}") private val productIndex: String, ) { - companion object { - private const val INDEX_NAME = "product_index" - } - /** * 상품 ID로 상품 정보를 조회합니다. * * @param productId 상품 ID * @return 상품 정보 또는 null */ - fun findById(productId: String): ProductDocument? { - return try { - val response = esClient.get( - { g -> g.index(INDEX_NAME).id(productId) }, - ProductDocument::class.java - ) + fun findById(productId: String): ProductDocument? = + try { + val response = + esClient.get( + { g -> g.index(productIndex).id(productId) }, + ProductDocument::class.java, + ) if (response.found()) { response.source() @@ -44,5 +43,4 @@ class ProductRepository( log.error(e) { "Failed to get product: $productId" } null } - } } diff --git a/notification-service/src/main/kotlin/com/rep/notification/scheduler/RecommendationScheduler.kt b/notification-service/src/main/kotlin/com/rep/notification/scheduler/RecommendationScheduler.kt index 5c16742..b2e6ca3 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/scheduler/RecommendationScheduler.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/scheduler/RecommendationScheduler.kt @@ -13,6 +13,7 @@ import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.MeterRegistry import io.micrometer.core.instrument.Timer import kotlinx.coroutines.CloseableCoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking import mu.KotlinLogging @@ -21,7 +22,6 @@ import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component -import kotlinx.coroutines.ExperimentalCoroutinesApi import java.time.Instant import java.util.UUID @@ -37,14 +37,14 @@ private val log = KotlinLogging.logger {} * - 배치 처리: Kafka 버스트 방지를 위한 청크 단위 발송 * - Rate Limiting: 유저별 일일 알림 한도 준수 * - * @see docs/phase%204.md - RECOMMENDATION 알림 + * @see docs/phase 4.md - RECOMMENDATION 알림 */ @Component @ConditionalOnProperty( prefix = "notification.recommendation", name = ["enabled"], havingValue = "true", - matchIfMissing = true + matchIfMissing = true, ) @OptIn(ExperimentalCoroutinesApi::class) class RecommendationScheduler( @@ -55,36 +55,50 @@ class RecommendationScheduler( private val properties: NotificationProperties, @param:Qualifier("virtualThreadDispatcher") private val dispatcher: CloseableCoroutineDispatcher, - meterRegistry: MeterRegistry + meterRegistry: MeterRegistry, ) { // Metrics - private val batchStartedCounter = Counter.builder("batch.recommendation.started") - .description("Recommendation batch started count") - .register(meterRegistry) - - private val batchCompletedCounter = Counter.builder("batch.recommendation.completed") - .description("Recommendation batch completed count") - .register(meterRegistry) - - private val batchFailedCounter = Counter.builder("batch.recommendation.failed") - .description("Recommendation batch failed count") - .register(meterRegistry) - - private val notificationSentCounter = Counter.builder("batch.recommendation.sent") - .description("Recommendation notifications sent") - .register(meterRegistry) - - private val rateLimitedCounter = Counter.builder("batch.recommendation.rate_limited") - .description("Users skipped due to rate limiting") - .register(meterRegistry) - - private val noRecommendationCounter = Counter.builder("batch.recommendation.no_result") - .description("Users with no recommendations") - .register(meterRegistry) - - private val batchDurationTimer = Timer.builder("batch.recommendation.duration") - .description("Recommendation batch duration") - .register(meterRegistry) + private val batchStartedCounter = + Counter + .builder("batch.recommendation.started") + .description("Recommendation batch started count") + .register(meterRegistry) + + private val batchCompletedCounter = + Counter + .builder("batch.recommendation.completed") + .description("Recommendation batch completed count") + .register(meterRegistry) + + private val batchFailedCounter = + Counter + .builder("batch.recommendation.failed") + .description("Recommendation batch failed count") + .register(meterRegistry) + + private val notificationSentCounter = + Counter + .builder("batch.recommendation.sent") + .description("Recommendation notifications sent") + .register(meterRegistry) + + private val rateLimitedCounter = + Counter + .builder("batch.recommendation.rate_limited") + .description("Users skipped due to rate limiting") + .register(meterRegistry) + + private val noRecommendationCounter = + Counter + .builder("batch.recommendation.no_result") + .description("Users with no recommendations") + .register(meterRegistry) + + private val batchDurationTimer = + Timer + .builder("batch.recommendation.duration") + .description("Recommendation batch duration") + .register(meterRegistry) /** * 매일 지정 시간에 활성 유저에게 추천 알림 발송 @@ -93,11 +107,14 @@ class RecommendationScheduler( * - lockAtMostFor: 최대 1시간 락 유지 (장애 시 자동 해제) * - lockAtLeastFor: 최소 5분 락 유지 (빠른 완료 시에도 재실행 방지) */ - @Scheduled(cron = "\${notification.recommendation.cron:0 0 9 * * *}", zone = "Asia/Seoul") + @Scheduled( + cron = "\${notification.recommendation.cron:0 0 9 * * *}", + zone = "\${notification.recommendation.zone:Asia/Seoul}", + ) @SchedulerLock( name = "dailyRecommendationBatch", lockAtMostFor = "1h", - lockAtLeastFor = "5m" + lockAtLeastFor = "5m", ) fun sendDailyRecommendations() { val sample = Timer.start() @@ -123,9 +140,10 @@ class RecommendationScheduler( */ private suspend fun executeBatch() { // 1. 활성 유저 조회 - val activeUsers = activeUserRepository.getActiveUsers( - properties.recommendation.activeUserDays - ) + val activeUsers = + activeUserRepository.getActiveUsers( + properties.recommendation.activeUserDays, + ) if (activeUsers.isEmpty()) { log.info { "No active users found, skipping batch" } @@ -151,10 +169,11 @@ class RecommendationScheduler( } // 추천 상품 조회 - val recommendations = recommendationClient.getRecommendations( - userId = userId, - limit = properties.recommendation.limit - ) + val recommendations = + recommendationClient.getRecommendations( + userId = userId, + limit = properties.recommendation.limit, + ) if (recommendations == null || recommendations.recommendations.isEmpty()) { noResultCount++ @@ -188,16 +207,17 @@ class RecommendationScheduler( */ private fun createNotification( userId: String, - recommendations: com.rep.notification.client.RecommendationResponse + recommendations: com.rep.notification.client.RecommendationResponse, ): NotificationEvent { val products = recommendations.recommendations val productNames = products.joinToString(", ") { it.productName } val firstProductId = products.firstOrNull()?.productId ?: "unknown" - return NotificationEvent.newBuilder() + return NotificationEvent + .newBuilder() .setNotificationId(UUID.randomUUID().toString()) .setUserId(userId) - .setProductId(firstProductId) // 대표 상품 ID + .setProductId(firstProductId) // 대표 상품 ID .setNotificationType(NotificationType.RECOMMENDATION) .setTitle("오늘의 추천 상품") .setBody("${productNames}을(를) 추천드려요!") @@ -205,10 +225,9 @@ class RecommendationScheduler( mapOf( "strategy" to recommendations.strategy, "productCount" to products.size.toString(), - "productIds" to products.joinToString(",") { it.productId } - ) - ) - .setChannels(listOf(Channel.PUSH, Channel.IN_APP)) + "productIds" to products.joinToString(",") { it.productId }, + ), + ).setChannels(listOf(Channel.PUSH, Channel.IN_APP)) .setPriority(Priority.NORMAL) .setTimestamp(Instant.now()) .build() diff --git a/notification-service/src/main/kotlin/com/rep/notification/service/EventDetector.kt b/notification-service/src/main/kotlin/com/rep/notification/service/EventDetector.kt index 8b28201..299c203 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/service/EventDetector.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/service/EventDetector.kt @@ -25,7 +25,7 @@ private val log = KotlinLogging.logger {} * 가격 하락, 재입고 등 알림 발송 조건을 감지하고 * 대상 유저를 추출하여 알림을 발송합니다. * - * @see docs/phase%204.md - Event Detector + * @see docs/phase 4.md - Event Detector */ @Component class EventDetector( @@ -35,26 +35,33 @@ class EventDetector( private val productRepository: ProductRepository, private val properties: NotificationProperties, meterRegistry: MeterRegistry, - private val observationRegistry: ObservationRegistry + private val observationRegistry: ObservationRegistry, ) { - - private val priceDropDetectedCounter = Counter.builder("notification.event.detected") - .tag("type", "price_drop") - .description("Price drop events detected") - .register(meterRegistry) - - private val restockDetectedCounter = Counter.builder("notification.event.detected") - .tag("type", "restock") - .description("Restock events detected") - .register(meterRegistry) - - private val notificationTriggeredCounter = Counter.builder("notification.triggered") - .description("Total notifications triggered") - .register(meterRegistry) - - private val rateLimitedCounter = Counter.builder("notification.rate.limited") - .description("Notifications blocked by rate limiter") - .register(meterRegistry) + private val priceDropDetectedCounter = + Counter + .builder("notification.event.detected") + .tag("type", "price_drop") + .description("Price drop events detected") + .register(meterRegistry) + + private val restockDetectedCounter = + Counter + .builder("notification.event.detected") + .tag("type", "restock") + .description("Restock events detected") + .register(meterRegistry) + + private val notificationTriggeredCounter = + Counter + .builder("notification.triggered") + .description("Total notifications triggered") + .register(meterRegistry) + + private val rateLimitedCounter = + Counter + .builder("notification.rate.limited") + .description("Notifications blocked by rate limiter") + .register(meterRegistry) /** * 가격 하락을 감지합니다. @@ -63,91 +70,55 @@ class EventDetector( * @param event 재고/가격 변동 이벤트 */ suspend fun detectPriceDrop(event: ProductInventoryEvent) { - val previousPrice = event.previousPrice ?: return - val currentPrice = event.currentPrice ?: return - - if (previousPrice <= 0 || currentPrice < 0) return - - val dropPercentage = ((previousPrice - currentPrice) / previousPrice * 100).toInt() - - if (dropPercentage >= properties.priceDropThreshold) { - val observation = Observation.createNotStarted("notification.detect-price-drop", observationRegistry) - .lowCardinalityKeyValue("productId", event.productId.toString()) - .lowCardinalityKeyValue("dropPercent", dropPercentage.toString()) - observation.start() - - try { - priceDropDetectedCounter.increment() - log.info { - "Price drop detected: productId=${event.productId}, " + - "previous=$previousPrice, current=$currentPrice, drop=$dropPercentage%" - } + val priceChange = extractPriceDrop(event) ?: return + if (priceChange.dropPercentage < properties.priceDropThreshold) return + + withObservation( + name = "notification.detect-price-drop", + productId = priceChange.productId, + extraKey = "dropPercent", + extraValue = priceChange.dropPercentage.toString(), + ) { + priceDropDetectedCounter.increment() + log.info { + "Price drop detected: productId=${priceChange.productId}, " + + "previous=${priceChange.previousPrice}, current=${priceChange.currentPrice}, " + + "drop=${priceChange.dropPercentage}%" + } - // 해당 상품에 관심 보인 유저 조회 - val targetUsers = targetResolver.findInterestedUsers( - productId = event.productId.toString(), + val targetUsers = + targetResolver.findInterestedUsers( + productId = priceChange.productId, actionTypes = listOf("VIEW", "CLICK", "ADD_TO_CART"), - withinDays = properties.interestedUserDays + withinDays = properties.interestedUserDays, ) - if (targetUsers.isEmpty()) { - log.debug { "No interested users found for productId=${event.productId}" } - return - } + if (targetUsers.isEmpty()) { + log.debug { "No interested users found for productId=${priceChange.productId}" } + return@withObservation + } - // 상품 정보 조회 - val product = productRepository.findById(event.productId.toString()) - val productName = product?.productName ?: "상품" - - // 알림 발송 (배치 처리로 Kafka 버스트 방지) - var sentCount = 0 - val batches = targetUsers.chunked(properties.batchSize) - - for ((batchIndex, batch) in batches.withIndex()) { - for (userId in batch) { - // Rate Limit 체크 - if (!rateLimiter.canSend(userId, event.productId.toString(), "PRICE_DROP")) { - rateLimitedCounter.increment() - continue - } - - val notification = NotificationEvent.newBuilder() - .setNotificationId(UUID.randomUUID().toString()) - .setUserId(userId) - .setProductId(event.productId.toString()) - .setNotificationType(NotificationType.PRICE_DROP) - .setTitle("가격이 떨어졌어요!") - .setBody("${productName}이(가) ${dropPercentage}% 할인 중입니다!") - .setData( - mapOf( - "previousPrice" to previousPrice.toString(), - "currentPrice" to currentPrice.toString(), - "dropPercentage" to dropPercentage.toString() - ) - ) - .setChannels(listOf(Channel.PUSH, Channel.IN_APP)) - .setPriority(Priority.HIGH) - .setTimestamp(Instant.now()) - .setTraceId(event.traceId?.toString()) - .build() - - notificationProducer.send(notification) - sentCount++ - notificationTriggeredCounter.increment() - } - - // 마지막 배치가 아니면 딜레이 적용 (Kafka 버스트 방지) - if (batchIndex < batches.size - 1) { - delay(properties.batchDelayMs) - } + val productName = resolveProductName(priceChange.productId) + val sentCount = + sendNotificationsInBatches( + targetUsers = targetUsers, + productId = priceChange.productId, + rateLimitType = "PRICE_DROP", + ) { userId -> + buildPriceDropNotification( + userId = userId, + productId = priceChange.productId, + productName = productName, + previousPrice = priceChange.previousPrice, + currentPrice = priceChange.currentPrice, + dropPercentage = priceChange.dropPercentage, + traceId = event.traceId?.toString(), + ) } - log.info { - "Price drop notifications sent: productId=${event.productId}, " + - "targetUsers=${targetUsers.size}, sent=$sentCount, batches=${batches.size}" - } - } finally { - observation.stop() + log.info { + "Price drop notifications sent: productId=${priceChange.productId}, " + + "targetUsers=${targetUsers.size}, sent=${sentCount.sentCount}, batches=${sentCount.batchCount}" } } } @@ -159,74 +130,184 @@ class EventDetector( * @param event 재고/가격 변동 이벤트 */ suspend fun detectRestock(event: ProductInventoryEvent) { - val previousStock = event.previousStock ?: return - val currentStock = event.currentStock ?: return - - if (previousStock == 0 && currentStock > 0) { - val observation = Observation.createNotStarted("notification.detect-restock", observationRegistry) - .lowCardinalityKeyValue("productId", event.productId.toString()) - observation.start() + val restock = extractRestock(event) ?: return - try { - restockDetectedCounter.increment() - log.info { "Restock detected: productId=${event.productId}, stock=$currentStock" } + withObservation(name = "notification.detect-restock", productId = restock.productId) { + restockDetectedCounter.increment() + log.info { "Restock detected: productId=${restock.productId}, stock=${restock.currentStock}" } - // 장바구니에 담은 유저 조회 - val targetUsers = targetResolver.findUsersWithCartItem(event.productId.toString()) + val targetUsers = targetResolver.findUsersWithCartItem(restock.productId) + if (targetUsers.isEmpty()) { + log.debug { "No cart users found for productId=${restock.productId}" } + return@withObservation + } - if (targetUsers.isEmpty()) { - log.debug { "No cart users found for productId=${event.productId}" } - return + val productName = resolveProductName(restock.productId) + val sentCount = + sendNotificationsInBatches( + targetUsers = targetUsers, + productId = restock.productId, + rateLimitType = "BACK_IN_STOCK", + ) { userId -> + buildRestockNotification( + userId = userId, + productId = restock.productId, + productName = productName, + currentStock = restock.currentStock, + traceId = event.traceId?.toString(), + ) } - // 상품 정보 조회 - val product = productRepository.findById(event.productId.toString()) - val productName = product?.productName ?: "상품" - - // 알림 발송 (배치 처리로 Kafka 버스트 방지) - var sentCount = 0 - val batches = targetUsers.chunked(properties.batchSize) - - for ((batchIndex, batch) in batches.withIndex()) { - for (userId in batch) { - // Rate Limit 체크 - if (!rateLimiter.canSend(userId, event.productId.toString(), "BACK_IN_STOCK")) { - rateLimitedCounter.increment() - continue - } - - val notification = NotificationEvent.newBuilder() - .setNotificationId(UUID.randomUUID().toString()) - .setUserId(userId) - .setProductId(event.productId.toString()) - .setNotificationType(NotificationType.BACK_IN_STOCK) - .setTitle("재입고 알림") - .setBody("${productName}이(가) 다시 입고되었습니다!") - .setData(mapOf("currentStock" to currentStock.toString())) - .setChannels(listOf(Channel.PUSH, Channel.SMS)) - .setPriority(Priority.HIGH) - .setTimestamp(Instant.now()) - .setTraceId(event.traceId?.toString()) - .build() - - notificationProducer.send(notification) - sentCount++ - notificationTriggeredCounter.increment() - } - - // 마지막 배치가 아니면 딜레이 적용 (Kafka 버스트 방지) - if (batchIndex < batches.size - 1) { - delay(properties.batchDelayMs) - } - } + log.info { + "Restock notifications sent: productId=${restock.productId}, " + + "targetUsers=${targetUsers.size}, sent=${sentCount.sentCount}, batches=${sentCount.batchCount}" + } + } + } - log.info { - "Restock notifications sent: productId=${event.productId}, " + - "targetUsers=${targetUsers.size}, sent=$sentCount, batches=${batches.size}" + private suspend fun sendNotificationsInBatches( + targetUsers: List, + productId: String, + rateLimitType: String, + buildNotification: (String) -> NotificationEvent, + ): BatchSendResult { + val batches = targetUsers.chunked(properties.batchSize) + var sentCount = 0 + + for ((batchIndex, batch) in batches.withIndex()) { + for (userId in batch) { + if (!rateLimiter.canSend(userId, productId, rateLimitType)) { + rateLimitedCounter.increment() + continue } - } finally { - observation.stop() + + notificationProducer.send(buildNotification(userId)) + sentCount++ + notificationTriggeredCounter.increment() } + + if (batchIndex < batches.size - 1) { + delay(properties.batchDelayMs) + } + } + + return BatchSendResult(sentCount = sentCount, batchCount = batches.size) + } + + private fun buildPriceDropNotification( + userId: String, + productId: String, + productName: String, + previousPrice: Float, + currentPrice: Float, + dropPercentage: Int, + traceId: String?, + ): NotificationEvent = + NotificationEvent + .newBuilder() + .setNotificationId(UUID.randomUUID().toString()) + .setUserId(userId) + .setProductId(productId) + .setNotificationType(NotificationType.PRICE_DROP) + .setTitle("가격이 떨어졌어요!") + .setBody("${productName}이(가) $dropPercentage% 할인 중입니다!") + .setData( + mapOf( + "previousPrice" to previousPrice.toString(), + "currentPrice" to currentPrice.toString(), + "dropPercentage" to dropPercentage.toString(), + ), + ).setChannels(listOf(Channel.PUSH, Channel.IN_APP)) + .setPriority(Priority.HIGH) + .setTimestamp(Instant.now()) + .setTraceId(traceId) + .build() + + private fun buildRestockNotification( + userId: String, + productId: String, + productName: String, + currentStock: Int, + traceId: String?, + ): NotificationEvent = + NotificationEvent + .newBuilder() + .setNotificationId(UUID.randomUUID().toString()) + .setUserId(userId) + .setProductId(productId) + .setNotificationType(NotificationType.BACK_IN_STOCK) + .setTitle("재입고 알림") + .setBody("${productName}이(가) 다시 입고되었습니다!") + .setData(mapOf("currentStock" to currentStock.toString())) + .setChannels(listOf(Channel.PUSH, Channel.SMS)) + .setPriority(Priority.HIGH) + .setTimestamp(Instant.now()) + .setTraceId(traceId) + .build() + + private fun resolveProductName(productId: String): String = productRepository.findById(productId)?.productName ?: "상품" + + private suspend fun withObservation( + name: String, + productId: String, + extraKey: String? = null, + extraValue: String? = null, + block: suspend () -> Unit, + ) { + val observation = + Observation + .createNotStarted(name, observationRegistry) + .lowCardinalityKeyValue("productId", productId) + if (extraKey != null && extraValue != null) { + observation.lowCardinalityKeyValue(extraKey, extraValue) + } + observation.start() + try { + block() + } finally { + observation.stop() } } + + private fun extractPriceDrop(event: ProductInventoryEvent): PriceDropChange? { + val previousPrice = event.previousPrice ?: return null + val currentPrice = event.currentPrice ?: return null + if (previousPrice <= 0 || currentPrice < 0) return null + + val dropPercentage = ((previousPrice - currentPrice) / previousPrice * 100).toInt() + return PriceDropChange( + productId = event.productId.toString(), + previousPrice = previousPrice, + currentPrice = currentPrice, + dropPercentage = dropPercentage, + ) + } + + private fun extractRestock(event: ProductInventoryEvent): RestockChange? { + val previousStock = event.previousStock ?: return null + val currentStock = event.currentStock ?: return null + if (previousStock != 0 || currentStock <= 0) return null + + return RestockChange( + productId = event.productId.toString(), + currentStock = currentStock, + ) + } + + private data class PriceDropChange( + val productId: String, + val previousPrice: Float, + val currentPrice: Float, + val dropPercentage: Int, + ) + + private data class RestockChange( + val productId: String, + val currentStock: Int, + ) + + private data class BatchSendResult( + val sentCount: Int, + val batchCount: Int, + ) } diff --git a/notification-service/src/main/kotlin/com/rep/notification/service/NotificationHistoryService.kt b/notification-service/src/main/kotlin/com/rep/notification/service/NotificationHistoryService.kt index 1c66d36..b486e78 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/service/NotificationHistoryService.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/service/NotificationHistoryService.kt @@ -6,6 +6,7 @@ import com.rep.model.SendStatus import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.MeterRegistry import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import java.time.Instant @@ -16,24 +17,25 @@ private val log = KotlinLogging.logger {} * * ES notification_history_index에 알림 발송 이력을 저장합니다. * - * @see docs/phase%204.md - 알림 이력 저장 + * @see docs/phase 4.md - 알림 이력 저장 */ @Component class NotificationHistoryService( private val esClient: ElasticsearchClient, - meterRegistry: MeterRegistry + @Value("\${elasticsearch.index.notification-history:notification_history_index}") private val historyIndex: String, + meterRegistry: MeterRegistry, ) { - companion object { - private const val INDEX_NAME = "notification_history_index" - } - - private val saveSuccessCounter = Counter.builder("notification.history.save.success") - .description("Notification history saved successfully") - .register(meterRegistry) + private val saveSuccessCounter = + Counter + .builder("notification.history.save.success") + .description("Notification history saved successfully") + .register(meterRegistry) - private val saveFailedCounter = Counter.builder("notification.history.save.failed") - .description("Notification history save failures") - .register(meterRegistry) + private val saveFailedCounter = + Counter + .builder("notification.history.save.failed") + .description("Notification history save failures") + .register(meterRegistry) /** * 알림 이력을 저장합니다. @@ -41,31 +43,36 @@ class NotificationHistoryService( * @param notification 알림 이벤트 * @param status 발송 상태 */ - fun save(notification: NotificationEvent, status: SendStatus) { + fun save( + notification: NotificationEvent, + status: SendStatus, + ) { try { - val document = mapOf( - "notificationId" to notification.notificationId.toString(), - "userId" to notification.userId.toString(), - "productId" to notification.productId.toString(), - "type" to notification.notificationType.toString(), - "title" to notification.title.toString(), - "body" to notification.body.toString(), - "data" to notification.data.mapKeys { it.key.toString() }.mapValues { it.value.toString() }, - "channels" to notification.channels.map { it.toString() }, - "priority" to notification.priority.toString(), - "status" to status.name, - "sentAt" to Instant.now().toString() // ISO 8601 형식 (ES date 타입 호환) - ) + val document = + mapOf( + "notificationId" to notification.notificationId.toString(), + "userId" to notification.userId.toString(), + "productId" to notification.productId.toString(), + "type" to notification.notificationType.toString(), + "title" to notification.title.toString(), + "body" to notification.body.toString(), + "data" to notification.data.mapKeys { it.key.toString() }.mapValues { it.value.toString() }, + "channels" to notification.channels.map { it.toString() }, + "priority" to notification.priority.toString(), + "status" to status.name, + "sentAt" to Instant.now().toString(), // ISO 8601 형식 (ES date 타입 호환) + "traceId" to notification.traceId?.toString(), + ) esClient.index { i -> - i.index(INDEX_NAME) + i + .index(historyIndex) .id(notification.notificationId.toString()) .document(document) } log.debug { "Saved notification history: ${notification.notificationId}, status=$status" } saveSuccessCounter.increment() - } catch (e: Exception) { log.error(e) { "Failed to save notification history: ${notification.notificationId}" } saveFailedCounter.increment() diff --git a/notification-service/src/main/kotlin/com/rep/notification/service/NotificationProducer.kt b/notification-service/src/main/kotlin/com/rep/notification/service/NotificationProducer.kt index e86f769..ec3c482 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/service/NotificationProducer.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/service/NotificationProducer.kt @@ -15,21 +15,25 @@ private val log = KotlinLogging.logger {} * * notification.push.v1 토픽으로 알림 메시지를 발행합니다. * - * @see docs/phase%204.md - Notification Producer + * @see docs/phase 4.md - Notification Producer */ @Component class NotificationProducer( private val kafkaTemplate: KafkaTemplate, private val properties: NotificationProperties, - meterRegistry: MeterRegistry + meterRegistry: MeterRegistry, ) { - private val sendSuccessCounter = Counter.builder("notification.send.success") - .description("Notifications sent successfully") - .register(meterRegistry) + private val sendSuccessCounter = + Counter + .builder("notification.send.success") + .description("Notifications sent successfully") + .register(meterRegistry) - private val sendFailedCounter = Counter.builder("notification.send.failed") - .description("Notifications failed to send") - .register(meterRegistry) + private val sendFailedCounter = + Counter + .builder("notification.send.failed") + .description("Notifications failed to send") + .register(meterRegistry) /** * 알림 메시지를 Kafka로 발행합니다. @@ -37,21 +41,22 @@ class NotificationProducer( * @param notification 알림 이벤트 */ fun send(notification: NotificationEvent) { - kafkaTemplate.send( - properties.notificationTopic, - notification.userId.toString(), - notification - ).whenComplete { result, ex -> - if (ex != null) { - log.error(ex) { "Failed to send notification: ${notification.notificationId}" } - sendFailedCounter.increment() - } else { - log.debug { - "Notification sent: ${notification.notificationId}, " + - "offset=${result.recordMetadata.offset()}" + kafkaTemplate + .send( + properties.notificationTopic, + notification.userId.toString(), + notification, + ).whenComplete { result, ex -> + if (ex != null) { + log.error(ex) { "Failed to send notification: ${notification.notificationId}" } + sendFailedCounter.increment() + } else { + log.debug { + "Notification sent: ${notification.notificationId}, " + + "offset=${result.recordMetadata.offset()}" + } + sendSuccessCounter.increment() } - sendSuccessCounter.increment() } - } } } diff --git a/notification-service/src/main/kotlin/com/rep/notification/service/NotificationRateLimiter.kt b/notification-service/src/main/kotlin/com/rep/notification/service/NotificationRateLimiter.kt index 9e3852f..df09577 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/service/NotificationRateLimiter.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/service/NotificationRateLimiter.kt @@ -3,7 +3,6 @@ package com.rep.notification.service import com.rep.notification.config.NotificationProperties import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.MeterRegistry -import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import mu.KotlinLogging import org.springframework.data.redis.core.ReactiveRedisTemplate @@ -21,30 +20,36 @@ private val log = KotlinLogging.logger {} * 1. 동일 유저+상품+타입에 대해 일정 시간 내 중복 알림 방지 * 2. 유저별 일일 알림 횟수 제한 * - * @see docs/phase%204.md - 알림 중복 방지 + * @see docs/phase 4.md - 알림 중복 방지 */ @Component class NotificationRateLimiter( private val redisTemplate: ReactiveRedisTemplate, private val properties: NotificationProperties, - meterRegistry: MeterRegistry + meterRegistry: MeterRegistry, ) { companion object { private const val SENT_KEY_PREFIX = "notification:sent:" private const val DAILY_KEY_PREFIX = "notification:daily:" } - private val duplicateBlockedCounter = Counter.builder("notification.rate.duplicate.blocked") - .description("Notifications blocked due to duplicate") - .register(meterRegistry) + private val duplicateBlockedCounter = + Counter + .builder("notification.rate.duplicate.blocked") + .description("Notifications blocked due to duplicate") + .register(meterRegistry) - private val dailyLimitBlockedCounter = Counter.builder("notification.rate.daily.blocked") - .description("Notifications blocked due to daily limit") - .register(meterRegistry) + private val dailyLimitBlockedCounter = + Counter + .builder("notification.rate.daily.blocked") + .description("Notifications blocked due to daily limit") + .register(meterRegistry) - private val failCloseBlockedCounter = Counter.builder("notification.rate.fail_close.blocked") - .description("Notifications blocked due to Redis failure (fail-close policy)") - .register(meterRegistry) + private val failCloseBlockedCounter = + Counter + .builder("notification.rate.fail_close.blocked") + .description("Notifications blocked due to Redis failure (fail-close policy)") + .register(meterRegistry) /** * 동일 유저 + 동일 상품 + 동일 타입에 대해 중복 알림을 방지합니다. @@ -58,17 +63,25 @@ class NotificationRateLimiter( * @param notificationType 알림 유형 (PRICE_DROP, BACK_IN_STOCK 등) * @return true면 발송 가능, false면 중복으로 차단 */ - suspend fun shouldSend(userId: String, productId: String, notificationType: String): Boolean { + suspend fun shouldSend( + userId: String, + productId: String, + notificationType: String, + ): Boolean { val key = "$SENT_KEY_PREFIX$userId:$productId:$notificationType" return try { // 원자적 SETNX: 키가 없으면 설정하고 true, 있으면 false - val wasSet = redisTemplate.opsForValue() - .setIfAbsent(key, "1", Duration.ofHours(properties.duplicatePreventionHours)) - .awaitSingleOrNull() ?: false + val wasSet = + redisTemplate + .opsForValue() + .setIfAbsent(key, "1", Duration.ofHours(properties.duplicatePreventionHours)) + .awaitSingleOrNull() ?: false if (!wasSet) { - log.debug { "Duplicate notification blocked: userId=$userId, productId=$productId, type=$notificationType" } + log.debug { + "Duplicate notification blocked: userId=$userId, productId=$productId, type=$notificationType" + } duplicateBlockedCounter.increment() } @@ -98,14 +111,17 @@ class NotificationRateLimiter( // 1. 키가 없으면 TTL과 함께 생성 (원자적) // 키가 이미 있으면 무시됨 (기존 TTL 유지) - redisTemplate.opsForValue() + redisTemplate + .opsForValue() .setIfAbsent(key, "0", ttl) .awaitSingleOrNull() // 2. 카운트 증가 (항상 TTL이 설정된 상태에서 실행됨) - val count = redisTemplate.opsForValue() - .increment(key) - .awaitSingleOrNull() ?: 1L + val count = + redisTemplate + .opsForValue() + .increment(key) + .awaitSingleOrNull() ?: 1L if (count > properties.dailyLimitPerUser) { log.debug { "Daily limit exceeded for userId=$userId, count=$count" } @@ -127,9 +143,11 @@ class NotificationRateLimiter( * * @return true면 발송 가능 */ - suspend fun canSend(userId: String, productId: String, notificationType: String): Boolean { - return checkDailyLimit(userId) && shouldSend(userId, productId, notificationType) - } + suspend fun canSend( + userId: String, + productId: String, + notificationType: String, + ): Boolean = checkDailyLimit(userId) && shouldSend(userId, productId, notificationType) /** * 자정까지 남은 시간을 계산합니다. diff --git a/notification-service/src/main/kotlin/com/rep/notification/service/TargetResolver.kt b/notification-service/src/main/kotlin/com/rep/notification/service/TargetResolver.kt index cfdae5d..d43ca59 100644 --- a/notification-service/src/main/kotlin/com/rep/notification/service/TargetResolver.kt +++ b/notification-service/src/main/kotlin/com/rep/notification/service/TargetResolver.kt @@ -9,6 +9,7 @@ import io.micrometer.core.instrument.MeterRegistry import io.micrometer.observation.Observation import io.micrometer.observation.ObservationRegistry import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component private val log = KotlinLogging.logger {} @@ -18,26 +19,27 @@ private val log = KotlinLogging.logger {} * * ES user_behavior_index에서 특정 상품에 관심을 보인 유저를 집계합니다. * - * @see docs/phase%204.md - Target Resolver + * @see docs/phase 4.md - Target Resolver */ @Component class TargetResolver( private val esClient: ElasticsearchClient, private val properties: NotificationProperties, meterRegistry: MeterRegistry, - private val observationRegistry: ObservationRegistry + @Value("\${elasticsearch.index.user-behavior:user_behavior_index}") private val behaviorIndex: String, + private val observationRegistry: ObservationRegistry, ) { - companion object { - private const val BEHAVIOR_INDEX = "user_behavior_index" - } - - private val queryCounter = Counter.builder("notification.target.query") - .description("Target user queries executed") - .register(meterRegistry) + private val queryCounter = + Counter + .builder("notification.target.query") + .description("Target user queries executed") + .register(meterRegistry) - private val targetFoundCounter = Counter.builder("notification.target.found") - .description("Target users found") - .register(meterRegistry) + private val targetFoundCounter = + Counter + .builder("notification.target.found") + .description("Target users found") + .register(meterRegistry) /** * 특정 상품에 관심을 보인 유저 목록을 추출합니다. @@ -50,52 +52,59 @@ class TargetResolver( fun findInterestedUsers( productId: String, actionTypes: List, - withinDays: Int = properties.interestedUserDays + withinDays: Int = properties.interestedUserDays, ): List { - val observation = Observation.createNotStarted("es.target-resolve", observationRegistry) - .lowCardinalityKeyValue("productId", productId) - .lowCardinalityKeyValue("actionTypes", actionTypes.joinToString(",")) + val observation = + Observation + .createNotStarted("es.target-resolve", observationRegistry) + .lowCardinalityKeyValue("productId", productId) + .lowCardinalityKeyValue("actionTypes", actionTypes.joinToString(",")) observation.start() return try { queryCounter.increment() - val response = esClient.search({ s -> - s.index(BEHAVIOR_INDEX) - .size(0) // 집계만 필요 - .query { q -> - q.bool { b -> - // 상품 ID 필터 - b.must { m -> - m.term { t -> t.field("productId").value(productId) } - } - // 행동 유형 필터 - b.must { m -> - m.terms { t -> - t.field("actionType").terms { tv -> - tv.value(actionTypes.map { FieldValue.of(it) }) + val response = + esClient.search({ s -> + s + .index(behaviorIndex) + .size(0) // 집계만 필요 + .query { q -> + q.bool { b -> + // 상품 ID 필터 + b.must { m -> + m.term { t -> t.field("productId").value(productId) } + } + // 행동 유형 필터 + b.must { m -> + m.terms { t -> + t.field("actionType").terms { tv -> + tv.value(actionTypes.map { FieldValue.of(it) }) + } } } - } - // 기간 필터 - b.must { m -> - m.range { r -> - r.field("timestamp").gte(JsonData.of("now-${withinDays}d")) + // 기간 필터 + b.must { m -> + m.range { r -> + r.field("timestamp").gte(JsonData.of("now-${withinDays}d")) + } } + b + } + }.aggregations("users") { agg -> + agg.terms { t -> + t.field("userId").size(properties.targetUserLimit) } - b - } - } - .aggregations("users") { agg -> - agg.terms { t -> - t.field("userId").size(properties.targetUserLimit) } - } - }, Void::class.java) + }, Void::class.java) - val users = response.aggregations()["users"] - ?.sterms()?.buckets()?.array() - ?.map { it.key().stringValue() } ?: emptyList() + val users = + response + .aggregations()["users"] + ?.sterms() + ?.buckets() + ?.array() + ?.map { it.key().stringValue() } ?: emptyList() targetFoundCounter.increment(users.size.toDouble()) @@ -106,7 +115,6 @@ class TargetResolver( observation.stop() users - } catch (e: Exception) { log.error(e) { "Failed to find interested users for productId=$productId" } observation.error(e) @@ -122,11 +130,10 @@ class TargetResolver( * @param productId 상품 ID * @return 유저 ID 목록 */ - fun findUsersWithCartItem(productId: String): List { - return findInterestedUsers( + fun findUsersWithCartItem(productId: String): List = + findInterestedUsers( productId = productId, actionTypes = listOf("ADD_TO_CART"), - withinDays = properties.cartUserDays + withinDays = properties.cartUserDays, ) - } } diff --git a/notification-service/src/main/resources/application.yml b/notification-service/src/main/resources/application.yml index a66b746..073a57d 100644 --- a/notification-service/src/main/resources/application.yml +++ b/notification-service/src/main/resources/application.yml @@ -27,6 +27,13 @@ spring: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} +# Elasticsearch index names +elasticsearch: + index: + product: ${ELASTICSEARCH_PRODUCT_INDEX:product_index} + user-behavior: ${ELASTICSEARCH_USER_BEHAVIOR_INDEX:user_behavior_index} + notification-history: ${ELASTICSEARCH_NOTIFICATION_HISTORY_INDEX:notification_history_index} + # Notification Service Settings notification: # 가격 하락 알림 임계값 (%) - 이 비율 이상 하락 시 알림 @@ -53,6 +60,7 @@ notification: recommendation: enabled: ${RECOMMENDATION_ENABLED:true} cron: ${RECOMMENDATION_CRON:0 0 9 * * *} + zone: ${RECOMMENDATION_ZONE:Asia/Seoul} active-user-days: ${RECOMMENDATION_ACTIVE_USER_DAYS:7} limit: ${RECOMMENDATION_LIMIT:3} # 로컬 개발: localhost:8080 @@ -125,6 +133,13 @@ spring: notification: recommendation: api-url: http://recommendation-api:8082 + zone: ${RECOMMENDATION_ZONE:Asia/Seoul} + +elasticsearch: + index: + product: product_index + user-behavior: user_behavior_index + notification-history: notification_history_index # Tracing (Phase 5) - Docker 환경에서 활성화 management: diff --git a/recommendation-api/build.gradle.kts b/recommendation-api/build.gradle.kts index 0e5484a..5524801 100644 --- a/recommendation-api/build.gradle.kts +++ b/recommendation-api/build.gradle.kts @@ -35,7 +35,7 @@ dependencies { // Logging implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") - implementation("net.logstash.logback:logstash-logback-encoder:8.0") // Phase 5: JSON 로깅 + implementation("net.logstash.logback:logstash-logback-encoder:8.0") // Phase 5: JSON 로깅 // Micrometer for metrics implementation("io.micrometer:micrometer-registry-prometheus") diff --git a/recommendation-api/src/main/kotlin/com/rep/recommendation/config/AsyncConfig.kt b/recommendation-api/src/main/kotlin/com/rep/recommendation/config/AsyncConfig.kt index 3c17327..fb9085e 100644 --- a/recommendation-api/src/main/kotlin/com/rep/recommendation/config/AsyncConfig.kt +++ b/recommendation-api/src/main/kotlin/com/rep/recommendation/config/AsyncConfig.kt @@ -18,7 +18,6 @@ import java.util.concurrent.Executors */ @Configuration class AsyncConfig { - private lateinit var virtualThreadExecutor: ExecutorService /** diff --git a/recommendation-api/src/main/kotlin/com/rep/recommendation/config/ElasticsearchConfig.kt b/recommendation-api/src/main/kotlin/com/rep/recommendation/config/ElasticsearchConfig.kt index f363f3c..bfe0400 100644 --- a/recommendation-api/src/main/kotlin/com/rep/recommendation/config/ElasticsearchConfig.kt +++ b/recommendation-api/src/main/kotlin/com/rep/recommendation/config/ElasticsearchConfig.kt @@ -17,7 +17,7 @@ private val log = KotlinLogging.logger {} data class ElasticsearchProperties( val host: String = "localhost", val port: Int = 9200, - val scheme: String = "http" + val scheme: String = "http", ) /** @@ -25,16 +25,18 @@ data class ElasticsearchProperties( */ @Configuration class ElasticsearchConfig( - private val properties: ElasticsearchProperties + private val properties: ElasticsearchProperties, ) { private var restClient: RestClient? = null private var transport: RestClientTransport? = null @Bean fun elasticsearchClient(): ElasticsearchClient { - restClient = RestClient.builder( - HttpHost(properties.host, properties.port, properties.scheme) - ).build() + restClient = + RestClient + .builder( + HttpHost(properties.host, properties.port, properties.scheme), + ).build() transport = RestClientTransport(restClient, JacksonJsonpMapper()) diff --git a/recommendation-api/src/main/kotlin/com/rep/recommendation/config/RecommendationProperties.kt b/recommendation-api/src/main/kotlin/com/rep/recommendation/config/RecommendationProperties.kt index c6fa5c1..788f34e 100644 --- a/recommendation-api/src/main/kotlin/com/rep/recommendation/config/RecommendationProperties.kt +++ b/recommendation-api/src/main/kotlin/com/rep/recommendation/config/RecommendationProperties.kt @@ -13,24 +13,21 @@ import org.springframework.validation.annotation.Validated data class RecommendationProperties( @field:Valid val knn: KnnProperties = KnnProperties(), - @field:Valid - val cache: CacheProperties = CacheProperties() + val cache: CacheProperties = CacheProperties(), ) data class KnnProperties( @field:Positive(message = "k must be positive") - val k: Int = 10 + val k: Int = 10, // numCandidates는 k * 10으로 동적 계산됨 (docs/phase 3.md 참고) ) data class CacheProperties( @field:Positive(message = "popularTtlMinutes must be positive") val popularTtlMinutes: Long = 10, - @field:Positive(message = "globalCacheSize must be positive") val globalCacheSize: Int = 100, - @field:Positive(message = "categoryCacheSize must be positive") - val categoryCacheSize: Int = 50 + val categoryCacheSize: Int = 50, ) diff --git a/recommendation-api/src/main/kotlin/com/rep/recommendation/config/RedisConfig.kt b/recommendation-api/src/main/kotlin/com/rep/recommendation/config/RedisConfig.kt index 6cb5ea0..22ca444 100644 --- a/recommendation-api/src/main/kotlin/com/rep/recommendation/config/RedisConfig.kt +++ b/recommendation-api/src/main/kotlin/com/rep/recommendation/config/RedisConfig.kt @@ -17,21 +17,21 @@ import org.springframework.data.redis.serializer.StringRedisSerializer */ @Configuration class RedisConfig { - @Bean @Primary fun reactiveRedisTemplate( - connectionFactory: ReactiveRedisConnectionFactory + connectionFactory: ReactiveRedisConnectionFactory, ): ReactiveRedisTemplate { val serializer = StringRedisSerializer() - val context = RedisSerializationContext - .newSerializationContext(serializer) - .key(serializer) - .value(serializer) - .hashKey(serializer) - .hashValue(serializer) - .build() + val context = + RedisSerializationContext + .newSerializationContext(serializer) + .key(serializer) + .value(serializer) + .hashKey(serializer) + .hashValue(serializer) + .build() return ReactiveRedisTemplate(connectionFactory, context) } diff --git a/recommendation-api/src/main/kotlin/com/rep/recommendation/config/WebConfig.kt b/recommendation-api/src/main/kotlin/com/rep/recommendation/config/WebConfig.kt index 27da978..6012773 100644 --- a/recommendation-api/src/main/kotlin/com/rep/recommendation/config/WebConfig.kt +++ b/recommendation-api/src/main/kotlin/com/rep/recommendation/config/WebConfig.kt @@ -11,16 +11,15 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer */ @Configuration class WebConfig : WebMvcConfigurer { - override fun addCorsMappings(registry: CorsRegistry) { - registry.addMapping("/api/**") + registry + .addMapping("/api/**") .allowedOrigins( - "http://localhost:5173", // Vite 기본 포트 - "http://localhost:3001", // 대체 포트 - "http://localhost:3000", // 대체 포트 - "http://frontend:80" // Docker - ) - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + "http://localhost:5173", // Vite 기본 포트 + "http://localhost:3001", // 대체 포트 + "http://localhost:3000", // 대체 포트 + "http://frontend:80", // Docker + ).allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600) diff --git a/recommendation-api/src/main/kotlin/com/rep/recommendation/controller/RecommendationController.kt b/recommendation-api/src/main/kotlin/com/rep/recommendation/controller/RecommendationController.kt index 3f02e9d..697e00c 100644 --- a/recommendation-api/src/main/kotlin/com/rep/recommendation/controller/RecommendationController.kt +++ b/recommendation-api/src/main/kotlin/com/rep/recommendation/controller/RecommendationController.kt @@ -6,7 +6,11 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.runBlocking import mu.KotlinLogging import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.* +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController private val log = KotlinLogging.logger {} @@ -15,15 +19,14 @@ private val log = KotlinLogging.logger {} * * 유저에게 개인화된 상품 추천을 제공합니다. * - * @see docs/phase%203.md - 추천 API 명세 + * @see docs/phase 3.md - 추천 API 명세 */ @RestController @RequestMapping("/api/v1/recommendations") class RecommendationController( private val recommendationService: RecommendationService, - private val virtualThreadDispatcher: CoroutineDispatcher + private val virtualThreadDispatcher: CoroutineDispatcher, ) { - /** * 인기 상품을 조회합니다 (Cold Start 또는 비로그인 유저용). * @@ -36,18 +39,19 @@ class RecommendationController( @GetMapping("/popular") fun getPopularProducts( @RequestParam(defaultValue = "10") limit: Int, - @RequestParam(required = false) category: String? + @RequestParam(required = false) category: String?, ): ResponseEntity { log.debug { "Popular products request: limit=$limit, category=$category" } - val response = runBlocking(virtualThreadDispatcher) { - recommendationService.getRecommendations( - userId = "_anonymous_", // 임의의 ID로 Cold Start 트리거 - limit = limit.coerceIn(1, 50), - category = category, - excludeViewed = false - ) - } + val response = + runBlocking(virtualThreadDispatcher) { + recommendationService.getRecommendations( + userId = "_anonymous_", // 임의의 ID로 Cold Start 트리거 + limit = limit.coerceIn(1, 50), + category = category, + excludeViewed = false, + ) + } return ResponseEntity.ok(response) } @@ -56,9 +60,7 @@ class RecommendationController( * 헬스체크 엔드포인트 */ @GetMapping("/health") - fun health(): ResponseEntity> { - return ResponseEntity.ok(mapOf("status" to "ok")) - } + fun health(): ResponseEntity> = ResponseEntity.ok(mapOf("status" to "ok")) /** * 유저에게 개인화된 상품을 추천합니다. @@ -83,20 +85,21 @@ class RecommendationController( @PathVariable userId: String, @RequestParam(defaultValue = "10") limit: Int, @RequestParam(required = false) category: String?, - @RequestParam(defaultValue = "true") excludeViewed: Boolean + @RequestParam(defaultValue = "true") excludeViewed: Boolean, ): ResponseEntity { log.debug { "Recommendation request: userId=$userId, limit=$limit, category=$category" } // Virtual Thread dispatcher로 runBlocking 실행 // Blocking I/O 발생 시에도 Virtual Thread가 unmount되어 처리량 유지 - val response = runBlocking(virtualThreadDispatcher) { - recommendationService.getRecommendations( - userId = userId, - limit = limit.coerceIn(1, 50), - category = category, - excludeViewed = excludeViewed - ) - } + val response = + runBlocking(virtualThreadDispatcher) { + recommendationService.getRecommendations( + userId = userId, + limit = limit.coerceIn(1, 50), + category = category, + excludeViewed = excludeViewed, + ) + } log.info { "Recommendation response: userId=$userId, count=${response.recommendations.size}, " + diff --git a/recommendation-api/src/main/kotlin/com/rep/recommendation/model/RecommendationModels.kt b/recommendation-api/src/main/kotlin/com/rep/recommendation/model/RecommendationModels.kt index 00f5f79..7ec13a2 100644 --- a/recommendation-api/src/main/kotlin/com/rep/recommendation/model/RecommendationModels.kt +++ b/recommendation-api/src/main/kotlin/com/rep/recommendation/model/RecommendationModels.kt @@ -6,8 +6,8 @@ package com.rep.recommendation.model data class RecommendationResponse( val userId: String, val recommendations: List, - val strategy: String, // "knn" | "popularity" | "category_best" - val latencyMs: Long + val strategy: String, // "knn" | "popularity" | "category_best" + val latencyMs: Long, ) /** @@ -18,5 +18,5 @@ data class ProductRecommendation( val productName: String, val category: String, val price: Float, - val score: Double = 0.0 + val score: Double = 0.0, ) diff --git a/recommendation-api/src/main/kotlin/com/rep/recommendation/repository/UserBehaviorRepository.kt b/recommendation-api/src/main/kotlin/com/rep/recommendation/repository/UserBehaviorRepository.kt index b83185f..1ccaaee 100644 --- a/recommendation-api/src/main/kotlin/com/rep/recommendation/repository/UserBehaviorRepository.kt +++ b/recommendation-api/src/main/kotlin/com/rep/recommendation/repository/UserBehaviorRepository.kt @@ -5,6 +5,7 @@ import co.elastic.clients.elasticsearch._types.FieldValue import co.elastic.clients.elasticsearch._types.SortOrder import co.elastic.clients.json.JsonData import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository private val log = KotlinLogging.logger {} @@ -17,12 +18,9 @@ private val log = KotlinLogging.logger {} */ @Repository class UserBehaviorRepository( - private val esClient: ElasticsearchClient + private val esClient: ElasticsearchClient, + @Value("\${elasticsearch.index.user-behavior:user_behavior_index}") private val behaviorIndex: String, ) { - companion object { - private const val INDEX_NAME = "user_behavior_index" - } - /** * 유저가 최근에 본 상품 ID 목록을 조회합니다. * @@ -30,47 +28,50 @@ class UserBehaviorRepository( * @param limit 조회할 최대 개수 * @return 상품 ID 목록 (최신순) */ - fun getRecentViewedProducts(userId: String, limit: Int = 100): List { - return try { - val response = esClient.search({ s -> - s.index(INDEX_NAME) - .size(limit) - .query { q -> - q.bool { b -> - b.must { m -> - m.term { t -> t.field("userId").value(userId) } - } - // VIEW, CLICK만 조회 (구매한 상품은 재구매 추천을 위해 제외하지 않음) - b.must { m -> - m.terms { t -> - t.field("actionType").terms { tv -> - tv.value(listOf(FieldValue.of("VIEW"), FieldValue.of("CLICK"))) + fun getRecentViewedProducts( + userId: String, + limit: Int = 100, + ): List = + try { + val response = + esClient.search({ s -> + s + .index(behaviorIndex) + .size(limit) + .query { q -> + q.bool { b -> + b.must { m -> + m.term { t -> t.field("userId").value(userId) } + } + // VIEW, CLICK만 조회 (구매한 상품은 재구매 추천을 위해 제외하지 않음) + b.must { m -> + m.terms { t -> + t.field("actionType").terms { tv -> + tv.value(listOf(FieldValue.of("VIEW"), FieldValue.of("CLICK"))) + } } } + // 최근 7일 이내 + b.must { m -> + m.range { r -> r.field("timestamp").gte(JsonData.of("now-7d")) } + } + b } - // 최근 7일 이내 - b.must { m -> - m.range { r -> r.field("timestamp").gte(JsonData.of("now-7d")) } - } - b - } - } - .sort { sort -> sort.field { f -> f.field("timestamp").order(SortOrder.Desc) } } - .source { src -> src.filter { f -> f.includes("productId") } } - }, Map::class.java) + }.sort { sort -> sort.field { f -> f.field("timestamp").order(SortOrder.Desc) } } + .source { src -> src.filter { f -> f.includes("productId") } } + }, Map::class.java) - response.hits().hits() + response + .hits() + .hits() .mapNotNull { hit -> @Suppress("UNCHECKED_CAST") (hit.source() as? Map)?.get("productId")?.toString() - } - .distinct() - + }.distinct() } catch (e: Exception) { log.error(e) { "Failed to get recent viewed products for userId=$userId" } emptyList() } - } /** * 유저가 최근에 구매한 상품 ID 목록을 조회합니다. @@ -79,40 +80,43 @@ class UserBehaviorRepository( * @param limit 조회할 최대 개수 * @return 상품 ID 목록 (최신순) */ - fun getRecentPurchasedProducts(userId: String, limit: Int = 50): List { - return try { - val response = esClient.search({ s -> - s.index(INDEX_NAME) - .size(limit) - .query { q -> - q.bool { b -> - b.must { m -> - m.term { t -> t.field("userId").value(userId) } - } - b.must { m -> - m.term { t -> t.field("actionType").value("PURCHASE") } - } - // 최근 30일 이내 - b.must { m -> - m.range { r -> r.field("timestamp").gte(JsonData.of("now-30d")) } + fun getRecentPurchasedProducts( + userId: String, + limit: Int = 50, + ): List = + try { + val response = + esClient.search({ s -> + s + .index(behaviorIndex) + .size(limit) + .query { q -> + q.bool { b -> + b.must { m -> + m.term { t -> t.field("userId").value(userId) } + } + b.must { m -> + m.term { t -> t.field("actionType").value("PURCHASE") } + } + // 최근 30일 이내 + b.must { m -> + m.range { r -> r.field("timestamp").gte(JsonData.of("now-30d")) } + } + b } - b - } - } - .sort { sort -> sort.field { f -> f.field("timestamp").order(SortOrder.Desc) } } - .source { src -> src.filter { f -> f.includes("productId") } } - }, Map::class.java) + }.sort { sort -> sort.field { f -> f.field("timestamp").order(SortOrder.Desc) } } + .source { src -> src.filter { f -> f.includes("productId") } } + }, Map::class.java) - response.hits().hits() + response + .hits() + .hits() .mapNotNull { hit -> @Suppress("UNCHECKED_CAST") (hit.source() as? Map)?.get("productId")?.toString() - } - .distinct() - + }.distinct() } catch (e: Exception) { log.error(e) { "Failed to get recent purchased products for userId=$userId" } emptyList() } - } } diff --git a/recommendation-api/src/main/kotlin/com/rep/recommendation/repository/UserPreferenceRepository.kt b/recommendation-api/src/main/kotlin/com/rep/recommendation/repository/UserPreferenceRepository.kt index 77b623d..95e27fe 100644 --- a/recommendation-api/src/main/kotlin/com/rep/recommendation/repository/UserPreferenceRepository.kt +++ b/recommendation-api/src/main/kotlin/com/rep/recommendation/repository/UserPreferenceRepository.kt @@ -10,6 +10,7 @@ import io.micrometer.core.instrument.MeterRegistry import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.withTimeoutOrNull import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.data.redis.core.ReactiveRedisTemplate import org.springframework.stereotype.Repository import java.time.Duration @@ -28,26 +29,32 @@ class UserPreferenceRepository( private val redisTemplate: ReactiveRedisTemplate, private val esClient: ElasticsearchClient, private val objectMapper: ObjectMapper, - meterRegistry: MeterRegistry + @Value("\${elasticsearch.index.user-preference:user_preference_index}") private val userPreferenceIndex: String, + meterRegistry: MeterRegistry, ) { companion object { private const val KEY_PREFIX = "user:preference:" - private const val ES_INDEX = "user_preference_index" private val TTL = Duration.ofHours(24) - private const val REDIS_TIMEOUT_MS = 500L // Redis 비정상 시 빠른 실패용 + private const val REDIS_TIMEOUT_MS = 500L // Redis 비정상 시 빠른 실패용 } - private val cacheHitCounter = Counter.builder("preference.cache.hit") - .description("User preference cache hits") - .register(meterRegistry) + private val cacheHitCounter = + Counter + .builder("preference.cache.hit") + .description("User preference cache hits") + .register(meterRegistry) - private val cacheMissCounter = Counter.builder("preference.cache.miss") - .description("User preference cache misses") - .register(meterRegistry) + private val cacheMissCounter = + Counter + .builder("preference.cache.miss") + .description("User preference cache misses") + .register(meterRegistry) - private val esFallbackCounter = Counter.builder("preference.es.fallback") - .description("ES fallback for user preference") - .register(meterRegistry) + private val esFallbackCounter = + Counter + .builder("preference.es.fallback") + .description("ES fallback for user preference") + .register(meterRegistry) /** * 유저 취향 벡터를 조회합니다. @@ -63,9 +70,10 @@ class UserPreferenceRepository( return try { // 1. Redis에서 조회 (타임아웃 적용) - val cached = withTimeoutOrNull(REDIS_TIMEOUT_MS) { - redisTemplate.opsForValue().get(key).awaitSingleOrNull() - } + val cached = + withTimeoutOrNull(REDIS_TIMEOUT_MS) { + redisTemplate.opsForValue().get(key).awaitSingleOrNull() + } if (cached != null) { log.debug { "Cache hit for userId=$userId" } @@ -95,17 +103,23 @@ class UserPreferenceRepository( /** * ES에서 조회한 벡터를 Redis에 캐싱합니다. */ - private suspend fun cacheToRedis(userId: String, vector: FloatArray, actionCount: Int) { + private suspend fun cacheToRedis( + userId: String, + vector: FloatArray, + actionCount: Int, + ) { try { val key = "$KEY_PREFIX$userId" - val data = UserPreferenceData( - preferenceVector = vector.toList(), - actionCount = actionCount, - updatedAt = System.currentTimeMillis() - ) + val data = + UserPreferenceData( + preferenceVector = vector.toList(), + actionCount = actionCount, + updatedAt = System.currentTimeMillis(), + ) // 캐싱 실패해도 ES 폴백 결과는 반환하므로 타임아웃으로 빠르게 포기 withTimeoutOrNull(REDIS_TIMEOUT_MS) { - redisTemplate.opsForValue() + redisTemplate + .opsForValue() .set(key, objectMapper.writeValueAsString(data), TTL) .awaitSingleOrNull() } @@ -120,12 +134,13 @@ class UserPreferenceRepository( * * @return Pair(벡터, actionCount) 또는 null */ - private fun getFromEs(userId: String): Pair? { - return try { - val response = esClient.get( - { g -> g.index(ES_INDEX).id(userId) }, - UserPreferenceDocument::class.java - ) + private fun getFromEs(userId: String): Pair? = + try { + val response = + esClient.get( + { g -> g.index(userPreferenceIndex).id(userId) }, + UserPreferenceDocument::class.java, + ) if (response.found()) { val source = response.source() @@ -144,5 +159,4 @@ class UserPreferenceRepository( log.warn(e) { "Failed to get preference vector from ES for userId=$userId" } null } - } } diff --git a/recommendation-api/src/main/kotlin/com/rep/recommendation/service/PopularProductsCache.kt b/recommendation-api/src/main/kotlin/com/rep/recommendation/service/PopularProductsCache.kt index a3adbe3..37a1274 100644 --- a/recommendation-api/src/main/kotlin/com/rep/recommendation/service/PopularProductsCache.kt +++ b/recommendation-api/src/main/kotlin/com/rep/recommendation/service/PopularProductsCache.kt @@ -6,8 +6,8 @@ import co.elastic.clients.elasticsearch._types.SortOrder import co.elastic.clients.json.JsonData import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue -import com.rep.recommendation.config.RecommendationProperties import com.rep.model.ProductDocument +import com.rep.recommendation.config.RecommendationProperties import com.rep.recommendation.model.ProductRecommendation import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.MeterRegistry @@ -15,11 +15,12 @@ import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.util.concurrent.ConcurrentHashMap import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.data.redis.core.ReactiveRedisTemplate import org.springframework.stereotype.Component import java.time.Duration +import java.util.concurrent.ConcurrentHashMap private val log = KotlinLogging.logger {} @@ -29,7 +30,7 @@ private val log = KotlinLogging.logger {} * Cold Start 유저에게 인기 상품을 추천하기 위한 캐시입니다. * Redis에 캐싱하고, 캐시 미스 시 ES에서 집계하여 조회합니다. * - * @see docs/phase%203.md - Cold Start 처리 + * @see docs/phase 3.md - Cold Start 처리 */ @Component class PopularProductsCache( @@ -37,28 +38,36 @@ class PopularProductsCache( private val redisTemplate: ReactiveRedisTemplate, private val objectMapper: ObjectMapper, private val properties: RecommendationProperties, - meterRegistry: MeterRegistry + meterRegistry: MeterRegistry, ) { companion object { private const val CACHE_KEY_GLOBAL = "popular:global" private const val CACHE_KEY_CATEGORY_PREFIX = "popular:category:" - private const val PRODUCT_INDEX = "product_index" - private const val BEHAVIOR_INDEX = "user_behavior_index" } + @Value("\${elasticsearch.index.product:product_index}") + private lateinit var productIndex: String + + @Value("\${elasticsearch.index.user-behavior:user_behavior_index}") + private lateinit var behaviorIndex: String + private val cacheTtl = Duration.ofMinutes(properties.cache.popularTtlMinutes) // Thundering Herd 방지용 Mutex private val globalCacheMutex = Mutex() private val categoryCacheMutexMap = ConcurrentHashMap() - private val cacheHitCounter = Counter.builder("popular.cache.hit") - .description("Popular products cache hits") - .register(meterRegistry) + private val cacheHitCounter = + Counter + .builder("popular.cache.hit") + .description("Popular products cache hits") + .register(meterRegistry) - private val cacheMissCounter = Counter.builder("popular.cache.miss") - .description("Popular products cache misses") - .register(meterRegistry) + private val cacheMissCounter = + Counter + .builder("popular.cache.miss") + .description("Popular products cache misses") + .register(meterRegistry) /** * 전체 인기 상품을 조회합니다. @@ -96,7 +105,8 @@ class PopularProductsCache( // Redis에 캐싱 if (products.isNotEmpty()) { - redisTemplate.opsForValue() + redisTemplate + .opsForValue() .set(CACHE_KEY_GLOBAL, objectMapper.writeValueAsString(products), cacheTtl) .awaitSingle() } @@ -115,7 +125,10 @@ class PopularProductsCache( * @param limit 조회할 개수 * @return 인기 상품 목록 */ - suspend fun getCategoryBest(category: String, limit: Int): List { + suspend fun getCategoryBest( + category: String, + limit: Int, + ): List { val cacheKey = "$CACHE_KEY_CATEGORY_PREFIX$category" // 1. Redis 캐시 확인 (락 없이 빠른 경로) @@ -146,7 +159,8 @@ class PopularProductsCache( // Redis에 캐싱 if (products.isNotEmpty()) { - redisTemplate.opsForValue() + redisTemplate + .opsForValue() .set(cacheKey, objectMapper.writeValueAsString(products), cacheTtl) .awaitSingle() } @@ -163,23 +177,28 @@ class PopularProductsCache( * 2. VIEW/CLICK 집계 (PURCHASE 결과 없을 경우) * 3. 최신 등록 상품 (모든 집계 결과 없을 경우) */ - private fun queryPopularProducts(category: String?, limit: Int): List { + private fun queryPopularProducts( + category: String?, + limit: Int, + ): List { return try { // 1. PURCHASE 집계 시도 - var productIds = queryBehaviorAggregation( - category = category, - actionTypes = listOf("PURCHASE"), - limit = limit - ) + var productIds = + queryBehaviorAggregation( + category = category, + actionTypes = listOf("PURCHASE"), + limit = limit, + ) // 2. PURCHASE 없으면 VIEW/CLICK 집계 if (productIds.isEmpty()) { log.debug { "No PURCHASE data, falling back to VIEW/CLICK for category=$category" } - productIds = queryBehaviorAggregation( - category = category, - actionTypes = listOf("VIEW", "CLICK"), - limit = limit - ) + productIds = + queryBehaviorAggregation( + category = category, + actionTypes = listOf("VIEW", "CLICK"), + limit = limit, + ) } // 3. VIEW/CLICK도 없으면 최신 상품 조회 @@ -190,7 +209,6 @@ class PopularProductsCache( // 상품 상세 정보 조회 getProductDetails(productIds) - } catch (e: Exception) { log.error(e) { "Failed to query popular products for category=$category" } emptyList() @@ -203,46 +221,47 @@ class PopularProductsCache( private fun queryBehaviorAggregation( category: String?, actionTypes: List, - limit: Int + limit: Int, ): List { - val aggResponse = esClient.search({ s -> - s.index(BEHAVIOR_INDEX) - .size(0) - .query { q -> - q.bool { b -> - // actionTypes 필터 (단일 또는 복수) - if (actionTypes.size == 1) { - b.must { m -> - m.term { t -> t.field("actionType").value(actionTypes.first()) } - } - } else { - b.must { m -> - m.terms { t -> - t.field("actionType").terms { tv -> - tv.value(actionTypes.map { FieldValue.of(it) }) + val aggResponse = + esClient.search({ s -> + s + .index(behaviorIndex) + .size(0) + .query { q -> + q.bool { b -> + // actionTypes 필터 (단일 또는 복수) + if (actionTypes.size == 1) { + b.must { m -> + m.term { t -> t.field("actionType").value(actionTypes.first()) } + } + } else { + b.must { m -> + m.terms { t -> + t.field("actionType").terms { tv -> + tv.value(actionTypes.map { FieldValue.of(it) }) + } } } } - } - // 최근 7일 - b.must { m -> - m.range { r -> r.field("timestamp").gte(JsonData.of("now-7d")) } - } - // 카테고리 필터 - if (category != null) { + // 최근 7일 b.must { m -> - m.term { t -> t.field("category").value(category) } + m.range { r -> r.field("timestamp").gte(JsonData.of("now-7d")) } } + // 카테고리 필터 + if (category != null) { + b.must { m -> + m.term { t -> t.field("category").value(category) } + } + } + b + } + }.aggregations("popular_products") { agg -> + agg.terms { t -> + t.field("productId").size(limit) } - b - } - } - .aggregations("popular_products") { agg -> - agg.terms { t -> - t.field("productId").size(limit) } - } - }, Void::class.java) + }, Void::class.java) // 안전한 집계 결과 추출 (NPE 방지) return runCatching { @@ -282,27 +301,30 @@ class PopularProductsCache( /** * 최신 등록 상품 조회 (행동 데이터가 없을 때 폴백) */ - private fun getLatestProducts(category: String?, limit: Int): List { - return try { - val response = esClient.search({ s -> - s.index(PRODUCT_INDEX) - .size(limit) - .query { q -> - if (category != null) { - q.term { t -> t.field("category").value(category) } - } else { - q.matchAll { it } - } - } - .sort { sort -> - sort.field { f -> f.field("createdAt").order(SortOrder.Desc) } - } - .source { src -> - src.filter { filter -> - filter.includes("productId", "productName", "category", "price") + private fun getLatestProducts( + category: String?, + limit: Int, + ): List = + try { + val response = + esClient.search({ s -> + s + .index(productIndex) + .size(limit) + .query { q -> + if (category != null) { + q.term { t -> t.field("category").value(category) } + } else { + q.matchAll { it } + } + }.sort { sort -> + sort.field { f -> f.field("createdAt").order(SortOrder.Desc) } + }.source { src -> + src.filter { filter -> + filter.includes("productId", "productName", "category", "price") + } } - } - }, ProductDocument::class.java) + }, ProductDocument::class.java) response.hits().hits().mapNotNull { hit -> hit.source()?.let { product -> @@ -311,7 +333,7 @@ class PopularProductsCache( productName = product.productName ?: "", category = product.category ?: "", price = product.price ?: 0f, - score = 0.0 + score = 0.0, ) } } @@ -319,7 +341,6 @@ class PopularProductsCache( log.error(e) { "Failed to get latest products for category=$category" } emptyList() } - } /** * 상품 ID 목록으로 상품 상세 정보를 조회합니다. @@ -328,30 +349,34 @@ class PopularProductsCache( if (productIds.isEmpty()) return emptyList() return try { - val response = esClient.mget( - { m -> m.index(PRODUCT_INDEX).ids(productIds) }, - ProductDocument::class.java - ) + val response = + esClient.mget( + { m -> m.index(productIndex).ids(productIds) }, + ProductDocument::class.java, + ) // 원래 순서 유지 (인기순) - val productMap = response.docs() - .filter { it.result()?.found() == true } - .mapNotNull { doc -> - val source = doc.result()?.source() - if (source != null) { - doc.result()?.id() to ProductRecommendation( - productId = source.productId ?: doc.result()?.id() ?: "", - productName = source.productName ?: "", - category = source.category ?: "", - price = source.price ?: 0f, - score = 0.0 // 인기 상품은 score 없음 - ) - } else null - } - .toMap() + val productMap = + response + .docs() + .filter { it.result()?.found() == true } + .mapNotNull { doc -> + val source = doc.result()?.source() + if (source != null) { + doc.result()?.id() to + ProductRecommendation( + productId = source.productId ?: doc.result()?.id() ?: "", + productName = source.productName ?: "", + category = source.category ?: "", + price = source.price ?: 0f, + score = 0.0, // 인기 상품은 score 없음 + ) + } else { + null + } + }.toMap() productIds.mapNotNull { productMap[it] } - } catch (e: Exception) { log.error(e) { "Failed to get product details for ${productIds.size} products" } emptyList() diff --git a/recommendation-api/src/main/kotlin/com/rep/recommendation/service/RecommendationService.kt b/recommendation-api/src/main/kotlin/com/rep/recommendation/service/RecommendationService.kt index 3b3674e..2d2ea7e 100644 --- a/recommendation-api/src/main/kotlin/com/rep/recommendation/service/RecommendationService.kt +++ b/recommendation-api/src/main/kotlin/com/rep/recommendation/service/RecommendationService.kt @@ -3,8 +3,8 @@ package com.rep.recommendation.service import co.elastic.clients.elasticsearch.ElasticsearchClient import co.elastic.clients.elasticsearch._types.query_dsl.Query import co.elastic.clients.json.JsonData -import com.rep.recommendation.config.RecommendationProperties import com.rep.model.ProductDocument +import com.rep.recommendation.config.RecommendationProperties import com.rep.recommendation.model.ProductRecommendation import com.rep.recommendation.model.RecommendationResponse import com.rep.recommendation.repository.UserBehaviorRepository @@ -15,6 +15,7 @@ import io.micrometer.core.instrument.Timer import io.micrometer.observation.Observation import io.micrometer.observation.ObservationRegistry import mu.KotlinLogging +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import java.util.concurrent.TimeUnit @@ -26,7 +27,7 @@ private val log = KotlinLogging.logger {} * 유저의 취향 벡터를 기반으로 KNN 검색을 수행하여 개인화된 상품을 추천합니다. * Cold Start 유저에게는 인기 상품을 추천합니다. * - * @see docs/phase%203.md + * @see docs/phase 3.md */ @Service class RecommendationService( @@ -36,39 +37,52 @@ class RecommendationService( private val esClient: ElasticsearchClient, private val properties: RecommendationProperties, private val meterRegistry: MeterRegistry, - private val observationRegistry: ObservationRegistry + private val observationRegistry: ObservationRegistry, ) { - companion object { - private const val PRODUCT_INDEX = "product_index" - } - - private val latencyTimer = Timer.builder("recommendation.latency") - .description("Recommendation API latency") - .register(meterRegistry) - - private val knnCounter = Counter.builder("recommendation.strategy") - .tag("type", "knn") - .register(meterRegistry) - - private val popularityCounter = Counter.builder("recommendation.strategy") - .tag("type", "popularity") - .register(meterRegistry) - - private val categoryBestCounter = Counter.builder("recommendation.strategy") - .tag("type", "category_best") - .register(meterRegistry) - - private val knnFailedCounter = Counter.builder("recommendation.knn.failed") - .description("KNN search failures") - .register(meterRegistry) - - private val fallbackUsedCounter = Counter.builder("recommendation.fallback.used") - .description("Fallback to popular products due to error") - .register(meterRegistry) - - private val emptyResultCounter = Counter.builder("recommendation.result.empty") - .description("Empty recommendation results") - .register(meterRegistry) + @Value("\${elasticsearch.index.product:product_index}") + private lateinit var productIndex: String + + private val latencyTimer = + Timer + .builder("recommendation.latency") + .description("Recommendation API latency") + .register(meterRegistry) + + private val knnCounter = + Counter + .builder("recommendation.strategy") + .tag("type", "knn") + .register(meterRegistry) + + private val popularityCounter = + Counter + .builder("recommendation.strategy") + .tag("type", "popularity") + .register(meterRegistry) + + private val categoryBestCounter = + Counter + .builder("recommendation.strategy") + .tag("type", "category_best") + .register(meterRegistry) + + private val knnFailedCounter = + Counter + .builder("recommendation.knn.failed") + .description("KNN search failures") + .register(meterRegistry) + + private val fallbackUsedCounter = + Counter + .builder("recommendation.fallback.used") + .description("Fallback to popular products due to error") + .register(meterRegistry) + + private val emptyResultCounter = + Counter + .builder("recommendation.result.empty") + .description("Empty recommendation results") + .register(meterRegistry) /** * 유저에게 개인화된 상품을 추천합니다. @@ -83,10 +97,12 @@ class RecommendationService( userId: String, limit: Int = 10, category: String? = null, - excludeViewed: Boolean = true + excludeViewed: Boolean = true, ): RecommendationResponse { - val observation = Observation.createNotStarted("recommendation.search", observationRegistry) - .lowCardinalityKeyValue("userId", userId) + val observation = + Observation + .createNotStarted("recommendation.search", observationRegistry) + .lowCardinalityKeyValue("userId", userId) observation.start() val startTime = System.nanoTime() @@ -104,18 +120,18 @@ class RecommendationService( observation.lowCardinalityKeyValue("strategy", result.strategy) observation.stop() result.copy(latencyMs = latencyMs) - } catch (e: Exception) { log.error(e) { "Failed to get recommendations for userId=$userId" } fallbackUsedCounter.increment() observation.error(e) // 에러 발생 시에도 인기 상품이라도 반환 - val fallbackProducts = try { - popularProductsCache.getTopProducts(limit) - } catch (e2: Exception) { - emptyList() - } + val fallbackProducts = + try { + popularProductsCache.getTopProducts(limit) + } catch (e2: Exception) { + emptyList() + } if (fallbackProducts.isEmpty()) { emptyResultCounter.increment() @@ -127,7 +143,7 @@ class RecommendationService( userId = userId, recommendations = fallbackProducts, strategy = "fallback", - latencyMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime) + latencyMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime), ) } } @@ -136,42 +152,46 @@ class RecommendationService( userId: String, limit: Int, category: String?, - excludeViewed: Boolean + excludeViewed: Boolean, ): RecommendationResponse { // 1. 유저 취향 벡터 조회 val preferenceVector = userPreferenceRepository.get(userId) // 2. 전략 결정 및 추천 실행 - val (products, strategy) = if (preferenceVector == null) { - // Cold Start: 인기 상품 반환 - if (category != null) { - log.debug { "Cold start with category filter for userId=$userId, using category_best strategy" } - categoryBestCounter.increment() - Pair(getColdStartRecommendations(limit, category), "category_best") + val (products, strategy) = + if (preferenceVector == null) { + // Cold Start: 인기 상품 반환 + if (category != null) { + log.debug { "Cold start with category filter for userId=$userId, using category_best strategy" } + categoryBestCounter.increment() + Pair(getColdStartRecommendations(limit, category), "category_best") + } else { + log.debug { "Cold start for userId=$userId, using popularity strategy" } + popularityCounter.increment() + Pair(getColdStartRecommendations(limit, category), "popularity") + } } else { - log.debug { "Cold start for userId=$userId, using popularity strategy" } - popularityCounter.increment() - Pair(getColdStartRecommendations(limit, category), "popularity") + // KNN 검색 + log.debug { "Using KNN strategy for userId=$userId" } + knnCounter.increment() + val excludeIds = + if (excludeViewed) { + userBehaviorRepository.getRecentViewedProducts(userId, 100) + } else { + emptyList() + } + + Pair( + searchSimilarProducts(preferenceVector, limit, category, excludeIds), + "knn", + ) } - } else { - // KNN 검색 - log.debug { "Using KNN strategy for userId=$userId" } - knnCounter.increment() - val excludeIds = if (excludeViewed) { - userBehaviorRepository.getRecentViewedProducts(userId, 100) - } else emptyList() - - Pair( - searchSimilarProducts(preferenceVector, limit, category, excludeIds), - "knn" - ) - } return RecommendationResponse( userId = userId, recommendations = products, strategy = strategy, - latencyMs = 0 // 나중에 설정됨 + latencyMs = 0, // 나중에 설정됨 ) } @@ -182,11 +202,13 @@ class RecommendationService( queryVector: FloatArray, k: Int, category: String?, - excludeIds: List + excludeIds: List, ): List { - val observation = Observation.createNotStarted("es.knn-search", observationRegistry) - .lowCardinalityKeyValue("category", category ?: "all") - .lowCardinalityKeyValue("k", k.toString()) + val observation = + Observation + .createNotStarted("es.knn-search", observationRegistry) + .lowCardinalityKeyValue("category", category ?: "all") + .lowCardinalityKeyValue("k", k.toString()) observation.start() return try { @@ -195,59 +217,67 @@ class RecommendationService( // 카테고리 필터 if (category != null) { - filterQueries.add(Query.of { q -> - q.term { t -> t.field("category").value(category) } - }) + filterQueries.add( + Query.of { q -> + q.term { t -> t.field("category").value(category) } + }, + ) } // 이미 본 상품 제외 if (excludeIds.isNotEmpty()) { - filterQueries.add(Query.of { q -> - q.bool { b -> - b.mustNot { mn -> mn.ids { ids -> ids.values(excludeIds) } } - } - }) + filterQueries.add( + Query.of { q -> + q.bool { b -> + b.mustNot { mn -> mn.ids { ids -> ids.values(excludeIds) } } + } + }, + ) } // 재고 있는 상품만 - filterQueries.add(Query.of { q -> - q.range { r -> r.field("stock").gt(JsonData.of(0)) } - }) + filterQueries.add( + Query.of { q -> + q.range { r -> r.field("stock").gt(JsonData.of(0)) } + }, + ) // queryVector를 List로 변환 val queryVectorList: List = queryVector.toList() - val response = esClient.search({ s -> - s.index(PRODUCT_INDEX) - .knn { knn -> - knn.field("productVector") - .queryVector(queryVectorList) - .k(k.toLong()) - .numCandidates((k * 10).toLong()) // 문서 권장: num_candidates = k * 10 - .filter(filterQueries) - } - .source { src -> - src.filter { filter -> - filter.includes("productId", "productName", "category", "price") + val response = + esClient.search({ s -> + s + .index(productIndex) + .knn { knn -> + knn + .field("productVector") + .queryVector(queryVectorList) + .k(k.toLong()) + .numCandidates((k * 10).toLong()) // 문서 권장: num_candidates = k * 10 + .filter(filterQueries) + }.source { src -> + src.filter { filter -> + filter.includes("productId", "productName", "category", "price") + } } + }, ProductDocument::class.java) + + val results = + response.hits().hits().mapNotNull { hit -> + hit.source()?.let { product -> + ProductRecommendation( + productId = product.productId ?: hit.id() ?: "", + productName = product.productName ?: "", + category = product.category ?: "", + price = product.price ?: 0f, + score = hit.score() ?: 0.0, + ) } - }, ProductDocument::class.java) - - val results = response.hits().hits().mapNotNull { hit -> - hit.source()?.let { product -> - ProductRecommendation( - productId = product.productId ?: hit.id() ?: "", - productName = product.productName ?: "", - category = product.category ?: "", - price = product.price ?: 0f, - score = hit.score() ?: 0.0 - ) } - } observation.stop() results - } catch (e: Exception) { log.error(e) { "KNN search failed" } knnFailedCounter.increment() @@ -262,14 +292,13 @@ class RecommendationService( */ private suspend fun getColdStartRecommendations( limit: Int, - category: String? - ): List { - return if (category != null) { + category: String?, + ): List = + if (category != null) { // 카테고리별 베스트 popularProductsCache.getCategoryBest(category, limit) } else { // 전체 인기 상품 popularProductsCache.getTopProducts(limit) } - } } diff --git a/recommendation-api/src/main/resources/application.yml b/recommendation-api/src/main/resources/application.yml index 7a0ed5d..f6200d1 100644 --- a/recommendation-api/src/main/resources/application.yml +++ b/recommendation-api/src/main/resources/application.yml @@ -20,6 +20,10 @@ elasticsearch: host: ${ELASTICSEARCH_HOST:localhost} port: ${ELASTICSEARCH_PORT:9200} scheme: ${ELASTICSEARCH_SCHEME:http} + index: + product: product_index + user-behavior: user_behavior_index + user-preference: user_preference_index # 추천 설정 recommendation: @@ -77,6 +81,10 @@ spring: elasticsearch: host: elasticsearch port: 9200 + index: + product: product_index + user-behavior: user_behavior_index + user-preference: user_preference_index server: port: 8082 diff --git a/simulator/build.gradle.kts b/simulator/build.gradle.kts index 595e403..b3fef35 100644 --- a/simulator/build.gradle.kts +++ b/simulator/build.gradle.kts @@ -17,9 +17,9 @@ dependencies { // Spring Boot implementation("org.springframework.boot:spring-boot-starter") - implementation("org.springframework.boot:spring-boot-starter-web") // REST API + implementation("org.springframework.boot:spring-boot-starter-web") // REST API implementation("org.springframework.boot:spring-boot-starter-actuator") - implementation("org.springframework.boot:spring-boot-starter-validation") // Bean Validation + implementation("org.springframework.boot:spring-boot-starter-validation") // Bean Validation implementation("org.springframework.kafka:spring-kafka") // Kotlin @@ -32,7 +32,7 @@ dependencies { // Logging implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") - implementation("net.logstash.logback:logstash-logback-encoder:8.0") // Phase 5: JSON 로깅 + implementation("net.logstash.logback:logstash-logback-encoder:8.0") // Phase 5: JSON 로깅 // Micrometer for metrics implementation("io.micrometer:micrometer-registry-prometheus") diff --git a/simulator/src/main/kotlin/com/rep/simulator/SimulatorApplication.kt b/simulator/src/main/kotlin/com/rep/simulator/SimulatorApplication.kt index 76ead92..f398c55 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/SimulatorApplication.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/SimulatorApplication.kt @@ -21,27 +21,27 @@ private val log = KotlinLogging.logger {} @EnableScheduling @EnableConfigurationProperties(SimulatorProperties::class, LoadTestProperties::class, TracingProperties::class) class SimulatorApplication { - @Bean fun run( simulator: TrafficSimulator, - properties: SimulatorProperties - ): CommandLineRunner = CommandLineRunner { - if (properties.enabled) { - log.info { "=".repeat(60) } - log.info { "REP-Engine Traffic Simulator Starting..." } - log.info { "=".repeat(60) } - log.info { "Configuration:" } - log.info { " - User Count: ${properties.userCount}" } - log.info { " - Delay (ms): ${properties.delayMillis}" } - log.info { " - Topic: ${properties.topic}" } - log.info { "=".repeat(60) } + properties: SimulatorProperties, + ): CommandLineRunner = + CommandLineRunner { + if (properties.enabled) { + log.info { "=".repeat(60) } + log.info { "REP-Engine Traffic Simulator Starting..." } + log.info { "=".repeat(60) } + log.info { "Configuration:" } + log.info { " - User Count: ${properties.userCount}" } + log.info { " - Delay (ms): ${properties.delayMillis}" } + log.info { " - Topic: ${properties.topic}" } + log.info { "=".repeat(60) } - simulator.startSimulation() - } else { - log.info { "Simulator is disabled. Set SIMULATOR_ENABLED=true to enable." } + simulator.startSimulation() + } else { + log.info { "Simulator is disabled. Set SIMULATOR_ENABLED=true to enable." } + } } - } } /** @@ -51,7 +51,7 @@ class SimulatorApplication { */ @Component class SimulatorLifecycleManager( - private val simulator: TrafficSimulator + private val simulator: TrafficSimulator, ) : ApplicationListener { override fun onApplicationEvent(event: ContextClosedEvent) { log.info { "Context closing, stopping simulator..." } diff --git a/simulator/src/main/kotlin/com/rep/simulator/config/ElasticsearchConfig.kt b/simulator/src/main/kotlin/com/rep/simulator/config/ElasticsearchConfig.kt index 83e04db..0e51ddd 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/config/ElasticsearchConfig.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/config/ElasticsearchConfig.kt @@ -15,7 +15,6 @@ private val log = KotlinLogging.logger {} @Configuration class ElasticsearchConfig { - @Value("\${elasticsearch.host:localhost}") private lateinit var host: String @@ -29,11 +28,12 @@ class ElasticsearchConfig { private var transport: RestClientTransport? = null @Bean - fun restClient(): RestClient { - return RestClient.builder( - HttpHost(host, port, scheme) - ).build().also { restClient = it } - } + fun restClient(): RestClient = + RestClient + .builder( + HttpHost(host, port, scheme), + ).build() + .also { restClient = it } @Bean fun elasticsearchClient(restClient: RestClient): ElasticsearchClient { diff --git a/simulator/src/main/kotlin/com/rep/simulator/config/KafkaProducerConfig.kt b/simulator/src/main/kotlin/com/rep/simulator/config/KafkaProducerConfig.kt index 280002f..00f2b35 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/config/KafkaProducerConfig.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/config/KafkaProducerConfig.kt @@ -21,9 +21,8 @@ import org.springframework.kafka.core.ProducerFactory */ @Configuration class KafkaProducerConfig( - private val kafkaProperties: KafkaProperties + private val kafkaProperties: KafkaProperties, ) { - private fun producerConfigs(): Map { val configProps = mutableMapOf() @@ -43,7 +42,9 @@ class KafkaProducerConfig( "enable.idempotence" -> configProps[ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG] = value.toBoolean() "linger.ms" -> configProps[ProducerConfig.LINGER_MS_CONFIG] = value.toInt() "batch.size" -> configProps[ProducerConfig.BATCH_SIZE_CONFIG] = value.toInt() - "max.in.flight.requests.per.connection" -> configProps[ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION] = value.toInt() + "max.in.flight.requests.per.connection" -> + configProps[ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION] = + value.toInt() "schema.registry.url" -> configProps[KafkaAvroSerializerConfig.SCHEMA_REGISTRY_URL_CONFIG] = value } } @@ -52,26 +53,21 @@ class KafkaProducerConfig( } @Bean - fun producerFactory(): ProducerFactory { - return DefaultKafkaProducerFactory(producerConfigs()) - } + fun producerFactory(): ProducerFactory = DefaultKafkaProducerFactory(producerConfigs()) @Bean - fun kafkaTemplate(): KafkaTemplate { - return KafkaTemplate(producerFactory()).apply { + fun kafkaTemplate(): KafkaTemplate = + KafkaTemplate(producerFactory()).apply { setObservationEnabled(true) } - } @Bean - fun inventoryProducerFactory(): ProducerFactory { - return DefaultKafkaProducerFactory(producerConfigs()) - } + fun inventoryProducerFactory(): ProducerFactory = + DefaultKafkaProducerFactory(producerConfigs()) @Bean - fun inventoryKafkaTemplate(): KafkaTemplate { - return KafkaTemplate(inventoryProducerFactory()).apply { + fun inventoryKafkaTemplate(): KafkaTemplate = + KafkaTemplate(inventoryProducerFactory()).apply { setObservationEnabled(true) } - } } diff --git a/simulator/src/main/kotlin/com/rep/simulator/config/SchemaRegistryHealthIndicator.kt b/simulator/src/main/kotlin/com/rep/simulator/config/SchemaRegistryHealthIndicator.kt index 98eef1d..153c6c6 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/config/SchemaRegistryHealthIndicator.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/config/SchemaRegistryHealthIndicator.kt @@ -22,46 +22,51 @@ private val log = KotlinLogging.logger {} * * simulator 모듈은 WebFlux 의존성이 없으므로 Java HttpClient를 사용합니다. * - * @see docs/phase%205.md + * @see docs/phase 5.md */ @Component @ConditionalOnProperty(name = ["spring.kafka.producer.properties.schema.registry.url"]) class SchemaRegistryHealthIndicator( @Value("\${spring.kafka.producer.properties.schema.registry.url}") - private val schemaRegistryUrl: String + private val schemaRegistryUrl: String, ) : HealthIndicator { + private val httpClient = + HttpClient + .newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build() - private val httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(5)) - .build() - - override fun health(): Health { - return try { - val request = HttpRequest.newBuilder() - .uri(URI.create("$schemaRegistryUrl/subjects")) - .timeout(Duration.ofSeconds(5)) - .GET() - .build() + override fun health(): Health = + try { + val request = + HttpRequest + .newBuilder() + .uri(URI.create("$schemaRegistryUrl/subjects")) + .timeout(Duration.ofSeconds(5)) + .GET() + .build() val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) if (response.statusCode() == 200) { - Health.up() + Health + .up() .withDetail("url", schemaRegistryUrl) .withDetail("status", "connected") .build() } else { - Health.down() + Health + .down() .withDetail("url", schemaRegistryUrl) .withDetail("status", "HTTP ${response.statusCode()}") .build() } } catch (e: Exception) { log.warn { "Schema Registry health check failed: ${e.message}" } - Health.down() + Health + .down() .withDetail("url", schemaRegistryUrl) .withDetail("error", e.message ?: "unknown error") .build() } - } } diff --git a/simulator/src/main/kotlin/com/rep/simulator/config/SimulatorProperties.kt b/simulator/src/main/kotlin/com/rep/simulator/config/SimulatorProperties.kt index df805d4..a53f5f9 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/config/SimulatorProperties.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/config/SimulatorProperties.kt @@ -10,23 +10,16 @@ import org.springframework.validation.annotation.Validated data class SimulatorProperties( @field:Positive(message = "userCount must be positive") val userCount: Int = 100, - @field:Positive(message = "delayMillis must be positive") val delayMillis: Long = 1000, - @field:NotBlank(message = "topic must not be blank") val topic: String = "user.action.v1", - val enabled: Boolean = true, - @field:Positive(message = "productCountPerCategory must be positive") val productCountPerCategory: Int = 100, - @field:NotBlank(message = "inventoryTopic must not be blank") val inventoryTopic: String = "product.inventory.v1", - @field:Positive(message = "inventoryIntervalMs must be positive") val inventoryIntervalMs: Long = 5000, - - val inventoryEnabled: Boolean = true + val inventoryEnabled: Boolean = true, ) diff --git a/simulator/src/main/kotlin/com/rep/simulator/config/WebConfig.kt b/simulator/src/main/kotlin/com/rep/simulator/config/WebConfig.kt index cdffabf..060762a 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/config/WebConfig.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/config/WebConfig.kt @@ -11,16 +11,15 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer */ @Configuration class WebConfig : WebMvcConfigurer { - override fun addCorsMappings(registry: CorsRegistry) { - registry.addMapping("/api/**") + registry + .addMapping("/api/**") .allowedOrigins( - "http://localhost:5173", // Vite 기본 포트 - "http://localhost:3001", // 대체 포트 - "http://localhost:3000", // 대체 포트 - "http://frontend:80" // Docker - ) - .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") + "http://localhost:5173", // Vite 기본 포트 + "http://localhost:3001", // 대체 포트 + "http://localhost:3000", // 대체 포트 + "http://frontend:80", // Docker + ).allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600) diff --git a/simulator/src/main/kotlin/com/rep/simulator/controller/SimulatorController.kt b/simulator/src/main/kotlin/com/rep/simulator/controller/SimulatorController.kt index f73bc37..b868cd5 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/controller/SimulatorController.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/controller/SimulatorController.kt @@ -2,8 +2,14 @@ package com.rep.simulator.controller import com.rep.simulator.service.InventorySimulator import com.rep.simulator.service.TrafficSimulator +import jakarta.validation.constraints.Min import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.* +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController /** * 시뮬레이터 제어 REST API @@ -11,27 +17,25 @@ import org.springframework.web.bind.annotation.* * 프론트엔드에서 시뮬레이터를 시작/정지하고 상태를 조회할 수 있습니다. */ @RestController +@Validated @RequestMapping("/api/v1/simulator") class SimulatorController( private val trafficSimulator: TrafficSimulator, - private val inventorySimulator: InventorySimulator + private val inventorySimulator: InventorySimulator, ) { - /** * 시뮬레이터 상태 조회 */ @GetMapping("/status") - fun getStatus(): ResponseEntity { - return ResponseEntity.ok(trafficSimulator.getStatus()) - } + fun getStatus(): ResponseEntity = ResponseEntity.ok(trafficSimulator.getStatus()) /** * 시뮬레이터 시작 */ @PostMapping("/start") fun start( - @RequestParam(defaultValue = "100") userCount: Int, - @RequestParam(defaultValue = "1000") delayMillis: Long + @RequestParam(defaultValue = "100") @Min(1) userCount: Int, + @RequestParam(defaultValue = "1000") @Min(1) delayMillis: Long, ): ResponseEntity { trafficSimulator.startSimulation(userCount, delayMillis) return ResponseEntity.ok(trafficSimulator.getStatus()) @@ -49,9 +53,8 @@ class SimulatorController( // === Inventory Simulator === @GetMapping("/inventory/status") - fun getInventoryStatus(): ResponseEntity { - return ResponseEntity.ok(inventorySimulator.getStatus()) - } + fun getInventoryStatus(): ResponseEntity = + ResponseEntity.ok(inventorySimulator.getStatus()) @PostMapping("/inventory/start") fun startInventory(): ResponseEntity { diff --git a/simulator/src/main/kotlin/com/rep/simulator/domain/ProductCatalog.kt b/simulator/src/main/kotlin/com/rep/simulator/domain/ProductCatalog.kt index 0e00b59..2562174 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/domain/ProductCatalog.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/domain/ProductCatalog.kt @@ -13,29 +13,38 @@ import kotlin.random.Random * 상품별 현재 가격/재고를 메모리에서 추적하며, * 가격 변동 및 재입고 이벤트를 생성합니다. */ -class ProductCatalog(productCountPerCategory: Int) { - +class ProductCatalog( + productCountPerCategory: Int, +) { companion object { - private val CATEGORIES = listOf( - "ELECTRONICS", "FASHION", "FOOD", "BEAUTY", "SPORTS", "HOME", "BOOKS" - ) + private val CATEGORIES = + listOf( + "ELECTRONICS", + "FASHION", + "FOOD", + "BEAUTY", + "SPORTS", + "HOME", + "BOOKS", + ) - private val BASE_PRICES = mapOf( - "ELECTRONICS" to 100_000..1_500_000, - "FASHION" to 20_000..300_000, - "FOOD" to 3_000..50_000, - "BEAUTY" to 10_000..150_000, - "SPORTS" to 30_000..500_000, - "HOME" to 15_000..400_000, - "BOOKS" to 10_000..40_000 - ) + private val BASE_PRICES = + mapOf( + "ELECTRONICS" to 100_000..1_500_000, + "FASHION" to 20_000..300_000, + "FOOD" to 3_000..50_000, + "BEAUTY" to 10_000..150_000, + "SPORTS" to 30_000..500_000, + "HOME" to 15_000..400_000, + "BOOKS" to 10_000..40_000, + ) } data class ProductState( val productId: String, val category: String, var price: Float, - var stock: Int + var stock: Int, ) private val products = ConcurrentHashMap() @@ -45,12 +54,13 @@ class ProductCatalog(productCountPerCategory: Int) { val priceRange = BASE_PRICES[category] ?: 10_000..100_000 for (seq in 1..productCountPerCategory) { val productId = "PROD-${category.take(3)}-${seq.toString().padStart(5, '0')}" - products[productId] = ProductState( - productId = productId, - category = category, - price = Random.nextInt(priceRange.first, priceRange.last + 1).toFloat(), - stock = Random.nextInt(0, 200) - ) + products[productId] = + ProductState( + productId = productId, + category = category, + price = Random.nextInt(priceRange.first, priceRange.last + 1).toFloat(), + stock = Random.nextInt(0, 200), + ) } } } @@ -66,7 +76,8 @@ class ProductCatalog(productCountPerCategory: Int) { product.price = newPrice - return ProductInventoryEvent.newBuilder() + return ProductInventoryEvent + .newBuilder() .setEventId(UUID.randomUUID().toString()) .setProductId(product.productId) .setEventType(InventoryEventType.PRICE_CHANGE) @@ -85,17 +96,19 @@ class ProductCatalog(productCountPerCategory: Int) { fun generateRestock(): ProductInventoryEvent { // 재고 0인 상품 찾기 val outOfStock = products.values.filter { it.stock == 0 } - val product = if (outOfStock.isNotEmpty()) { - outOfStock.random() - } else { - // 재고 0인 상품이 없으면 랜덤 상품의 재고를 0으로 만든 후 재입고 - products.values.random().also { it.stock = 0 } - } + val product = + if (outOfStock.isNotEmpty()) { + outOfStock.random() + } else { + // 재고 0인 상품이 없으면 랜덤 상품의 재고를 0으로 만든 후 재입고 + products.values.random().also { it.stock = 0 } + } val newStock = Random.nextInt(50, 201) product.stock = newStock - return ProductInventoryEvent.newBuilder() + return ProductInventoryEvent + .newBuilder() .setEventId(UUID.randomUUID().toString()) .setProductId(product.productId) .setEventType(InventoryEventType.STOCK_CHANGE) diff --git a/simulator/src/main/kotlin/com/rep/simulator/domain/UserSession.kt b/simulator/src/main/kotlin/com/rep/simulator/domain/UserSession.kt index 0d9c9de..434ff21 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/domain/UserSession.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/domain/UserSession.kt @@ -17,28 +17,29 @@ import kotlin.random.Random */ class UserSession( val userId: String, - private val productCountPerCategory: Int = 100 + private val productCountPerCategory: Int = 100, ) { - companion object { - private val CATEGORIES = listOf( - "ELECTRONICS", - "FASHION", - "FOOD", - "BEAUTY", - "SPORTS", - "HOME", - "BOOKS" - ) - - private val ACTION_WEIGHTS = mapOf( - ActionType.VIEW to 45, // 45% - 가장 빈번 - ActionType.CLICK to 25, // 25% - 관심 표현 - ActionType.SEARCH to 10, // 10% - 검색 - ActionType.ADD_TO_CART to 8, // 8% - 장바구니 - ActionType.PURCHASE to 5, // 5% - 구매 (가장 드묾) - ActionType.WISHLIST to 7 // 7% - 위시리스트 - ) + private val CATEGORIES = + listOf( + "ELECTRONICS", + "FASHION", + "FOOD", + "BEAUTY", + "SPORTS", + "HOME", + "BOOKS", + ) + + private val ACTION_WEIGHTS = + mapOf( + ActionType.VIEW to 45, // 45% - 가장 빈번 + ActionType.CLICK to 25, // 25% - 관심 표현 + ActionType.SEARCH to 10, // 10% - 검색 + ActionType.ADD_TO_CART to 8, // 8% - 장바구니 + ActionType.PURCHASE to 5, // 5% - 구매 (가장 드묾) + ActionType.WISHLIST to 7, // 7% - 위시리스트 + ) private val TOTAL_WEIGHT = ACTION_WEIGHTS.values.sum() } @@ -47,10 +48,11 @@ class UserSession( private val preferredCategory: String = CATEGORIES.random() // 유저별 선호 가격대 (10,000 ~ 500,000) - private val preferredPriceRange: IntRange = run { - val base = Random.nextInt(1, 50) * 10000 - base..(base + Random.nextInt(5, 20) * 10000) - } + private val preferredPriceRange: IntRange = + run { + val base = Random.nextInt(1, 50) * 10000 + base..(base + Random.nextInt(5, 20) * 10000) + } // 최근 본 상품 ID 목록 (연속성 시뮬레이션) private val recentProducts = mutableListOf() @@ -71,7 +73,8 @@ class UserSession( } } - return UserActionEvent.newBuilder() + return UserActionEvent + .newBuilder() .setTraceId(UUID.randomUUID().toString()) .setUserId(userId) .setProductId(productId) @@ -85,13 +88,12 @@ class UserSession( /** * 70% 확률로 선호 카테고리 선택 */ - private fun selectCategory(): String { - return if (Random.nextDouble() < 0.7) { + private fun selectCategory(): String = + if (Random.nextDouble() < 0.7) { preferredCategory } else { CATEGORIES.filter { it != preferredCategory }.random() } - } /** * 상품 ID 선택 @@ -101,14 +103,13 @@ class UserSession( * Note: seed_products.py에서 생성한 상품 ID 형식과 일치해야 함 * 형식: PROD-{category[:3]}-{00001~productCountPerCategory} */ - private fun selectProduct(category: String): String { - return if (recentProducts.isNotEmpty() && Random.nextDouble() < 0.3) { + private fun selectProduct(category: String): String = + if (recentProducts.isNotEmpty() && Random.nextDouble() < 0.3) { recentProducts.random() } else { val productNum = Random.nextInt(1, productCountPerCategory + 1) "PROD-${category.take(3)}-${productNum.toString().padStart(5, '0')}" } - } /** * 가중치 기반 행동 타입 선택 @@ -129,46 +130,52 @@ class UserSession( /** * 행동 타입별 메타데이터 생성 */ - private fun buildMetadata(actionType: ActionType): Map? { - return when (actionType) { - ActionType.SEARCH -> mapOf( - "searchQuery" to generateSearchQuery(), - "resultCount" to Random.nextInt(10, 100).toString() - ) - ActionType.VIEW -> mapOf( - "referrer" to listOf("home", "search", "recommendation", "category").random(), - "viewDurationMs" to Random.nextInt(1000, 30000).toString() - ) - ActionType.CLICK -> mapOf( - "position" to Random.nextInt(1, 20).toString() - ) - ActionType.PURCHASE -> mapOf( - "quantity" to Random.nextInt(1, 3).toString(), - "price" to Random.nextInt(preferredPriceRange.first, preferredPriceRange.last).toString() - ) - ActionType.ADD_TO_CART -> mapOf( - "quantity" to Random.nextInt(1, 5).toString() - ) - ActionType.WISHLIST -> mapOf( - "source" to listOf("product_detail", "recommendation", "search_result").random() - ) + private fun buildMetadata(actionType: ActionType): Map? = + when (actionType) { + ActionType.SEARCH -> + mapOf( + "searchQuery" to generateSearchQuery(), + "resultCount" to Random.nextInt(10, 100).toString(), + ) + ActionType.VIEW -> + mapOf( + "referrer" to listOf("home", "search", "recommendation", "category").random(), + "viewDurationMs" to Random.nextInt(1000, 30000).toString(), + ) + ActionType.CLICK -> + mapOf( + "position" to Random.nextInt(1, 20).toString(), + ) + ActionType.PURCHASE -> + mapOf( + "quantity" to Random.nextInt(1, 3).toString(), + "price" to Random.nextInt(preferredPriceRange.first, preferredPriceRange.last).toString(), + ) + ActionType.ADD_TO_CART -> + mapOf( + "quantity" to Random.nextInt(1, 5).toString(), + ) + ActionType.WISHLIST -> + mapOf( + "source" to listOf("product_detail", "recommendation", "search_result").random(), + ) } - } /** * 검색어 생성 (실제 서비스를 시뮬레이션) */ private fun generateSearchQuery(): String { - val searchTerms = when (preferredCategory) { - "ELECTRONICS" -> listOf("스마트폰", "노트북", "태블릿", "이어폰", "충전기", "갤럭시", "아이폰") - "FASHION" -> listOf("운동화", "청바지", "티셔츠", "원피스", "자켓", "코트", "스니커즈") - "FOOD" -> listOf("과자", "라면", "커피", "음료", "과일", "고기", "샐러드") - "BEAUTY" -> listOf("로션", "선크림", "립스틱", "파운데이션", "마스크팩", "샴푸") - "SPORTS" -> listOf("운동화", "요가매트", "덤벨", "러닝화", "스포츠웨어", "자전거") - "HOME" -> listOf("쿠션", "이불", "조명", "수납함", "커튼", "러그") - "BOOKS" -> listOf("소설", "자기계발", "경제", "역사", "과학", "에세이") - else -> listOf("인기상품", "추천", "신상품", "할인") - } + val searchTerms = + when (preferredCategory) { + "ELECTRONICS" -> listOf("스마트폰", "노트북", "태블릿", "이어폰", "충전기", "갤럭시", "아이폰") + "FASHION" -> listOf("운동화", "청바지", "티셔츠", "원피스", "자켓", "코트", "스니커즈") + "FOOD" -> listOf("과자", "라면", "커피", "음료", "과일", "고기", "샐러드") + "BEAUTY" -> listOf("로션", "선크림", "립스틱", "파운데이션", "마스크팩", "샴푸") + "SPORTS" -> listOf("운동화", "요가매트", "덤벨", "러닝화", "스포츠웨어", "자전거") + "HOME" -> listOf("쿠션", "이불", "조명", "수납함", "커튼", "러그") + "BOOKS" -> listOf("소설", "자기계발", "경제", "역사", "과학", "에세이") + else -> listOf("인기상품", "추천", "신상품", "할인") + } return searchTerms.random() } } diff --git a/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestController.kt b/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestController.kt index 5fec890..b8cf96b 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestController.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestController.kt @@ -1,7 +1,14 @@ package com.rep.simulator.loadtest import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.* +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController /** * 부하 테스트 REST API @@ -10,58 +17,55 @@ import org.springframework.web.bind.annotation.* @RequestMapping("/api/v1/load-test") class LoadTestController( private val loadTestService: LoadTestService, - private val resultStore: LoadTestResultStore + private val resultStore: LoadTestResultStore, ) { - @PostMapping("/start") - fun startTest(@RequestBody request: LoadTestStartRequest): ResponseEntity { - return try { + fun startTest( + @RequestBody request: LoadTestStartRequest, + ): ResponseEntity = + try { ResponseEntity.ok(loadTestService.startTest(request)) } catch (e: IllegalStateException) { ResponseEntity.badRequest().build() } - } @GetMapping("/status") - fun getStatus(): ResponseEntity { - return ResponseEntity.ok(loadTestService.getStatus()) - } + fun getStatus(): ResponseEntity = ResponseEntity.ok(loadTestService.getStatus()) @PostMapping("/stop") - fun stopTest(): ResponseEntity { - return ResponseEntity.ok(loadTestService.stopTest()) - } + fun stopTest(): ResponseEntity = ResponseEntity.ok(loadTestService.stopTest()) @GetMapping("/results") - fun getResults(): ResponseEntity> { - return ResponseEntity.ok(resultStore.findAll()) - } + fun getResults(): ResponseEntity> = ResponseEntity.ok(resultStore.findAll()) @GetMapping("/results/{id}") - fun getResult(@PathVariable id: String): ResponseEntity { - val result = resultStore.findById(id) - ?: return ResponseEntity.notFound().build() + fun getResult( + @PathVariable id: String, + ): ResponseEntity { + val result = + resultStore.findById(id) + ?: return ResponseEntity.notFound().build() return ResponseEntity.ok(result) } @DeleteMapping("/results/{id}") - fun deleteResult(@PathVariable id: String): ResponseEntity { - return if (resultStore.delete(id)) { + fun deleteResult( + @PathVariable id: String, + ): ResponseEntity = + if (resultStore.delete(id)) { ResponseEntity.noContent().build() } else { ResponseEntity.notFound().build() } - } @PatchMapping("/results/{id}/note") fun updateNote( @PathVariable id: String, - @RequestBody request: NoteUpdateRequest - ): ResponseEntity { - return if (resultStore.updateNote(id, request.note)) { + @RequestBody request: NoteUpdateRequest, + ): ResponseEntity = + if (resultStore.updateNote(id, request.note)) { ResponseEntity.noContent().build() } else { ResponseEntity.notFound().build() } - } } diff --git a/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestModels.kt b/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestModels.kt index 1f198a3..8b329ac 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestModels.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestModels.kt @@ -7,7 +7,7 @@ import java.time.Instant enum class LoadTestScenario { PIPELINE_STRESS, RECOMMENDATION_LOAD, - NOTIFICATION_LOAD + NOTIFICATION_LOAD, } enum class LoadTestPhase { @@ -15,14 +15,14 @@ enum class LoadTestPhase { RUNNING, STOPPING, COMPLETED, - FAILED + FAILED, } // === Request Models === data class LoadTestStartRequest( val scenario: LoadTestScenario, - val config: LoadTestConfig + val config: LoadTestConfig, ) data class LoadTestConfig( @@ -31,13 +31,13 @@ data class LoadTestConfig( val concurrentUsers: Int = 10, val durationSec: Int = 60, val requestIntervalMs: Long = 200, - val inventoryEnabled: Boolean = true + val inventoryEnabled: Boolean = true, ) data class StageConfig( val userCount: Int, val durationSec: Int, - val cooldownSec: Int = 5 + val cooldownSec: Int = 5, ) // === Status / Metrics === @@ -50,7 +50,7 @@ data class LoadTestStatus( val elapsedSec: Long = 0, val currentStage: Int = 0, val totalStages: Int = 0, - val metrics: LoadTestMetrics? = null + val metrics: LoadTestMetrics? = null, ) data class LoadTestMetrics( @@ -68,13 +68,13 @@ data class LoadTestMetrics( val redisMemoryUsedBytes: Double? = null, val totalRequestsSent: Long = 0, val totalErrors: Long = 0, - val avgLatencyMs: Double = 0.0 + val avgLatencyMs: Double = 0.0, ) data class TimestampedMetrics( val timestamp: Instant, val elapsedSec: Long, - val metrics: LoadTestMetrics + val metrics: LoadTestMetrics, ) // === Result Models === @@ -88,7 +88,7 @@ data class LoadTestResult( val durationSec: Long, val finalMetrics: LoadTestMetrics, val metricsTimeSeries: List, - val note: String = "" + val note: String = "", ) data class LoadTestResultSummary( @@ -102,9 +102,9 @@ data class LoadTestResultSummary( val kafkaConsumerLag: Double?, val totalErrors: Long, val totalRequestsSent: Long, - val avgLatencyMs: Double + val avgLatencyMs: Double, ) data class NoteUpdateRequest( - val note: String + val note: String, ) diff --git a/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestProperties.kt b/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestProperties.kt index 0a8b705..b0672cf 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestProperties.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestProperties.kt @@ -7,5 +7,5 @@ data class LoadTestProperties( val prometheusUrl: String = "http://localhost:9090", val recommendationApiUrl: String = "http://localhost:8080", val resultsDir: String = "./load-test-results", - val metricsCollectIntervalMs: Long = 3000 + val metricsCollectIntervalMs: Long = 3000, ) diff --git a/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestResultStore.kt b/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestResultStore.kt index dcf0217..d2dd9d5 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestResultStore.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestResultStore.kt @@ -19,14 +19,15 @@ private val log = KotlinLogging.logger {} */ @Component class LoadTestResultStore( - private val properties: LoadTestProperties + private val properties: LoadTestProperties, ) { - private val objectMapper = ObjectMapper().apply { - registerModule(JavaTimeModule()) - registerModule(KotlinModule.Builder().build()) - disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - enable(SerializationFeature.INDENT_OUTPUT) - } + private val objectMapper = + ObjectMapper().apply { + registerModule(JavaTimeModule()) + registerModule(KotlinModule.Builder().build()) + disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + enable(SerializationFeature.INDENT_OUTPUT) + } private val resultsPath: Path get() = Paths.get(properties.resultsDir) @@ -46,7 +47,8 @@ class LoadTestResultStore( fun findAll(): List { if (!Files.exists(resultsPath)) return emptyList() - return Files.list(resultsPath) + return Files + .list(resultsPath) .filter { it.toString().endsWith(".json") } .map { path -> try { @@ -56,8 +58,7 @@ class LoadTestResultStore( log.warn { "Failed to read result file ${path.fileName}: ${e.message}" } null } - } - .filter { it != null } + }.filter { it != null } .map { it!! } .sorted(Comparator.comparing(LoadTestResultSummary::startedAt).reversed()) .toList() @@ -84,24 +85,28 @@ class LoadTestResultStore( } } - fun updateNote(id: String, note: String): Boolean { + fun updateNote( + id: String, + note: String, + ): Boolean { val result = findById(id) ?: return false val updated = result.copy(note = note) save(updated) return true } - private fun LoadTestResult.toSummary() = LoadTestResultSummary( - id = id, - scenario = scenario, - startedAt = startedAt, - durationSec = durationSec, - note = note, - recApiP95Ms = finalMetrics.recApiP95Ms, - recApiP99Ms = finalMetrics.recApiP99Ms, - kafkaConsumerLag = finalMetrics.kafkaConsumerLag, - totalErrors = finalMetrics.totalErrors, - totalRequestsSent = finalMetrics.totalRequestsSent, - avgLatencyMs = finalMetrics.avgLatencyMs - ) + private fun LoadTestResult.toSummary() = + LoadTestResultSummary( + id = id, + scenario = scenario, + startedAt = startedAt, + durationSec = durationSec, + note = note, + recApiP95Ms = finalMetrics.recApiP95Ms, + recApiP99Ms = finalMetrics.recApiP99Ms, + kafkaConsumerLag = finalMetrics.kafkaConsumerLag, + totalErrors = finalMetrics.totalErrors, + totalRequestsSent = finalMetrics.totalRequestsSent, + avgLatencyMs = finalMetrics.avgLatencyMs, + ) } diff --git a/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestService.kt b/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestService.kt index 46eb474..499316a 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestService.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/loadtest/LoadTestService.kt @@ -2,7 +2,14 @@ package com.rep.simulator.loadtest import com.rep.simulator.service.InventorySimulator import com.rep.simulator.service.TrafficSimulator -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import mu.KotlinLogging import org.springframework.stereotype.Service import java.time.Instant @@ -25,19 +32,26 @@ class LoadTestService( private val recLoadGenerator: RecommendationLoadGenerator, private val metricsCollector: MetricsCollector, private val resultStore: LoadTestResultStore, - private val properties: LoadTestProperties + private val properties: LoadTestProperties, ) { private val virtualThreadDispatcher = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher() private val scope = CoroutineScope(virtualThreadDispatcher + SupervisorJob()) private val lock = ReentrantLock() @Volatile private var currentTestId: String? = null + @Volatile private var currentScenario: LoadTestScenario? = null + @Volatile private var currentConfig: LoadTestConfig? = null + @Volatile private var currentPhase: LoadTestPhase = LoadTestPhase.NOT_STARTED + @Volatile private var startedAt: Instant? = null + @Volatile private var currentStage: Int = 0 + @Volatile private var totalStages: Int = 0 + @Volatile private var latestMetrics: LoadTestMetrics? = null private val metricsTimeSeries = mutableListOf() @@ -46,63 +60,13 @@ class LoadTestService( fun startTest(request: LoadTestStartRequest): LoadTestStatus { lock.withLock { - if (currentPhase == LoadTestPhase.RUNNING || currentPhase == LoadTestPhase.STOPPING) { - throw IllegalStateException("A load test is already running (phase=$currentPhase)") - } - - val testId = "lt-${System.currentTimeMillis()}" - currentTestId = testId - currentScenario = request.scenario - currentConfig = request.config - currentPhase = LoadTestPhase.RUNNING - startedAt = Instant.now() - currentStage = 0 - totalStages = 0 - latestMetrics = null - metricsTimeSeries.clear() + ensureNoRunningTest() + val testId = initializeTestState(request) log.info { "Starting load test $testId: scenario=${request.scenario}" } - // Start metrics collection loop - metricsJob = scope.launch { - while (isActive) { - try { - val prometheusMetrics = metricsCollector.collect() - val recStats = recLoadGenerator.getStats() - val combined = prometheusMetrics.copy( - totalRequestsSent = recStats.totalRequests, - totalErrors = recStats.totalErrors, - avgLatencyMs = recStats.avgLatencyMs - ) - latestMetrics = combined - val elapsed = java.time.Duration.between(startedAt, Instant.now()).seconds - metricsTimeSeries.add(TimestampedMetrics(Instant.now(), elapsed, combined)) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log.warn { "Metrics collection failed: ${e.message}" } - } - delay(properties.metricsCollectIntervalMs) - } - } - - // Start scenario orchestrator - orchestratorJob = scope.launch { - try { - when (request.scenario) { - LoadTestScenario.PIPELINE_STRESS -> runPipelineStress(request.config) - LoadTestScenario.RECOMMENDATION_LOAD -> runRecommendationLoad(request.config) - LoadTestScenario.NOTIFICATION_LOAD -> runNotificationLoad(request.config) - } - completeTest() - } catch (e: CancellationException) { - log.info { "Load test $testId was cancelled" } - } catch (e: Exception) { - log.error(e) { "Load test $testId failed" } - currentPhase = LoadTestPhase.FAILED - saveResult() - } - } + metricsJob = launchMetricsCollection() + orchestratorJob = launchScenarioOrchestrator(testId, request) return getStatus() } @@ -117,18 +81,8 @@ class LoadTestService( log.info { "Stopping load test $currentTestId" } currentPhase = LoadTestPhase.STOPPING - // Stop all generators - trafficSimulator.stopSimulation() - inventorySimulator.stopSimulation() - recLoadGenerator.stop() - - // Cancel orchestrator - orchestratorJob?.cancel() - orchestratorJob = null - - // Cancel metrics collection - metricsJob?.cancel() - metricsJob = null + stopGenerators() + cancelRunningJobs() saveResult() currentPhase = LoadTestPhase.COMPLETED @@ -138,11 +92,14 @@ class LoadTestService( } fun getStatus(): LoadTestStatus { - val elapsed = if (startedAt != null && currentPhase == LoadTestPhase.RUNNING) { - java.time.Duration.between(startedAt, Instant.now()).seconds - } else { - 0L - } + val elapsed = + if (startedAt != null && currentPhase == LoadTestPhase.RUNNING) { + java.time.Duration + .between(startedAt, Instant.now()) + .seconds + } else { + 0L + } return LoadTestStatus( id = currentTestId, @@ -152,25 +109,28 @@ class LoadTestService( elapsedSec = elapsed, currentStage = currentStage, totalStages = totalStages, - metrics = latestMetrics + metrics = latestMetrics, ) } // === Scenario Orchestrators === private suspend fun runPipelineStress(config: LoadTestConfig) { - val stages = config.stages ?: listOf( - StageConfig(100, 30, 5), - StageConfig(300, 30, 5), - StageConfig(500, 30, 5), - StageConfig(800, 30, 10), - StageConfig(1000, 60, 15) - ) + val stages = + config.stages ?: listOf( + StageConfig(100, 30, 5), + StageConfig(300, 30, 5), + StageConfig(500, 30, 5), + StageConfig(800, 30, 10), + StageConfig(1000, 60, 15), + ) totalStages = stages.size for ((index, stage) in stages.withIndex()) { currentStage = index + 1 - log.info { "Pipeline stress stage ${currentStage}/${totalStages}: ${stage.userCount} users for ${stage.durationSec}s" } + log.info { + "Pipeline stress stage $currentStage/$totalStages: ${stage.userCount} users for ${stage.durationSec}s" + } trafficSimulator.startSimulation(stage.userCount, config.delayMillis) @@ -188,7 +148,9 @@ class LoadTestService( private suspend fun runRecommendationLoad(config: LoadTestConfig) { totalStages = 1 currentStage = 1 - log.info { "Recommendation load: ${config.concurrentUsers} users, interval=${config.requestIntervalMs}ms, duration=${config.durationSec}s" } + log.info { + "Recommendation load: ${config.concurrentUsers} users, interval=${config.requestIntervalMs}ms, duration=${config.durationSec}s" + } recLoadGenerator.start(config.concurrentUsers, config.requestIntervalMs) @@ -231,6 +193,97 @@ class LoadTestService( // === Helpers === + private fun ensureNoRunningTest() { + if (currentPhase == LoadTestPhase.RUNNING || currentPhase == LoadTestPhase.STOPPING) { + throw IllegalStateException("A load test is already running (phase=$currentPhase)") + } + } + + private fun initializeTestState(request: LoadTestStartRequest): String { + val testId = "lt-${System.currentTimeMillis()}" + currentTestId = testId + currentScenario = request.scenario + currentConfig = request.config + currentPhase = LoadTestPhase.RUNNING + startedAt = Instant.now() + currentStage = 0 + totalStages = 0 + latestMetrics = null + metricsTimeSeries.clear() + return testId + } + + private fun launchMetricsCollection(): Job = + scope.launch { + while (isActive) { + try { + collectAndStoreMetrics() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.warn { "Metrics collection failed: ${e.message}" } + } + delay(properties.metricsCollectIntervalMs) + } + } + + private suspend fun collectAndStoreMetrics() { + val prometheusMetrics = metricsCollector.collect() + val recStats = recLoadGenerator.getStats() + val combined = + prometheusMetrics.copy( + totalRequestsSent = recStats.totalRequests, + totalErrors = recStats.totalErrors, + avgLatencyMs = recStats.avgLatencyMs, + ) + latestMetrics = combined + + val testStart = startedAt ?: return + val elapsed = + java.time.Duration + .between(testStart, Instant.now()) + .seconds + metricsTimeSeries.add(TimestampedMetrics(Instant.now(), elapsed, combined)) + } + + private fun launchScenarioOrchestrator( + testId: String, + request: LoadTestStartRequest, + ): Job = + scope.launch { + try { + runScenario(request) + completeTest() + } catch (e: CancellationException) { + log.info { "Load test $testId was cancelled" } + } catch (e: Exception) { + log.error(e) { "Load test $testId failed" } + currentPhase = LoadTestPhase.FAILED + saveResult() + } + } + + private suspend fun runScenario(request: LoadTestStartRequest) { + when (request.scenario) { + LoadTestScenario.PIPELINE_STRESS -> runPipelineStress(request.config) + LoadTestScenario.RECOMMENDATION_LOAD -> runRecommendationLoad(request.config) + LoadTestScenario.NOTIFICATION_LOAD -> runNotificationLoad(request.config) + } + } + + private fun stopGenerators() { + trafficSimulator.stopSimulation() + inventorySimulator.stopSimulation() + recLoadGenerator.stop() + } + + private fun cancelRunningJobs() { + orchestratorJob?.cancel() + orchestratorJob = null + metricsJob?.cancel() + metricsJob = null + } + private fun completeTest() { metricsJob?.cancel() metricsJob = null @@ -246,16 +299,20 @@ class LoadTestService( val start = startedAt ?: return val now = Instant.now() - val result = LoadTestResult( - id = testId, - scenario = scenario, - config = config, - startedAt = start, - completedAt = now, - durationSec = java.time.Duration.between(start, now).seconds, - finalMetrics = latestMetrics ?: LoadTestMetrics(), - metricsTimeSeries = metricsTimeSeries.toList() - ) + val result = + LoadTestResult( + id = testId, + scenario = scenario, + config = config, + startedAt = start, + completedAt = now, + durationSec = + java.time.Duration + .between(start, now) + .seconds, + finalMetrics = latestMetrics ?: LoadTestMetrics(), + metricsTimeSeries = metricsTimeSeries.toList(), + ) try { resultStore.save(result) diff --git a/simulator/src/main/kotlin/com/rep/simulator/loadtest/MetricsCollector.kt b/simulator/src/main/kotlin/com/rep/simulator/loadtest/MetricsCollector.kt index 34b6b2f..5b2d45b 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/loadtest/MetricsCollector.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/loadtest/MetricsCollector.kt @@ -13,58 +13,67 @@ private val log = KotlinLogging.logger {} */ @Component class MetricsCollector( - private val properties: LoadTestProperties + private val properties: LoadTestProperties, ) { private val restTemplate = RestTemplate() - suspend fun collect(): LoadTestMetrics = withContext(Dispatchers.IO) { - LoadTestMetrics( - kafkaConsumerLag = query("sum(kafka_consumergroup_lag)"), - kafkaProcessedRate = query("sum(rate(kafka_consumer_processed_total[30s]))"), - esBulkSuccessRate = query("sum(rate(es_bulk_success_total[30s]))"), - esBulkFailedTotal = query("sum(es_bulk_failed_total)"), - preferenceUpdateRate = query("sum(rate(preference_update_success_total[30s]))"), - recApiP50Ms = query( - """histogram_quantile(0.50, sum(rate(http_server_requests_seconds_bucket{application="recommendation-api"}[30s])) by (le)) * 1000""" - ), - recApiP95Ms = query( - """histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{application="recommendation-api"}[30s])) by (le)) * 1000""" - ), - recApiP99Ms = query( - """histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{application="recommendation-api"}[30s])) by (le)) * 1000""" - ), - notificationTriggered = query("sum(notification_triggered_total)"), - notificationRateLimited = query("sum(notification_rate_limited_total)"), - jvmHeapUsedBytes = query("""sum(jvm_memory_used_bytes{area="heap"})"""), - redisMemoryUsedBytes = query("redis_memory_used_bytes") - ) - } + suspend fun collect(): LoadTestMetrics = + withContext(Dispatchers.IO) { + LoadTestMetrics( + kafkaConsumerLag = query("sum(kafka_consumergroup_lag)"), + kafkaProcessedRate = query("sum(rate(kafka_consumer_processed_total[30s]))"), + esBulkSuccessRate = query("sum(rate(es_bulk_success_total[30s]))"), + esBulkFailedTotal = query("sum(es_bulk_failed_total)"), + preferenceUpdateRate = query("sum(rate(preference_update_success_total[30s]))"), + recApiP50Ms = + query( + """histogram_quantile(0.50, sum(rate(http_server_requests_seconds_bucket{application="recommendation-api"}[30s])) by (le)) * 1000""", + ), + recApiP95Ms = + query( + """histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{application="recommendation-api"}[30s])) by (le)) * 1000""", + ), + recApiP99Ms = + query( + """histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{application="recommendation-api"}[30s])) by (le)) * 1000""", + ), + notificationTriggered = query("sum(notification_triggered_total)"), + notificationRateLimited = query("sum(notification_rate_limited_total)"), + jvmHeapUsedBytes = query("""sum(jvm_memory_used_bytes{area="heap"})"""), + redisMemoryUsedBytes = query("redis_memory_used_bytes"), + ) + } - private fun query(promql: String): Double? { - return try { + private fun query(promql: String): Double? = + try { val url = "${properties.prometheusUrl}/api/v1/query?query={query}" val response = restTemplate.getForObject(url, PrometheusResponse::class.java, promql) - val value = response?.data?.result?.firstOrNull()?.value?.getOrNull(1) as? String + val value = + response + ?.data + ?.result + ?.firstOrNull() + ?.value + ?.getOrNull(1) as? String value?.toDoubleOrNull() } catch (e: Exception) { log.debug { "Prometheus query failed for [$promql]: ${e.message}" } null } - } } // Prometheus API response models data class PrometheusResponse( val status: String? = null, - val data: PrometheusData? = null + val data: PrometheusData? = null, ) data class PrometheusData( val resultType: String? = null, - val result: List? = null + val result: List? = null, ) data class PrometheusResult( val metric: Map? = null, - val value: List? = null + val value: List? = null, ) diff --git a/simulator/src/main/kotlin/com/rep/simulator/loadtest/RecommendationLoadGenerator.kt b/simulator/src/main/kotlin/com/rep/simulator/loadtest/RecommendationLoadGenerator.kt index a937c3a..44dc8a6 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/loadtest/RecommendationLoadGenerator.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/loadtest/RecommendationLoadGenerator.kt @@ -1,6 +1,16 @@ package com.rep.simulator.loadtest -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import mu.KotlinLogging import org.springframework.boot.web.client.RestTemplateBuilder import org.springframework.stereotype.Component @@ -18,7 +28,7 @@ private val log = KotlinLogging.logger {} */ @Component class RecommendationLoadGenerator( - private val properties: LoadTestProperties + private val properties: LoadTestProperties, ) { private val virtualThreadDispatcher = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher() private val scope = CoroutineScope(virtualThreadDispatcher + SupervisorJob()) @@ -29,12 +39,16 @@ class RecommendationLoadGenerator( private val totalErrors = AtomicLong(0) private val totalLatencyMs = AtomicLong(0) - private val restTemplate = RestTemplateBuilder() - .connectTimeout(Duration.ofSeconds(3)) - .readTimeout(Duration.ofSeconds(5)) - .build() + private val restTemplate = + RestTemplateBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .readTimeout(Duration.ofSeconds(5)) + .build() - fun start(concurrentUsers: Int, requestIntervalMs: Long) { + fun start( + concurrentUsers: Int, + requestIntervalMs: Long, + ) { if (isRunning.compareAndSet(false, true)) { totalRequests.set(0) totalErrors.set(0) @@ -42,20 +56,25 @@ class RecommendationLoadGenerator( log.info { "Starting recommendation load: $concurrentUsers users, interval=${requestIntervalMs}ms" } - loadJob = scope.launch { - (1..concurrentUsers).map { i -> - async { - val userId = "USER-${i.toString().padStart(6, '0')}" - runUserLoop(userId, requestIntervalMs) - } - }.awaitAll() - } + loadJob = + scope.launch { + (1..concurrentUsers) + .map { i -> + async { + val userId = "USER-${i.toString().padStart(6, '0')}" + runUserLoop(userId, requestIntervalMs) + } + }.awaitAll() + } } else { log.warn { "Recommendation load generator is already running" } } } - private suspend fun runUserLoop(userId: String, intervalMs: Long) { + private suspend fun runUserLoop( + userId: String, + intervalMs: Long, + ) { while (currentCoroutineContext().isActive) { try { val startTime = System.currentTimeMillis() @@ -87,15 +106,16 @@ class RecommendationLoadGenerator( fun getStats(): LoadGeneratorStats { val requests = totalRequests.get() - val avgLatency = if (requests - totalErrors.get() > 0) { - totalLatencyMs.get().toDouble() / (requests - totalErrors.get()) - } else { - 0.0 - } + val avgLatency = + if (requests - totalErrors.get() > 0) { + totalLatencyMs.get().toDouble() / (requests - totalErrors.get()) + } else { + 0.0 + } return LoadGeneratorStats( totalRequests = requests, totalErrors = totalErrors.get(), - avgLatencyMs = avgLatency + avgLatencyMs = avgLatency, ) } @@ -104,6 +124,6 @@ class RecommendationLoadGenerator( data class LoadGeneratorStats( val totalRequests: Long, val totalErrors: Long, - val avgLatencyMs: Double + val avgLatencyMs: Double, ) } diff --git a/simulator/src/main/kotlin/com/rep/simulator/service/InventorySimulator.kt b/simulator/src/main/kotlin/com/rep/simulator/service/InventorySimulator.kt index 5dcc5ba..7b19ea5 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/service/InventorySimulator.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/service/InventorySimulator.kt @@ -6,12 +6,22 @@ import com.rep.simulator.domain.ProductCatalog import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.MeterRegistry import jakarta.annotation.PreDestroy -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import mu.KotlinLogging import org.springframework.kafka.core.KafkaTemplate import org.springframework.stereotype.Service import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import kotlin.random.Random @@ -28,85 +38,103 @@ private val log = KotlinLogging.logger {} class InventorySimulator( private val inventoryKafkaTemplate: KafkaTemplate, private val properties: SimulatorProperties, - private val meterRegistry: MeterRegistry + private val meterRegistry: MeterRegistry, ) { private val virtualThreadDispatcher = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher() private val scope = CoroutineScope(virtualThreadDispatcher + SupervisorJob()) private var simulationJob: Job? = null private val isRunning = AtomicBoolean(false) + private val isShuttingDown = AtomicBoolean(false) + private val pendingEvents = AtomicInteger(0) private val totalEventsSent = AtomicLong(0) private val catalog = ProductCatalog(properties.productCountPerCategory) - private val sentCounter = Counter.builder("simulator.inventory.events.sent") - .tag("topic", properties.inventoryTopic) - .register(meterRegistry) - - private val failedCounter = Counter.builder("simulator.inventory.events.failed") - .tag("topic", properties.inventoryTopic) - .register(meterRegistry) - - private val priceChangeCounter = Counter.builder("simulator.inventory.events.type") - .tag("type", "price_change") - .register(meterRegistry) - - private val restockCounter = Counter.builder("simulator.inventory.events.type") - .tag("type", "restock") - .register(meterRegistry) + private val sentCounter = + Counter + .builder("simulator.inventory.events.sent") + .tag("topic", properties.inventoryTopic) + .register(meterRegistry) + + private val failedCounter = + Counter + .builder("simulator.inventory.events.failed") + .tag("topic", properties.inventoryTopic) + .register(meterRegistry) + + private val priceChangeCounter = + Counter + .builder("simulator.inventory.events.type") + .tag("type", "price_change") + .register(meterRegistry) + + private val restockCounter = + Counter + .builder("simulator.inventory.events.type") + .tag("type", "restock") + .register(meterRegistry) fun startSimulation() { if (isRunning.compareAndSet(false, true)) { log.info { "Starting inventory simulation, interval=${properties.inventoryIntervalMs}ms" } - simulationJob = scope.launch { - while (currentCoroutineContext().isActive) { - try { - val event = generateEvent() - sendToKafka(event) - delay(properties.inventoryIntervalMs) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log.error(e) { "Error generating inventory event" } - delay(1000) + simulationJob = + scope.launch { + while (currentCoroutineContext().isActive) { + try { + val event = generateEvent() + sendToKafka(event) + delay(properties.inventoryIntervalMs) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log.error(e) { "Error generating inventory event" } + delay(1000) + } } } - } } else { log.warn { "Inventory simulation is already running" } } } - private fun generateEvent(): ProductInventoryEvent { - return if (Random.nextDouble() < 0.7) { + private fun generateEvent(): ProductInventoryEvent = + if (Random.nextDouble() < 0.7) { priceChangeCounter.increment() catalog.generatePriceChange() } else { restockCounter.increment() catalog.generateRestock() } - } private fun sendToKafka(event: ProductInventoryEvent) { - val future = inventoryKafkaTemplate.send( - properties.inventoryTopic, - event.productId.toString(), - event - ) + pendingEvents.incrementAndGet() + val future = + inventoryKafkaTemplate.send( + properties.inventoryTopic, + event.productId.toString(), + event, + ) future.whenComplete { result, ex -> - if (ex == null) { - sentCounter.increment() - val count = totalEventsSent.incrementAndGet() - if (count % 100 == 0L) { - log.info { "Inventory events sent: $count, type=${event.eventType}, offset=${result.recordMetadata.offset()}" } + try { + if (ex == null) { + sentCounter.increment() + val count = totalEventsSent.incrementAndGet() + if (count % 100 == 0L) { + log.info { + "Inventory events sent: $count, type=${event.eventType}, offset=${result.recordMetadata.offset()}" + } + } else { + log.debug { "Sent inventory event: ${event.eventId}, type=${event.eventType}" } + } } else { - log.debug { "Sent inventory event: ${event.eventId}, type=${event.eventType}" } + failedCounter.increment() + log.error(ex) { "Failed to send inventory event: ${event.eventId}" } } - } else { - failedCounter.increment() - log.error(ex) { "Failed to send inventory event: ${event.eventId}" } + } finally { + pendingEvents.decrementAndGet() } } } @@ -114,27 +142,45 @@ class InventorySimulator( fun stopSimulation() { if (isRunning.compareAndSet(true, false)) { log.info { "Stopping inventory simulation..." } + + // 1. shutdown 플래그 설정 + isShuttingDown.set(true) + + // 2. 코루틴 취소 simulationJob?.cancel() simulationJob = null + // 3. in-flight 이벤트 완료 대기 (최대 5초) + val maxWaitMs = 5000L + val startTime = System.currentTimeMillis() + while (pendingEvents.get() > 0) { + if (System.currentTimeMillis() - startTime > maxWaitMs) { + log.warn { "Timeout waiting for ${pendingEvents.get()} pending inventory events" } + break + } + Thread.sleep(100) + } + + // 4. Kafka Producer flush try { inventoryKafkaTemplate.flush() + log.info { "Inventory Kafka producer flushed successfully" } } catch (e: Exception) { log.error(e) { "Failed to flush inventory Kafka producer" } } + isShuttingDown.set(false) log.info { "Inventory simulation stopped. Total events sent: ${totalEventsSent.get()}" } } } - fun getStatus(): InventorySimulationStatus { - return InventorySimulationStatus( + fun getStatus(): InventorySimulationStatus = + InventorySimulationStatus( isRunning = isRunning.get(), totalEventsSent = totalEventsSent.get(), intervalMs = properties.inventoryIntervalMs, - catalogSize = catalog.size() + catalogSize = catalog.size(), ) - } @PreDestroy fun cleanup() { @@ -147,6 +193,6 @@ class InventorySimulator( val isRunning: Boolean, val totalEventsSent: Long, val intervalMs: Long, - val catalogSize: Int + val catalogSize: Int, ) } diff --git a/simulator/src/main/kotlin/com/rep/simulator/service/TrafficSimulator.kt b/simulator/src/main/kotlin/com/rep/simulator/service/TrafficSimulator.kt index 0c39d2f..4c78036 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/service/TrafficSimulator.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/service/TrafficSimulator.kt @@ -7,7 +7,20 @@ import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.Gauge import io.micrometer.core.instrument.MeterRegistry import jakarta.annotation.PreDestroy -import kotlinx.coroutines.* +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import mu.KotlinLogging import org.springframework.kafka.core.KafkaTemplate import org.springframework.stereotype.Service @@ -27,13 +40,13 @@ private val log = KotlinLogging.logger {} * 다수의 가상 유저가 동시에 활동하는 상황을 시뮬레이션합니다. * Java 25 Virtual Threads + Kotlin Coroutines 조합으로 수만 명의 유저를 경량 처리합니다. * - * @see ADR-001: 동시성 처리 전략 + * @see docs/adr-001-concurrency-strategy.md */ @Service class TrafficSimulator( private val kafkaTemplate: KafkaTemplate, private val properties: SimulatorProperties, - private val meterRegistry: MeterRegistry + private val meterRegistry: MeterRegistry, ) { // Java 25 Virtual Threads 기반 Dispatcher // Blocking I/O 호출 시에도 시스템 처리량 유지 @@ -48,23 +61,29 @@ class TrafficSimulator( // 현재 실행 중인 설정값 (REST API 응답용) @Volatile private var currentUserCount: Int = 0 + @Volatile private var currentDelayMillis: Long = 0L // Metrics - private val sentCounter: Counter = Counter.builder("simulator.events.sent") - .tag("topic", properties.topic) - .register(meterRegistry) + private val sentCounter: Counter = + Counter + .builder("simulator.events.sent") + .tag("topic", properties.topic) + .register(meterRegistry) - private val failedCounter: Counter = Counter.builder("simulator.events.failed") - .tag("topic", properties.topic) - .register(meterRegistry) + private val failedCounter: Counter = + Counter + .builder("simulator.events.failed") + .tag("topic", properties.topic) + .register(meterRegistry) private val totalEventsSent = AtomicLong(0) private val activeSessionCount = AtomicInteger(0) init { // 활성 세션 수 Gauge 등록 - Gauge.builder("simulator.sessions.active") { activeSessionCount.get() } + Gauge + .builder("simulator.sessions.active") { activeSessionCount.get() } .description("Number of active user sessions") .register(meterRegistry) } @@ -79,8 +98,11 @@ class TrafficSimulator( */ fun startSimulation( userCount: Int = properties.userCount, - delayMillis: Long = properties.delayMillis + delayMillis: Long = properties.delayMillis, ) { + require(userCount > 0) { "userCount must be greater than 0" } + require(delayMillis > 0) { "delayMillis must be greater than 0" } + simulationLock.withLock { if (simulationJob?.isActive == true) { log.warn { "Simulation is already running" } @@ -96,25 +118,28 @@ class TrafficSimulator( // shutdown 플래그 초기화 isShuttingDown.set(false) - simulationJob = scope.launch { - val sessions = (1..userCount).map { i -> - async { - activeSessionCount.incrementAndGet() - try { - val session = UserSession( - userId = "USER-${i.toString().padStart(6, '0')}", - productCountPerCategory = properties.productCountPerCategory - ) - runUserSession(session, delayMillis) - } finally { - activeSessionCount.decrementAndGet() + simulationJob = + scope.launch { + val sessions = + (1..userCount).map { i -> + async { + activeSessionCount.incrementAndGet() + try { + val session = + UserSession( + userId = "USER-${i.toString().padStart(6, '0')}", + productCountPerCategory = properties.productCountPerCategory, + ) + runUserSession(session, delayMillis) + } finally { + activeSessionCount.decrementAndGet() + } + } } - } - } - // 모든 세션이 취소될 때까지 대기 - sessions.awaitAll() - } + // 모든 세션이 취소될 때까지 대기 + sessions.awaitAll() + } } } @@ -123,7 +148,10 @@ class TrafficSimulator( * * isShuttingDown 플래그를 체크하여 graceful shutdown을 지원합니다. */ - private suspend fun runUserSession(session: UserSession, delayMillis: Long) { + private suspend fun runUserSession( + session: UserSession, + delayMillis: Long, + ) { log.debug { "Starting session for ${session.userId}" } try { @@ -225,14 +253,13 @@ class TrafficSimulator( /** * 시뮬레이션 상태를 반환합니다. */ - fun getStatus(): SimulationStatus { - return SimulationStatus( + fun getStatus(): SimulationStatus = + SimulationStatus( isRunning = simulationJob?.isActive == true, totalEventsSent = totalEventsSent.get(), userCount = currentUserCount, - delayMillis = currentDelayMillis + delayMillis = currentDelayMillis, ) - } @PreDestroy fun cleanup() { @@ -246,6 +273,6 @@ class TrafficSimulator( val isRunning: Boolean, val totalEventsSent: Long, val userCount: Int, - val delayMillis: Long + val delayMillis: Long, ) } diff --git a/simulator/src/main/kotlin/com/rep/simulator/tracing/AnomalyDetector.kt b/simulator/src/main/kotlin/com/rep/simulator/tracing/AnomalyDetector.kt index 26a94a8..91dad32 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/tracing/AnomalyDetector.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/tracing/AnomalyDetector.kt @@ -1,6 +1,10 @@ package com.rep.simulator.tracing -import com.rep.simulator.tracing.model.* +import com.rep.simulator.tracing.model.AnomalyScanResult +import com.rep.simulator.tracing.model.AnomalyType +import com.rep.simulator.tracing.model.JaegerTrace +import com.rep.simulator.tracing.model.Severity +import com.rep.simulator.tracing.model.TraceAnomaly import mu.KotlinLogging import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component @@ -13,12 +17,11 @@ private val log = KotlinLogging.logger {} class AnomalyDetector( private val tracingService: TracingService, private val anomalyRepository: AnomalyRepository, - private val properties: TracingProperties + private val properties: TracingProperties, ) { - private val scanning = AtomicBoolean(false) - @Scheduled(fixedDelayString = "#{${tracing.anomaly.scan-interval-minutes:5} * 60000}") + @Scheduled(fixedDelayString = "#{\${tracing.anomaly.scan-interval-minutes:5} * 60000}") fun scheduledScan() { if (!properties.anomaly.errorScanEnabled) return try { @@ -41,12 +44,15 @@ class AnomalyDetector( val services = tracingService.getServices() for (service in services) { - val traces = tracingService.searchTraces( - service = service, - limit = 50, - start = Instant.now().minusSeconds(properties.anomaly.scanIntervalMinutes * 60).toEpochMilli() * 1000, - end = Instant.now().toEpochMilli() * 1000 - ) + val traces = + tracingService.searchTraces( + service = service, + limit = 50, + start = + Instant.now().minusSeconds(properties.anomaly.scanIntervalMinutes * 60).toEpochMilli() * + 1000, + end = Instant.now().toEpochMilli() * 1000, + ) for (trace in traces) { totalScanned++ @@ -65,7 +71,7 @@ class AnomalyDetector( return AnomalyScanResult( newAnomalies = newAnomalies, totalScanned = totalScanned, - scanDurationMs = duration + scanDurationMs = duration, ) } @@ -90,8 +96,8 @@ class AnomalyDetector( operationName = rootSpan?.operationName ?: "unknown", durationMs = traceDurationMs, thresholdMs = properties.anomaly.slowThresholdMs, - spanCount = trace.spans.size - ) + spanCount = trace.spans.size, + ), ) count++ } @@ -103,12 +109,17 @@ class AnomalyDetector( val serviceName = trace.processes[span.processID]?.serviceName ?: "unknown" // ERROR_SPAN detection - val hasError = span.tags.any { it.key == "error" && it.value == true } || - span.tags.any { it.key == "otel.status_code" && it.value == "ERROR" } + val hasError = + span.tags.any { it.key == "error" && it.value == true } || + span.tags.any { it.key == "otel.status_code" && it.value == "ERROR" } if (hasError) { if (!anomalyRepository.existsByTraceIdAndType(trace.traceID, AnomalyType.ERROR_SPAN)) { - val errorMsg = span.tags.find { it.key == "error.message" || it.key == "otel.status_description" }?.value?.toString() + val errorMsg = + span.tags + .find { it.key == "error.message" || it.key == "otel.status_description" } + ?.value + ?.toString() anomalyRepository.save( TraceAnomaly( traceId = trace.traceID, @@ -117,8 +128,8 @@ class AnomalyDetector( serviceName = serviceName, operationName = span.operationName, durationMs = spanDurationMs, - errorMessage = errorMsg - ) + errorMessage = errorMsg, + ), ) count++ } @@ -126,7 +137,8 @@ class AnomalyDetector( // DLQ_ROUTED detection if (span.operationName.contains("dlq", ignoreCase = true) || - span.tags.any { it.key == "messaging.destination.name" && it.value.toString().contains(".dlq") }) { + span.tags.any { it.key == "messaging.destination.name" && it.value.toString().contains(".dlq") } + ) { if (!anomalyRepository.existsByTraceIdAndType(trace.traceID, AnomalyType.DLQ_ROUTED)) { anomalyRepository.save( TraceAnomaly( @@ -135,8 +147,8 @@ class AnomalyDetector( severity = Severity.CRITICAL, serviceName = serviceName, operationName = span.operationName, - durationMs = spanDurationMs - ) + durationMs = spanDurationMs, + ), ) count++ } diff --git a/simulator/src/main/kotlin/com/rep/simulator/tracing/AnomalyRepository.kt b/simulator/src/main/kotlin/com/rep/simulator/tracing/AnomalyRepository.kt index b69aacd..4f3b63d 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/tracing/AnomalyRepository.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/tracing/AnomalyRepository.kt @@ -3,7 +3,6 @@ package com.rep.simulator.tracing import co.elastic.clients.elasticsearch.ElasticsearchClient import co.elastic.clients.elasticsearch._types.SortOrder import co.elastic.clients.elasticsearch.core.IndexRequest -import co.elastic.clients.elasticsearch.core.SearchRequest import co.elastic.clients.json.JsonData import com.rep.simulator.tracing.model.AnomalyType import com.rep.simulator.tracing.model.Severity @@ -18,7 +17,7 @@ private val log = KotlinLogging.logger {} @Repository class AnomalyRepository( - private val esClient: ElasticsearchClient + private val esClient: ElasticsearchClient, ) { companion object { private const val INDEX = "trace_anomaly_index" @@ -26,28 +25,29 @@ class AnomalyRepository( fun save(anomaly: TraceAnomaly): String? { val id = anomaly.id ?: UUID.randomUUID().toString() - val doc = mapOf( - "traceId" to anomaly.traceId, - "type" to anomaly.type.name, - "severity" to anomaly.severity.name, - "serviceName" to anomaly.serviceName, - "operationName" to anomaly.operationName, - "durationMs" to anomaly.durationMs, - "thresholdMs" to anomaly.thresholdMs, - "errorMessage" to anomaly.errorMessage, - "spanCount" to anomaly.spanCount, - "metadata" to anomaly.metadata, - "note" to anomaly.note, - "isBookmark" to anomaly.isBookmark, - "detectedAt" to anomaly.detectedAt.toEpochMilli(), - "createdAt" to anomaly.createdAt.toEpochMilli() - ) + val doc = + mapOf( + "traceId" to anomaly.traceId, + "type" to anomaly.type.name, + "severity" to anomaly.severity.name, + "serviceName" to anomaly.serviceName, + "operationName" to anomaly.operationName, + "durationMs" to anomaly.durationMs, + "thresholdMs" to anomaly.thresholdMs, + "errorMessage" to anomaly.errorMessage, + "spanCount" to anomaly.spanCount, + "metadata" to anomaly.metadata, + "note" to anomaly.note, + "isBookmark" to anomaly.isBookmark, + "detectedAt" to anomaly.detectedAt.toEpochMilli(), + "createdAt" to anomaly.createdAt.toEpochMilli(), + ) return try { esClient.index( IndexRequest.of { r -> r.index(INDEX).id(id).document(doc) - } + }, ) id } catch (e: Exception) { @@ -62,39 +62,41 @@ class AnomalyRepository( from: Long?, to: Long?, page: Int, - size: Int + size: Int, ): List { return try { - val response = esClient.search({ s -> - s.index(INDEX) - .from(page * size) - .size(size) - .sort { sort -> sort.field { f -> f.field("detectedAt").order(SortOrder.Desc) } } - .query { q -> - q.bool { b -> - // isBookmark = false (anomalies only) - b.must { m -> m.term { t -> t.field("isBookmark").value(false) } } - - if (type != null) { - b.must { m -> m.term { t -> t.field("type").value(type.name) } } - } - if (service != null) { - b.must { m -> m.term { t -> t.field("serviceName").value(service) } } - } - if (from != null || to != null) { - b.must { m -> - m.range { r -> - val rangeBuilder = r.field("detectedAt") - if (from != null) rangeBuilder.gte(JsonData.of(from)) - if (to != null) rangeBuilder.lte(JsonData.of(to)) - rangeBuilder + val response = + esClient.search({ s -> + s + .index(INDEX) + .from(page * size) + .size(size) + .sort { sort -> sort.field { f -> f.field("detectedAt").order(SortOrder.Desc) } } + .query { q -> + q.bool { b -> + // isBookmark = false (anomalies only) + b.must { m -> m.term { t -> t.field("isBookmark").value(false) } } + + if (type != null) { + b.must { m -> m.term { t -> t.field("type").value(type.name) } } + } + if (service != null) { + b.must { m -> m.term { t -> t.field("serviceName").value(service) } } + } + if (from != null || to != null) { + b.must { m -> + m.range { r -> + val rangeBuilder = r.field("detectedAt") + if (from != null) rangeBuilder.gte(JsonData.of(from)) + if (to != null) rangeBuilder.lte(JsonData.of(to)) + rangeBuilder + } } } + b } - b } - } - }, Map::class.java) + }, Map::class.java) response.hits().hits().mapNotNull { hit -> @Suppress("UNCHECKED_CAST") @@ -109,14 +111,16 @@ class AnomalyRepository( fun getBookmarks(): List { return try { - val response = esClient.search({ s -> - s.index(INDEX) - .size(100) - .sort { sort -> sort.field { f -> f.field("createdAt").order(SortOrder.Desc) } } - .query { q -> - q.term { t -> t.field("isBookmark").value(true) } - } - }, Map::class.java) + val response = + esClient.search({ s -> + s + .index(INDEX) + .size(100) + .sort { sort -> sort.field { f -> f.field("createdAt").order(SortOrder.Desc) } } + .query { q -> + q.term { t -> t.field("isBookmark").value(true) } + } + }, Map::class.java) response.hits().hits().mapNotNull { hit -> @Suppress("UNCHECKED_CAST") @@ -128,7 +132,7 @@ class AnomalyRepository( operationName = source["operationName"]?.toString() ?: "", durationMs = (source["durationMs"] as? Number)?.toLong() ?: 0, note = source["note"]?.toString(), - createdAt = Instant.ofEpochMilli((source["createdAt"] as? Number)?.toLong() ?: 0) + createdAt = Instant.ofEpochMilli((source["createdAt"] as? Number)?.toLong() ?: 0), ) } } catch (e: Exception) { @@ -139,18 +143,19 @@ class AnomalyRepository( fun saveBookmark(bookmark: TraceBookmark): String { val id = UUID.randomUUID().toString() - val doc = mapOf( - "traceId" to bookmark.traceId, - "type" to "BOOKMARK", - "severity" to "WARNING", - "serviceName" to bookmark.serviceName, - "operationName" to bookmark.operationName, - "durationMs" to bookmark.durationMs, - "note" to bookmark.note, - "isBookmark" to true, - "detectedAt" to bookmark.createdAt.toEpochMilli(), - "createdAt" to bookmark.createdAt.toEpochMilli() - ) + val doc = + mapOf( + "traceId" to bookmark.traceId, + "type" to "BOOKMARK", + "severity" to "WARNING", + "serviceName" to bookmark.serviceName, + "operationName" to bookmark.operationName, + "durationMs" to bookmark.durationMs, + "note" to bookmark.note, + "isBookmark" to true, + "detectedAt" to bookmark.createdAt.toEpochMilli(), + "createdAt" to bookmark.createdAt.toEpochMilli(), + ) try { esClient.index(IndexRequest.of { r -> r.index(INDEX).id(id).document(doc) }) @@ -170,7 +175,10 @@ class AnomalyRepository( } } - fun updateBookmarkNote(id: String, note: String) { + fun updateBookmarkNote( + id: String, + note: String, + ) { try { esClient.update, Map>({ u -> u.index(INDEX).id(id).doc(mapOf("note" to note)) @@ -181,40 +189,48 @@ class AnomalyRepository( } } - fun existsByTraceIdAndType(traceId: String, type: AnomalyType): Boolean { - return try { - val response = esClient.count { c -> - c.index(INDEX).query { q -> - q.bool { b -> - b.must { m -> m.term { t -> t.field("traceId").value(traceId) } } - b.must { m -> m.term { t -> t.field("type").value(type.name) } } - b.must { m -> m.term { t -> t.field("isBookmark").value(false) } } - b + fun existsByTraceIdAndType( + traceId: String, + type: AnomalyType, + ): Boolean = + try { + val response = + esClient.count { c -> + c.index(INDEX).query { q -> + q.bool { b -> + b.must { m -> m.term { t -> t.field("traceId").value(traceId) } } + b.must { m -> m.term { t -> t.field("type").value(type.name) } } + b.must { m -> m.term { t -> t.field("isBookmark").value(false) } } + b + } } } - } response.count() > 0 } catch (e: Exception) { false } - } - private fun mapToAnomaly(id: String, source: Map): TraceAnomaly { - return TraceAnomaly( + private fun mapToAnomaly( + id: String, + source: Map, + ): TraceAnomaly = + TraceAnomaly( id = id, traceId = source["traceId"]?.toString() ?: "", - type = try { - AnomalyType.valueOf(source["type"]?.toString() ?: "SLOW_TRACE") - } catch (e: Exception) { - log.warn { "Unknown anomaly type: ${source["type"]}, defaulting to SLOW_TRACE" } - AnomalyType.SLOW_TRACE - }, - severity = try { - Severity.valueOf(source["severity"]?.toString() ?: "WARNING") - } catch (e: Exception) { - log.warn { "Unknown severity: ${source["severity"]}, defaulting to WARNING" } - Severity.WARNING - }, + type = + try { + AnomalyType.valueOf(source["type"]?.toString() ?: "SLOW_TRACE") + } catch (e: Exception) { + log.warn { "Unknown anomaly type: ${source["type"]}, defaulting to SLOW_TRACE" } + AnomalyType.SLOW_TRACE + }, + severity = + try { + Severity.valueOf(source["severity"]?.toString() ?: "WARNING") + } catch (e: Exception) { + log.warn { "Unknown severity: ${source["severity"]}, defaulting to WARNING" } + Severity.WARNING + }, serviceName = source["serviceName"]?.toString() ?: "", operationName = source["operationName"]?.toString() ?: "", durationMs = (source["durationMs"] as? Number)?.toLong() ?: 0, @@ -224,7 +240,6 @@ class AnomalyRepository( note = source["note"]?.toString(), isBookmark = source["isBookmark"] as? Boolean ?: false, detectedAt = Instant.ofEpochMilli((source["detectedAt"] as? Number)?.toLong() ?: 0), - createdAt = Instant.ofEpochMilli((source["createdAt"] as? Number)?.toLong() ?: 0) + createdAt = Instant.ofEpochMilli((source["createdAt"] as? Number)?.toLong() ?: 0), ) - } } diff --git a/simulator/src/main/kotlin/com/rep/simulator/tracing/TracingController.kt b/simulator/src/main/kotlin/com/rep/simulator/tracing/TracingController.kt index 0e8478e..27c0b12 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/tracing/TracingController.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/tracing/TracingController.kt @@ -1,25 +1,37 @@ package com.rep.simulator.tracing -import com.rep.simulator.tracing.model.* +import com.rep.simulator.tracing.model.AnomalyScanResult +import com.rep.simulator.tracing.model.AnomalyType +import com.rep.simulator.tracing.model.CreateBookmarkRequest +import com.rep.simulator.tracing.model.JaegerTrace +import com.rep.simulator.tracing.model.TraceAnomaly +import com.rep.simulator.tracing.model.TraceBookmark +import com.rep.simulator.tracing.model.TraceSummary +import com.rep.simulator.tracing.model.UpdateBookmarkNoteRequest import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.* +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v1/tracing") class TracingController( private val tracingService: TracingService, private val anomalyDetector: AnomalyDetector, - private val anomalyRepository: AnomalyRepository + private val anomalyRepository: AnomalyRepository, ) { - // ============================================ // Trace Query (Jaeger Proxy) // ============================================ @GetMapping("/services") - fun getServices(): ResponseEntity> { - return ResponseEntity.ok(tracingService.getServices()) - } + fun getServices(): ResponseEntity> = ResponseEntity.ok(tracingService.getServices()) @GetMapping("/traces") fun searchTraces( @@ -28,16 +40,19 @@ class TracingController( @RequestParam(required = false) minDuration: String?, @RequestParam(required = false) maxDuration: String?, @RequestParam(required = false) start: Long?, - @RequestParam(required = false) end: Long? + @RequestParam(required = false) end: Long?, ): ResponseEntity> { val traces = tracingService.searchTraces(service, limit, minDuration, maxDuration, start, end) return ResponseEntity.ok(tracingService.toTraceSummaries(traces)) } @GetMapping("/traces/{traceId}") - fun getTraceDetail(@PathVariable traceId: String): ResponseEntity { - val trace = tracingService.getTrace(traceId) - ?: return ResponseEntity.notFound().build() + fun getTraceDetail( + @PathVariable traceId: String, + ): ResponseEntity { + val trace = + tracingService.getTrace(traceId) + ?: return ResponseEntity.notFound().build() return ResponseEntity.ok(trace) } @@ -52,7 +67,7 @@ class TracingController( @RequestParam(required = false) from: Long?, @RequestParam(required = false) to: Long?, @RequestParam(defaultValue = "0") page: Int, - @RequestParam(defaultValue = "20") size: Int + @RequestParam(defaultValue = "20") size: Int, ): ResponseEntity> { val anomalies = anomalyRepository.searchAnomalies(type, service, from, to, page, size) return ResponseEntity.ok(anomalies) @@ -69,25 +84,28 @@ class TracingController( // ============================================ @GetMapping("/bookmarks") - fun getBookmarks(): ResponseEntity> { - return ResponseEntity.ok(anomalyRepository.getBookmarks()) - } + fun getBookmarks(): ResponseEntity> = ResponseEntity.ok(anomalyRepository.getBookmarks()) @PostMapping("/bookmarks") - fun addBookmark(@RequestBody request: CreateBookmarkRequest): ResponseEntity> { - val bookmark = TraceBookmark( - traceId = request.traceId, - serviceName = request.serviceName, - operationName = request.operationName, - durationMs = request.durationMs, - note = request.note - ) + fun addBookmark( + @RequestBody request: CreateBookmarkRequest, + ): ResponseEntity> { + val bookmark = + TraceBookmark( + traceId = request.traceId, + serviceName = request.serviceName, + operationName = request.operationName, + durationMs = request.durationMs, + note = request.note, + ) val id = anomalyRepository.saveBookmark(bookmark) return ResponseEntity.ok(mapOf("id" to id)) } @DeleteMapping("/bookmarks/{id}") - fun deleteBookmark(@PathVariable id: String): ResponseEntity { + fun deleteBookmark( + @PathVariable id: String, + ): ResponseEntity { anomalyRepository.deleteBookmark(id) return ResponseEntity.noContent().build() } @@ -95,7 +113,7 @@ class TracingController( @PatchMapping("/bookmarks/{id}") fun updateBookmarkNote( @PathVariable id: String, - @RequestBody request: UpdateBookmarkNoteRequest + @RequestBody request: UpdateBookmarkNoteRequest, ): ResponseEntity { anomalyRepository.updateBookmarkNote(id, request.note) return ResponseEntity.noContent().build() diff --git a/simulator/src/main/kotlin/com/rep/simulator/tracing/TracingProperties.kt b/simulator/src/main/kotlin/com/rep/simulator/tracing/TracingProperties.kt index 858bb60..75647b6 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/tracing/TracingProperties.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/tracing/TracingProperties.kt @@ -5,16 +5,16 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "tracing") data class TracingProperties( val jaeger: JaegerProperties = JaegerProperties(), - val anomaly: AnomalyProperties = AnomalyProperties() + val anomaly: AnomalyProperties = AnomalyProperties(), ) data class JaegerProperties( - val queryUrl: String = "http://localhost:16686" + val queryUrl: String = "http://localhost:16686", ) data class AnomalyProperties( val slowThresholdMs: Long = 500, val errorScanEnabled: Boolean = true, val scanIntervalMinutes: Long = 5, - val retentionDays: Int = 30 + val retentionDays: Int = 30, ) diff --git a/simulator/src/main/kotlin/com/rep/simulator/tracing/TracingService.kt b/simulator/src/main/kotlin/com/rep/simulator/tracing/TracingService.kt index 8837cd1..500fb39 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/tracing/TracingService.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/tracing/TracingService.kt @@ -1,37 +1,41 @@ package com.rep.simulator.tracing -import com.rep.simulator.tracing.model.* +import com.rep.simulator.tracing.model.JaegerServicesResponse +import com.rep.simulator.tracing.model.JaegerTrace +import com.rep.simulator.tracing.model.JaegerTracesResponse +import com.rep.simulator.tracing.model.TraceSummary import mu.KotlinLogging import org.springframework.stereotype.Service import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.bodyToMono -import java.time.Instant private val log = KotlinLogging.logger {} @Service class TracingService( properties: TracingProperties, - webClientBuilder: WebClient.Builder + webClientBuilder: WebClient.Builder, ) { - private val jaegerClient: WebClient = webClientBuilder - .baseUrl(properties.jaeger.queryUrl) - .build() + private val jaegerClient: WebClient = + webClientBuilder + .baseUrl(properties.jaeger.queryUrl) + .build() - fun getServices(): List { - return try { - val response = jaegerClient.get() - .uri("/api/services") - .retrieve() - .bodyToMono() - .block() + fun getServices(): List = + try { + val response = + jaegerClient + .get() + .uri("/api/services") + .retrieve() + .bodyToMono() + .block() response?.data ?: emptyList() } catch (e: Exception) { log.error(e) { "Failed to get services from Jaeger" } emptyList() } - } fun searchTraces( service: String?, @@ -39,56 +43,58 @@ class TracingService( minDuration: String? = null, maxDuration: String? = null, start: Long? = null, - end: Long? = null - ): List { - return try { - val response = jaegerClient.get() - .uri { uriBuilder -> - uriBuilder.path("/api/traces") - if (service != null) uriBuilder.queryParam("service", service) - uriBuilder.queryParam("limit", limit) - if (minDuration != null) uriBuilder.queryParam("minDuration", minDuration) - if (maxDuration != null) uriBuilder.queryParam("maxDuration", maxDuration) - if (start != null) uriBuilder.queryParam("start", start) - if (end != null) uriBuilder.queryParam("end", end) - uriBuilder.build() - } - .retrieve() - .bodyToMono() - .block() + end: Long? = null, + ): List = + try { + val response = + jaegerClient + .get() + .uri { uriBuilder -> + uriBuilder.path("/api/traces") + if (service != null) uriBuilder.queryParam("service", service) + uriBuilder.queryParam("limit", limit) + if (minDuration != null) uriBuilder.queryParam("minDuration", minDuration) + if (maxDuration != null) uriBuilder.queryParam("maxDuration", maxDuration) + if (start != null) uriBuilder.queryParam("start", start) + if (end != null) uriBuilder.queryParam("end", end) + uriBuilder.build() + }.retrieve() + .bodyToMono() + .block() response?.data ?: emptyList() } catch (e: Exception) { log.error(e) { "Failed to search traces from Jaeger" } emptyList() } - } - fun getTrace(traceId: String): JaegerTrace? { - return try { - val response = jaegerClient.get() - .uri("/api/traces/{traceId}", traceId) - .retrieve() - .bodyToMono() - .block() + fun getTrace(traceId: String): JaegerTrace? = + try { + val response = + jaegerClient + .get() + .uri("/api/traces/{traceId}", traceId) + .retrieve() + .bodyToMono() + .block() response?.data?.firstOrNull() } catch (e: Exception) { log.error(e) { "Failed to get trace $traceId from Jaeger" } null } - } - fun toTraceSummaries(traces: List): List { - return traces.map { trace -> + fun toTraceSummaries(traces: List): List = + traces.map { trace -> val rootSpan = trace.spans.minByOrNull { it.startTime } val traceEndTime = trace.spans.maxOfOrNull { it.startTime + it.duration } ?: 0 val traceDurationUs = if (rootSpan != null) traceEndTime - rootSpan.startTime else 0 val services = trace.spans.map { trace.processes[it.processID]?.serviceName }.distinct() - val hasError = trace.spans.any { span -> - span.tags.any { it.key == "error" && it.value == true } || - span.tags.any { it.key == "otel.status_code" && it.value == "ERROR" } - } + val hasError = + trace.spans.any { span -> + span.tags.any { it.key == "error" && it.value == true } || + span.tags.any { it.key == "otel.status_code" && it.value == "ERROR" } + } TraceSummary( traceId = trace.traceID, @@ -98,8 +104,7 @@ class TracingService( spanCount = trace.spans.size, serviceCount = services.size, hasError = hasError, - startTime = rootSpan?.startTime ?: 0 + startTime = rootSpan?.startTime ?: 0, ) } - } } diff --git a/simulator/src/main/kotlin/com/rep/simulator/tracing/model/TraceModels.kt b/simulator/src/main/kotlin/com/rep/simulator/tracing/model/TraceModels.kt index 9beec09..426c662 100644 --- a/simulator/src/main/kotlin/com/rep/simulator/tracing/model/TraceModels.kt +++ b/simulator/src/main/kotlin/com/rep/simulator/tracing/model/TraceModels.kt @@ -7,17 +7,17 @@ import java.time.Instant // ============================================ data class JaegerServicesResponse( - val data: List = emptyList() + val data: List = emptyList(), ) data class JaegerTracesResponse( - val data: List = emptyList() + val data: List = emptyList(), ) data class JaegerTrace( val traceID: String, val spans: List = emptyList(), - val processes: Map = emptyMap() + val processes: Map = emptyMap(), ) data class JaegerSpan( @@ -29,29 +29,29 @@ data class JaegerSpan( val duration: Long, val tags: List = emptyList(), val logs: List = emptyList(), - val processID: String + val processID: String, ) data class SpanReference( val refType: String, val traceID: String, - val spanID: String + val spanID: String, ) data class JaegerTag( val key: String, val type: String, - val value: Any? + val value: Any?, ) data class JaegerLog( val timestamp: Long, - val fields: List = emptyList() + val fields: List = emptyList(), ) data class JaegerProcess( val serviceName: String, - val tags: List = emptyList() + val tags: List = emptyList(), ) // ============================================ @@ -66,7 +66,7 @@ data class TraceSummary( val spanCount: Int, val serviceCount: Int, val hasError: Boolean, - val startTime: Long + val startTime: Long, ) // ============================================ @@ -74,11 +74,17 @@ data class TraceSummary( // ============================================ enum class AnomalyType { - SLOW_TRACE, SLOW_SPAN, ERROR_SPAN, DLQ_ROUTED, HIGH_RETRY + SLOW_TRACE, + SLOW_SPAN, + ERROR_SPAN, + DLQ_ROUTED, + HIGH_RETRY, } enum class Severity { - CRITICAL, ERROR, WARNING + CRITICAL, + ERROR, + WARNING, } data class TraceAnomaly( @@ -96,7 +102,7 @@ data class TraceAnomaly( val note: String? = null, val isBookmark: Boolean = false, val detectedAt: Instant = Instant.now(), - val createdAt: Instant = Instant.now() + val createdAt: Instant = Instant.now(), ) // ============================================ @@ -110,7 +116,7 @@ data class TraceBookmark( val operationName: String, val durationMs: Long, val note: String? = null, - val createdAt: Instant = Instant.now() + val createdAt: Instant = Instant.now(), ) data class CreateBookmarkRequest( @@ -118,11 +124,11 @@ data class CreateBookmarkRequest( val serviceName: String, val operationName: String, val durationMs: Long, - val note: String? = null + val note: String? = null, ) data class UpdateBookmarkNoteRequest( - val note: String + val note: String, ) // ============================================ @@ -135,11 +141,11 @@ data class AnomalySearchParams( val from: Long? = null, val to: Long? = null, val page: Int = 0, - val size: Int = 20 + val size: Int = 20, ) data class AnomalyScanResult( val newAnomalies: Int, val totalScanned: Int, - val scanDurationMs: Long + val scanDurationMs: Long, ) diff --git a/simulator/src/test/kotlin/com/rep/simulator/SimulatorApplicationTests.kt b/simulator/src/test/kotlin/com/rep/simulator/SimulatorApplicationTests.kt index 387765f..7139369 100644 --- a/simulator/src/test/kotlin/com/rep/simulator/SimulatorApplicationTests.kt +++ b/simulator/src/test/kotlin/com/rep/simulator/SimulatorApplicationTests.kt @@ -7,7 +7,6 @@ import org.springframework.test.context.ActiveProfiles @SpringBootTest @ActiveProfiles("test") class SimulatorApplicationTests { - @Test fun contextLoads() { // Context load test