feat(i18n): 번역 작업 큐와 언어 감지 캐시를 도입한다

조회 중 외부 번역 호출을 줄이고 누락 번역을 비동기 job으로 처리한다.
This commit is contained in:
2026-05-06 18:02:36 +09:00
parent dfb97fba80
commit 3a0c30e340
30 changed files with 1561 additions and 848 deletions

View File

@@ -11,7 +11,7 @@ import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationR
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
@@ -26,7 +26,7 @@ class ChatCharacterControllerTest {
private val chatRoomService = Mockito.mock(ChatRoomService::class.java)
private val characterCommentService = Mockito.mock(CharacterCommentService::class.java)
private val curationQueryService = Mockito.mock(CharacterCurationQueryService::class.java)
private val translationService = Mockito.mock(PapagoTranslationService::class.java)
private val resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
private val aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java)
private val langContext = LangContext().apply { setLang(Lang.JA) }
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
@@ -36,7 +36,7 @@ class ChatCharacterControllerTest {
chatRoomService = chatRoomService,
characterCommentService = characterCommentService,
curationQueryService = curationQueryService,
translationService = translationService,
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
langContext = langContext,
memberContentPreferenceService = memberContentPreferenceService,
@@ -73,7 +73,7 @@ class ChatCharacterControllerTest {
chatRoomService = chatRoomService,
characterCommentService = characterCommentService,
curationQueryService = curationQueryService,
translationService = translationService,
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
langContext = LangContext().apply { setLang(Lang.EN) },
memberContentPreferenceService = memberContentPreferenceService,

View File

@@ -18,7 +18,7 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.junit.jupiter.api.Assertions.assertEquals
@@ -44,7 +44,7 @@ class AudioContentServiceTest {
private lateinit var commentRepository: AudioContentCommentRepository
private lateinit var audioContentLikeRepository: AudioContentLikeRepository
private lateinit var pinContentRepository: PinContentRepository
private lateinit var translationService: PapagoTranslationService
private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler
private lateinit var contentTranslationRepository: ContentTranslationRepository
private lateinit var s3Uploader: S3Uploader
private lateinit var audioContentCloudFront: AudioContentCloudFront
@@ -66,7 +66,7 @@ class AudioContentServiceTest {
commentRepository = Mockito.mock(AudioContentCommentRepository::class.java)
audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java)
pinContentRepository = Mockito.mock(PinContentRepository::class.java)
translationService = Mockito.mock(PapagoTranslationService::class.java)
resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java)
s3Uploader = Mockito.mock(S3Uploader::class.java)
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
@@ -85,7 +85,7 @@ class AudioContentServiceTest {
commentRepository = commentRepository,
audioContentLikeRepository = audioContentLikeRepository,
pinContentRepository = pinContentRepository,
translationService = translationService,
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
contentTranslationRepository = contentTranslationRepository,
s3Uploader = s3Uploader,
objectMapper = ObjectMapper(),

View File

@@ -0,0 +1,42 @@
package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class LanguageDetectionCacheServiceTest {
@Test
fun shouldReuseCachedLanguageDetectionForSameNormalizedText() {
val repository = Mockito.mock(LanguageDetectionResultRepository::class.java)
val service = LanguageDetectionCacheService(repository)
val sourceHash = SourceTextNormalizer.hash("Hello world")
Mockito.`when`(
repository.findBySourceHashAndProviderAndNormalizationVersion(
sourceHash = sourceHash,
provider = "papago",
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
)
).thenReturn(
LanguageDetectionResult(
sourceHash = sourceHash,
sourceTextSample = "Hello world",
detectedLanguage = "en",
provider = "papago",
confidence = null,
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
)
)
var providerCalls = 0
val detected = service.detectWithCache("Hello world") {
providerCalls++
"ko"
}
assertEquals("en", detected)
assertEquals(0, providerCalls)
Mockito.verify(repository, Mockito.never()).save(Mockito.any(LanguageDetectionResult::class.java))
}
}

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class SourceTextNormalizerTest {
@Test
fun shouldNormalizeWhitespaceAndUnicodeBeforeHashing() {
val composed = "카페\n\t소개"
val decomposed = "카페 소개"
assertEquals("카페 소개", SourceTextNormalizer.normalize(composed))
assertEquals(SourceTextNormalizer.hash(composed), SourceTextNormalizer.hash(decomposed))
}
}

View File

@@ -0,0 +1,87 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
class TranslationJobSchedulerTest {
@Test
fun shouldCreateOnePendingJobForMissingNormalizedText() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val scheduler = TranslationJobScheduler(jobRepository)
Mockito.`when`(
jobRepository.findActiveJob(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
targetLanguage = "en",
sourceHash = SourceTextNormalizer.hash("제목")
)
).thenReturn(null)
scheduler.scheduleMissingTranslation(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
sourceText = " 제목\n",
sourceLanguage = "ko",
targetLanguage = "EN"
)
val captor = ArgumentCaptor.forClass(TranslationJob::class.java)
Mockito.verify(jobRepository).save(captor.capture())
val saved = captor.value
assertEquals(LanguageTranslationTargetType.CONTENT, saved.resourceType)
assertEquals(10L, saved.resourceId)
assertEquals("title", saved.fieldKey)
assertEquals("제목", saved.sourceText)
assertEquals(SourceTextNormalizer.hash("제목"), saved.sourceHash)
assertEquals("ko", saved.sourceLanguage)
assertEquals("en", saved.targetLanguage)
assertEquals(TranslationJobStatus.PENDING, saved.status)
assertNotNull(saved.nextRetryAt)
}
@Test
fun shouldNotCreateDuplicateJobWhenSameCompletedJobAlreadyExists() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val scheduler = TranslationJobScheduler(jobRepository)
val sourceHash = SourceTextNormalizer.hash("제목")
Mockito.`when`(
jobRepository.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
targetLanguage = "en",
sourceHash = sourceHash
)
).thenReturn(
TranslationJob(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
sourceHash = sourceHash,
sourceText = "제목",
sourceLanguage = "ko",
targetLanguage = "en",
status = TranslationJobStatus.COMPLETED
)
)
scheduler.scheduleMissingTranslation(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
sourceText = "제목",
sourceLanguage = "ko",
targetLanguage = "en"
)
Mockito.verify(jobRepository, Mockito.never()).save(Mockito.any(TranslationJob::class.java))
}
}

View File

@@ -0,0 +1,136 @@
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.Mockito
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.transaction.support.AbstractPlatformTransactionManager
import org.springframework.transaction.support.DefaultTransactionStatus
import java.time.LocalDateTime
import java.util.Optional
class TranslationJobWorkerTest {
@Test
fun shouldRunEveryTenMinutesByDefault() {
val scheduled = TranslationJobWorker::class.java
.getDeclaredMethod("runPendingJobs")
.getAnnotation(Scheduled::class.java)
assertEquals("\${sodalive.translation-job.fixed-delay-ms:600000}", scheduled.fixedDelayString)
}
@Test
fun shouldClaimPendingJobByLockedRepositoryMethod() {
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 job = translationJob()
job.id = 100L
Mockito.`when`(jobRepository.findNextPendingJobIdForUpdate(anyLocalDateTime())).thenReturn(100L)
Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(job))
worker.processNextJob()
Mockito.verify(jobRepository).findNextPendingJobIdForUpdate(anyLocalDateTime())
Mockito.verify(jobRepository, Mockito.never())
.findFirstByStatusAndNextRetryAtLessThanEqualOrderByCreatedAtAsc(
anyTranslationJobStatus(),
anyLocalDateTime()
)
}
@Test
fun shouldRetryFailedJobWithBackoff() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
val provider = failingProvider()
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
materializer = materializer,
transactionManager = TestTransactionManager()
)
val job = translationJob()
job.id = 200L
val beforeRetryAt = job.nextRetryAt
Mockito.`when`(jobRepository.findNextPendingJobIdForUpdate(anyLocalDateTime())).thenReturn(200L)
Mockito.`when`(jobRepository.findById(200L)).thenReturn(Optional.of(job))
worker.processNextJob()
assertEquals(TranslationJobStatus.PENDING, job.status)
assertEquals(1, job.retryCount)
assertEquals("provider down", job.lastErrorMessage)
assertTrue(job.nextRetryAt.isAfter(beforeRetryAt))
}
private fun translationJob(): TranslationJob {
return TranslationJob(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
sourceHash = SourceTextNormalizer.hash("제목"),
sourceText = "제목",
sourceLanguage = "ko",
targetLanguage = "en"
)
}
private fun successfulProvider(): TranslationProvider {
return object : TranslationProvider {
override val providerName: String = "papago"
override val providerVersion: String = "nmt-v1"
override fun translate(request: TranslateRequest): TranslateResult {
return TranslateResult(listOf("title"))
}
}
}
private fun failingProvider(): TranslationProvider {
return object : TranslationProvider {
override val providerName: String = "papago"
override val providerVersion: String = "nmt-v1"
override fun translate(request: TranslateRequest): TranslateResult {
throw IllegalStateException("provider down")
}
}
}
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() {
override fun doGetTransaction(): Any {
return Any()
}
override fun doBegin(transaction: Any, definition: org.springframework.transaction.TransactionDefinition) {
}
override fun doCommit(status: DefaultTransactionStatus) {
}
override fun doRollback(status: DefaultTransactionStatus) {
}
}