From 13ca6a97b9bc7402458711f4c03a07a620db7aee Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 6 May 2026 20:21:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20=EB=B2=88=EC=97=AD=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EC=9D=84=20=EA=B7=B8=EB=A3=B9=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=EB=A1=9C=20=EC=B2=98=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260506_번역언어감지효율화구상.md | 17 ++- .../translation/TranslationJobRepository.kt | 28 ++-- .../i18n/translation/TranslationJobWorker.kt | 55 ++++--- .../translation/TranslationJobWorkerTest.kt | 135 +++++++++++++++--- 4 files changed, 181 insertions(+), 54 deletions(-) diff --git a/docs/20260506_번역언어감지효율화구상.md b/docs/20260506_번역언어감지효율화구상.md index b4c06917..1d424a6e 100644 --- a/docs/20260506_번역언어감지효율화구상.md +++ b/docs/20260506_번역언어감지효율화구상.md @@ -203,7 +203,12 @@ - Papago 호출은 DB lock을 잡은 트랜잭션 밖에서 수행하고, claim/완료/실패 상태 변경만 짧은 트랜잭션으로 처리한다. - `FAILED`로 즉시 종료하는 최소 구현에서 지수 백오프 기반 재시도 정책으로 보완한다. - 재시도 정책은 `retry_count`, `next_retry_at`, `last_error_message`를 함께 갱신하고, 최대 재시도 초과 상태를 운영자가 확인할 수 있게 한다. -- worker 처리량과 부하를 운영 설정으로 제어할 수 있도록 `fixed-delay-ms`, tick당 처리 건수, 최대 재시도 횟수, Papago 호출 rate limit을 설정화한다. +- worker 처리량과 부하를 운영 설정으로 제어할 수 있도록 `fixed-delay-ms`, tick당 처리 group 수, 최대 재시도 횟수, Papago 호출 rate limit을 설정화한다. +- tick당 처리 단위는 단순 job row 수가 아니라 `(resource_type, resource_id, target_language)` group으로 잡는다. +- 1차 운영 기준은 tick당 최대 5개 group 처리로 둔다. +- group 내부의 field job은 순차 처리하고, 같은 resource/locale의 모든 필드가 `translation_memory`에 준비된 뒤 read model을 materialize한다. +- 콘텐츠 기준으로는 1개 group이 `title`, `detail`, `tags` 3개 field job이므로 tick당 최대 15개 field job이 된다. +- 캐릭터처럼 필드가 많은 리소스도 group 5개 제한 안에서 처리해 Papago 호출 burst를 완화한다. - 운영 관측을 위해 pending/running/failed/completed count, oldest pending age, 처리 성공/실패 수, Papago 호출 시간, materialize 실패 수를 로그 또는 metric으로 남긴다. ### 번역 job 실행 주기 조정 검토 @@ -214,10 +219,11 @@ - 10분 주기의 장점은 불필요한 DB polling과 Papago 호출 burst 가능성을 줄이고, 낮은 트래픽 환경에서 백그라운드 작업 부하를 완화하는 것이다. - 10분 주기의 단점은 번역 read model 반영 지연이 최대 10분 이상으로 늘어날 수 있다는 점이다. - 조회 정책이 원문 즉시 반환 + job 예약 방식이므로 API 응답 실패로 이어지지는 않지만, 사용자는 첫 조회 후 최대 다음 worker 실행까지 원문을 볼 수 있다. -- 현재 tick당 최대 처리 건수가 20건이면 10분 주기에서 burst backlog 회복 속도가 느려진다. -- 10분 주기를 적용하려면 tick당 처리 건수를 운영 설정으로 조정하거나, pending backlog가 특정 기준을 넘을 때 수동/운영자 재처리 또는 임시 짧은 주기 전환이 가능해야 한다. +- 기존 tick당 20 job row 처리 방식은 같은 resource/locale의 일부 필드만 처리하고 다음 tick으로 넘어갈 수 있다. +- 10분 주기에서는 부분 처리된 resource의 read model 반영이 다음 tick까지 지연될 수 있으므로 `(resource_type, resource_id, target_language)` group 단위 처리로 보완한다. +- 10분 주기를 적용하려면 tick당 처리 group 수를 운영 설정으로 조정하거나, pending backlog가 특정 기준을 넘을 때 수동/운영자 재처리 또는 임시 짧은 주기 전환이 가능해야 한다. - 생성 직후 번역 노출이 중요한 리소스가 발견되면 해당 리소스만 별도 즉시 처리 정책을 두고, 일반 조회 fallback은 10분 주기를 유지한다. -- 1차 운영 기준은 `fixed-delay-ms = 600000`, 원문 fallback 허용, backlog/oldest pending age 모니터링으로 둔다. +- 1차 운영 기준은 `fixed-delay-ms = 600000`, tick당 최대 5개 group, 원문 fallback 허용, backlog/oldest pending age 모니터링으로 둔다. ### 단계별 적용 - 1단계: `TranslationProvider` 인터페이스를 만들고 기존 `PapagoTranslationService`를 provider 구현으로 감싼다. @@ -251,6 +257,9 @@ - 2026-05-06: MySQL unique 제약은 활성 상태 partial unique를 표현할 수 없으므로 완료 job이 있는 동일 key 재예약 시 중복 insert가 발생하지 않도록 repository 파생 쿼리 기반 회귀 테스트를 추가했다. RED는 중복 job 조회 메서드 미구현으로 `compileTestKotlin` 실패, GREEN은 동일 테스트 `BUILD SUCCESSFUL`로 확인했다. - 2026-05-06: 최종 확인에서 Kotlin LSP 서버가 설정되어 있지 않아 `lsp_diagnostics`는 `No LSP server configured for extension: .kt`로 실행할 수 없었다. 대체 검증으로 `./gradlew test`, `./gradlew build`, 신규 focused test `--rerun-tasks`를 실행했고 모두 `BUILD SUCCESSFUL`을 확인했다. - 2026-05-06: 운영 안정화 보완 구현으로 `TranslationJobWorker` 기본 fixed delay를 10분(`600000ms`)으로 변경하고, MySQL `FOR UPDATE SKIP LOCKED` 기반 job id claim, 실패 시 `PENDING` 재전환 + `next_retry_at` backoff + 최대 재시도 후 `FAILED` 전환을 적용했다. RED는 `TranslationJobWorkerTest`에서 원자 claim 메서드 미구현으로 `compileTestKotlin` 실패, GREEN은 동일 focused test `BUILD SUCCESSFUL`로 확인했다. +- 2026-05-06: 문서에 반영한 group 단위 처리 정책을 구현했다. `TranslationJobWorker`는 tick당 최대 5개 `(resource_type, resource_id, target_language)` group을 처리하고, group 내부 pending field job을 `RUNNING`으로 claim한 뒤 모두 성공한 경우 한 번만 read model을 materialize한다. RED는 `findPendingJobIdsForGroupForUpdate`, `processNextGroup` 미구현으로 `TranslationJobWorkerTest`의 `compileTestKotlin` 실패를 확인했고, GREEN은 동일 focused test `BUILD SUCCESSFUL`로 확인했다. +- 2026-05-06: group 처리 리뷰에서 materialize 실패 후 재시도 불가 가능성과 seed row 기반 claim의 group 분리 가능성을 확인했다. 보완 구현으로 단일 native query `findNextPendingGroupJobIdsForUpdate`에서 다음 pending group의 job id들을 `FOR UPDATE SKIP LOCKED`로 함께 claim하고, materialize 실패 시 group job들을 backoff 재시도 대상으로 되돌리도록 수정했다. RED는 새 group claim 메서드 미구현으로 `TranslationJobWorkerTest`의 `compileTestKotlin` 실패를 확인했고, GREEN은 동일 focused test `BUILD SUCCESSFUL`로 확인했다. +- 2026-05-06: group 처리 전환 후 남은 이전 row 단위 claim 함수 사용처를 `rg`, AST 검색, explore/librarian 병렬 탐색으로 확인했다. production 경로가 `processNextGroup` + `findNextPendingGroupJobIdsForUpdate`로 수렴되어 `processNextJob`, `findFirstByStatusAndNextRetryAtLessThanEqualOrderByCreatedAtAsc`, `findNextPendingJobIdForUpdate`, `findPendingJobIdsForGroupForUpdate`를 제거했다. ## 2026-05-06 구현 DDL diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobRepository.kt index 6069b65a..06ce0b09 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobRepository.kt @@ -33,22 +33,26 @@ interface TranslationJobRepository : JpaRepository { @Param("sourceHash") sourceHash: String ): TranslationJob? - fun findFirstByStatusAndNextRetryAtLessThanEqualOrderByCreatedAtAsc( - status: TranslationJobStatus, - nextRetryAt: LocalDateTime - ): TranslationJob? - @Query( value = """ - select id - from translation_job - where status = 'PENDING' - and next_retry_at <= :now - order by created_at asc - limit 1 + select j.id + from translation_job j + join ( + select resource_type, resource_id, target_language + from translation_job + where status = 'PENDING' + and next_retry_at <= :now + order by created_at asc + limit 1 + ) g on j.resource_type = g.resource_type + and j.resource_id = g.resource_id + and j.target_language = g.target_language + where j.status = 'PENDING' + and j.next_retry_at <= :now + order by j.created_at asc for update skip locked """, nativeQuery = true ) - fun findNextPendingJobIdForUpdate(@Param("now") now: LocalDateTime): Long? + fun findNextPendingGroupJobIdsForUpdate(@Param("now") now: LocalDateTime): List } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorker.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorker.kt index fab7ff1c..9513c383 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorker.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorker.kt @@ -20,33 +20,52 @@ class TranslationJobWorker( @Scheduled(fixedDelayString = "\${sodalive.translation-job.fixed-delay-ms:600000}") fun runPendingJobs() { - repeat(MAX_JOBS_PER_TICK) { - if (!processNextJob()) return + repeat(MAX_GROUPS_PER_TICK) { + if (!processNextGroup()) return } } - fun processNextJob(): Boolean { - val job = claimNextJob() ?: return false + fun processNextGroup(): Boolean { + val jobs = claimNextGroup() + if (jobs.isEmpty()) return false + + val firstJob = jobs.first() + val succeededJobs = mutableListOf() + val failedJobs = mutableListOf>() + jobs.forEach { job -> + try { + ensureMemory(job) + succeededJobs.add(job) + } catch (ex: Exception) { + failedJobs.add(job to ex) + } + } + + if (failedJobs.isNotEmpty()) { + succeededJobs.forEach { completeJob(it.id!!) } + failedJobs.forEach { (job, ex) -> failJob(job.id!!, ex) } + return true + } + try { - ensureMemory(job) - materializer.materialize(job.resourceType, job.resourceId, job.targetLanguage) - completeJob(job.id!!) + materializer.materialize(firstJob.resourceType, firstJob.resourceId, firstJob.targetLanguage) + succeededJobs.forEach { completeJob(it.id!!) } } catch (ex: Exception) { - failJob(job.id!!, ex) + succeededJobs.forEach { failJob(it.id!!, ex) } } return true } - private fun claimNextJob(): TranslationJob? { + private fun claimNextGroup(): List { return transactionTemplate.execute { - val jobId = translationJobRepository.findNextPendingJobIdForUpdate(LocalDateTime.now()) - ?: return@execute null - val job = translationJobRepository.findById(jobId).orElse(null) - ?: return@execute null - job.status = TranslationJobStatus.RUNNING - translationJobRepository.save(job) - job - } + val jobIds = translationJobRepository.findNextPendingGroupJobIdsForUpdate(LocalDateTime.now()) + jobIds.mapNotNull { jobId -> + val job = translationJobRepository.findById(jobId).orElse(null) ?: return@mapNotNull null + job.status = TranslationJobStatus.RUNNING + translationJobRepository.save(job) + job + } + }.orEmpty() } private fun ensureMemory(job: TranslationJob) { @@ -129,7 +148,7 @@ class TranslationJobWorker( } companion object { - private const val MAX_JOBS_PER_TICK = 20 + private const val MAX_GROUPS_PER_TICK = 5 private const val MAX_ERROR_LENGTH = 1000 private const val MAX_RETRY_COUNT = 3 } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt index 22e3eb3a..6c2ea3f0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.i18n.translation import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor import org.mockito.Mockito import org.springframework.scheduling.annotation.Scheduled import org.springframework.transaction.support.AbstractPlatformTransactionManager @@ -21,7 +22,7 @@ class TranslationJobWorkerTest { } @Test - fun shouldClaimPendingJobByLockedRepositoryMethod() { + fun shouldClaimPendingJobGroupByLockedRepositoryMethod() { val jobRepository = Mockito.mock(TranslationJobRepository::class.java) val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java) val provider = successfulProvider() @@ -36,17 +37,111 @@ class TranslationJobWorkerTest { val job = translationJob() job.id = 100L - Mockito.`when`(jobRepository.findNextPendingJobIdForUpdate(anyLocalDateTime())).thenReturn(100L) + Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime())).thenReturn(listOf(100L)) Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(job)) - worker.processNextJob() + worker.processNextGroup() - Mockito.verify(jobRepository).findNextPendingJobIdForUpdate(anyLocalDateTime()) - Mockito.verify(jobRepository, Mockito.never()) - .findFirstByStatusAndNextRetryAtLessThanEqualOrderByCreatedAtAsc( - anyTranslationJobStatus(), - anyLocalDateTime() - ) + Mockito.verify(jobRepository).findNextPendingGroupJobIdsForUpdate(anyLocalDateTime()) + } + + @Test + fun shouldProcessAllJobsInClaimedGroupBeforeMaterializing() { + val jobRepository = Mockito.mock(TranslationJobRepository::class.java) + val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java) + val provider = successfulProvider() + val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java) + val worker = TranslationJobWorker( + translationJobRepository = jobRepository, + translationMemoryRepository = memoryRepository, + translationProvider = provider, + materializer = materializer, + transactionManager = TestTransactionManager() + ) + val titleJob = translationJob(fieldKey = "title", sourceText = "제목") + titleJob.id = 100L + val detailJob = translationJob(fieldKey = "detail", sourceText = "설명") + detailJob.id = 101L + + Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime())) + .thenReturn(listOf(100L, 101L)) + Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(titleJob)) + Mockito.`when`(jobRepository.findById(101L)).thenReturn(Optional.of(detailJob)) + + worker.processNextGroup() + + assertEquals(TranslationJobStatus.COMPLETED, titleJob.status) + assertEquals(TranslationJobStatus.COMPLETED, detailJob.status) + Mockito.verify(materializer, Mockito.times(1)).materialize(LanguageTranslationTargetType.CONTENT, 10L, "en") + } + + @Test + fun shouldRetryGroupWhenMaterializationFails() { + val jobRepository = Mockito.mock(TranslationJobRepository::class.java) + val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java) + val provider = successfulProvider() + val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java) + val worker = TranslationJobWorker( + translationJobRepository = jobRepository, + translationMemoryRepository = memoryRepository, + translationProvider = provider, + materializer = materializer, + transactionManager = TestTransactionManager() + ) + val titleJob = translationJob(fieldKey = "title", sourceText = "제목") + titleJob.id = 100L + val detailJob = translationJob(fieldKey = "detail", sourceText = "설명") + detailJob.id = 101L + val beforeRetryAt = titleJob.nextRetryAt + + Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime())) + .thenReturn(listOf(100L, 101L)) + Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(titleJob)) + Mockito.`when`(jobRepository.findById(101L)).thenReturn(Optional.of(detailJob)) + Mockito.`when`(materializer.materialize(LanguageTranslationTargetType.CONTENT, 10L, "en")) + .thenThrow(IllegalStateException("materialize down")) + + worker.processNextGroup() + + assertEquals(TranslationJobStatus.PENDING, titleJob.status) + assertEquals(TranslationJobStatus.PENDING, detailJob.status) + assertEquals(1, titleJob.retryCount) + assertEquals(1, detailJob.retryCount) + assertTrue(titleJob.nextRetryAt.isAfter(beforeRetryAt)) + assertTrue(detailJob.nextRetryAt.isAfter(beforeRetryAt)) + } + + @Test + fun shouldLimitRunToFiveGroupsPerTick() { + val jobRepository = Mockito.mock(TranslationJobRepository::class.java) + val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java) + val provider = successfulProvider() + val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java) + val worker = TranslationJobWorker( + translationJobRepository = jobRepository, + translationMemoryRepository = memoryRepository, + translationProvider = provider, + materializer = materializer, + transactionManager = TestTransactionManager() + ) + val jobs = (1L..6L).map { id -> + translationJob(resourceId = id).also { it.id = id } + } + Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime())) + .thenReturn(listOf(1L), listOf(2L), listOf(3L), listOf(4L), listOf(5L), listOf(6L)) + jobs.forEach { job -> + Mockito.`when`(jobRepository.findById(job.id!!)).thenReturn(Optional.of(job)) + } + + worker.runPendingJobs() + + val savedJobCaptor = ArgumentCaptor.forClass(TranslationJob::class.java) + Mockito.verify(jobRepository, Mockito.atLeastOnce()).save(savedJobCaptor.capture()) + val completedResourceIds = savedJobCaptor.allValues + .filter { it.status == TranslationJobStatus.COMPLETED } + .map { it.resourceId } + .toSet() + assertEquals(setOf(1L, 2L, 3L, 4L, 5L), completedResourceIds) } @Test @@ -66,10 +161,10 @@ class TranslationJobWorkerTest { job.id = 200L val beforeRetryAt = job.nextRetryAt - Mockito.`when`(jobRepository.findNextPendingJobIdForUpdate(anyLocalDateTime())).thenReturn(200L) + Mockito.`when`(jobRepository.findNextPendingGroupJobIdsForUpdate(anyLocalDateTime())).thenReturn(listOf(200L)) Mockito.`when`(jobRepository.findById(200L)).thenReturn(Optional.of(job)) - worker.processNextJob() + worker.processNextGroup() assertEquals(TranslationJobStatus.PENDING, job.status) assertEquals(1, job.retryCount) @@ -77,13 +172,17 @@ class TranslationJobWorkerTest { assertTrue(job.nextRetryAt.isAfter(beforeRetryAt)) } - private fun translationJob(): TranslationJob { + private fun translationJob( + resourceId: Long = 10L, + fieldKey: String = "title", + sourceText: String = "제목" + ): TranslationJob { return TranslationJob( resourceType = LanguageTranslationTargetType.CONTENT, - resourceId = 10L, - fieldKey = "title", - sourceHash = SourceTextNormalizer.hash("제목"), - sourceText = "제목", + resourceId = resourceId, + fieldKey = fieldKey, + sourceHash = SourceTextNormalizer.hash(sourceText), + sourceText = sourceText, sourceLanguage = "ko", targetLanguage = "en" ) @@ -114,10 +213,6 @@ class TranslationJobWorkerTest { private fun anyLocalDateTime(): LocalDateTime { return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() } - - private fun anyTranslationJobStatus(): TranslationJobStatus { - return Mockito.any(TranslationJobStatus::class.java) ?: TranslationJobStatus.PENDING - } } private class TestTransactionManager : AbstractPlatformTransactionManager() {