Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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하기 위해
* 개별 트랜잭션으로 분리
Expand All @@ -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 {
Comment on lines +46 to +50

@coderabbitai coderabbitai Bot Jan 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

fd -type f -name "*.kt" -o -name "*.java" | xargs rg -l "GeneratorTransactional" | head -20

Repository: few-letter/few-be

Length of output: 1148


🏁 Script executed:

fd -type f \( -name "*.kt" -o -name "*.java" \) | xargs rg "@interface GeneratorTransactional" -A 15

Repository: few-letter/few-be

Length of output: 233


🏁 Script executed:

fd -type f -path "*/generator/*" \( -name "*.kt" -o -name "*.java" \) | xargs rg "GeneratorTransactional" -B 2 -A 2 | head -50

Repository: few-letter/few-be

Length of output: 5908


🏁 Script executed:

cat -n domain/generator/src/main/kotlin/com/few/generator/service/ContentsCommonGenerationService.kt | sed -n '40,100p'

Repository: few-letter/few-be

Length of output: 2716


🏁 Script executed:

cat -n domain/generator/src/main/kotlin/com/few/generator/support/jpa/GeneratorTransactional.kt

Repository: few-letter/few-be

Length of output: 2270


🏁 Script executed:

rg "@GeneratorTransactional" -A 5 | rg -B 5 "suspend fun"

Repository: few-letter/few-be

Length of output: 664


🏁 Script executed:

rg "suspend fun" domain/generator/src/main/kotlin -A 1 | rg -B 1 "@GeneratorTransactional"

Repository: few-letter/few-be

Length of output: 43


suspend 함수에 @GeneratorTransactional 사용 시 트랜잭션 관리 실패 가능성

Spring의 @Transactional은 ThreadLocal 기반으로 동작하는데, suspend 함수는 코루틴 컨텍스트 전환 시 다른 스레드에서 실행될 수 있어 트랜잭션 컨텍스트가 유실될 수 있습니다. 다음 중 하나로 해결하세요:

  1. suspend 키워드를 제거하고 blocking 함수로 변경
  2. Spring의 TransactionalOperator를 사용하여 reactive/코루틴 방식으로 구현
🤖 Prompt for AI Agents
In
`@domain/generator/src/main/kotlin/com/few/generator/service/ContentsCommonGenerationService.kt`
around lines 46 - 50, The annotated suspend function createSingleGroupGen uses
`@GeneratorTransactional` which can lose ThreadLocal transaction context across
coroutine switches; either remove the suspend modifier and make
createSingleGroupGen a blocking function so `@GeneratorTransactional` works
reliably, or keep it suspend and replace `@GeneratorTransactional` usage by
wrapping the method body with a TransactionalOperator (or equivalent
coroutine-aware transaction API) to start/commit/rollback the transaction in a
coroutine-safe way; update callers accordingly to the chosen approach.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

같은 코루틴스콥 사용하는데 다른 쓰레드에서 수행될 수도 있어?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🌐 Web query:

Kotlin coroutines suspend functions Spring `@Transactional` ThreadLocal context

💡 Result:

Short answer

  • Spring supports suspend functions in transactions (since Spring Framework 5.3), but reactive/coroutine transactions are not the same as classic ThreadLocal-based transactions. Use reactive transaction support (TransactionalOperator.executeAndAwait or reactive transaction managers) or keep blocking/JPA work on a single thread. [1][2][3]

Why this matters

  • Classic @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

  • Spring 5.3+ recognizes suspend functions and has coroutine-friendly helpers: TransactionalOperator.executeAndAwait and Flow.transactional for reactive transaction management. These work with reactive drivers (R2DBC, reactive Mongo, etc.) where transaction state is kept in Reactor/Coroutine context rather than ThreadLocal. Use these for non-blocking DB access. [1][2][3]
  • For JPA (blocking) you must not rely on suspend functions that switch threads while a transaction is active. Options:
    • Keep the @Transactional method non-suspending (regular synchronous) so the whole transaction runs on one thread. [5]
    • If you must use suspend code, confine the coroutine to a single thread (e.g., runBlocking or use a single-threaded Dispatcher) so ThreadLocal stays valid for the transaction scope. This is a workaround but loses non-blocking benefits. [6]
    • Replace blocking JPA/JDBC with reactive alternatives (R2DBC, reactive drivers) so you can use reactive/coroutine transactions properly. [4][3]

ThreadLocal / context propagation options

  • Micrometer’s context-propagation and kotlinx-coroutines-reactor help propagate observability/tracing/other contextual data into coroutine contexts (PropagationContextElement, Hooks.enableAutomaticContextPropagation). This helps traces/logs but does not make ThreadLocal-backed transaction managers magically coroutine-aware. [1]

Practical checklist

  • If using reactive DB (R2DBC, reactive Mongo, etc.): use reactive transaction manager + TransactionalOperator.executeAndAwait or Flow.transactional. [1][3]
  • If using JPA/Hibernate (blocking): avoid suspending @Transactional methods or confine execution to one thread; consider migrating to reactive drivers if you need coroutine-native transactions. [4][5][7]
  • If you need ThreadLocal-like context (tracing/security) in coroutines, add io.micrometer:context-propagation and/or kotlinx-coroutines-reactor and configure PropagationContextElement / Hooks as documented. [1]

Sources

  1. Spring Framework — Coroutines (transactions & context propagation). [spring.io docs].
  2. Spring Data / Coroutines docs (Coroutine repositories + transactional notes).
  3. Spring blog: Reactive transactions background & Reactor Context.
  4. StackOverflow: differences/limitations of @Transactional on suspend functions (JPA vs reactive).
  5. Spring TransactionSynchronizationManager docs (ThreadLocal-based resource management).
  6. Blog posts describing coroutine dispatchers, Unconfined behavior and ThreadLocal implications.

(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:

#!/bin/bash

# Check what type of database/transaction manager is being used
echo "=== Checking GeneratorTransactional annotation ==="
cat domain/generator/src/main/kotlin/com/few/generator/support/jpa/GeneratorTransactional.kt

echo -e "\n=== Checking for coroutine dispatcher configuration ==="
rg -n "Dispatchers\." domain/generator/src/main/kotlin/ -A 2 -B 2

echo -e "\n=== Checking how createSingleGroupGen is called ==="
rg -n "createSingleGroupGen" domain/generator/src/main/kotlin/ -A 3 -B 3

echo -e "\n=== Checking for withContext usage ==="
rg -n "withContext" domain/generator/src/main/kotlin/ -A 2 -B 2

echo -e "\n=== Checking database dependencies (JPA vs R2DBC) ==="
fd -e gradle -e xml | xargs rg -l "spring-boot-starter-data-jpa|hibernate|r2dbc" | head -5

Length of output: 7544


@hun-ca 좋은 질문입니다! 하지만 스코프(Scope)와 디스패처(Dispatcher)는 다릅니다. 같은 코루틴 스코프를 사용해도 스레드가 전환될 수 있습니다.

현재 코드 분석

코드베이스를 확인해보니:

// AbstractGroupGenSchedulingUseCase.kt:34
protected val groupGenScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

// AbstractGroupGenSchedulingUseCase.kt:129-130
runBlocking(groupGenScope.coroutineContext) {
    contentsCommonGenerationService.createSingleGroupGen(category, region)
}

Dispatchers.IO는 스레드 풀이므로 suspend 지점마다 다른 스레드에서 재개될 수 있습니다.

더욱이 KeyWordsCreator에서 명시적으로 dispatcher를 전환합니다:

// KeyWordsCreator.kt:57
withContext(Dispatchers.IO) {
    // 키워드 추출 로직
}

핵심 개념

  • CoroutineScope: 코루틴의 생명주기를 관리하는 범위
  • Dispatcher: 코루틴이 어느 스레드(풀)에서 실행될지 결정
  • Dispatchers.IO: 공유 스레드 풀 (기본 64개)

같은 scope여도 Dispatchers.IO를 사용하면 suspend 지점에서 다른 스레드로 전환 가능합니다.

JPA @Transactional 문제

@GeneratorTransactional은 JPA의 @Transactional을 래핑한 것으로, ThreadLocal에 트랜잭션 컨텍스트를 저장합니다. 스레드가 바뀌면 트랜잭션 컨텍스트를 잃을 수 있습니다.

해결 방안

  1. 현재 동작 확인: runBlocking 사용으로 실제 문제가 발생하는지 트랜잭션 테스트 필요
  2. 안전한 방법 (권장):
    • suspend 키워드 제거하고 일반 함수로 변경
    • 또는 Dispatchers.IO.limitedParallelism(1)로 단일 스레드 사용
  3. 장기적 해결: R2DBC 등 리액티브 DB로 마이그레이션

현재 코드가 실제 문제를 일으키는지 트랜잭션 롤백 테스트를 해보시는 것을 권장드립니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dispatchers.IO.limitedParallelism(1) 로 단일 쓰레드 사용하는 방식으로 리펙토링한 코드 알려줘

Copy link
Copy Markdown

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

abstract class AbstractGroupGenSchedulingUseCase(
    // ... 기존 dependencies
) {
    // 변경 전: protected val groupGenScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    
    // 변경 후: 단일 스레드 디스패처 사용
    protected val singleThreadDispatcher = Dispatchers.IO.limitedParallelism(1)
    protected val groupGenScope = CoroutineScope(SupervisorJob() + singleThreadDispatcher)
    
    // ... 나머지 코드는 동일
}

2. KeyWordsCreator 수정 (중요!)

domain/generator/src/main/kotlin/com/few/generator/service/specifics/groupgen/KeyWordsCreator.kt

현재 KeyWordsCreatorwithContext(Dispatchers.IO)로 디스패처를 전환하고 있다면, 이 부분도 수정이 필요합니다:

// 변경 전:
suspend fun extractKeywords(...) {
    withContext(Dispatchers.IO) {
        // 키워드 추출 로직
    }
}

// 변경 후: withContext 제거하여 parent context 사용
suspend fun extractKeywords(...) {
    // withContext 없이 그냥 실행
    // parent coroutine의 dispatcher를 그대로 사용
    // 키워드 추출 로직
}

3. 동작 원리

// 호출 흐름:
groupGenScope.launch {  // singleThreadDispatcher 사용
    runBlocking(groupGenScope.coroutineContext) {
        contentsCommonGenerationService.createSingleGroupGen(...)
            // `@GeneratorTransactional` 시작 (Thread A)
            // suspend 지점에서도 Thread A 유지
            keywordExtractor.extractKeywordsFromGens(...)
                // withContext 없으면 동일 Thread A 유지
            // 트랜잭션 커밋/롤백 (Thread A)
    }
}

주의사항

  1. 성능 트레이드오프: 단일 스레드 사용으로 병렬 처리 불가
  2. Blocking 작업 주의: 단일 스레드에서 long-running blocking 작업 시 다른 작업 대기
  3. 일관성 보장: 모든 nested suspend 함수에서 withContext 사용 시 같은 dispatcher 사용 필요

대안: 범위를 더 좁히기

트랜잭션 범위만 단일 스레드로 실행하고 싶다면:

`@GeneratorTransactional`
open suspend fun createSingleGroupGen(
    category: Category,
    region: Region,
): GroupGenProcessingResult = withContext(Dispatchers.IO.limitedParallelism(1)) {
    // 모든 트랜잭션 로직
    // ...
}

어떤 방식이 더 적합할지 피드백 주시면 추가로 설명드리겠습니다!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

단일 스레드 사용하면 코루틴 병렬처리가 불가능하다고?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hun-ca 좋은 지적입니다! 제 설명이 부정확했습니다. 정확히 말하면:

동시성 vs 병렬성

  • 동시성(Concurrency): 코루틴은 단일 스레드에서도 suspend/resume으로 여전히 동시 실행 가능합니다
  • 병렬성(Parallelism): 여러 스레드에서 실제로 동시에 실행되는 것

단일 스레드를 사용해도 코루틴의 동시성은 유지되지만, 진짜 병렬 실행은 불가능합니다.

현재 코드의 문제

제가 제안한 방식의 실제 문제는:

// 이렇게 하면 문제!
protected val groupGenScope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1))

// 결과: 모든 카테고리가 순차 처리됨 (한 번에 하나씩)
categories.forEach { category ->
    groupGenScope.launch {  // 모두 같은 단일 스레드 큐에 들어감
        createSingleGroupGen(category, region)
    }
}

더 나은 해결 방법

트랜잭션 범위만 단일 스레드로 제한:

// AbstractGroupGenSchedulingUseCase.kt
protected val groupGenScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)  // 그대로 유지
private val transactionDispatcher = Dispatchers.IO.limitedParallelism(1)  // 추가

// ContentsCommonGenerationService.kt
`@GeneratorTransactional`
open suspend fun createSingleGroupGen(
    category: Category,
    region: Region,
): GroupGenProcessingResult = withContext(transactionDispatcher) {
    // 트랜잭션 로직만 단일 스레드에서 실행
    // 하지만 여러 카테고리는 여전히 병렬로 각자의 트랜잭션 실행
}

이렇게 하면:

  • ✅ 여러 카테고리가 병렬로 처리됨
  • ✅ 각 카테고리의 트랜잭션은 단일 스레드에서 안전하게 실행
  • ⚠️ 단, 모든 트랜잭션이 같은 하나의 스레드를 공유하므로 순차 대기

더 나은 방법이 필요하시면 말씀해주세요!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 코루틴 안에서 blocking HTTP call을 하더라도 해당 코루틴이 응답을 대기하는 동안 다른 코루틴은 수행 가능하지?

Copy link
Copy Markdown

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 (스레드 차단)

// 단일 스레드 dispatcher 사용
val singleThreadDispatcher = Dispatchers.IO.limitedParallelism(1)
val scope = CoroutineScope(singleThreadDispatcher)

scope.launch {  // 코루틴 1
    val response = URL("https://api.example.com").readText()  // ❌ 스레드 차단!
    // HTTP 응답 대기 중 → 스레드가 BLOCKED 상태
    // 다른 코루틴 실행 불가!
}

scope.launch {  // 코루틴 2
    // 코루틴 1이 완료될 때까지 대기...
}

Blocking 함수는 작업이 완료될 때까지 실행 중인 스레드를 점유하여 해당 스레드에서 다른 작업이 실행되는 것을 방지합니다.

✅ Suspending Call (코루틴 일시중단)

scope.launch {  // 코루틴 1
    val response = httpClient.get("https://api.example.com")  // ✅ 코루틴 suspend!
    // HTTP 응답 대기 중 → 스레드는 해제됨
    // 다른 코루틴 실행 가능!
}

scope.launch {  // 코루틴 2
    // 코루틴 1이 suspend된 동안 실행 가능!
}

코루틴이 suspending 함수를 만나면 기본 스레드를 차단하지 않고 자발적으로 실행을 일시 중단할 수 있습니다. 이를 통해 다른 코루틴이 그 사이에 계속 실행될 수 있습니다.

현재 코드의 문제

KeyWordsCreator에서 실제로 어떤 HTTP 클라이언트를 사용하는지가 중요합니다:

// ❌ 만약 이런 식이면 blocking
withContext(Dispatchers.IO) {
    URL(url).readText()  // Java의 blocking IO
}

// ✅ 이런 식이어야 suspending
withContext(Dispatchers.IO) {
    httpClient.get(url)  // Ktor 등의 suspending HTTP client
}

결론

  • Blocking call: 스레드를 차단 → 단일 스레드에서는 다른 코루틴 실행 불가
  • Suspending call: 코루틴만 일시중단 → 스레드는 해제되어 다른 코루틴 실행 가능
  • 코루틴이라고 자동으로 non-blocking이 되는 것은 아닙니다 - suspend 키워드를 사용해도 내부에서 blocking 호출을 하면 스레드가 차단됩니다

현재 코드가 어떤 HTTP 클라이언트를 사용하는지 확인해보시겠어요?

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

high: It's better to throw a more specific exception like NoSuchElementException or create a custom exception that clearly indicates that the Gen was not found for the given category and region. This can help in better error handling and debugging.

Suggested change
if (gens.isEmpty()) {
throw BadRequestException("${region.name} Group Gen 생성 실패 - Cause: 카테고리 ${category.title}에 대한 Gen이 없습니다.")
}
if (gens.isEmpty()) {
throw NoSuchElementException("${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<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
Expand Up @@ -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
Expand All @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Dispatchers.IO.limitedParallelism(1)로 변경하면서 groupGenScope를 사용하는 모든 코루틴이 단일 스레드에서 순차적으로 실행되게 됩니다.
KeywordExtractor.extractKeywordsFromGens 내부에서는 여러 Gen에 대한 키워드 추출을 async를 사용해 병렬로 처리하도록 구현되어 있습니다. 하지만 이 변경으로 인해 키워드 추출 작업들이 병렬로 실행되지 않고 순차적으로 실행되어 성능 저하가 발생할 수 있습니다.

카테고리별 GroupGen 생성은 이미 createGroupGens 메서드의 forEach 루프를 통해 순차적으로 처리되고 있으므로, 디스패처 수준에서 병렬 처리를 제한할 필요는 없어 보입니다.

성능 저하를 막기 위해 limitedParallelism(1)을 제거하는 것을 제안합니다.

Suggested change
protected val groupGenScope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1))
protected val groupGenScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

critical: The exception message "$regionName group scheduling is already running. Please try again later." is not localized. Ensure that all user-facing messages are properly localized for internationalization.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
Loading