From a8cb7054b716a9268004f5acd3aba35c1700e288 Mon Sep 17 00:00:00 2001 From: hun-ca Date: Fri, 23 Jan 2026 08:05:44 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20ContentsCommonGenerationService?= =?UTF-8?q?=EC=97=90=20GroupGen=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createSingleGroupGen 메서드 추가하여 단일 카테고리 GroupGen 생성 로직 추출 - 키워드 추출, 그룹화, 그룹 콘텐츠 생성 로직을 Service로 이동 - 개별 트랜잭션 처리로 롤백 범위 최소화 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../ContentsCommonGenerationService.kt | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/domain/generator/src/main/kotlin/com/few/generator/service/ContentsCommonGenerationService.kt b/domain/generator/src/main/kotlin/com/few/generator/service/ContentsCommonGenerationService.kt index 276e7f0d9..80ce2d1a8 100644 --- a/domain/generator/src/main/kotlin/com/few/generator/service/ContentsCommonGenerationService.kt +++ b/domain/generator/src/main/kotlin/com/few/generator/service/ContentsCommonGenerationService.kt @@ -2,15 +2,32 @@ package com.few.generator.service import com.few.common.domain.Category import com.few.common.domain.Region +import com.few.common.exception.BadRequestException +import com.few.generator.config.GroupingProperties +import com.few.generator.domain.vo.GenDetail +import com.few.generator.domain.vo.GroupGenProcessingResult +import com.few.generator.service.specifics.groupgen.GenGroupper +import com.few.generator.service.specifics.groupgen.GroupContentGenerator +import com.few.generator.service.specifics.groupgen.KeywordExtractor import com.few.generator.support.jpa.GeneratorTransactional +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service +import kotlin.system.measureTimeMillis @Service class ContentsCommonGenerationService( protected val rawContentsService: RawContentsService, protected val provisioningService: ProvisioningService, protected val genService: GenService, + protected val applicationEventPublisher: ApplicationEventPublisher, + protected val groupingProperties: GroupingProperties, + protected val keywordExtractor: KeywordExtractor, + protected val genGrouper: GenGroupper, + protected val groupContentGenerator: GroupContentGenerator, ) { + protected val log = KotlinLogging.logger {} + /** * RawContents, ProvisioningContents, Gen 중 1개라도 실패시 rollback하기 위해 * 개별 트랜잭션으로 분리 @@ -25,4 +42,56 @@ class ContentsCommonGenerationService( val provisioningContent = provisioningService.createAndSave(rawContent) genService.createAndSave(rawContent, provisioningContent) } + + @GeneratorTransactional + open suspend fun createSingleGroupGen( + category: Category, + region: Region, + ): GroupGenProcessingResult { + val gens = + genService.findAllByCreatedAtTodayAndCategoryAndRegion( + category, + region, + ) + + if (gens.isEmpty()) { + throw BadRequestException("${region.name} Group Gen 생성 실패 - Cause: 카테고리 ${category.title}에 대한 Gen이 없습니다.") + } + + if (gens.size < groupingProperties.minGroupSize) { + throw BadRequestException( + "${region.name} Group Gen 생성 실패 - Cause: 카테고리 ${category.title}의 Gen 개수(${gens.size})가 최소 그룹 크기(${groupingProperties.minGroupSize})보다 작습니다.", + ) + } + + log.info { "${region.name} 카테고리 ${category.title}에서 ${gens.size}개 Gen 발견, 키워드 추출 시작" } + + // 배치로 ProvisioningContents 조회하여 N+1 쿼리 방지 + val provisioningContentsIds = gens.map { it.provisioningContentsId } + val provisioningContentsMap = + provisioningService + .findAllByIdIn(provisioningContentsIds) + .associateBy { it.id!! } + + // 키워드 추출 시간 측정 및 실행 (코루틴 버전) + val genDetails: List + val keywordExtractionTime = + measureTimeMillis { + genDetails = keywordExtractor.extractKeywordsFromGens(gens, provisioningContentsMap) + } + + log.info { "키워드 추출 완료, 그룹화 시작" } + + // 그룹화 수행 + val group = genGrouper.performGrouping(genDetails, category) + val validatedGroup = genGrouper.validateGroupSize(group) + + if (validatedGroup == null) { + throw BadRequestException("${region.name} Group Gen 생성 실패 - Cause: 카테고리 ${category.title} Gen Grouping 실패") + } + + // 그룹 콘텐츠 생성 + val result = groupContentGenerator.generateGroupContent(category, gens, validatedGroup, provisioningContentsMap, region) + return GroupGenProcessingResult(result, keywordExtractionTime, gens.size) + } } \ No newline at end of file From 540e75b0253ef9fdda9fd75e31536456ad004a6b Mon Sep 17 00:00:00 2001 From: hun-ca Date: Fri, 23 Jan 2026 08:06:02 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20AbstractGroupGenSchedulingUseCa?= =?UTF-8?q?se=EC=97=90=EC=84=9C=20ContentsCommonGenerationService=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createGroupGenInternalWithMetrics 로직을 ContentsCommonGenerationService로 위임 - 불필요한 의존성 제거 (provisioningService, groupingProperties, keywordExtractor 등) - UseCase는 스케줄링 조율 역할만 담당하도록 책임 분리 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../AbstractGroupGenSchedulingUseCase.kt | 66 +------------------ 1 file changed, 3 insertions(+), 63 deletions(-) diff --git a/domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt b/domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt index 8baf45a35..e2bbdab86 100644 --- a/domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt +++ b/domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt @@ -4,18 +4,11 @@ import com.few.common.domain.Category import com.few.common.domain.Region import com.few.common.exception.BadRequestException import com.few.generator.config.GeneratorGsonConfig.Companion.GSON_BEAN_NAME -import com.few.generator.config.GroupingProperties import com.few.generator.domain.GroupGen -import com.few.generator.domain.vo.GenDetail -import com.few.generator.domain.vo.GroupGenProcessingResult import com.few.generator.event.ContentsSchedulingEvent +import com.few.generator.service.ContentsCommonGenerationService import com.few.generator.service.GenService -import com.few.generator.service.ProvisioningService -import com.few.generator.service.specifics.groupgen.GenGroupper -import com.few.generator.service.specifics.groupgen.GroupContentGenerator import com.few.generator.service.specifics.groupgen.GroupGenMetrics -import com.few.generator.service.specifics.groupgen.KeywordExtractor -import com.few.generator.support.jpa.GeneratorTransactional import com.google.gson.Gson import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CoroutineScope @@ -31,14 +24,10 @@ import kotlin.system.measureTimeMillis abstract class AbstractGroupGenSchedulingUseCase( protected val applicationEventPublisher: ApplicationEventPublisher, protected val genService: GenService, - protected val provisioningService: ProvisioningService, - protected val groupingProperties: GroupingProperties, @Qualifier(GSON_BEAN_NAME) protected val gson: Gson, protected val groupGenMetrics: GroupGenMetrics, - protected val keywordExtractor: KeywordExtractor, - protected val genGrouper: GenGroupper, - protected val groupContentGenerator: GroupContentGenerator, + protected val contentsCommonGenerationService: ContentsCommonGenerationService, ) { protected val log = KotlinLogging.logger {} protected val isRunning = AtomicBoolean(false) @@ -48,7 +37,6 @@ abstract class AbstractGroupGenSchedulingUseCase( abstract val regionName: String abstract val eventTitle: String - @GeneratorTransactional open fun execute() { if (!isRunning.compareAndSet(false, true)) { throw BadRequestException("$regionName group scheduling is already running. Please try again later.") @@ -139,7 +127,7 @@ abstract class AbstractGroupGenSchedulingUseCase( try { val internalResult = runBlocking(groupGenScope.coroutineContext) { - createGroupGenInternalWithMetrics(category) + contentsCommonGenerationService.createSingleGroupGen(category, region) } result = internalResult.groupGen keywordExtractionTime = internalResult.keywordExtractionTime @@ -170,54 +158,6 @@ abstract class AbstractGroupGenSchedulingUseCase( return result ?: throw BadRequestException("$regionName Group Gen 생성 실패 - Cause: Unknown (카테고리: ${category.title})") } - private suspend fun createGroupGenInternalWithMetrics(category: Category): GroupGenProcessingResult { - val gens = - genService.findAllByCreatedAtTodayAndCategoryAndRegion( - category, - region, - ) - - if (gens.isEmpty()) { - throw BadRequestException("$regionName Group Gen 생성 실패 - Cause: 카테고리 ${category.title}에 대한 Gen이 없습니다.") - } - - if (gens.size < groupingProperties.minGroupSize) { - throw BadRequestException( - "$regionName Group Gen 생성 실패 - Cause: 카테고리 ${category.title}의 Gen 개수(${gens.size})가 최소 그룹 크기(${groupingProperties.minGroupSize})보다 작습니다.", - ) - } - - log.info { "$regionName 카테고리 ${category.title}에서 ${gens.size}개 Gen 발견, 키워드 추출 시작" } - - // 배치로 ProvisioningContents 조회하여 N+1 쿼리 방지 - val provisioningContentsIds = gens.map { it.provisioningContentsId } - val provisioningContentsMap = - provisioningService - .findAllByIdIn(provisioningContentsIds) - .associateBy { it.id!! } - - // 키워드 추출 시간 측정 및 실행 (코루틴 버전) - val genDetails: List - val keywordExtractionTime = - measureTimeMillis { - genDetails = keywordExtractor.extractKeywordsFromGens(gens, provisioningContentsMap) - } - - log.info { "키워드 추출 완료, 그룹화 시작" } - - // 그룹화 수행 - val group = genGrouper.performGrouping(genDetails, category) - val validatedGroup = genGrouper.validateGroupSize(group) - - if (validatedGroup == null) { - throw BadRequestException("$regionName Group Gen 생성 실패 - Cause: 카테고리 ${category.title} Gen Grouping 실패") - } - - // 그룹 콘텐츠 생성 - val result = groupContentGenerator.generateGroupContent(category, gens, validatedGroup, provisioningContentsMap, region) - return GroupGenProcessingResult(result, keywordExtractionTime, gens.size) - } - private fun updateSuccessMetrics( category: Category, groupGen: GroupGen, From f9a6f580b0af28387da9eff91215785d931f34f3 Mon Sep 17 00:00:00 2001 From: hun-ca Date: Fri, 23 Jan 2026 08:06:18 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20GroupGenSchedulingUseCase=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9E=90=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Global/LocalGroupGenSchedulingUseCase에서 불필요한 의존성 제거 - ContentsCommonGenerationService를 통한 의존성 주입으로 단순화 - provisioningService, groupingProperties, keywordExtractor, genGrouper, groupContentGenerator 제거 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../usecase/GlobalGroupGenSchedulingUseCase.kt | 18 +++--------------- .../usecase/LocalGroupGenSchedulingUseCase.kt | 18 +++--------------- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/domain/generator/src/main/kotlin/com/few/generator/usecase/GlobalGroupGenSchedulingUseCase.kt b/domain/generator/src/main/kotlin/com/few/generator/usecase/GlobalGroupGenSchedulingUseCase.kt index 03110aaaf..2e30f06a6 100644 --- a/domain/generator/src/main/kotlin/com/few/generator/usecase/GlobalGroupGenSchedulingUseCase.kt +++ b/domain/generator/src/main/kotlin/com/few/generator/usecase/GlobalGroupGenSchedulingUseCase.kt @@ -2,14 +2,10 @@ package com.few.generator.usecase import com.few.common.domain.Region import com.few.generator.config.GeneratorGsonConfig.Companion.GSON_BEAN_NAME -import com.few.generator.config.GroupingProperties import com.few.generator.event.GenSchedulingCompletedEvent +import com.few.generator.service.ContentsCommonGenerationService import com.few.generator.service.GenService -import com.few.generator.service.ProvisioningService -import com.few.generator.service.specifics.groupgen.GenGroupper -import com.few.generator.service.specifics.groupgen.GroupContentGenerator import com.few.generator.service.specifics.groupgen.GroupGenMetrics -import com.few.generator.service.specifics.groupgen.KeywordExtractor import com.google.gson.Gson import org.springframework.beans.factory.annotation.Qualifier import org.springframework.context.ApplicationEventPublisher @@ -21,24 +17,16 @@ import org.springframework.stereotype.Component class GlobalGroupGenSchedulingUseCase( applicationEventPublisher: ApplicationEventPublisher, genService: GenService, - provisioningService: ProvisioningService, - groupingProperties: GroupingProperties, @Qualifier(GSON_BEAN_NAME) gson: Gson, groupGenMetrics: GroupGenMetrics, - keywordExtractor: KeywordExtractor, - genGrouper: GenGroupper, - groupContentGenerator: GroupContentGenerator, + contentsCommonGenerationService: ContentsCommonGenerationService, ) : AbstractGroupGenSchedulingUseCase( applicationEventPublisher, genService, - provisioningService, - groupingProperties, gson, groupGenMetrics, - keywordExtractor, - genGrouper, - groupContentGenerator, + contentsCommonGenerationService, ) { override val region = Region.GLOBAL override val regionName = "GLOBAL" diff --git a/domain/generator/src/main/kotlin/com/few/generator/usecase/LocalGroupGenSchedulingUseCase.kt b/domain/generator/src/main/kotlin/com/few/generator/usecase/LocalGroupGenSchedulingUseCase.kt index bec39c217..646cf38e8 100644 --- a/domain/generator/src/main/kotlin/com/few/generator/usecase/LocalGroupGenSchedulingUseCase.kt +++ b/domain/generator/src/main/kotlin/com/few/generator/usecase/LocalGroupGenSchedulingUseCase.kt @@ -2,14 +2,10 @@ package com.few.generator.usecase import com.few.common.domain.Region import com.few.generator.config.GeneratorGsonConfig.Companion.GSON_BEAN_NAME -import com.few.generator.config.GroupingProperties import com.few.generator.event.GenSchedulingCompletedEvent +import com.few.generator.service.ContentsCommonGenerationService import com.few.generator.service.GenService -import com.few.generator.service.ProvisioningService -import com.few.generator.service.specifics.groupgen.GenGroupper -import com.few.generator.service.specifics.groupgen.GroupContentGenerator import com.few.generator.service.specifics.groupgen.GroupGenMetrics -import com.few.generator.service.specifics.groupgen.KeywordExtractor import com.google.gson.Gson import org.springframework.beans.factory.annotation.Qualifier import org.springframework.context.ApplicationEventPublisher @@ -21,24 +17,16 @@ import org.springframework.stereotype.Component class LocalGroupGenSchedulingUseCase( applicationEventPublisher: ApplicationEventPublisher, genService: GenService, - provisioningService: ProvisioningService, - groupingProperties: GroupingProperties, @Qualifier(GSON_BEAN_NAME) gson: Gson, groupGenMetrics: GroupGenMetrics, - keywordExtractor: KeywordExtractor, - genGrouper: GenGroupper, - groupContentGenerator: GroupContentGenerator, + contentsCommonGenerationService: ContentsCommonGenerationService, ) : AbstractGroupGenSchedulingUseCase( applicationEventPublisher, genService, - provisioningService, - groupingProperties, gson, groupGenMetrics, - keywordExtractor, - genGrouper, - groupContentGenerator, + contentsCommonGenerationService, ) { override val region = Region.LOCAL override val regionName = "LOCAL" From a4ff26754cb33e3b2165db2960d2d04daa6c31da Mon Sep 17 00:00:00 2001 From: hun-ca Date: Tue, 27 Jan 2026 22:49:13 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20groupGenScope=EC=9D=84=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=20=EC=8A=A4=EB=A0=88=EB=93=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=ED=95=98=EC=97=AC=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EC=95=88=EC=A0=84=EC=84=B1=20=ED=99=95?= =?UTF-8?q?=EB=B3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dispatchers.IO -> Dispatchers.IO.limitedParallelism(1)로 변경 - Spring @Transactional은 ThreadLocal 기반이므로 단일 스레드 보장 필요 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../generator/usecase/AbstractGroupGenSchedulingUseCase.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt b/domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt index e2bbdab86..6b257b06f 100644 --- a/domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt +++ b/domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt @@ -13,6 +13,7 @@ import com.google.gson.Gson import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.runBlocking import org.springframework.beans.factory.annotation.Qualifier @@ -21,6 +22,7 @@ import java.time.LocalDateTime import java.util.concurrent.atomic.AtomicBoolean import kotlin.system.measureTimeMillis +@OptIn(ExperimentalCoroutinesApi::class) abstract class AbstractGroupGenSchedulingUseCase( protected val applicationEventPublisher: ApplicationEventPublisher, protected val genService: GenService, @@ -31,7 +33,7 @@ abstract class AbstractGroupGenSchedulingUseCase( ) { protected val log = KotlinLogging.logger {} protected val isRunning = AtomicBoolean(false) - protected val groupGenScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + protected val groupGenScope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1)) abstract val region: Region abstract val regionName: String