Conversation
- createSingleGroupGen 메서드 추가하여 단일 카테고리 GroupGen 생성 로직 추출 - 키워드 추출, 그룹화, 그룹 콘텐츠 생성 로직을 Service로 이동 - 개별 트랜잭션 처리로 롤백 범위 최소화 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
…nService 사용 - createGroupGenInternalWithMetrics 로직을 ContentsCommonGenerationService로 위임 - 불필요한 의존성 제거 (provisioningService, groupingProperties, keywordExtractor 등) - UseCase는 스케줄링 조율 역할만 담당하도록 책임 분리 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Global/LocalGroupGenSchedulingUseCase에서 불필요한 의존성 제거 - ContentsCommonGenerationService를 통한 의존성 주입으로 단순화 - provisioningService, groupingProperties, keywordExtractor, genGrouper, groupContentGenerator 제거 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Walkthrough그룹 생성 트리를 ContentsCommonGenerationService로 집중화하는 변경입니다. 새로운 Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Scheduler
participant AbstractUseCase
participant ContentsService as ContentsCommonGenerationService
participant GenRepo as GenRepository
participant Provisioning as ProvisioningService
participant Keyword as KeywordExtractor
participant Grouper as GenGroupper
participant Generator as GroupContentGenerator
participant EventPub as ApplicationEventPublisher
Scheduler->>AbstractUseCase: execute()
AbstractUseCase->>ContentsService: createSingleGroupGen(category, region)
ContentsService->>GenRepo: fetch today's Gens by category/region
GenRepo-->>ContentsService: gens
ContentsService->>Provisioning: batch fetch provisioning contents
Provisioning-->>ContentsService: provisioningContents
ContentsService->>Keyword: extract keywords (measure time)
Keyword-->>ContentsService: keywords
ContentsService->>Grouper: group gens by keywords
Grouper-->>ContentsService: groups
ContentsService->>Generator: generate group content
Generator-->>ContentsService: generatedResult
ContentsService->>EventPub: publish completion event
ContentsService-->>AbstractUseCase: GroupGenProcessingResult
AbstractUseCase-->>Scheduler: 완료 / metrics
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: Organization UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (1)
🧰 Additional context used🧠 Learnings (1)📚 Learning: 2026-01-22T09:30:57.808ZApplied to files:
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
🔇 Additional comments (1)
✏️ Tip: You can disable this entire section by setting Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt (1)
141-156: 에러 메트릭이 중복 기록될 수 있습니다.예외 발생 시
processingError가 설정되어 142-144 라인에서 에러가 기록되고, 이후result가 null이므로 148-152 라인에서 다시 에러가 기록됩니다.♻️ 제안된 수정
// 처리 중 에러가 발생한 경우 메트릭 기록 processingError?.let { error -> groupGenMetrics.recordGroupGenError(category, error.message ?: "Unknown error", totalProcessingTime) } // 전체 처리 시간이 측정된 후 메트릭 기록 val finalResult = result - if (finalResult == null || (finalResult.headline.isEmpty() && finalResult.summary.isEmpty())) { + if (processingError == null && (finalResult == null || (finalResult.headline.isEmpty() && finalResult.summary.isEmpty()))) { log.warn { "$regionName 그룹 생성 실패 또는 빈 결과: category=${category.title}, headline=${finalResult?.headline?.isNotEmpty()}, summary=${finalResult?.summary?.isNotEmpty()}" } groupGenMetrics.recordGroupGenError(category, "GroupGen creation failed", totalProcessingTime) - } else { + } else if (finalResult != null && (finalResult.headline.isNotEmpty() || finalResult.summary.isNotEmpty())) { // 성공 시 전체 처리 시간 포함한 메트릭 업데이트 updateSuccessMetrics(category, finalResult, keywordExtractionTime, totalProcessingTime, totalGens) }
🤖 Fix all issues with AI agents
In
`@domain/generator/src/main/kotlin/com/few/generator/service/ContentsCommonGenerationService.kt`:
- Around line 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.
🧹 Nitpick comments (1)
domain/generator/src/main/kotlin/com/few/generator/service/ContentsCommonGenerationService.kt (1)
70-74: Non-null assertion(!!) 대신 안전한 null 처리를 권장합니다.
it.id!!는 엔티티의 ID가 null인 경우NullPointerException을 발생시킵니다.requireNotNull을 사용하여 명확한 에러 메시지를 제공하면 디버깅이 용이합니다.♻️ 제안된 수정
val provisioningContentsMap = provisioningService .findAllByIdIn(provisioningContentsIds) - .associateBy { it.id!! } + .associateBy { requireNotNull(it.id) { "ProvisioningContents ID가 null입니다." } }
📜 Review details
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
domain/generator/src/main/kotlin/com/few/generator/service/ContentsCommonGenerationService.ktdomain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.ktdomain/generator/src/main/kotlin/com/few/generator/usecase/GlobalGroupGenSchedulingUseCase.ktdomain/generator/src/main/kotlin/com/few/generator/usecase/LocalGroupGenSchedulingUseCase.kt
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
- GitHub Check: api-test
- GitHub Check: Build and analyze
- GitHub Check: lint
🔇 Additional comments (6)
domain/generator/src/main/kotlin/com/few/generator/service/ContentsCommonGenerationService.kt (2)
57-65: 입력 검증 로직이 잘 구현되어 있습니다.빈 Gen 목록과 최소 그룹 크기에 대한 검증이 명확한 에러 메시지와 함께 적절하게 구현되어 있습니다.
76-81: 키워드 추출 시간 측정 및 배치 조회 패턴이 적절합니다.N+1 쿼리 방지를 위한 배치 조회와
measureTimeMillis를 통한 성능 측정이 잘 구현되어 있습니다.domain/generator/src/main/kotlin/com/few/generator/usecase/GlobalGroupGenSchedulingUseCase.kt (1)
17-30: 의존성 통합을 통한 생성자 단순화가 잘 적용되었습니다.여러 개의 세부 서비스 의존성을
ContentsCommonGenerationService하나로 통합하여 결합도를 낮추고 책임 분리를 명확히 했습니다.domain/generator/src/main/kotlin/com/few/generator/usecase/LocalGroupGenSchedulingUseCase.kt (1)
17-30: GlobalGroupGenSchedulingUseCase와 일관된 리팩토링이 적용되었습니다.
ContentsCommonGenerationService를 통한 의존성 통합이 일관되게 적용되어 코드 유지보수성이 향상되었습니다.domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt (2)
127-139:runBlocking사용이 적절합니다.
groupGenScope.coroutineContext를 사용하여 전용 코루틴 스코프에서 실행하고, 에러 처리도 적절하게 구현되어 있습니다. 다만,ContentsCommonGenerationService.createSingleGroupGen의suspend+@Transactional조합 문제가 해결되면 이 부분도 정상 동작할 것입니다.
97-115: 개별 트랜잭션 분리 목표가 잘 달성되었습니다.각 카테고리별로
try-catch를 사용하여 실패 시에도 다른 카테고리 처리를 계속하고, 트랜잭션은 서비스 레이어의createSingleGroupGen에서 관리하여 롤백 범위를 최소화했습니다.
✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.
| @GeneratorTransactional | ||
| open suspend fun createSingleGroupGen( | ||
| category: Category, | ||
| region: Region, | ||
| ): GroupGenProcessingResult { |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
fd -type f -name "*.kt" -o -name "*.java" | xargs rg -l "GeneratorTransactional" | head -20Repository: few-letter/few-be
Length of output: 1148
🏁 Script executed:
fd -type f \( -name "*.kt" -o -name "*.java" \) | xargs rg "@interface GeneratorTransactional" -A 15Repository: 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 -50Repository: 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.ktRepository: 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 함수는 코루틴 컨텍스트 전환 시 다른 스레드에서 실행될 수 있어 트랜잭션 컨텍스트가 유실될 수 있습니다. 다음 중 하나로 해결하세요:
suspend키워드를 제거하고 blocking 함수로 변경- 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.
There was a problem hiding this comment.
같은 코루틴스콥 사용하는데 다른 쓰레드에서 수행될 수도 있어?
There was a problem hiding this comment.
🧩 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
@Transactionalmethod 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]
- Keep the
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
@Transactionalmethods 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
- Spring Framework — Coroutines (transactions & context propagation). [spring.io docs].
- Spring Data / Coroutines docs (Coroutine repositories + transactional notes).
- Spring blog: Reactive transactions background & Reactor Context.
- StackOverflow: differences/limitations of
@Transactionalon suspend functions (JPA vs reactive). - Spring TransactionSynchronizationManager docs (ThreadLocal-based resource management).
- 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에 트랜잭션 컨텍스트를 저장합니다. 스레드가 바뀌면 트랜잭션 컨텍스트를 잃을 수 있습니다.
해결 방안
- 현재 동작 확인:
runBlocking사용으로 실제 문제가 발생하는지 트랜잭션 테스트 필요 - 안전한 방법 (권장):
suspend키워드 제거하고 일반 함수로 변경- 또는
Dispatchers.IO.limitedParallelism(1)로 단일 스레드 사용
- 장기적 해결: R2DBC 등 리액티브 DB로 마이그레이션
현재 코드가 실제 문제를 일으키는지 트랜잭션 롤백 테스트를 해보시는 것을 권장드립니다.
There was a problem hiding this comment.
Dispatchers.IO.limitedParallelism(1) 로 단일 쓰레드 사용하는 방식으로 리펙토링한 코드 알려줘
There was a problem hiding this comment.
@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
현재 KeyWordsCreator가 withContext(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)
}
}주의사항
- 성능 트레이드오프: 단일 스레드 사용으로 병렬 처리 불가
- Blocking 작업 주의: 단일 스레드에서 long-running blocking 작업 시 다른 작업 대기
- 일관성 보장: 모든 nested suspend 함수에서
withContext사용 시 같은 dispatcher 사용 필요
대안: 범위를 더 좁히기
트랜잭션 범위만 단일 스레드로 실행하고 싶다면:
`@GeneratorTransactional`
open suspend fun createSingleGroupGen(
category: Category,
region: Region,
): GroupGenProcessingResult = withContext(Dispatchers.IO.limitedParallelism(1)) {
// 모든 트랜잭션 로직
// ...
}어떤 방식이 더 적합할지 피드백 주시면 추가로 설명드리겠습니다!
There was a problem hiding this comment.
단일 스레드 사용하면 코루틴 병렬처리가 불가능하다고?
There was a problem hiding this comment.
@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) {
// 트랜잭션 로직만 단일 스레드에서 실행
// 하지만 여러 카테고리는 여전히 병렬로 각자의 트랜잭션 실행
}이렇게 하면:
- ✅ 여러 카테고리가 병렬로 처리됨
- ✅ 각 카테고리의 트랜잭션은 단일 스레드에서 안전하게 실행
⚠️ 단, 모든 트랜잭션이 같은 하나의 스레드를 공유하므로 순차 대기
더 나은 방법이 필요하시면 말씀해주세요!
There was a problem hiding this comment.
해당 코루틴 안에서 blocking HTTP call을 하더라도 해당 코루틴이 응답을 대기하는 동안 다른 코루틴은 수행 가능하지?
There was a problem hiding this comment.
@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 클라이언트를 사용하는지 확인해보시겠어요?
- 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 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt (1)
9-36:limitedParallelism(1)은 스레드 고정을 보장하지 않아 트랜잭션 안전성이 깨질 수 있습니다36줄의
Dispatchers.IO.limitedParallelism(1)은 동시 실행만 제한할 뿐, suspend 후 다른 IO 스레드에서 재개될 수 있습니다. ThreadLocal 기반@Transactional안전성을 필요로 한다면 스레드 고정 디스패처를 사용해야 합니다.✅ 권장 변경 (스레드 고정 디스패처 + 생명주기 관리)
-import kotlinx.coroutines.ExperimentalCoroutinesApi +import java.util.concurrent.Executors -@OptIn(ExperimentalCoroutinesApi::class) abstract class AbstractGroupGenSchedulingUseCase( protected val applicationEventPublisher: ApplicationEventPublisher, @@ - protected val groupGenScope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1)) + private val groupGenDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + protected val groupGenScope = CoroutineScope(SupervisorJob() + groupGenDispatcher) + + fun close() { + groupGenDispatcher.close() // 리소스 누수 방지 + groupGenScope.cancel() + }또는 ThreadLocal이 필수라면
ThreadLocal.asContextElement()로 컨텍스트에 바인딩하고limitedParallelism(1)을 유지할 수 있습니다.
📜 Review details
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-01-22T09:30:57.808Z
Learnt from: hun-ca
Repo: few-letter/few-be PR: 906
File: domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGenSchedulingUseCase.kt:35-36
Timestamp: 2026-01-22T09:30:57.808Z
Learning: AbstractGenSchedulingUseCase의 executeAsync() 메서드는 컨텐츠 생성 작업의 긴 실행 시간으로 인해 의도적으로 Async로 구현되어 있으며, API 호출자에게 즉시 응답을 반환하는 fire-and-forget 패턴을 사용합니다.
Applied to files:
domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
- GitHub Check: api-test
- GitHub Check: Build and analyze
- GitHub Check: lint
🔇 Additional comments (1)
domain/generator/src/main/kotlin/com/few/generator/usecase/AbstractGroupGenSchedulingUseCase.kt (1)
130-136: createSingleGroupGen으로 단일 경로로 묶은 점 좋습니다Line 132에서 공통 서비스로 위임해 생성 흐름이 집중되고, 이후 메트릭 처리도 일관되게 유지할 수 있어요.
✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.
|
/gemini-review |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a new workflow for group-based content generation, refactoring the logic into a separate service to handle individual generation tasks within their own transactions. It includes features for category and region-based grouping, keyword extraction time measurement, and unified scheduling, while also enhancing stability and maintainability through concurrency control, specifically using limitedParallelism(1) for resource-intensive LLM operations. A security audit found no vulnerabilities. Feedback has been provided to improve error handling and further refine concurrency control.
| open fun execute() { | ||
| if (!isRunning.compareAndSet(false, true)) { | ||
| throw BadRequestException("$regionName group scheduling is already running. Please try again later.") |
| if (gens.isEmpty()) { | ||
| throw BadRequestException("${region.name} Group Gen 생성 실패 - Cause: 카테고리 ${category.title}에 대한 Gen이 없습니다.") | ||
| } |
There was a problem hiding this comment.
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.
| 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이 없습니다.") | |
| } |
| 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.
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.
|
안녕하세요, hun-ca님. 리뷰 요청을 확인했습니다. |
| 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.
Dispatchers.IO.limitedParallelism(1)로 변경하면서 groupGenScope를 사용하는 모든 코루틴이 단일 스레드에서 순차적으로 실행되게 됩니다.
KeywordExtractor.extractKeywordsFromGens 내부에서는 여러 Gen에 대한 키워드 추출을 async를 사용해 병렬로 처리하도록 구현되어 있습니다. 하지만 이 변경으로 인해 키워드 추출 작업들이 병렬로 실행되지 않고 순차적으로 실행되어 성능 저하가 발생할 수 있습니다.
카테고리별 GroupGen 생성은 이미 createGroupGens 메서드의 forEach 루프를 통해 순차적으로 처리되고 있으므로, 디스패처 수준에서 병렬 처리를 제한할 필요는 없어 보입니다.
성능 저하를 막기 위해 limitedParallelism(1)을 제거하는 것을 제안합니다.
| protected val groupGenScope = CoroutineScope(SupervisorJob() + Dispatchers.IO.limitedParallelism(1)) | |
| protected val groupGenScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) |
🎫 연관 이슈
resolved #909
💁♂️ PR 내용
🙈 PR 참고 사항
🚩 추가된 SQL 운영계 실행계획
Summary by CodeRabbit
릴리스 노트
✏️ Tip: You can customize this high-level summary in your review settings.