From 31db86b57f20554a8227f48d5995ecccd5fccda8 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 12:12:30 +0900 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20ProxyYoutubeClient=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=83=81=ED=83=9C=20=EC=BD=94=EB=93=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20YouTube=20=EC=9E=90=EB=A7=89=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=EB=AA=85=EC=8B=9C=EC=A0=81=20=EB=B6=84=EB=A5=98=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../output/http/adapter/ProxyYoutubeClient.kt | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/ProxyYoutubeClient.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/ProxyYoutubeClient.kt index 8c6f644..b666928 100644 --- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/ProxyYoutubeClient.kt +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/ProxyYoutubeClient.kt @@ -1,5 +1,7 @@ package com.linktrip.output.http.adapter +import com.linktrip.common.exception.ExceptionCode +import com.linktrip.common.exception.LinktripException import io.github.thoroldvix.api.YoutubeClient import mu.KotlinLogging import java.net.Authenticator @@ -47,14 +49,7 @@ class ProxyYoutubeClient( requestBuilder.header(key, value) } - val response = - client.send( - requestBuilder.build(), - HttpResponse.BodyHandlers.ofString(), - ) - - logger.debug { "프록시 요청: url=$url, status=${response.statusCode()}" } - return response.body() + return execute(requestBuilder.build(), url) } override fun post( @@ -68,13 +63,45 @@ class ProxyYoutubeClient( .POST(HttpRequest.BodyPublishers.ofString(json)) .build() + return execute(request, url) + } + + private fun execute( + request: HttpRequest, + url: String, + ): String { val response = client.send( request, HttpResponse.BodyHandlers.ofString(), ) + val status = response.statusCode() + val body = response.body() ?: "" + + logger.debug { "프록시 요청: url=$url, status=$status, bytes=${body.length}" } - return response.body() + throwIfFailureStatus(status) + return body + } + + private fun throwIfFailureStatus(status: Int) { + when { + status == 429 || status == 403 -> + throw LinktripException( + ExceptionCode.BAD_GATEWAY_YOUTUBE, + "프록시 IP 차단 의심: status=$status", + ) + status in 500..599 -> + throw LinktripException( + ExceptionCode.BAD_GATEWAY_YOUTUBE, + "YouTube 일시 오류: status=$status", + ) + status !in 200..299 -> + throw LinktripException( + ExceptionCode.BAD_GATEWAY_YOUTUBE, + "예상치 못한 HTTP 응답: status=$status", + ) + } } companion object { From 547cf0dc439d9c77732cb06d4227e9c3cfaa38ab Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 12:12:38 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat:=20YoutubeTranscriptClient=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20sentinel=20=EC=98=81=EC=83=81?= =?UTF-8?q?=20ping=20=EC=9C=BC=EB=A1=9C=20IP=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?=EC=97=AC=EB=B6=80=20=ED=8C=90=EC=A0=95=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 2 + .../http/adapter/VideoAnalyzeAdapter.kt | 105 +++++++----------- .../http/adapter/YoutubeTranscriptClient.kt | 100 +++++++++++++++++ .../http/properties/YouTubeProperties.kt | 5 + 4 files changed, 145 insertions(+), 67 deletions(-) create mode 100644 linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YoutubeTranscriptClient.kt diff --git a/linktrip-bootstrap/src/main/resources/application.yml b/linktrip-bootstrap/src/main/resources/application.yml index c532c81..66b2870 100644 --- a/linktrip-bootstrap/src/main/resources/application.yml +++ b/linktrip-bootstrap/src/main/resources/application.yml @@ -44,3 +44,5 @@ gcp: youtube: api-key: ${YOUTUBE_API_KEY} + health-check: + sentinel-video-id: ${YOUTUBE_SENTINEL_VIDEO_ID:TVM6Nswlfbg} diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt index 975ec80..23846f1 100644 --- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt @@ -12,9 +12,7 @@ import com.linktrip.common.exception.ExceptionCode import com.linktrip.common.exception.LinktripException import com.linktrip.output.http.dto.AiApiResponse import com.linktrip.output.http.properties.GcpProperties -import com.linktrip.output.http.properties.YouTubeProperties -import io.github.thoroldvix.api.TranscriptApiFactory -import io.github.thoroldvix.api.YoutubeTranscriptApi +import io.github.thoroldvix.api.TranscriptRetrievalException import mu.KotlinLogging import org.springframework.stereotype.Component import java.io.FileInputStream @@ -32,7 +30,7 @@ private val logger = KotlinLogging.logger {} class VideoAnalyzeAdapter( private val gcpProperties: GcpProperties, private val objectMapper: ObjectMapper, - private val youTubeProperties: YouTubeProperties, + private val transcriptClient: YoutubeTranscriptClient, ) : VideoAnalyzePort { private val credentials: GoogleCredentials by lazy { FileInputStream(gcpProperties.credentialsPath).use { stream -> @@ -52,21 +50,6 @@ class VideoAnalyzeAdapter( .build() } - private val transcriptApi: YoutubeTranscriptApi by lazy { - if (youTubeProperties.proxy.isEnabled()) { - logger.info { "YouTube 자막 프록시 활성화 (prod)" } - TranscriptApiFactory.createWithClient( - ProxyYoutubeClient( - youTubeProperties.proxy.username, - youTubeProperties.proxy.password, - ), - ) - } else { - logger.info { "YouTube 자막 직접 연결 (dev)" } - TranscriptApiFactory.createDefault() - } - } - @PreDestroy fun close() { runCatching { client.close() } @@ -97,43 +80,33 @@ class VideoAnalyzeAdapter( private fun tryExtractTranscript(videoId: String): String? = try { - val transcriptList = transcriptApi.listTranscripts(videoId) - val transcript = - runCatching { transcriptList.findTranscript("ko") } - .recoverCatching { transcriptList.findTranscript("en") } - .recoverCatching { transcriptList.findGeneratedTranscript("ko") } - .recoverCatching { transcriptList.findGeneratedTranscript("en") } - .getOrNull() - - transcript?.fetch()?.let { content -> - content.content.joinToString("\n") { fragment -> - "[${formatTimestamp(fragment.start.toLong())}] ${fragment.text}" - } - } - } catch (e: Exception) { - val message = e.message ?: "" - when { - message.contains("Too Many Requests", ignoreCase = true) || - message.contains("429", ignoreCase = true) -> { - logger.error { "YouTube 자막 Rate Limit 초과 (videoId=$videoId): ${e.message}" } - throw LinktripException( - ExceptionCode.BAD_GATEWAY_YOUTUBE, - "YouTube 자막 요청이 Rate Limit에 걸렸습니다.", - ) - } - message.contains("Could not retrieve transcript", ignoreCase = true) -> { - logger.warn { "자막이 존재하지 않는 영상 (videoId=$videoId)" } - null - } - else -> { - logger.error(e) { "자막 추출 중 일시적 오류 (videoId=$videoId)" } - throw LinktripException( - ExceptionCode.BAD_GATEWAY_YOUTUBE, - "YouTube 자막 조회 중 일시적 오류가 발생했습니다.", - ) - } - } + transcriptClient.fetchTranscript(videoId) + } catch (e: TranscriptRetrievalException) { + // 라이브러리가 우리 LinktripException 을 한 단계 감쌌을 수 있어 cause 를 우선 확인. + (e.cause as? LinktripException)?.let { throw it } + classifyAmbiguousFailure(videoId, e) + } catch (e: IllegalArgumentException) { + logger.warn { "videoId 형식 오류 (videoId=$videoId): ${e.message}" } + null + } + + /** + * 모호한 자막 실패 → sentinel ping 으로 "프록시 죽음" vs "영상 고유 문제" 분류. + */ + private fun classifyAmbiguousFailure( + videoId: String, + cause: TranscriptRetrievalException, + ): String? { + if (transcriptClient.isProxyHealthy()) { + logger.warn(cause) { "Sentinel 정상 → 자막 없음/영상 접근 불가 (videoId=$videoId)" } + return null } + logger.warn(cause) { "Sentinel 실패 → IP 차단 의심 (videoId=$videoId)" } + throw LinktripException( + ExceptionCode.BAD_GATEWAY_YOUTUBE, + "Sentinel 확인 결과 IP 차단 의심.", + ) + } private fun analyzeFromTranscriptInternal( transcript: String, @@ -176,7 +149,16 @@ class VideoAnalyzeAdapter( } } - private fun analyzeFromVideo(youtubeUrl: String): VideoAnalysisResult { + /** + * 자막 없이 Gemini 가 YouTube 영상 자체를 직접 인제스트해 분석하는 fallback. + * + * 현재 미사용: + * - 자막 기반 분석 대비 토큰 소비량이 약 25배에 달해 비용이 크게 증가한다. + * - 자막이 추출되는 영상은 [analyzeFromTranscript] 가 먼저 처리하므로 호출 경로가 닿지 않는다. + * + * 자막 추출 자체가 불가능한 영상에 대한 fallback 으로 와이어업할 경우에만 사용한다. + */ + private fun analyzeByAiVideoIngestion(youtubeUrl: String): VideoAnalysisResult { try { val response = client.models.generateContent( @@ -217,17 +199,6 @@ class VideoAnalyzeAdapter( ?: throw LinktripException(ExceptionCode.BAD_REQUEST_YOUTUBE_URL) } - private fun formatTimestamp(totalSeconds: Long): String { - val hours = totalSeconds / 3600 - val minutes = (totalSeconds % 3600) / 60 - val seconds = totalSeconds % 60 - return if (hours > 0) { - "%d:%02d:%02d".format(hours, minutes, seconds) - } else { - "%d:%02d".format(minutes, seconds) - } - } - private val TRANSCRIPT_PROMPT = """ You are a travel video transcript analyzer. Return ONLY a raw JSON object (no markdown, no explanation). diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YoutubeTranscriptClient.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YoutubeTranscriptClient.kt new file mode 100644 index 0000000..9272e2b --- /dev/null +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YoutubeTranscriptClient.kt @@ -0,0 +1,100 @@ +package com.linktrip.output.http.adapter + +import com.linktrip.output.http.properties.YouTubeProperties +import io.github.thoroldvix.api.TranscriptApiFactory +import io.github.thoroldvix.api.YoutubeTranscriptApi +import mu.KotlinLogging +import org.springframework.stereotype.Component + +private val logger = KotlinLogging.logger {} + +/** + * youtube-transcript-api 라이브러리에 대한 단일 게이트웨이. + * + * - 자막 추출 (요약 흐름) + * - sentinel ping 을 통한 프록시 헬스 체크 (모호한 실패 분류) + * + * 두 흐름 모두 같은 [transcriptApi] 인스턴스를 통과하므로 프록시 설정/HTTP 동작이 일관된다. + */ +@Component +class YoutubeTranscriptClient( + private val youTubeProperties: YouTubeProperties, +) { + private val transcriptApi: YoutubeTranscriptApi by lazy { + if (youTubeProperties.proxy.isEnabled()) { + logger.info { "YouTube 자막 프록시 활성화 (prod)" } + TranscriptApiFactory.createWithClient( + ProxyYoutubeClient( + youTubeProperties.proxy.username, + youTubeProperties.proxy.password, + ), + ) + } else { + logger.info { "YouTube 자막 직접 연결 (dev)" } + TranscriptApiFactory.createDefault() + } + } + + /** + * 자막 텍스트 추출. ko 수동 → en 수동 → ko 자동 → en 자동 순으로 폴백. + * + * @return 포맷팅된 자막 텍스트, 어느 언어에도 자막이 없으면 null + * @throws com.linktrip.common.exception.LinktripException 프록시/HTTP 레벨 실패 (ProxyYoutubeClient 가 throw) + * @throws io.github.thoroldvix.api.TranscriptRetrievalException 라이브러리가 던지는 모호한 실패 + */ + fun fetchTranscript(videoId: String): String? { + val list = transcriptApi.listTranscripts(videoId) + val transcript = + runCatching { list.findTranscript("ko") } + .recoverCatching { list.findTranscript("en") } + .recoverCatching { list.findGeneratedTranscript("ko") } + .recoverCatching { list.findGeneratedTranscript("en") } + .getOrNull() + + return transcript?.fetch()?.let { content -> + content.content.joinToString("\n") { fragment -> + "[${formatTimestamp(fragment.start.toLong())}] ${fragment.text}" + } + } + } + + /** + * 프록시(IP) 가 정상인지 sentinel 영상으로 매번 즉시 확인. + * + * 큐 컨슈머가 이미 자연스러운 rate limit 이라 호출 빈도가 낮고, IP 차단은 실시간 상태가 중요하므로 캐시 없음. + * + * @return true = 프록시 정상, false = 차단 의심 (또는 sentinel 미설정) + */ + fun isProxyHealthy(): Boolean { + val sentinelId = youTubeProperties.healthCheck.sentinelVideoId + if (sentinelId.isBlank()) { + logger.warn { "Sentinel videoId 미설정 — 프록시 비정상으로 간주" } + return false + } + + val healthy = + runCatching { + transcriptApi.listTranscripts(sentinelId) + true + }.getOrElse { e -> + logger.warn(e) { "Sentinel ping 실패: videoId=$sentinelId" } + false + } + + logger.info { "프록시 헬스체크 결과: healthy=$healthy (sentinelId=$sentinelId)" } + return healthy + } + + companion object { + private fun formatTimestamp(totalSeconds: Long): String { + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, seconds) + } else { + "%d:%02d".format(minutes, seconds) + } + } + } +} diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/YouTubeProperties.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/YouTubeProperties.kt index f342df5..0c6504e 100644 --- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/YouTubeProperties.kt +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/YouTubeProperties.kt @@ -6,6 +6,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties data class YouTubeProperties( val apiKey: String, val proxy: ProxyProperties = ProxyProperties(), + val healthCheck: HealthCheckProperties = HealthCheckProperties(), ) { data class ProxyProperties( val username: String = "", @@ -13,4 +14,8 @@ data class YouTubeProperties( ) { fun isEnabled(): Boolean = username.isNotBlank() && password.isNotBlank() } + + data class HealthCheckProperties( + val sentinelVideoId: String = "", + ) } From e08c2601c1dc4883385ae6cedcefa6dba4176652 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 12:58:00 +0900 Subject: [PATCH 03/19] =?UTF-8?q?feat:=20webshare=20=ED=94=84=EB=A1=9D?= =?UTF-8?q?=EC=8B=9C=20=EB=9D=BC=EC=9A=B4=EB=93=9C=EB=A1=9C=EB=B9=88=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20=EB=B0=8F=20sentinel=20=EC=B0=A8=EB=8B=A8?= =?UTF-8?q?=20=ED=8C=90=EC=A0=95=20=EB=AA=85=ED=99=95=ED=99=94=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../output/http/adapter/ProxyYoutubeClient.kt | 118 +++++++++--------- .../http/adapter/VideoAnalyzeAdapter.kt | 4 +- .../http/adapter/YoutubeTranscriptClient.kt | 17 ++- .../http/properties/YouTubeProperties.kt | 4 +- 4 files changed, 74 insertions(+), 69 deletions(-) diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/ProxyYoutubeClient.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/ProxyYoutubeClient.kt index b666928..ebcdd18 100644 --- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/ProxyYoutubeClient.kt +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/ProxyYoutubeClient.kt @@ -15,41 +15,34 @@ import java.net.http.HttpResponse private val logger = KotlinLogging.logger {} +/** + * webshare 프록시 라운드로빈 클라이언트. + * + * [proxyClients] 에 지정된 순서대로 각 프록시로 요청을 시도한다 (우선순위 = 리스트 순서). + * - 2xx → 성공 반환 + * - 429/403 (IP 차단) → 다음 프록시 시도 + * - 5xx / 기타 비2xx → 즉시 [LinktripException] throw (일시 오류는 큐 컨슈머가 PENDING 으로 재시도) + * + * 전체 프록시가 429/403 으로 소진되면 IP 전면 차단으로 간주하고 [LinktripException] throw. + */ class ProxyYoutubeClient( - private val proxyUsername: String, - private val proxyPassword: String, + usernames: List, + password: String, ) : YoutubeClient { - private val client: HttpClient by lazy { - HttpClient.newBuilder() - .proxy( - ProxySelector.of( - InetSocketAddress(PROXY_HOST, PROXY_PORT), - ), - ) - .authenticator( - object : Authenticator() { - override fun getPasswordAuthentication(): PasswordAuthentication = - PasswordAuthentication(proxyUsername, proxyPassword.toCharArray()) - }, - ) - .followRedirects(HttpClient.Redirect.NORMAL) - .build() - } + private data class ProxyClient(val username: String, val httpClient: HttpClient) + + private val proxyClients: List = + usernames.map { username -> + ProxyClient(username, buildHttpClient(username, password)) + } override fun get( url: String, headers: Map, ): String { - val requestBuilder = - HttpRequest.newBuilder() - .uri(URI.create(url)) - .GET() - - headers.forEach { (key, value) -> - requestBuilder.header(key, value) - } - - return execute(requestBuilder.build(), url) + val builder = HttpRequest.newBuilder().uri(URI.create(url)).GET() + headers.forEach { (key, value) -> builder.header(key, value) } + return executeWithRotation(builder.build(), url) } override fun post( @@ -62,48 +55,61 @@ class ProxyYoutubeClient( .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build() - - return execute(request, url) + return executeWithRotation(request, url) } - private fun execute( + private fun executeWithRotation( request: HttpRequest, url: String, ): String { - val response = - client.send( - request, - HttpResponse.BodyHandlers.ofString(), - ) - val status = response.statusCode() - val body = response.body() ?: "" + var lastBlockedStatus = -1 + for (proxy in proxyClients) { + val response = proxy.httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + val status = response.statusCode() + val body = response.body() ?: "" - logger.debug { "프록시 요청: url=$url, status=$status, bytes=${body.length}" } + logger.debug { "프록시 요청: username=${proxy.username}, url=$url, status=$status, bytes=${body.length}" } - throwIfFailureStatus(status) - return body - } + when (status) { + in 200..299 -> return body + 429, 403 -> { + logger.info { "프록시 IP 차단으로 다음 프록시 시도: username=${proxy.username}, status=$status" } + lastBlockedStatus = status + } - private fun throwIfFailureStatus(status: Int) { - when { - status == 429 || status == 403 -> - throw LinktripException( - ExceptionCode.BAD_GATEWAY_YOUTUBE, - "프록시 IP 차단 의심: status=$status", - ) - status in 500..599 -> - throw LinktripException( - ExceptionCode.BAD_GATEWAY_YOUTUBE, - "YouTube 일시 오류: status=$status", - ) - status !in 200..299 -> - throw LinktripException( + in 500..599 -> + throw LinktripException( + ExceptionCode.BAD_GATEWAY_YOUTUBE, + "YouTube 일시 오류: status=$status", + ) + + else -> throw LinktripException( ExceptionCode.BAD_GATEWAY_YOUTUBE, "예상치 못한 HTTP 응답: status=$status", ) + } } + throw LinktripException( + ExceptionCode.BAD_GATEWAY_YOUTUBE, + "전 프록시 IP 차단 확정 (마지막 status=$lastBlockedStatus, proxies=${proxyClients.size}개)", + ) } + private fun buildHttpClient( + username: String, + password: String, + ): HttpClient = + HttpClient.newBuilder() + .proxy(ProxySelector.of(InetSocketAddress(PROXY_HOST, PROXY_PORT))) + .authenticator( + object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication = + PasswordAuthentication(username, password.toCharArray()) + }, + ) + .followRedirects(HttpClient.Redirect.NORMAL) + .build() + companion object { private const val PROXY_HOST = "p.webshare.io" private const val PROXY_PORT = 80 diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt index 23846f1..d5b3bfc 100644 --- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/VideoAnalyzeAdapter.kt @@ -101,10 +101,10 @@ class VideoAnalyzeAdapter( logger.warn(cause) { "Sentinel 정상 → 자막 없음/영상 접근 불가 (videoId=$videoId)" } return null } - logger.warn(cause) { "Sentinel 실패 → IP 차단 의심 (videoId=$videoId)" } + logger.warn(cause) { "Sentinel 실패 → 전 프록시 IP 차단 확정 (videoId=$videoId)" } throw LinktripException( ExceptionCode.BAD_GATEWAY_YOUTUBE, - "Sentinel 확인 결과 IP 차단 의심.", + "Sentinel 확인 결과 전 프록시 IP 차단 확정.", ) } diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YoutubeTranscriptClient.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YoutubeTranscriptClient.kt index 9272e2b..48b9577 100644 --- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YoutubeTranscriptClient.kt +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/YoutubeTranscriptClient.kt @@ -14,7 +14,7 @@ private val logger = KotlinLogging.logger {} * - 자막 추출 (요약 흐름) * - sentinel ping 을 통한 프록시 헬스 체크 (모호한 실패 분류) * - * 두 흐름 모두 같은 [transcriptApi] 인스턴스를 통과하므로 프록시 설정/HTTP 동작이 일관된다. + * 두 흐름 모두 같은 [transcriptApi] 인스턴스를 통과하므로 프록시 라운드로빈/HTTP 동작이 일관된다. */ @Component class YoutubeTranscriptClient( @@ -22,11 +22,11 @@ class YoutubeTranscriptClient( ) { private val transcriptApi: YoutubeTranscriptApi by lazy { if (youTubeProperties.proxy.isEnabled()) { - logger.info { "YouTube 자막 프록시 활성화 (prod)" } + logger.info { "YouTube 자막 프록시 활성화 (prod, ${youTubeProperties.proxy.usernames.size}개 프록시 로테이션)" } TranscriptApiFactory.createWithClient( ProxyYoutubeClient( - youTubeProperties.proxy.username, - youTubeProperties.proxy.password, + usernames = youTubeProperties.proxy.usernames, + password = youTubeProperties.proxy.password, ), ) } else { @@ -59,11 +59,11 @@ class YoutubeTranscriptClient( } /** - * 프록시(IP) 가 정상인지 sentinel 영상으로 매번 즉시 확인. + * sentinel 영상으로 프록시(IP) 가 정상인지 매번 즉시 확인. * - * 큐 컨슈머가 이미 자연스러운 rate limit 이라 호출 빈도가 낮고, IP 차단은 실시간 상태가 중요하므로 캐시 없음. + * 내부에서 [ProxyYoutubeClient] 라운드로빈이 동작하므로, 이 ping 실패 = 등록된 모든 프록시 차단 확정. * - * @return true = 프록시 정상, false = 차단 의심 (또는 sentinel 미설정) + * @return true = 적어도 하나의 프록시가 정상, false = 전 프록시 차단 확정 (또는 sentinel 미설정) */ fun isProxyHealthy(): Boolean { val sentinelId = youTubeProperties.healthCheck.sentinelVideoId @@ -77,10 +77,9 @@ class YoutubeTranscriptClient( transcriptApi.listTranscripts(sentinelId) true }.getOrElse { e -> - logger.warn(e) { "Sentinel ping 실패: videoId=$sentinelId" } + logger.warn(e) { "Sentinel ping 실패 — 전 프록시 차단 확정 (sentinelId=$sentinelId)" } false } - logger.info { "프록시 헬스체크 결과: healthy=$healthy (sentinelId=$sentinelId)" } return healthy } diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/YouTubeProperties.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/YouTubeProperties.kt index 0c6504e..f627adb 100644 --- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/YouTubeProperties.kt +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/properties/YouTubeProperties.kt @@ -9,10 +9,10 @@ data class YouTubeProperties( val healthCheck: HealthCheckProperties = HealthCheckProperties(), ) { data class ProxyProperties( - val username: String = "", val password: String = "", + val usernames: List = emptyList(), ) { - fun isEnabled(): Boolean = username.isNotBlank() && password.isNotBlank() + fun isEnabled(): Boolean = password.isNotBlank() && usernames.isNotEmpty() } data class HealthCheckProperties( From 547be43321bd7fbd96a7f82f11bcf4065fbde8f7 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 12:58:06 +0900 Subject: [PATCH 04/19] =?UTF-8?q?chore:=20prod=20=ED=94=84=EB=A1=9D?= =?UTF-8?q?=EC=8B=9C=20=EC=84=A4=EC=A0=95=EC=9D=84=20username=20=EB=8B=A8?= =?UTF-8?q?=EC=88=98=EC=97=90=EC=84=9C=20usernames=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linktrip-bootstrap/src/main/resources/application-prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linktrip-bootstrap/src/main/resources/application-prod.yml b/linktrip-bootstrap/src/main/resources/application-prod.yml index 2b540a2..2ee7f00 100644 --- a/linktrip-bootstrap/src/main/resources/application-prod.yml +++ b/linktrip-bootstrap/src/main/resources/application-prod.yml @@ -15,5 +15,5 @@ jwt: youtube: proxy: - username: ${YOUTUBE_PROXY_USERNAME} password: ${YOUTUBE_PROXY_PASSWORD} + usernames: ${YOUTUBE_PROXY_USERNAMES} From 5ab26ce41f213a63b9c250f98d342624b6f1cd64 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 12:58:12 +0900 Subject: [PATCH 05/19] =?UTF-8?q?ci:=20YOUTUBE=5FPROXY=5FUSERNAME=20?= =?UTF-8?q?=E2=86=92=20YOUTUBE=5FPROXY=5FUSERNAMES=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=98=EC=98=81=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cicd-release.yml | 6 +++--- docker/docker-compose.prod.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cicd-release.yml b/.github/workflows/cicd-release.yml index f373d85..75a51e4 100644 --- a/.github/workflows/cicd-release.yml +++ b/.github/workflows/cicd-release.yml @@ -78,13 +78,13 @@ jobs: DISCORD_MENTION_USER_ID: ${{ secrets.DISCORD_MENTION_USER_ID }} GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }} YOUTUBE_API_KEY: ${{ secrets.YOUTUBE_API_KEY }} - YOUTUBE_PROXY_USERNAME: ${{ secrets.YOUTUBE_PROXY_USERNAME }} + YOUTUBE_PROXY_USERNAMES: ${{ secrets.YOUTUBE_PROXY_USERNAMES }} YOUTUBE_PROXY_PASSWORD: ${{ secrets.YOUTUBE_PROXY_PASSWORD }} with: host: ${{ secrets.SERVER_HOST }} username: ec2-user key: ${{ secrets.SERVER_KEY }} - envs: MYSQL_ROOT_PASSWORD,JWT_SECRET_KEY,DISCORD_WEBHOOK_ERROR_URL,DISCORD_MENTION_USER_ID,GCP_PROJECT_ID,YOUTUBE_API_KEY,YOUTUBE_PROXY_USERNAME,YOUTUBE_PROXY_PASSWORD + envs: MYSQL_ROOT_PASSWORD,JWT_SECRET_KEY,DISCORD_WEBHOOK_ERROR_URL,DISCORD_MENTION_USER_ID,GCP_PROJECT_ID,YOUTUBE_API_KEY,YOUTUBE_PROXY_USERNAMES,YOUTUBE_PROXY_PASSWORD script: | set -e @@ -114,7 +114,7 @@ jobs: echo "DISCORD_MENTION_USER_ID=${DISCORD_MENTION_USER_ID}" >> $DEPLOY_DIR/.env echo "GCP_PROJECT_ID=${GCP_PROJECT_ID}" >> $DEPLOY_DIR/.env echo "YOUTUBE_API_KEY=${YOUTUBE_API_KEY}" >> $DEPLOY_DIR/.env - echo "YOUTUBE_PROXY_USERNAME=${YOUTUBE_PROXY_USERNAME}" >> $DEPLOY_DIR/.env + echo "YOUTUBE_PROXY_USERNAMES=${YOUTUBE_PROXY_USERNAMES}" >> $DEPLOY_DIR/.env echo "YOUTUBE_PROXY_PASSWORD=${YOUTUBE_PROXY_PASSWORD}" >> $DEPLOY_DIR/.env chmod 600 $DEPLOY_DIR/.env diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 0ca01ea..7d88944 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -19,7 +19,7 @@ services: - GCP_CREDENTIALS_PATH=/app/config/gcp-credentials.json - GCP_VERTEX_AI_LOCATION=us-central1 - YOUTUBE_API_KEY=${YOUTUBE_API_KEY} - - YOUTUBE_PROXY_USERNAME=${YOUTUBE_PROXY_USERNAME} + - YOUTUBE_PROXY_USERNAMES=${YOUTUBE_PROXY_USERNAMES} - YOUTUBE_PROXY_PASSWORD=${YOUTUBE_PROXY_PASSWORD} volumes: - ./gcp-credentials.json:/app/config/gcp-credentials.json:ro From 33e7fb5e9097d93acd5a51a29eefb511dbcf150b Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 13:09:02 +0900 Subject: [PATCH 06/19] =?UTF-8?q?feat:=20sentinel=20video=20id=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linktrip-bootstrap/src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linktrip-bootstrap/src/main/resources/application.yml b/linktrip-bootstrap/src/main/resources/application.yml index 66b2870..31add0a 100644 --- a/linktrip-bootstrap/src/main/resources/application.yml +++ b/linktrip-bootstrap/src/main/resources/application.yml @@ -45,4 +45,4 @@ gcp: youtube: api-key: ${YOUTUBE_API_KEY} health-check: - sentinel-video-id: ${YOUTUBE_SENTINEL_VIDEO_ID:TVM6Nswlfbg} + sentinel-video-id: TVM6Nswlfbg From 39cde62327771bf6a75892417d1eecd09c5e5c56 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 13:14:17 +0900 Subject: [PATCH 07/19] =?UTF-8?q?feat:=20COMPLETED=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20POST=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=ED=95=98=EC=97=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=ED=8F=B4=EB=A7=81=20=EC=A0=9C=EA=B1=B0=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../input/http/controller/VideoController.kt | 19 ++++++++++-- .../input/http/controller/docs/VideoDocs.kt | 31 ++++++++++++------- .../response/VideoAnalyzeAcceptResponse.kt | 27 ---------------- 3 files changed, 36 insertions(+), 41 deletions(-) delete mode 100644 linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/VideoAnalyzeAcceptResponse.kt diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/VideoController.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/VideoController.kt index 8574c5a..98e2ed1 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/VideoController.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/VideoController.kt @@ -16,7 +16,6 @@ import com.linktrip.input.http.controller.dto.response.ApiResponse import com.linktrip.input.http.controller.dto.response.DiscoverChannelResponses import com.linktrip.input.http.controller.dto.response.DiscoverVideoCursorResponse import com.linktrip.input.http.controller.dto.response.DiscoverVideoResponses -import com.linktrip.input.http.controller.dto.response.VideoAnalyzeAcceptResponse import com.linktrip.input.http.controller.dto.response.VideoAnalyzeResponse import org.springframework.validation.annotation.Validated import org.springframework.web.bind.annotation.GetMapping @@ -41,7 +40,7 @@ class VideoController( override fun analyzeVideo( @AuthenticatedMember memberId: String, @Validated @RequestBody request: VideoAnalyzeRequest, - ): ApiResponse { + ): ApiResponse { val task = videoAnalyzeUseCase.analyzeVideo(request.youtubeUrl) when (task.status) { @@ -51,7 +50,21 @@ class VideoController( else -> tripPlanUseCase.registerRequest(memberId, task.id) } - return ApiResponse.accepted(VideoAnalyzeAcceptResponse.from(task)) + // COMPLETED 면 결과 인라인 반환해서 클라이언트가 추가 폴링 없이 바로 사용. 그 외엔 status 만 의미 있음. + val response = + if (task.status == VideoAnalysisTaskStatus.COMPLETED) { + val result = videoScheduleUseCase.getVideoSchedule(task.id) + VideoAnalyzeResponse.from(result.videoAnalysisTask, result.items, result.timelines) + } else { + VideoAnalyzeResponse.from(task, emptyList(), emptyList()) + } + + return when (task.status) { + VideoAnalysisTaskStatus.PENDING, + VideoAnalysisTaskStatus.PROCESSING, + -> ApiResponse.accepted(response) + else -> ApiResponse.ok(response) + } } @GetMapping("/schedule/{videoAnalysisTaskId}") diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/docs/VideoDocs.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/docs/VideoDocs.kt index 2f898e0..c9c76d0 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/docs/VideoDocs.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/docs/VideoDocs.kt @@ -5,7 +5,6 @@ import com.linktrip.input.http.controller.dto.response.ApiResponse import com.linktrip.input.http.controller.dto.response.DiscoverChannelResponses import com.linktrip.input.http.controller.dto.response.DiscoverVideoCursorResponse import com.linktrip.input.http.controller.dto.response.DiscoverVideoResponses -import com.linktrip.input.http.controller.dto.response.VideoAnalyzeAcceptResponse import com.linktrip.input.http.controller.dto.response.VideoAnalyzeResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter @@ -23,15 +22,21 @@ interface VideoDocs { YouTube URL을 전달하면 AI(Gemini)가 영상을 분석하여 여행 일정, 타임라인, 요약 등을 추출합니다. **처리 흐름:** - 1. 최초 요청 → 분석 작업 생성 후 202 Accepted 반환 (비동기 분석 시작) - 2. 동일 URL 재요청 → 기존 분석 결과 상태 반환 (중복 분석 방지) - 3. 이전 분석 실패(FAILED) URL 재요청 → 재분석 시작 + 1. 최초 요청 → 분석 작업 생성 후 202 Accepted 반환 (비동기 분석 시작, schedule 폴링 필요) + 2. 동일 URL 재요청 + - 분석 완료된 영상이면 → 200 OK + 결과 데이터 인라인 반환 (추가 폴링 불필요) + - 진행 중이면 → 202 Accepted + 현재 상태만 반환 + 3. 이전 분석 실패(FAILED) URL 재요청 → 재분석 시작 (202 Accepted) - **상태값:** - - `PENDING`: 분석 진행 중 (폴링 필요) - - `COMPLETED`: 분석 완료 (schedule API로 상세 조회 가능) - - `INVALID`: 여행 영상이 아닌 것으로 판정 - - `FAILED`: 분석 실패 (재요청 시 재분석) + **상태값 / HTTP 상태 코드:** + - `PENDING` → 202 Accepted: 분석 진행 중. 결과 필드는 비어있음. schedule API 폴링 필요 + - `PROCESSING` → 202 Accepted: 컨슈머가 처리 중. 결과 필드는 비어있음 + - `COMPLETED` → 200 OK: 분석 완료. 응답에 모든 결과 데이터 포함 (추가 호출 불필요) + - `INVALID` → 200 OK: 여행 영상이 아닌 것으로 판정. 결과 필드는 비어있음 + - `FAILED` → 202 Accepted: 직전 실패 후 재분석 시작 (status 는 PENDING 으로 응답) + + **응답 shape:** + schedule API(GET /video/schedule/{id}) 와 동일한 [VideoAnalyzeResponse]. status 가 COMPLETED 일 때만 결과 필드가 채워짐. **멱등성:** - `GET`을 제외한 모든 API는 `Idempotency-Key` 헤더가 필수입니다. @@ -49,9 +54,13 @@ interface VideoDocs { ) @ApiResponses( value = [ + io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "이미 분석 완료된 영상 (COMPLETED/INVALID) — 결과 인라인 반환", + ), io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "202", - description = "분석 요청 접수 완료", + description = "분석 진행 중 (PENDING/PROCESSING) — 폴링 필요", ), io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", @@ -116,7 +125,7 @@ interface VideoDocs { fun analyzeVideo( @Parameter(hidden = true) memberId: String, request: VideoAnalyzeRequest, - ): ApiResponse + ): ApiResponse @Operation( summary = "영상 분석 결과 상세 조회", diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/VideoAnalyzeAcceptResponse.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/VideoAnalyzeAcceptResponse.kt deleted file mode 100644 index 3cc9d39..0000000 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/VideoAnalyzeAcceptResponse.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.linktrip.input.http.controller.dto.response - -import com.linktrip.application.domain.video.VideoAnalysisTask -import io.swagger.v3.oas.annotations.media.Schema - -@Schema(description = "영상 분석 요청 접수 응답") -data class VideoAnalyzeAcceptResponse( - @field:Schema(description = "영상 분석 작업 ID (이 ID로 schedule API 폴링)", example = "019d41ff-fae2-7d90-96c9-2530a95f64cf") - val id: String, - @field:Schema(description = "정규화된 YouTube URL", example = "https://www.youtube.com/watch?v=2oLfUjAqEcM") - val youtubeUrl: String, - @field:Schema( - description = "분석 상태: PENDING(분석 중), COMPLETED(완료), INVALID(여행 영상 아님), FAILED(실패)", - example = "PENDING", - allowableValues = ["PENDING", "COMPLETED", "INVALID", "FAILED"], - ) - val status: String, -) { - companion object { - fun from(videoAnalysisTask: VideoAnalysisTask): VideoAnalyzeAcceptResponse = - VideoAnalyzeAcceptResponse( - id = videoAnalysisTask.id, - youtubeUrl = videoAnalysisTask.youtubeUrl, - status = videoAnalysisTask.status.name, - ) - } -} From d1c70e2c84b9e2a7f204ae6586f742a18cf3ff62 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 13:51:23 +0900 Subject: [PATCH 08/19] =?UTF-8?q?feat:=20=EC=98=81=EC=83=81=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=ED=81=90=20USER/BATCH=20=EC=9A=B0=EC=84=A0?= =?UTF-8?q?=EC=88=9C=EC=9C=84=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20task.sour?= =?UTF-8?q?ce=20=EC=98=81=EC=86=8D=ED=99=94=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/video/KeywordAnalyzeService.kt | 5 ++- .../application/domain/video/Source.kt | 14 ++++++ .../domain/video/VideoAnalysisTask.kt | 8 +++- .../domain/video/VideoAnalyzeEvent.kt | 1 + .../domain/video/VideoAnalyzeEventListener.kt | 6 ++- .../domain/video/VideoAnalyzeService.kt | 12 ++++-- .../domain/youtube/YouTubeCollectService.kt | 6 ++- .../port/input/VideoAnalyzeUseCase.kt | 6 ++- .../output/queue/VideoAnalysisQueuePort.kt | 2 + .../input/http/controller/VideoController.kt | 3 +- .../InMemoryVideoAnalysisQueueAdapter.kt | 43 +++++++++++++++---- .../mysql/entity/VideoAnalysisTaskEntity.kt | 6 +++ 12 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Source.kt diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/KeywordAnalyzeService.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/KeywordAnalyzeService.kt index 3b3dbba..e93b6bc 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/KeywordAnalyzeService.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/KeywordAnalyzeService.kt @@ -64,7 +64,10 @@ class KeywordAnalyzeService( private fun analyzeVideo(videoId: String): VideoAnalysisTask? = try { - videoAnalyzeUseCase.analyzeVideo(VideoAnalysisTask.buildUrl(videoId)) + videoAnalyzeUseCase.analyzeVideo( + youtubeUrl = VideoAnalysisTask.buildUrl(videoId), + source = Source.BATCH, + ) } catch (e: Exception) { logger.warn { "영상 분석 요청 스킵: videoId=$videoId, reason=${e.message}" } null diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Source.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Source.kt new file mode 100644 index 0000000..c35c686 --- /dev/null +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Source.kt @@ -0,0 +1,14 @@ +package com.linktrip.application.domain.video + +/** + * 영상 분석 요청의 출처. 큐 우선순위 결정에 사용된다. + * + * priority 가 작을수록 먼저 dequeue 된다 (Java [java.util.PriorityQueue] 컨벤션). + */ +enum class Source(val priority: Int) { + /** 사용자 직접 요청 (POST /video/analyze 등). 항상 먼저 처리. */ + USER(0), + + /** 시스템 배치/수집 (YouTube 정기 수집, stranded 백필 등). USER 가 비었을 때만 처리. */ + BATCH(10), +} diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisTask.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisTask.kt index 2fd233f..df71e96 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisTask.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisTask.kt @@ -10,6 +10,8 @@ data class VideoAnalysisTask( val youtubeUrl: String, val valid: Boolean, val status: VideoAnalysisTaskStatus, + /** 이 task 를 생성한 출처 (audit + 큐 재시도 priority 결정용). 한 번 정해지면 변경하지 않는다. */ + val source: Source, val summary: String? = null, val estimatedMinCost: Long? = null, val estimatedMaxCost: Long? = null, @@ -45,13 +47,17 @@ data class VideoAnalysisTask( private val VIDEO_ID_REGEX = Regex("(?:youtube\\.com/(?:watch\\?v=|embed/|shorts/)|youtu\\.be/)([\\w-]+)") - fun create(youtubeUrl: String): VideoAnalysisTask { + fun create( + youtubeUrl: String, + source: Source, + ): VideoAnalysisTask { val normalizedUrl = normalizeUrl(youtubeUrl) return VideoAnalysisTask( id = IdGenerator.generate(), youtubeUrl = normalizedUrl, valid = false, status = VideoAnalysisTaskStatus.PENDING, + source = source, ) } diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEvent.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEvent.kt index a1a333c..435f4a1 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEvent.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEvent.kt @@ -3,4 +3,5 @@ package com.linktrip.application.domain.video data class VideoAnalyzeEvent( val videoAnalysisTaskId: String, val youtubeUrl: String, + val source: Source, ) diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListener.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListener.kt index 49d1933..8e19c1f 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListener.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListener.kt @@ -16,7 +16,9 @@ class VideoAnalyzeEventListener( @Async("VideoAnalyzeExecutor") @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun handle(event: VideoAnalyzeEvent) { - logger.info { "영상 분석 큐 등록: id=${event.videoAnalysisTaskId}, url=${event.youtubeUrl}" } - videoAnalysisQueuePort.enqueue(event.videoAnalysisTaskId, event.youtubeUrl) + logger.info { + "영상 분석 큐 등록: id=${event.videoAnalysisTaskId}, source=${event.source}, url=${event.youtubeUrl}" + } + videoAnalysisQueuePort.enqueue(event.videoAnalysisTaskId, event.youtubeUrl, event.source) } } diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeService.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeService.kt index 3cb45e3..ff3ba55 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeService.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalyzeService.kt @@ -11,7 +11,10 @@ class VideoAnalyzeService( private val videoAnalysisTaskPersistencePort: VideoAnalysisTaskPersistencePort, ) : VideoAnalyzeUseCase { @Transactional - override fun analyzeVideo(youtubeUrl: String): VideoAnalysisTask { + override fun analyzeVideo( + youtubeUrl: String, + source: Source, + ): VideoAnalysisTask { val normalizedUrl = VideoAnalysisTask.normalizeUrl(youtubeUrl) videoAnalysisTaskPersistencePort.findByYoutubeUrl(normalizedUrl)?.let { existing -> @@ -19,12 +22,13 @@ class VideoAnalyzeService( if (existing.status != VideoAnalysisTaskStatus.FAILED) return existing videoAnalysisTaskPersistencePort.updateStatus(existing.id, VideoAnalysisTaskStatus.PENDING) - Events.raise(VideoAnalyzeEvent(existing.id, normalizedUrl)) + Events.raise(VideoAnalyzeEvent(existing.id, normalizedUrl, source)) return existing.copy(status = VideoAnalysisTaskStatus.PENDING) } - val videoAnalysisTask = videoAnalysisTaskPersistencePort.save(VideoAnalysisTask.create(normalizedUrl)) - Events.raise(VideoAnalyzeEvent(videoAnalysisTask.id, normalizedUrl)) + val videoAnalysisTask = + videoAnalysisTaskPersistencePort.save(VideoAnalysisTask.create(normalizedUrl, source)) + Events.raise(VideoAnalyzeEvent(videoAnalysisTask.id, normalizedUrl, source)) return videoAnalysisTask } diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/youtube/YouTubeCollectService.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/youtube/YouTubeCollectService.kt index a13e406..37f9122 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/youtube/YouTubeCollectService.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/youtube/YouTubeCollectService.kt @@ -1,5 +1,6 @@ package com.linktrip.application.domain.youtube +import com.linktrip.application.domain.video.Source import com.linktrip.application.domain.video.VideoAnalysisTask import com.linktrip.application.port.input.VideoAnalyzeUseCase import com.linktrip.application.port.output.external.YouTubePort @@ -95,7 +96,10 @@ class YouTubeCollectService( private fun requestVideoAnalysis(videos: List) { videos.forEach { video -> try { - videoAnalyzeUseCase.analyzeVideo(VideoAnalysisTask.buildUrl(video.videoId)) + videoAnalyzeUseCase.analyzeVideo( + youtubeUrl = VideoAnalysisTask.buildUrl(video.videoId), + source = Source.BATCH, + ) } catch (e: Exception) { logger.warn(e) { "영상 분석 요청 실패: videoId=${video.videoId}" } } diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/input/VideoAnalyzeUseCase.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/input/VideoAnalyzeUseCase.kt index 498f23b..f38261c 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/port/input/VideoAnalyzeUseCase.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/input/VideoAnalyzeUseCase.kt @@ -1,7 +1,11 @@ package com.linktrip.application.port.input +import com.linktrip.application.domain.video.Source import com.linktrip.application.domain.video.VideoAnalysisTask interface VideoAnalyzeUseCase { - fun analyzeVideo(youtubeUrl: String): VideoAnalysisTask + fun analyzeVideo( + youtubeUrl: String, + source: Source, + ): VideoAnalysisTask } diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/queue/VideoAnalysisQueuePort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/queue/VideoAnalysisQueuePort.kt index 425e96c..1504e29 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/queue/VideoAnalysisQueuePort.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/queue/VideoAnalysisQueuePort.kt @@ -1,11 +1,13 @@ package com.linktrip.application.port.output.queue +import com.linktrip.application.domain.video.Source import com.linktrip.application.domain.video.VideoAnalyzeEvent interface VideoAnalysisQueuePort { fun enqueue( videoAnalysisTaskId: String, youtubeUrl: String, + source: Source, ) fun dequeue(): VideoAnalyzeEvent? diff --git a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/VideoController.kt b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/VideoController.kt index 98e2ed1..6e3ae22 100644 --- a/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/VideoController.kt +++ b/linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/VideoController.kt @@ -1,5 +1,6 @@ package com.linktrip.input.http.controller +import com.linktrip.application.domain.video.Source import com.linktrip.application.domain.video.VideoAnalysisTaskStatus import com.linktrip.application.port.input.DiscoverChannelUseCase import com.linktrip.application.port.input.DiscoverVideoUseCase @@ -41,7 +42,7 @@ class VideoController( @AuthenticatedMember memberId: String, @Validated @RequestBody request: VideoAnalyzeRequest, ): ApiResponse { - val task = videoAnalyzeUseCase.analyzeVideo(request.youtubeUrl) + val task = videoAnalyzeUseCase.analyzeVideo(request.youtubeUrl, Source.USER) when (task.status) { VideoAnalysisTaskStatus.COMPLETED -> diff --git a/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/InMemoryVideoAnalysisQueueAdapter.kt b/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/InMemoryVideoAnalysisQueueAdapter.kt index 1456a41..99a6001 100644 --- a/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/InMemoryVideoAnalysisQueueAdapter.kt +++ b/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/InMemoryVideoAnalysisQueueAdapter.kt @@ -1,29 +1,53 @@ package com.linktrip.output.cache.caffeine.adapter +import com.linktrip.application.domain.video.Source import com.linktrip.application.domain.video.VideoAnalyzeEvent import com.linktrip.application.port.output.queue.VideoAnalysisQueuePort import mu.KotlinLogging import org.springframework.stereotype.Component -import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.PriorityBlockingQueue +import java.util.concurrent.atomic.AtomicLong private val logger = KotlinLogging.logger {} +/** + * 우선순위 + FIFO in-memory 영상 분석 큐. + * + * 정렬 기준 (먼저 처리되는 순서): + * 1. [Source.priority] 작은 순 (USER 가 BATCH 보다 먼저) + * 2. enqueue 시퀀스 작은 순 (동일 priority 안에서는 FIFO) + * + * 시퀀스는 [AtomicLong] 으로 단조 증가 → 같은 source 끼리 들어온 순서가 보장된다. + * (PriorityBlockingQueue 자체는 동일 우선순위 요소 순서를 보장하지 않으므로 tiebreaker 필요) + */ @Component class InMemoryVideoAnalysisQueueAdapter : VideoAnalysisQueuePort { - private val queue = LinkedBlockingQueue() + private data class QueueEntry(val event: VideoAnalyzeEvent, val sequence: Long) + + private val sequence = AtomicLong(0) + + private val queue = + PriorityBlockingQueue( + INITIAL_CAPACITY, + compareBy({ it.event.source.priority }, { it.sequence }), + ) override fun enqueue( videoAnalysisTaskId: String, youtubeUrl: String, + source: Source, ) { - val event = VideoAnalyzeEvent(videoAnalysisTaskId, youtubeUrl) - queue.put(event) - logger.info { "영상 분석 큐 추가: taskId=$videoAnalysisTaskId (큐 크기: ${queue.size})" } + val event = VideoAnalyzeEvent(videoAnalysisTaskId, youtubeUrl, source) + val entry = QueueEntry(event, sequence.incrementAndGet()) + queue.put(entry) + logger.info { + "영상 분석 큐 추가: taskId=$videoAnalysisTaskId, source=$source (큐 크기: ${queue.size})" + } } override fun dequeue(): VideoAnalyzeEvent? = try { - queue.take() + queue.take().event } catch (_: InterruptedException) { Thread.currentThread().interrupt() null @@ -31,6 +55,9 @@ class InMemoryVideoAnalysisQueueAdapter : VideoAnalysisQueuePort { override fun size(): Int = queue.size - override fun contains(videoAnalysisTaskId: String): Boolean = - queue.any { it.videoAnalysisTaskId == videoAnalysisTaskId } + override fun contains(videoAnalysisTaskId: String): Boolean = queue.any { it.event.videoAnalysisTaskId == videoAnalysisTaskId } + + companion object { + private const val INITIAL_CAPACITY = 16 + } } diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/VideoAnalysisTaskEntity.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/VideoAnalysisTaskEntity.kt index a1946b5..eb997bd 100644 --- a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/VideoAnalysisTaskEntity.kt +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/VideoAnalysisTaskEntity.kt @@ -1,6 +1,7 @@ package com.linktrip.output.persistence.mysql.entity import com.linktrip.application.domain.video.CostBasis +import com.linktrip.application.domain.video.Source import com.linktrip.application.domain.video.VideoAnalysisTask import com.linktrip.application.domain.video.VideoAnalysisTaskStatus import com.linktrip.common.exception.ExceptionCode @@ -32,6 +33,9 @@ class VideoAnalysisTaskEntity( @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 20) var status: VideoAnalysisTaskStatus = VideoAnalysisTaskStatus.PENDING, + @Enumerated(EnumType.STRING) + @Column(name = "source", nullable = false, length = 20) + val source: Source, @Column(name = "estimated_min_cost") var estimatedMinCost: Long? = null, @Column(name = "estimated_max_cost") @@ -59,6 +63,7 @@ class VideoAnalysisTaskEntity( youtubeUrl = this.youtubeUrl, valid = this.valid, status = this.status, + source = this.source, summary = this.summary, estimatedMinCost = this.estimatedMinCost, estimatedMaxCost = this.estimatedMaxCost, @@ -75,6 +80,7 @@ class VideoAnalysisTaskEntity( youtubeUrl = videoAnalysisTask.youtubeUrl, valid = videoAnalysisTask.valid, status = videoAnalysisTask.status, + source = videoAnalysisTask.source, summary = videoAnalysisTask.summary, estimatedMinCost = videoAnalysisTask.estimatedMinCost, estimatedMaxCost = videoAnalysisTask.estimatedMaxCost, From eefe2e7409d12b2ebc65200358f35ba58d2c02a3 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 13:51:28 +0900 Subject: [PATCH 09/19] =?UTF-8?q?feat:=20PENDING=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EB=B0=8F=20=EB=B6=80=ED=8C=85=20=EC=9E=AC=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20=EC=8B=9C=20task.source=20=EB=B3=B4=EC=A1=B4?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=9B=90=EB=9E=98=20priority=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/linktrip/bootstrap/ApplicationInitializer.kt | 2 +- .../com/linktrip/input/batch/VideoAnalysisRetryJobConfig.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/linktrip-bootstrap/src/main/kotlin/com/linktrip/bootstrap/ApplicationInitializer.kt b/linktrip-bootstrap/src/main/kotlin/com/linktrip/bootstrap/ApplicationInitializer.kt index 767010b..b62af62 100644 --- a/linktrip-bootstrap/src/main/kotlin/com/linktrip/bootstrap/ApplicationInitializer.kt +++ b/linktrip-bootstrap/src/main/kotlin/com/linktrip/bootstrap/ApplicationInitializer.kt @@ -50,7 +50,7 @@ class ApplicationInitializer( } tasks.forEach { task -> - videoAnalysisQueuePort.enqueue(task.id, task.youtubeUrl) + videoAnalysisQueuePort.enqueue(task.id, task.youtubeUrl, task.source) } logger.info { "미처리 영상 분석 건 큐 재적재 완료: ${tasks.size}건" } } diff --git a/linktrip-input-batch/src/main/kotlin/com/linktrip/input/batch/VideoAnalysisRetryJobConfig.kt b/linktrip-input-batch/src/main/kotlin/com/linktrip/input/batch/VideoAnalysisRetryJobConfig.kt index 6e21511..52e8914 100644 --- a/linktrip-input-batch/src/main/kotlin/com/linktrip/input/batch/VideoAnalysisRetryJobConfig.kt +++ b/linktrip-input-batch/src/main/kotlin/com/linktrip/input/batch/VideoAnalysisRetryJobConfig.kt @@ -56,7 +56,7 @@ class VideoAnalysisRetryJobConfig( } newTasks.forEach { task -> - videoAnalysisQueuePort.enqueue(task.id, task.youtubeUrl) + videoAnalysisQueuePort.enqueue(task.id, task.youtubeUrl, task.source) } logger.info { "방치된 PENDING 영상 분석 건 큐 재투입: ${newTasks.size}건 " + From 54203c9d1f7fd26624c756cc5e63c8c0f2ebeb3f Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 13:51:33 +0900 Subject: [PATCH 10/19] =?UTF-8?q?test:=20USER/BATCH=20source=20=EC=A0=84?= =?UTF-8?q?=EB=8B=AC=20=EB=B0=8F=20=ED=86=B5=EA=B3=84=20=EB=B6=84=EB=A5=98?= =?UTF-8?q?=20=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20=EC=8B=9C=EB=82=98?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../video/VideoAnalysisQueueConsumerTest.kt | 12 ++-- .../domain/video/VideoAnalysisTaskTest.kt | 20 ++++-- .../video/VideoAnalyzeEventListenerTest.kt | 22 ++++-- .../domain/video/VideoAnalyzeServiceTest.kt | 70 ++++++++++++++----- .../domain/video/VideoScheduleServiceTest.kt | 1 + .../youtube/YouTubeCollectServiceTest.kt | 17 ++--- 6 files changed, 99 insertions(+), 43 deletions(-) diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumerTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumerTest.kt index 83739d1..8bfc04c 100644 --- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumerTest.kt +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisQueueConsumerTest.kt @@ -79,7 +79,7 @@ class VideoAnalysisQueueConsumerTest { // when - processAnalysis를 직접 호출 (리플렉션 대신 consumer 내부 메서드 테스트) val method = consumer.javaClass.getDeclaredMethod("processAnalysis", VideoAnalyzeEvent::class.java) method.isAccessible = true - method.invoke(consumer, VideoAnalyzeEvent("s1", "https://youtube.com/1")) + method.invoke(consumer, VideoAnalyzeEvent("s1", "https://youtube.com/1", Source.USER)) // then verify(videoAnalysisResultSaver).save( @@ -113,7 +113,7 @@ class VideoAnalysisQueueConsumerTest { // when val method = consumer.javaClass.getDeclaredMethod("processAnalysis", VideoAnalyzeEvent::class.java) method.isAccessible = true - method.invoke(consumer, VideoAnalyzeEvent("s1", "https://youtube.com/1")) + method.invoke(consumer, VideoAnalyzeEvent("s1", "https://youtube.com/1", Source.USER)) // then verify(videoAnalysisTaskPersistencePort).updateValidAndStatus( @@ -142,7 +142,7 @@ class VideoAnalysisQueueConsumerTest { // when val method = consumer.javaClass.getDeclaredMethod("processAnalysis", VideoAnalyzeEvent::class.java) method.isAccessible = true - method.invoke(consumer, VideoAnalyzeEvent("s1", "https://youtube.com/1")) + method.invoke(consumer, VideoAnalyzeEvent("s1", "https://youtube.com/1", Source.USER)) // then verify(videoAnalysisTaskPersistencePort).updateStatus("s1", VideoAnalysisTaskStatus.FAILED) @@ -166,7 +166,7 @@ class VideoAnalysisQueueConsumerTest { // when val method = consumer.javaClass.getDeclaredMethod("processAnalysis", VideoAnalyzeEvent::class.java) method.isAccessible = true - method.invoke(consumer, VideoAnalyzeEvent("s1", "https://youtube.com/1")) + method.invoke(consumer, VideoAnalyzeEvent("s1", "https://youtube.com/1", Source.USER)) // then - 분석 결과는 저장되고, 알림도 전송된다 verify(videoAnalysisResultSaver).save( @@ -205,7 +205,7 @@ class VideoAnalysisQueueConsumerTest { // when val method = consumer.javaClass.getDeclaredMethod("processAnalysis", VideoAnalyzeEvent::class.java) method.isAccessible = true - method.invoke(consumer, VideoAnalyzeEvent("s1", "https://youtube.com/1")) + method.invoke(consumer, VideoAnalyzeEvent("s1", "https://youtube.com/1", Source.USER)) // then assertEquals(true, request1.processed) @@ -259,7 +259,7 @@ class VideoAnalysisQueueConsumerTest { // when val method = consumer.javaClass.getDeclaredMethod("processAnalysis", VideoAnalyzeEvent::class.java) method.isAccessible = true - method.invoke(consumer, VideoAnalyzeEvent("s1", "https://youtube.com/1")) + method.invoke(consumer, VideoAnalyzeEvent("s1", "https://youtube.com/1", Source.USER)) // then val itemsCaptor = argumentCaptor>() diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisTaskTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisTaskTest.kt index c493e70..c7a854c 100644 --- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisTaskTest.kt +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalysisTaskTest.kt @@ -59,24 +59,34 @@ class VideoAnalysisTaskTest { } @Test - fun `create는 정규화된 URL로 PENDING 상태의 VideoAnalysisTask를 생성한다`() { - // given - youtu.be 단축 URL + fun `create는 정규화된 URL로 PENDING 상태와 지정된 source로 VideoAnalysisTask를 생성한다`() { + // given - youtu.be 단축 URL + USER source val shortUrl = "https://youtu.be/abc123" // when - val task = VideoAnalysisTask.create(shortUrl) + val task = VideoAnalysisTask.create(shortUrl, Source.USER) - // then - 정규화된 URL, PENDING 상태, valid=false + // then - 정규화된 URL, PENDING 상태, valid=false, source=USER assertEquals("https://www.youtube.com/watch?v=abc123", task.youtubeUrl) assertEquals(VideoAnalysisTaskStatus.PENDING, task.status) + assertEquals(Source.USER, task.source) assertFalse(task.valid) assertTrue(task.id.isNotBlank()) } + @Test + fun `create에 BATCH source를 전달하면_source=BATCH로 task가 생성되어 통계 분류가 가능하다`() { + // when - BATCH source 로 생성 + val task = VideoAnalysisTask.create("https://youtu.be/abc123", Source.BATCH) + + // then - source=BATCH 로 audit 정보가 박혀 통계 집계 가능 + assertEquals(Source.BATCH, task.source) + } + @Test fun `create에 유효하지 않은 URL을 전달하면 예외가 발생한다`() { assertThrows { - VideoAnalysisTask.create("https://naver.com/video") + VideoAnalysisTask.create("https://naver.com/video", Source.USER) } } } diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListenerTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListenerTest.kt index acd2df7..a5b148b 100644 --- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListenerTest.kt +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeEventListenerTest.kt @@ -17,14 +17,26 @@ class VideoAnalyzeEventListenerTest { lateinit var listener: VideoAnalyzeEventListener @Test - fun `이벤트를 수신하면_큐에 enqueue만 수행한다`() { - // given - val event = VideoAnalyzeEvent("s1", "https://youtube.com/1") + fun `USER 이벤트를 수신하면_큐에 USER source 로 enqueue 한다`() { + // given - 사용자 직접 요청에서 발생한 이벤트 + val event = VideoAnalyzeEvent("s1", "https://youtube.com/1", Source.USER) // when listener.handle(event) - // then - 큐에 넣기만 하고 직접 분석하지 않는다 - verify(videoAnalysisQueuePort).enqueue("s1", "https://youtube.com/1") + // then - event 의 source 가 그대로 큐로 전달되어야 USER 우선순위가 보장된다 + verify(videoAnalysisQueuePort).enqueue("s1", "https://youtube.com/1", Source.USER) + } + + @Test + fun `BATCH 이벤트를 수신하면_큐에 BATCH source 로 enqueue 한다`() { + // given - 배치/수집에서 발생한 이벤트 + val event = VideoAnalyzeEvent("s2", "https://youtube.com/2", Source.BATCH) + + // when + listener.handle(event) + + // then - BATCH source 가 보존되어 사용자 요청보다 후순위로 처리됨 + verify(videoAnalysisQueuePort).enqueue("s2", "https://youtube.com/2", Source.BATCH) } } diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeServiceTest.kt index dc7eb8f..a5b1367 100644 --- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeServiceTest.kt +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeServiceTest.kt @@ -43,7 +43,7 @@ class VideoAnalyzeServiceTest { } @Test - fun `신규 YouTube URL로 분석 요청하면_VideoAnalysisTask를 PENDING 상태로 저장하고_분석 이벤트를 발행한다`() { + fun `신규 USER 요청이면_USER source 로 task 가 저장되고_USER source 로 분석 이벤트가 발행된다`() { // given - 아직 분석된 적 없는 새로운 YouTube URL val url = "https://www.youtube.com/watch?v=test123" whenever(videoAnalysisTaskPersistencePort.findByYoutubeUrl(url)).thenReturn(null) @@ -51,27 +51,55 @@ class VideoAnalyzeServiceTest { Fixtures.monkey.giveMeBuilder() .set("youtubeUrl", url) .set("status", VideoAnalysisTaskStatus.PENDING) + .set("source", Source.USER) .sample() whenever(videoAnalysisTaskPersistencePort.save(any())).thenReturn(saved) - // when - 영상 분석을 요청한다 - val result = service.analyzeVideo(url) + // when - USER source 로 영상 분석을 요청한다 + val result = service.analyzeVideo(url, Source.USER) - // then - PENDING 상태로 저장되고, 올바른 초기 상태의 VideoAnalysisTask가 저장되며, 분석 이벤트가 발행된다 + // then - PENDING + source=USER 로 저장되고, 동일 source 로 이벤트가 발행된다 assertEquals(VideoAnalysisTaskStatus.PENDING, result.status) assertEquals(url, result.youtubeUrl) val saveCaptor = argumentCaptor() verify(videoAnalysisTaskPersistencePort).save(saveCaptor.capture()) - val savedArg = saveCaptor.firstValue - assertEquals(url, savedArg.youtubeUrl) - assertEquals(VideoAnalysisTaskStatus.PENDING, savedArg.status) - assertFalse(savedArg.valid) + assertEquals(url, saveCaptor.firstValue.youtubeUrl) + assertEquals(VideoAnalysisTaskStatus.PENDING, saveCaptor.firstValue.status) + assertEquals(Source.USER, saveCaptor.firstValue.source) + assertFalse(saveCaptor.firstValue.valid) val eventCaptor = argumentCaptor() verify(mockPublisher).publishEvent(eventCaptor.capture()) assertEquals(saved.id, eventCaptor.firstValue.videoAnalysisTaskId) assertEquals(url, eventCaptor.firstValue.youtubeUrl) + assertEquals(Source.USER, eventCaptor.firstValue.source) + } + + @Test + fun `신규 BATCH 요청이면_BATCH source 로 task 가 저장되어 통계 분류가 가능하다`() { + // given - YouTube 정기 수집/백필 스케줄러가 신규 영상을 분석 요청 + val url = "https://www.youtube.com/watch?v=batch1" + whenever(videoAnalysisTaskPersistencePort.findByYoutubeUrl(url)).thenReturn(null) + val saved = + Fixtures.monkey.giveMeBuilder() + .set("youtubeUrl", url) + .set("status", VideoAnalysisTaskStatus.PENDING) + .set("source", Source.BATCH) + .sample() + whenever(videoAnalysisTaskPersistencePort.save(any())).thenReturn(saved) + + // when + service.analyzeVideo(url, Source.BATCH) + + // then - task 와 event 모두 source=BATCH (배치 통계 + 큐 우선순위 둘 다 정상) + val saveCaptor = argumentCaptor() + verify(videoAnalysisTaskPersistencePort).save(saveCaptor.capture()) + assertEquals(Source.BATCH, saveCaptor.firstValue.source) + + val eventCaptor = argumentCaptor() + verify(mockPublisher).publishEvent(eventCaptor.capture()) + assertEquals(Source.BATCH, eventCaptor.firstValue.source) } @Test @@ -82,11 +110,12 @@ class VideoAnalyzeServiceTest { Fixtures.monkey.giveMeBuilder() .set("youtubeUrl", normalizedUrl) .set("status", VideoAnalysisTaskStatus.PENDING) + .set("source", Source.USER) .sample() whenever(videoAnalysisTaskPersistencePort.findByYoutubeUrl(normalizedUrl)).thenReturn(existing) // when - 같은 URL로 다시 분석을 요청한다 - val result = service.analyzeVideo(normalizedUrl) + val result = service.analyzeVideo(normalizedUrl, Source.USER) // then - 기존 객체를 그대로 반환하고, save/updateStatus/이벤트 모두 호출되지 않는다 assertEquals(existing.id, result.id) @@ -105,11 +134,12 @@ class VideoAnalyzeServiceTest { Fixtures.monkey.giveMeBuilder() .set("youtubeUrl", normalizedUrl) .set("status", VideoAnalysisTaskStatus.COMPLETED) + .set("source", Source.USER) .sample() whenever(videoAnalysisTaskPersistencePort.findByYoutubeUrl(normalizedUrl)).thenReturn(existing) // when - 완료된 URL로 다시 분석을 요청한다 - val result = service.analyzeVideo(normalizedUrl) + val result = service.analyzeVideo(normalizedUrl, Source.USER) // then - 기존 완료된 결과를 그대로 반환하고, 재분석하지 않는다 assertEquals(VideoAnalysisTaskStatus.COMPLETED, result.status) @@ -126,11 +156,12 @@ class VideoAnalyzeServiceTest { Fixtures.monkey.giveMeBuilder() .set("youtubeUrl", normalizedUrl) .set("status", VideoAnalysisTaskStatus.INVALID) + .set("source", Source.USER) .sample() whenever(videoAnalysisTaskPersistencePort.findByYoutubeUrl(normalizedUrl)).thenReturn(existing) // when - INVALID 상태의 URL로 다시 요청한다 - val result = service.analyzeVideo(normalizedUrl) + val result = service.analyzeVideo(normalizedUrl, Source.USER) // then - INVALID 상태 그대로 반환하고, 이벤트를 발행하지 않는다 assertEquals(VideoAnalysisTaskStatus.INVALID, result.status) @@ -138,31 +169,31 @@ class VideoAnalyzeServiceTest { } @Test - fun `이전에 실패한 FAILED 상태의 URL로 요청하면_PENDING으로 상태를 변경하고_재분석 이벤트를 발행한다`() { - // given - 이전 분석이 실패한 URL (정규화된 형태) + fun `BATCH 로 생성된 FAILED task 에 USER 가 재요청하면_이번 재시도 이벤트는 USER source 로 발행된다`() { + // given - 배치가 만들어둔 FAILED 영상 (예: 정기 수집이 실패한 영상). audit 상 task.source=BATCH val normalizedUrl = "https://www.youtube.com/watch?v=failed" val existing = Fixtures.monkey.giveMeBuilder() .set("id", "failed-id") .set("youtubeUrl", normalizedUrl) .set("status", VideoAnalysisTaskStatus.FAILED) + .set("source", Source.BATCH) .sample() whenever(videoAnalysisTaskPersistencePort.findByYoutubeUrl(normalizedUrl)).thenReturn(existing) - // when - 실패한 URL로 재분석을 요청한다 - val result = service.analyzeVideo(normalizedUrl) + // when - 사용자가 직접 같은 영상을 재요청 + val result = service.analyzeVideo(normalizedUrl, Source.USER) - // then - PENDING으로 상태가 변경되고, 재분석 이벤트에 기존 ID와 URL이 담긴다 + // then - 상태는 PENDING 으로 전환, 재시도 이벤트는 현 trigger 인 USER 로 발행 (큐에서 USER 우선순위로 처리됨) assertEquals(VideoAnalysisTaskStatus.PENDING, result.status) assertEquals("failed-id", result.id) - assertEquals(normalizedUrl, result.youtubeUrl) - verify(videoAnalysisTaskPersistencePort).updateStatus("failed-id", VideoAnalysisTaskStatus.PENDING) val eventCaptor = argumentCaptor() verify(mockPublisher).publishEvent(eventCaptor.capture()) assertEquals("failed-id", eventCaptor.firstValue.videoAnalysisTaskId) assertEquals(normalizedUrl, eventCaptor.firstValue.youtubeUrl) + assertEquals(Source.USER, eventCaptor.firstValue.source) } @Test @@ -175,11 +206,12 @@ class VideoAnalyzeServiceTest { Fixtures.monkey.giveMeBuilder() .set("youtubeUrl", normalizedUrl) .set("status", VideoAnalysisTaskStatus.PENDING) + .set("source", Source.USER) .sample() whenever(videoAnalysisTaskPersistencePort.save(any())).thenReturn(saved) // when - youtu.be URL로 분석을 요청한다 - val result = service.analyzeVideo(shortUrl) + val result = service.analyzeVideo(shortUrl, Source.USER) // then - 정규화된 URL로 PENDING 상태의 VideoAnalysisTask가 생성된다 assertEquals(VideoAnalysisTaskStatus.PENDING, result.status) diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoScheduleServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoScheduleServiceTest.kt index 6260ad0..fc2b30f 100644 --- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoScheduleServiceTest.kt +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoScheduleServiceTest.kt @@ -37,6 +37,7 @@ class VideoScheduleServiceTest { youtubeUrl = "https://youtube.com/1", valid = true, status = VideoAnalysisTaskStatus.COMPLETED, + source = Source.USER, ) val items = listOf( diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/youtube/YouTubeCollectServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/youtube/YouTubeCollectServiceTest.kt index f5a2423..5b065db 100644 --- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/youtube/YouTubeCollectServiceTest.kt +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/youtube/YouTubeCollectServiceTest.kt @@ -1,5 +1,6 @@ package com.linktrip.application.domain.youtube +import com.linktrip.application.domain.video.Source import com.linktrip.application.domain.video.VideoAnalysisTask import com.linktrip.application.port.input.VideoAnalyzeUseCase import com.linktrip.application.port.output.external.YouTubePort @@ -46,8 +47,8 @@ class YouTubeCollectServiceTest { ) whenever(youTubePort.searchVideos(any(), any())).thenReturn(listOf(searchResult)) whenever(youTubeVideoPersistencePort.findExistingVideoIds(any())).thenReturn(emptySet()) - whenever(videoAnalyzeUseCase.analyzeVideo(any())).thenReturn( - VideoAnalysisTask.create("https://www.youtube.com/watch?v=v1"), + whenever(videoAnalyzeUseCase.analyzeVideo(any(), any())).thenReturn( + VideoAnalysisTask.create("https://www.youtube.com/watch?v=v1", Source.BATCH), ) val videoDetail = @@ -75,7 +76,7 @@ class YouTubeCollectServiceTest { // then - 메타데이터가 태깅되어 저장된다 verify(youTubeVideoPersistencePort).saveAll(any()) - verify(videoAnalyzeUseCase, times(5)).analyzeVideo("https://www.youtube.com/watch?v=v1") + verify(videoAnalyzeUseCase, times(5)).analyzeVideo("https://www.youtube.com/watch?v=v1", Source.BATCH) } @Test @@ -99,7 +100,7 @@ class YouTubeCollectServiceTest { // then - 모두 중복이므로 저장을 수행하지 않는다 verify(youTubeVideoPersistencePort, never()).saveAll(any()) - verify(videoAnalyzeUseCase, never()).analyzeVideo(any()) + verify(videoAnalyzeUseCase, never()).analyzeVideo(any(), any()) } @Test @@ -113,7 +114,7 @@ class YouTubeCollectServiceTest { // then - 상세 조회와 저장을 모두 건너뛴다 verify(youTubePort, never()).getVideoDetails(any()) verify(youTubeVideoPersistencePort, never()).saveAll(any()) - verify(videoAnalyzeUseCase, never()).analyzeVideo(any()) + verify(videoAnalyzeUseCase, never()).analyzeVideo(any(), any()) } @Test @@ -182,8 +183,8 @@ class YouTubeCollectServiceTest { ) whenever(youTubePort.searchVideos(any(), any())).thenReturn(listOf(searchResult)) whenever(youTubeVideoPersistencePort.findExistingVideoIds(any())).thenReturn(emptySet()) - whenever(videoAnalyzeUseCase.analyzeVideo(any())).thenReturn( - VideoAnalysisTask.create("https://www.youtube.com/watch?v=v-asia"), + whenever(videoAnalyzeUseCase.analyzeVideo(any(), any())).thenReturn( + VideoAnalysisTask.create("https://www.youtube.com/watch?v=v-asia", Source.BATCH), ) val videoDetail = @@ -211,7 +212,7 @@ class YouTubeCollectServiceTest { // then - 저장이 수행된다 verify(youTubeVideoPersistencePort).saveAll(any()) - verify(videoAnalyzeUseCase).analyzeVideo("https://www.youtube.com/watch?v=v-asia") + verify(videoAnalyzeUseCase).analyzeVideo("https://www.youtube.com/watch?v=v-asia", Source.BATCH) } @Test From d8c239a1cc5c9f4412d64c95ea1469379ea3acb7 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 13:59:20 +0900 Subject: [PATCH 11/19] =?UTF-8?q?refactor:=20VideoAnalysisTask=20=EC=9D=98?= =?UTF-8?q?=20YOUTUBE=5FVIDEO=5FBASE=5FURL=20=EC=9D=84=20public=20const=20?= =?UTF-8?q?=EB=A1=9C=20=EB=85=B8=EC=B6=9C=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/linktrip/application/domain/video/VideoAnalysisTask.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisTask.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisTask.kt index df71e96..04a614e 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisTask.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/VideoAnalysisTask.kt @@ -21,7 +21,7 @@ data class VideoAnalysisTask( val updatedAt: LocalDateTime = LocalDateTime.now(), ) { companion object { - private const val YOUTUBE_VIDEO_BASE_URL = "https://www.youtube.com/watch?v=" + const val YOUTUBE_VIDEO_BASE_URL = "https://www.youtube.com/watch?v=" /** * 지원 URL 형식: From bc12352eb1db66111764de53db264fd74f8f8677 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 13:59:26 +0900 Subject: [PATCH 12/19] =?UTF-8?q?feat:=20youtube=5Fvideo=20=EC=97=90=20?= =?UTF-8?q?=EC=9E=88=EC=A7=80=EB=A7=8C=20=EB=B6=84=EC=84=9D=EB=90=9C=20?= =?UTF-8?q?=EC=A0=81=20=EC=97=86=EB=8A=94=20=EC=98=81=EC=83=81=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../YouTubeVideoPersistencePort.kt | 6 ++++ .../adapter/YouTubeVideoPersistenceAdapter.kt | 3 ++ .../YouTubeVideoQuerydslRepository.kt | 30 +++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/YouTubeVideoPersistencePort.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/YouTubeVideoPersistencePort.kt index 2b0d14e..bbdff7a 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/YouTubeVideoPersistencePort.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/YouTubeVideoPersistencePort.kt @@ -20,4 +20,10 @@ interface YouTubeVideoPersistencePort { cursor: LocalDateTime?, size: Int, ): CursorPage + + /** + * youtube_video 에 있지만 아직 video_analysis_task 가 생성되지 않은 (= 한 번도 분석 요청된 적 없는) videoId 목록. + * 오래된 것부터 [limit] 건. backfill 스케줄러에서 stranded 영상 소진용. + */ + fun findUnanalyzedVideoIds(limit: Int): List } diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeVideoPersistenceAdapter.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeVideoPersistenceAdapter.kt index f27d907..4080857 100644 --- a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeVideoPersistenceAdapter.kt +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeVideoPersistenceAdapter.kt @@ -43,6 +43,9 @@ class YouTubeVideoPersistenceAdapter( querydslRepository.findAllByRegionOrderByViewCountDesc(region) .map { it.toDomain() } + @Transactional(readOnly = true) + override fun findUnanalyzedVideoIds(limit: Int): List = querydslRepository.findUnanalyzedVideoIds(limit) + @Transactional(readOnly = true) override fun findAllByTheme( theme: String, diff --git a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/YouTubeVideoQuerydslRepository.kt b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/YouTubeVideoQuerydslRepository.kt index 8193f6f..25fe12c 100644 --- a/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/YouTubeVideoQuerydslRepository.kt +++ b/linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/YouTubeVideoQuerydslRepository.kt @@ -1,5 +1,7 @@ package com.linktrip.output.persistence.mysql.repository +import com.linktrip.application.domain.video.VideoAnalysisTask +import com.linktrip.output.persistence.mysql.entity.QVideoAnalysisTaskEntity import com.linktrip.output.persistence.mysql.entity.QYouTubeVideoEntity import com.linktrip.output.persistence.mysql.entity.YouTubeVideoEntity import com.querydsl.jpa.impl.JPAQueryFactory @@ -12,6 +14,7 @@ class YouTubeVideoQuerydslRepository( private val queryFactory: JPAQueryFactory, ) { private val video = QYouTubeVideoEntity.youTubeVideoEntity + private val analysisTask = QVideoAnalysisTaskEntity.videoAnalysisTaskEntity fun findAllByVideoIdIn(videoIds: List): List = queryFactory @@ -73,4 +76,31 @@ class YouTubeVideoQuerydslRepository( .offset(pageable.offset) .limit(pageable.pageSize.toLong()) .fetch() + + /** + * youtube_video 에 있지만 video_analysis_task 에는 없는 videoId 를 오래된 순으로 [limit] 건 조회. + * + * LEFT JOIN 후 매칭된 분석 task 가 없는(IS NULL) row 만 선택. SQL 의 anti-join 정석 패턴. + * + * ```sql + * SELECT v.video_id + * FROM youtube_video v + * LEFT JOIN video_analysis_task t + * ON t.youtube_url = CONCAT('https://www.youtube.com/watch?v=', v.video_id) + * WHERE t.id IS NULL + * ORDER BY v.created_at ASC + * LIMIT :limit + * ``` + */ + fun findUnanalyzedVideoIds(limit: Int): List { + val constructedUrl = video.videoId.prepend(VideoAnalysisTask.YOUTUBE_VIDEO_BASE_URL) + return queryFactory + .select(video.videoId) + .from(video) + .leftJoin(analysisTask).on(analysisTask.youtubeUrl.eq(constructedUrl)) + .where(analysisTask.id.isNull) + .orderBy(video.createdAt.asc()) + .limit(limit.toLong()) + .fetch() + } } From c872403c2a7676a6e8171a1ed8c9bb940bfb32e8 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 13:59:34 +0900 Subject: [PATCH 13/19] =?UTF-8?q?feat:=20=EC=88=98=EC=A7=91=EB=90=9C=20?= =?UTF-8?q?=EB=AF=B8=EC=B2=98=EB=A6=AC=20=EC=98=81=EC=83=81=EC=9D=84=20BAT?= =?UTF-8?q?CH=20=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=EB=A1=9C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=B6=84=EC=84=9D=20enqueue=20=ED=95=98=EB=8A=94?= =?UTF-8?q?=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-prod.yml | 6 ++ .../batch/VideoAnalysisBackfillScheduler.kt | 60 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 linktrip-input-batch/src/main/kotlin/com/linktrip/input/batch/VideoAnalysisBackfillScheduler.kt diff --git a/linktrip-bootstrap/src/main/resources/application-prod.yml b/linktrip-bootstrap/src/main/resources/application-prod.yml index 2ee7f00..1b9f412 100644 --- a/linktrip-bootstrap/src/main/resources/application-prod.yml +++ b/linktrip-bootstrap/src/main/resources/application-prod.yml @@ -17,3 +17,9 @@ youtube: proxy: password: ${YOUTUBE_PROXY_PASSWORD} usernames: ${YOUTUBE_PROXY_USERNAMES} + +batch: + video-analysis-backfill: + # 수집은 됐지만 (youtube_video) 한 번도 분석된 적 없는 영상을 BATCH 우선순위로 자동 분석. + # 10분마다 5건씩. dev 에서는 토큰 비용 통제 위해 기본 비활성. + enabled: true diff --git a/linktrip-input-batch/src/main/kotlin/com/linktrip/input/batch/VideoAnalysisBackfillScheduler.kt b/linktrip-input-batch/src/main/kotlin/com/linktrip/input/batch/VideoAnalysisBackfillScheduler.kt new file mode 100644 index 0000000..95cdc96 --- /dev/null +++ b/linktrip-input-batch/src/main/kotlin/com/linktrip/input/batch/VideoAnalysisBackfillScheduler.kt @@ -0,0 +1,60 @@ +package com.linktrip.input.batch + +import com.linktrip.application.domain.video.Source +import com.linktrip.application.domain.video.VideoAnalysisTask +import com.linktrip.application.port.input.VideoAnalyzeUseCase +import com.linktrip.application.port.output.persistence.YouTubeVideoPersistencePort +import mu.KotlinLogging +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +private val logger = KotlinLogging.logger {} + +/** + * 수집은 됐지만 (youtube_video) 한 번도 분석 요청이 안 된 영상 (= video_analysis_task 미존재) + * 을 BATCH 우선순위로 큐에 적재해 자동 분석시키는 스케줄러. + * + * **활성화**: `batch.video-analysis-backfill.enabled=true` 인 환경에서만 빈 등록. + * dev 등 토큰 비용 통제가 필요한 환경에서는 자동 실행되지 않는다. + * + * **우선순위**: 사용자 USER 요청은 큐에서 항상 먼저 처리되므로 이 자동 분석이 사용자 응답을 지연시키지 않는다. + */ +@Component +@ConditionalOnProperty(name = ["batch.video-analysis-backfill.enabled"], havingValue = "true") +class VideoAnalysisBackfillScheduler( + private val youTubeVideoPersistencePort: YouTubeVideoPersistencePort, + private val videoAnalyzeUseCase: VideoAnalyzeUseCase, +) { + @Scheduled(cron = SCHEDULE_CRON) + fun run() { + val videoIds = youTubeVideoPersistencePort.findUnanalyzedVideoIds(BATCH_SIZE) + if (videoIds.isEmpty()) { + logger.info { "미처리 영상 없음" } + return + } + + logger.info { "수집 영상 자동 분석 큐 적재 시작: ${videoIds.size}건 (오래된 순)" } + var success = 0 + videoIds.forEach { videoId -> + try { + videoAnalyzeUseCase.analyzeVideo( + youtubeUrl = VideoAnalysisTask.buildUrl(videoId), + source = Source.BATCH, + ) + success++ + } catch (e: Exception) { + logger.warn(e) { "수집 영상 자동 분석 요청 실패: videoId=$videoId" } + } + } + logger.info { "수집 영상 자동 분석 큐 적재 완료: 성공=$success, 실패=${videoIds.size - success}" } + } + + companion object { + /** 10분마다 실행. 사용자 트래픽 대비 적당한 페이스. */ + private const val SCHEDULE_CRON = "0 */10 * * * *" + + /** 1회 실행당 enqueue 건수. USER 요청이 끼어들 여지를 위해 작게. */ + private const val BATCH_SIZE = 5 + } +} From d0428ab996123fa64bb73c607e2eb3e1e3d7e513 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 14:00:46 +0900 Subject: [PATCH 14/19] =?UTF-8?q?chore:=20=EB=88=84=EB=9D=BD=EB=90=9C=20So?= =?UTF-8?q?urce=20enum=20=EC=8B=A0=EA=B7=9C=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=8F=20USER/BATCH=20source=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/kotlin/com/linktrip/application/domain/video/Source.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Source.kt b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Source.kt index c35c686..f77fdb3 100644 --- a/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Source.kt +++ b/linktrip-application/src/main/kotlin/com/linktrip/application/domain/video/Source.kt @@ -9,6 +9,6 @@ enum class Source(val priority: Int) { /** 사용자 직접 요청 (POST /video/analyze 등). 항상 먼저 처리. */ USER(0), - /** 시스템 배치/수집 (YouTube 정기 수집, stranded 백필 등). USER 가 비었을 때만 처리. */ + /** 시스템 배치/수집 (YouTube 정기 수집, stranded backfill 등). USER 가 비었을 때만 처리. */ BATCH(10), } From af1dc722caf6fc113f90410a72e634890af61953 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 14:01:04 +0900 Subject: [PATCH 15/19] =?UTF-8?q?chore:=20=EB=88=84=EB=9D=BD=EB=90=9C=20So?= =?UTF-8?q?urce=20enum=20=EC=8B=A0=EA=B7=9C=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=8F=20USER/BATCH=20source=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/domain/video/VideoAnalyzeServiceTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeServiceTest.kt b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeServiceTest.kt index a5b1367..2530136 100644 --- a/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeServiceTest.kt +++ b/linktrip-application/src/test/kotlin/com/linktrip/application/domain/video/VideoAnalyzeServiceTest.kt @@ -78,7 +78,7 @@ class VideoAnalyzeServiceTest { @Test fun `신규 BATCH 요청이면_BATCH source 로 task 가 저장되어 통계 분류가 가능하다`() { - // given - YouTube 정기 수집/백필 스케줄러가 신규 영상을 분석 요청 + // given - YouTube 정기 수집/backfill 스케줄러가 신규 영상을 분석 요청 val url = "https://www.youtube.com/watch?v=batch1" whenever(videoAnalysisTaskPersistencePort.findByYoutubeUrl(url)).thenReturn(null) val saved = From 6a268976a809c5760318793d1fc0c776213e5b75 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 14:08:00 +0900 Subject: [PATCH 16/19] refactor: ktlintformat (#65) --- .../caffeine/adapter/InMemoryVideoAnalysisQueueAdapter.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/InMemoryVideoAnalysisQueueAdapter.kt b/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/InMemoryVideoAnalysisQueueAdapter.kt index 99a6001..e0b1109 100644 --- a/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/InMemoryVideoAnalysisQueueAdapter.kt +++ b/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/InMemoryVideoAnalysisQueueAdapter.kt @@ -55,7 +55,8 @@ class InMemoryVideoAnalysisQueueAdapter : VideoAnalysisQueuePort { override fun size(): Int = queue.size - override fun contains(videoAnalysisTaskId: String): Boolean = queue.any { it.event.videoAnalysisTaskId == videoAnalysisTaskId } + override fun contains(videoAnalysisTaskId: String): Boolean = + queue.any { it.event.videoAnalysisTaskId == videoAnalysisTaskId } companion object { private const val INITIAL_CAPACITY = 16 From 338b879d59728b18078909482cf4cab30010ed32 Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 14:12:00 +0900 Subject: [PATCH 17/19] =?UTF-8?q?fix:=20YouTubeVideoCachingAdapter=20?= =?UTF-8?q?=EC=97=90=20findUnanalyzedVideoIds=20=EC=9C=84=EC=9E=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=88=84=EB=9D=BD=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cache/caffeine/adapter/YouTubeVideoCachingAdapter.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/YouTubeVideoCachingAdapter.kt b/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/YouTubeVideoCachingAdapter.kt index 472f834..916d364 100644 --- a/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/YouTubeVideoCachingAdapter.kt +++ b/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/YouTubeVideoCachingAdapter.kt @@ -38,4 +38,8 @@ class YouTubeVideoCachingAdapter( cursor: LocalDateTime?, size: Int, ): CursorPage = delegate.findAllByTheme(theme, cursor, size) + + // backfill 스케줄러 전용 — 매번 최신 미처리 영상 조회 필요하므로 캐시 안 함 + override fun findUnanalyzedVideoIds(limit: Int): List = delegate.findUnanalyzedVideoIds(limit) } + From 31e95953183e0e43d0f41dfb1705c0f04c16c5be Mon Sep 17 00:00:00 2001 From: toychip Date: Fri, 17 Apr 2026 14:14:12 +0900 Subject: [PATCH 18/19] refactor: ktlintformat (#65) --- .../output/cache/caffeine/adapter/YouTubeVideoCachingAdapter.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/YouTubeVideoCachingAdapter.kt b/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/YouTubeVideoCachingAdapter.kt index 916d364..095008b 100644 --- a/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/YouTubeVideoCachingAdapter.kt +++ b/linktrip-output-cache/caffeine/src/main/kotlin/com/linktrip/output/cache/caffeine/adapter/YouTubeVideoCachingAdapter.kt @@ -42,4 +42,3 @@ class YouTubeVideoCachingAdapter( // backfill 스케줄러 전용 — 매번 최신 미처리 영상 조회 필요하므로 캐시 안 함 override fun findUnanalyzedVideoIds(limit: Int): List = delegate.findUnanalyzedVideoIds(limit) } - From f738855f00a6d380acb304a94c2353251b90c13a Mon Sep 17 00:00:00 2001 From: toychip Date: Wed, 22 Apr 2026 15:45:08 +0900 Subject: [PATCH 19/19] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9D=EC=8B=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83?= =?UTF-8?q?=EC=9D=84=20=EC=9E=90=EB=A7=89=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20+=20=ED=94=84=EB=A1=9D=EC=8B=9C=20RTT=20=EA=B0=90?= =?UTF-8?q?=EC=95=88=ED=95=B4=2060=EC=B4=88=EB=A1=9C=20=EC=99=84=ED=99=94?= =?UTF-8?q?=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../output/http/adapter/ProxyYoutubeClient.kt | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/ProxyYoutubeClient.kt b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/ProxyYoutubeClient.kt index ebcdd18..d045a1f 100644 --- a/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/ProxyYoutubeClient.kt +++ b/linktrip-output-http/src/main/kotlin/com/linktrip/output/http/adapter/ProxyYoutubeClient.kt @@ -4,6 +4,7 @@ import com.linktrip.common.exception.ExceptionCode import com.linktrip.common.exception.LinktripException import io.github.thoroldvix.api.YoutubeClient import mu.KotlinLogging +import java.io.IOException import java.net.Authenticator import java.net.InetSocketAddress import java.net.PasswordAuthentication @@ -12,6 +13,7 @@ import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse +import java.time.Duration private val logger = KotlinLogging.logger {} @@ -21,9 +23,10 @@ private val logger = KotlinLogging.logger {} * [proxyClients] 에 지정된 순서대로 각 프록시로 요청을 시도한다 (우선순위 = 리스트 순서). * - 2xx → 성공 반환 * - 429/403 (IP 차단) → 다음 프록시 시도 + * - IOException (DNS/연결/읽기 타임아웃 등 네트워크 장애) → 다음 프록시 시도 * - 5xx / 기타 비2xx → 즉시 [LinktripException] throw (일시 오류는 큐 컨슈머가 PENDING 으로 재시도) * - * 전체 프록시가 429/403 으로 소진되면 IP 전면 차단으로 간주하고 [LinktripException] throw. + * 모든 프록시가 차단/네트워크 장애로 소진되면 [LinktripException] throw. */ class ProxyYoutubeClient( usernames: List, @@ -40,7 +43,11 @@ class ProxyYoutubeClient( url: String, headers: Map, ): String { - val builder = HttpRequest.newBuilder().uri(URI.create(url)).GET() + val builder = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(REQUEST_TIMEOUT) + .GET() headers.forEach { (key, value) -> builder.header(key, value) } return executeWithRotation(builder.build(), url) } @@ -52,6 +59,7 @@ class ProxyYoutubeClient( val request = HttpRequest.newBuilder() .uri(URI.create(url)) + .timeout(REQUEST_TIMEOUT) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build() @@ -63,8 +71,24 @@ class ProxyYoutubeClient( url: String, ): String { var lastBlockedStatus = -1 + var lastNetworkFailure: String? = null for (proxy in proxyClients) { - val response = proxy.httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + val response = + try { + proxy.httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + } catch (e: IOException) { + // 네트워크 장애 (DNS, 연결 거부, 타임아웃 등) → 라운드로빈 의도대로 다음 프록시로 + logger.warn(e) { "프록시 통신 실패로 다음 프록시 시도: username=${proxy.username}" } + lastNetworkFailure = e.message ?: e::class.simpleName + continue + } catch (_: InterruptedException) { + // 컨슈머 스레드 중단 신호 — interrupt flag 복원 후 종료 + Thread.currentThread().interrupt() + throw LinktripException( + ExceptionCode.BAD_GATEWAY_YOUTUBE, + "프록시 요청 중단됨", + ) + } val status = response.statusCode() val body = response.body() ?: "" @@ -91,7 +115,8 @@ class ProxyYoutubeClient( } throw LinktripException( ExceptionCode.BAD_GATEWAY_YOUTUBE, - "전 프록시 IP 차단 확정 (마지막 status=$lastBlockedStatus, proxies=${proxyClients.size}개)", + "전 프록시 소진 (마지막 차단=$lastBlockedStatus, 마지막 네트워크 실패=$lastNetworkFailure, " + + "proxies=${proxyClients.size}개)", ) } @@ -100,6 +125,7 @@ class ProxyYoutubeClient( password: String, ): HttpClient = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) .proxy(ProxySelector.of(InetSocketAddress(PROXY_HOST, PROXY_PORT))) .authenticator( object : Authenticator() { @@ -114,6 +140,18 @@ class ProxyYoutubeClient( private const val PROXY_HOST = "p.webshare.io" private const val PROXY_PORT = 80 + /** 프록시까지의 TCP 연결 수립 타임아웃. 죽은 프록시에서 무한 대기 방지. */ + private val CONNECT_TIMEOUT: Duration = Duration.ofSeconds(10) + + /** + * YouTube 응답까지의 전체 요청 타임아웃. + * + * 긴 영상(1시간+) 의 자막 XML 다운로드 + 해외 프록시 RTT 까지 감안해서 넉넉히 60초. + * 너무 짧으면 멀쩡한 프록시도 false-timeout 으로 다음 프록시 시도하게 되어 라운드로빈 가속만 됨. + * 그래도 무한 대기는 방지 (느린 프록시가 컨슈머 스레드 점유 안 하도록). + */ + private val REQUEST_TIMEOUT: Duration = Duration.ofSeconds(60) + init { System.setProperty("jdk.http.auth.tunneling.disabledSchemes", "") }