-
Notifications
You must be signed in to change notification settings - Fork 0
[Fix/#909] Group Gen 개별 생성 트랜잭션으로 분리 #911
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
a8cb705
540e75b
f9a6f58
41cea86
a4ff267
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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이 없습니다.") | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+57
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. high: It's better to throw a more specific exception like
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| 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<GenDetail> | ||||||||||||||
| 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) | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -4,22 +4,16 @@ 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 | ||||||
| import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.ExperimentalCoroutinesApi | ||||||
| import kotlinx.coroutines.SupervisorJob | ||||||
| import kotlinx.coroutines.runBlocking | ||||||
| import org.springframework.beans.factory.annotation.Qualifier | ||||||
|
|
@@ -28,27 +22,23 @@ 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, | ||||||
| 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) | ||||||
| protected val groupGenScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) | ||||||
| protected val groupGenScope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1)) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. medium: Limiting parallelism to 1 might be too restrictive. Consider using a configuration property to control the level of parallelism, allowing for more flexibility in different environments. Also, consider the number of cores available and the nature of the tasks being executed to determine an appropriate level of parallelism. If the tasks are I/O bound, a higher level of parallelism might be beneficial. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
카테고리별 성능 저하를 막기 위해
Suggested change
|
||||||
|
|
||||||
| abstract val region: Region | ||||||
| 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.") | ||||||
|
Comment on lines
42
to
44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
|
|
@@ -139,7 +129,7 @@ abstract class AbstractGroupGenSchedulingUseCase( | |||||
| try { | ||||||
| val internalResult = | ||||||
| runBlocking(groupGenScope.coroutineContext) { | ||||||
| createGroupGenInternalWithMetrics(category) | ||||||
| contentsCommonGenerationService.createSingleGroupGen(category, region) | ||||||
| } | ||||||
| result = internalResult.groupGen | ||||||
| keywordExtractionTime = internalResult.keywordExtractionTime | ||||||
|
|
@@ -170,54 +160,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<GenDetail> | ||||||
| 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, | ||||||
|
|
||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: few-letter/few-be
Length of output: 1148
🏁 Script executed:
Repository: few-letter/few-be
Length of output: 233
🏁 Script executed:
Repository: few-letter/few-be
Length of output: 5908
🏁 Script executed:
Repository: few-letter/few-be
Length of output: 2716
🏁 Script executed:
Repository: few-letter/few-be
Length of output: 2270
🏁 Script executed:
Repository: few-letter/few-be
Length of output: 664
🏁 Script executed:
Repository: few-letter/few-be
Length of output: 43
suspend함수에@GeneratorTransactional사용 시 트랜잭션 관리 실패 가능성Spring의
@Transactional은 ThreadLocal 기반으로 동작하는데,suspend함수는 코루틴 컨텍스트 전환 시 다른 스레드에서 실행될 수 있어 트랜잭션 컨텍스트가 유실될 수 있습니다. 다음 중 하나로 해결하세요:suspend키워드를 제거하고 blocking 함수로 변경TransactionalOperator를 사용하여 reactive/코루틴 방식으로 구현🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
같은 코루틴스콥 사용하는데 다른 쓰레드에서 수행될 수도 있어?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
💡 Result:
Short answer
Why this matters
@Transactional(imperative) binds transactional state and resources (EntityManager, JDBC Connection) to the current Thread via ThreadLocal. Coroutines can suspend and resume on different threads, so ThreadLocal-bound state is lost across suspensions. That breaks JPA/JDBC transaction semantics if you rely on coroutine continuations switching threads. [4][5]What Spring provides and recommended approaches
@Transactionalmethod non-suspending (regular synchronous) so the whole transaction runs on one thread. [5]ThreadLocal / context propagation options
Practical checklist
@Transactionalmethods or confine execution to one thread; consider migrating to reactive drivers if you need coroutine-native transactions. [4][5][7]Sources
@Transactionalon suspend functions (JPA vs reactive).(If you want, I can list direct links to the exact docs/examples or show minimal code examples for: TransactionalOperator.executeAndAwait, confining a suspend function to a single-threaded dispatcher, or migrating a repo to R2DBC.)
🏁 Script executed:
Length of output: 7544
@hun-ca좋은 질문입니다! 하지만 스코프(Scope)와 디스패처(Dispatcher)는 다릅니다. 같은 코루틴 스코프를 사용해도 스레드가 전환될 수 있습니다.현재 코드 분석
코드베이스를 확인해보니:
Dispatchers.IO는 스레드 풀이므로 suspend 지점마다 다른 스레드에서 재개될 수 있습니다.더욱이
KeyWordsCreator에서 명시적으로 dispatcher를 전환합니다:핵심 개념
Dispatchers.IO: 공유 스레드 풀 (기본 64개)같은 scope여도
Dispatchers.IO를 사용하면 suspend 지점에서 다른 스레드로 전환 가능합니다.JPA
@Transactional문제@GeneratorTransactional은 JPA의@Transactional을 래핑한 것으로, ThreadLocal에 트랜잭션 컨텍스트를 저장합니다. 스레드가 바뀌면 트랜잭션 컨텍스트를 잃을 수 있습니다.해결 방안
runBlocking사용으로 실제 문제가 발생하는지 트랜잭션 테스트 필요suspend키워드 제거하고 일반 함수로 변경Dispatchers.IO.limitedParallelism(1)로 단일 스레드 사용현재 코드가 실제 문제를 일으키는지 트랜잭션 롤백 테스트를 해보시는 것을 권장드립니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dispatchers.IO.limitedParallelism(1) 로 단일 쓰레드 사용하는 방식으로 리펙토링한 코드 알려줘
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@hun-ca다음과 같이 리팩토링할 수 있습니다:1. AbstractGroupGenSchedulingUseCase 수정
domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt
2. KeyWordsCreator 수정 (중요!)
domain/generator/src/main/kotlin/com/few/generator/service/specifics/groupgen/KeyWordsCreator.kt
현재
KeyWordsCreator가withContext(Dispatchers.IO)로 디스패처를 전환하고 있다면, 이 부분도 수정이 필요합니다:3. 동작 원리
주의사항
withContext사용 시 같은 dispatcher 사용 필요대안: 범위를 더 좁히기
트랜잭션 범위만 단일 스레드로 실행하고 싶다면:
어떤 방식이 더 적합할지 피드백 주시면 추가로 설명드리겠습니다!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
단일 스레드 사용하면 코루틴 병렬처리가 불가능하다고?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@hun-ca좋은 지적입니다! 제 설명이 부정확했습니다. 정확히 말하면:동시성 vs 병렬성
단일 스레드를 사용해도 코루틴의 동시성은 유지되지만, 진짜 병렬 실행은 불가능합니다.
현재 코드의 문제
제가 제안한 방식의 실제 문제는:
더 나은 해결 방법
트랜잭션 범위만 단일 스레드로 제한:
이렇게 하면:
더 나은 방법이 필요하시면 말씀해주세요!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
해당 코루틴 안에서 blocking HTTP call을 하더라도 해당 코루틴이 응답을 대기하는 동안 다른 코루틴은 수행 가능하지?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@hun-ca아니요, blocking HTTP call은 스레드를 차단하므로 다른 코루틴이 실행될 수 없습니다.핵심 차이
❌ Blocking Call (스레드 차단)
Blocking 함수는 작업이 완료될 때까지 실행 중인 스레드를 점유하여 해당 스레드에서 다른 작업이 실행되는 것을 방지합니다.
✅ Suspending Call (코루틴 일시중단)
scope.launch { // 코루틴 1 val response = httpClient.get("https://api.example.com") // ✅ 코루틴 suspend! // HTTP 응답 대기 중 → 스레드는 해제됨 // 다른 코루틴 실행 가능! } scope.launch { // 코루틴 2 // 코루틴 1이 suspend된 동안 실행 가능! }코루틴이 suspending 함수를 만나면 기본 스레드를 차단하지 않고 자발적으로 실행을 일시 중단할 수 있습니다. 이를 통해 다른 코루틴이 그 사이에 계속 실행될 수 있습니다.
현재 코드의 문제
KeyWordsCreator에서 실제로 어떤 HTTP 클라이언트를 사용하는지가 중요합니다:결론
현재 코드가 어떤 HTTP 클라이언트를 사용하는지 확인해보시겠어요?