in-370-외부-api-호출-bulkhead-패턴-사용 -> develop#223
Hidden character warning
Conversation
WalkthroughResilience4j의 Bulkhead와 RateLimiter를 유튜브 관련 API 클라이언트 및 OpenAiService에 도입하고, 각각의 fallback 메서드에서 429 예외로 매핑하는 공통 유틸을 추가했다. 유튜브 채널 애널리틱스 조회와 업로드 주기 계산을 가상 스레드 ExecutorService 기반 배치/병렬 처리로 전환했다. 관련 설정(application.yml, ExternalApiExecutorConfig)과 테스트가 함께 추가/수정되었다. ChangesResilience4j 도입 및 외부 API 병렬 처리
VideoServiceTest 인증 방식 변경
AnalyticsCalculatorTest 인자 타입 정리
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant YoutubeDataApiClient
participant Resilience4j
participant ExternalApiLimitExceptionMapper
Client->>YoutubeDataApiClient: getYoutubeChannels(...)
YoutubeDataApiClient->>Resilience4j: Bulkhead/RateLimiter 확인
alt 제한 초과
Resilience4j-->>YoutubeDataApiClient: BulkheadFullException/RequestNotPermitted
YoutubeDataApiClient->>ExternalApiLimitExceptionMapper: toRuntimeException(throwable)
ExternalApiLimitExceptionMapper-->>Client: ApiException(EXTERNAL_API_RATE_LIMITED)
else 정상
YoutubeDataApiClient-->>Client: 결과 반환
end
sequenceDiagram
participant Service as YoutubeChannelAnalyticsFetchService
participant Executor as externalApiExecutor
participant ApiClient as YoutubeAnalyticsApiClient
Service->>Service: chunked(videos, BATCH_SIZE)
loop 배치마다
Service->>Executor: supplyAsync(fetchVideoAnalyticsForVideo)
Executor->>ApiClient: getYoutubeAnalytics(...)
ApiClient-->>Executor: 결과 또는 예외
end
Executor-->>Service: join 결과 취합 및 필터링
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
src/main/java/com/example/inflace/global/client/YoutubeAnalyticsApiClient.java (1)
98-107: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueFallback 로그 메시지가 API 호출 실패 상황에서 오해의 소지가 있습니다.
youtubeAnalyticsFallback은 Bulkhead/RateLimiter 거부뿐 아니라 API 호출 자체가 실패(4xx/5xx)한 경우에도 호출됩니다. 현재 로그 메시지 "blocked or failed before execution"는 후자의 경우 실제로는 API 호출이 실행되었음에도 "before execution"으로 표현되어 디버깅 시 혼란을 줄 수 있습니다.🔧 제안: 로그 메시지 개선
log.warn("YouTube Analytics API call blocked or failed before execution. googleId={} metrics={} dimensions={}", googleId, request.formattedMetricsList(), request.dimensions(), throwable);- log.warn("YouTube Analytics API call blocked or failed before execution. googleId={} metrics={} dimensions={}", - googleId, request.formattedMetricsList(), request.dimensions(), throwable); + log.warn("YouTube Analytics API call rejected or failed. googleId={} metrics={} dimensions={}", + googleId, request.formattedMetricsList(), request.dimensions(), throwable);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/example/inflace/global/client/YoutubeAnalyticsApiClient.java` around lines 98 - 107, The fallback log message in youtubeAnalyticsFallback is misleading because it says the call was “blocked or failed before execution” even when the API request actually ran and then failed. Update the log text in YoutubeAnalyticsApiClient.youtubeAnalyticsFallback to clearly distinguish pre-execution rejections from downstream API failures, while still logging googleId, request.formattedMetricsList(), request.dimensions(), and the throwable for debugging.src/main/java/com/example/inflace/domain/channel/service/sync/YoutubeChannelAnalyticsFetchService.java (1)
613-619: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
chunked메서드가YoutubeDataApiClient에도 동일하게 존재하여 중복됩니다.그래프 컨텍스트에 따르면
YoutubeDataApiClient.java의 613-619번 라인에 동일한 구현의 privatechunked메서드가 있습니다. 두 클래스에서 같은 유틸리티 로직이 중복되고 있으므로 공용 유틸 클래스로 추출을 고려해 보세요.♻️ 제안: 공용 유틸 클래스로 추출
- private <T> List<List<T>> chunked(List<T> values, int size) { - List<List<T>> chunks = new ArrayList<>(); - for (int start = 0; start < values.size(); start += size) { - chunks.add(values.subList(start, Math.min(values.size(), start + size))); - } - return chunks; - }// 예: src/main/java/com/example/inflace/global/util/ListUtils.java public final class ListUtils { private ListUtils() {} public static <T> List<List<T>> chunked(List<T> values, int size) { List<List<T>> chunks = new ArrayList<>(); for (int start = 0; start < values.size(); start += size) { chunks.add(values.subList(start, Math.min(values.size(), start + size))); } return chunks; } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main/java/com/example/inflace/domain/channel/service/sync/YoutubeChannelAnalyticsFetchService.java` around lines 613 - 619, `chunked` 로직이 `YoutubeChannelAnalyticsFetchService`와 `YoutubeDataApiClient`에 중복되어 있으니, 두 곳의 private 메서드를 공용 유틸로 합치세요. `chunked`를 `ListUtils` 같은 재사용 가능한 클래스의 static 메서드로 추출하고, `YoutubeChannelAnalyticsFetchService`와 `YoutubeDataApiClient`는 그 공용 메서드를 호출하도록 변경해 중복 구현을 제거하세요.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@build.gradle`:
- Around line 28-29: Update the resilience4j dependency to the latest stable
release by changing the io.github.resilience4j:resilience4j-spring-boot3 version
from 2.2.0 to 2.4.0 in build.gradle. Keep the existing Spring Boot actuator
dependency unchanged and make sure the version bump is applied wherever the
resilience4j artifact is declared.
In
`@src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java`:
- Around line 275-294: `computeAvgUploadDays` is waiting indefinitely on YouTube
requests, so add an actual response timeout instead of relying on the
rate-limiter setting alone. Update the async flow around
`CompletableFuture.supplyAsync(...)` and `CompletableFuture::join` to use
`orTimeout()` (or equivalent) for the upload-day computation, and configure the
`RestClient` used by `computeUploadDays` with a read timeout so slow HTTP
responses fail fast. Keep the existing exception handling/logging in
`BrandCollaborationService` to surface timeout failures clearly.
In `@src/main/java/com/example/inflace/global/client/YoutubeDataApiClient.java`:
- Around line 90-91: The current `@RateLimiter` on YoutubeDataApiClient only
limits each method invocation once, so it does not control the individual API
requests made inside the pagination/chunk loops. Review getMyVideoIds and
getYoutubeVideos and move rate limiting to the actual YouTube API call points
inside those loops, or switch to HTTP-client-level throttling, while keeping
`@Bulkhead` on the outer methods for concurrency control. Ensure the
fallbackMethod wiring still works after refactoring and that each remote call is
counted against the limiter rather than the enclosing method.
---
Nitpick comments:
In
`@src/main/java/com/example/inflace/domain/channel/service/sync/YoutubeChannelAnalyticsFetchService.java`:
- Around line 613-619: `chunked` 로직이 `YoutubeChannelAnalyticsFetchService`와
`YoutubeDataApiClient`에 중복되어 있으니, 두 곳의 private 메서드를 공용 유틸로 합치세요. `chunked`를
`ListUtils` 같은 재사용 가능한 클래스의 static 메서드로 추출하고,
`YoutubeChannelAnalyticsFetchService`와 `YoutubeDataApiClient`는 그 공용 메서드를 호출하도록
변경해 중복 구현을 제거하세요.
In
`@src/main/java/com/example/inflace/global/client/YoutubeAnalyticsApiClient.java`:
- Around line 98-107: The fallback log message in youtubeAnalyticsFallback is
misleading because it says the call was “blocked or failed before execution”
even when the API request actually ran and then failed. Update the log text in
YoutubeAnalyticsApiClient.youtubeAnalyticsFallback to clearly distinguish
pre-execution rejections from downstream API failures, while still logging
googleId, request.formattedMetricsList(), request.dimensions(), and the
throwable for debugging.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: d20eef6f-4ac0-4735-9e4c-49f7b89a4081
📒 Files selected for processing (15)
build.gradlesrc/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.javasrc/main/java/com/example/inflace/domain/channel/service/sync/YoutubeChannelAnalyticsFetchService.javasrc/main/java/com/example/inflace/global/client/YoutubeAnalyticsApiClient.javasrc/main/java/com/example/inflace/global/client/YoutubeDataApiClient.javasrc/main/java/com/example/inflace/global/client/YoutubeSearchApiClient.javasrc/main/java/com/example/inflace/global/config/ExternalApiExecutorConfig.javasrc/main/java/com/example/inflace/global/exception/ErrorDefine.javasrc/main/java/com/example/inflace/global/exception/ExternalApiLimitExceptionMapper.javasrc/main/java/com/example/inflace/infra/openai/service/OpenAiService.javasrc/main/resources/application.ymlsrc/test/java/com/example/inflace/domain/channel/service/sync/YoutubeChannelAnalyticsFetchServiceTest.javasrc/test/java/com/example/inflace/domain/video/service/VideoServiceTest.javasrc/test/java/com/example/inflace/global/exception/ExternalApiLimitExceptionMapperTest.javasrc/test/java/com/example/inflace/global/util/AnalyticsCalculatorTest.java
| implementation 'org.springframework.boot:spring-boot-starter-actuator' | ||
| implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0' |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
What is the latest stable version of resilience4j-spring-boot3? Is version 2.2.0 the latest?
💡 Result:
The latest stable version of resilience4j-spring-boot3 is 2.4.0, which was released on March 14, 2026 [1][2]. Therefore, version 2.2.0 is not the latest version [1][3]. You can find the latest releases in the Maven Central repository [4][1].
Citations:
- 1: https://central.sonatype.com/artifact/io.github.resilience4j/resilience4j-spring-boot3/2.4.0
- 2: https://github.com/resilience4j/resilience4j/releases/tag/v2.4.0
- 3: https://repo1.maven.org/maven2/io/github/resilience4j/resilience4j-all/
- 4: https://central.sonatype.com/artifact/io.github.resilience4j/resilience4j-spring-boot3/versions
Resilience4j 버전을 최신 안정판으로 올려주세요.
io.github.resilience4j:resilience4j-spring-boot3의 최신 안정 버전은 2.4.0이므로, 현재 2.2.0은 구버전입니다. 보안 패치와 버그 수정을 반영하려면 업그레이드가 필요합니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@build.gradle` around lines 28 - 29, Update the resilience4j dependency to the
latest stable release by changing the
io.github.resilience4j:resilience4j-spring-boot3 version from 2.2.0 to 2.4.0 in
build.gradle. Keep the existing Spring Boot actuator dependency unchanged and
make sure the version bump is applied wherever the resilience4j artifact is
declared.
| List<CompletableFuture<Double>> futures = channelMap.values().stream() | ||
| .filter(c -> c.contentDetails() != null && c.contentDetails().relatedPlaylists() != null) | ||
| .map(c -> c.contentDetails().relatedPlaylists().uploads()) | ||
| .filter(StringUtils::hasText) | ||
| .mapToDouble(playlistId -> { | ||
| List<Instant> instants = youtubeDataApiClient | ||
| .getRecentUploadDates(playlistId, RECENT_UPLOADS_COUNT) | ||
| .stream().map(Instant::parse).sorted().toList(); | ||
| if (instants.size() < 2) return Double.NaN; | ||
| long spanSeconds = instants.getLast().getEpochSecond() - instants.getFirst().getEpochSecond(); | ||
| return spanSeconds / 86400.0 / (instants.size() - 1); | ||
| }) | ||
| .map(playlistId -> CompletableFuture | ||
| .supplyAsync(() -> computeUploadDays(playlistId), externalApiExecutor) | ||
| .exceptionally(exception -> { | ||
| log.warn("Failed to compute average upload days. playlistId={}", playlistId, exception); | ||
| return Double.NaN; | ||
| })) | ||
| .toList(); | ||
|
|
||
| OptionalDouble avg = futures.stream() | ||
| .map(CompletableFuture::join) | ||
| .mapToDouble(Double::doubleValue) | ||
| .filter(d -> !Double.isNaN(d) && d > 0) | ||
| .average(); | ||
|
|
||
| return avg.isPresent() ? avg.getAsDouble() : null; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n## file list\n'
git ls-files | rg '^src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService\.java$|^src/main/java/.*RestClient.*|^src/main/java/.*YouTube.*|^src/main/java/.*Executor.*|^src/main/java/.*Http.*|^src/main/resources/.*application.*'
printf '\n## outline BrandCollaborationService\n'
ast-grep outline src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java --view expanded || true
printf '\n## relevant snippets\n'
sed -n '1,220p' src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java
printf '\n--- middle ---\n'
sed -n '220,360p' src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java
printf '\n## timeout/search terms\n'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' \
'orTimeout|completeOnTimeout|readTimeout|connectTimeout|timeout|RestClient|WebClient|HttpClient|ExecutorService|externalApiExecutor|computeUploadDays|analyzeTrends' \
src/main/java src/main/resourcesRepository: JECT-Study/inflace-4th-server
Length of output: 25367
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n## BrandCollaborationService outline\n'
ast-grep outline src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java --view expanded || true
printf '\n## BrandCollaborationService relevant lines\n'
sed -n '240,320p' src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java
printf '\n## search for timeout and async config\n'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' \
'orTimeout|completeOnTimeout|readTimeout|connectTimeout|timeout|externalApiExecutor|computeUploadDays|analyzeTrends|RestClient|WebClient|HttpClient|ExecutorService' \
src/main/java src/main/resourcesRepository: JECT-Study/inflace-4th-server
Length of output: 11566
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n## RestClientConfig\n'
sed -n '1,120p' src/main/java/com/example/inflace/global/config/RestClientConfig.java
printf '\n## YoutubeDataApiClient\n'
sed -n '1,260p' src/main/java/com/example/inflace/global/client/YoutubeDataApiClient.java
printf '\n## YoutubeSearchApiClient\n'
sed -n '1,220p' src/main/java/com/example/inflace/global/client/YoutubeSearchApiClient.java
printf '\n## GoogleApiClient\n'
sed -n '1,220p' src/main/java/com/example/inflace/global/client/GoogleApiClient.java
printf '\n## any timeout-related config files\n'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' \
'setReadTimeout|setConnectTimeout|readTimeout|connectTimeout|timeout-duration|timeout\(' \
src/main/java src/main/resourcesRepository: JECT-Study/inflace-4th-server
Length of output: 18032
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n## RestClientConfig\n'
sed -n '1,120p' src/main/java/com/example/inflace/global/config/RestClientConfig.java
printf '\n## YoutubeDataApiClient\n'
sed -n '1,260p' src/main/java/com/example/inflace/global/client/YoutubeDataApiClient.java
printf '\n## YoutubeSearchApiClient\n'
sed -n '1,220p' src/main/java/com/example/inflace/global/client/YoutubeSearchApiClient.java
printf '\n## GoogleApiClient\n'
sed -n '1,220p' src/main/java/com/example/inflace/global/client/GoogleApiClient.java
printf '\n## timeout-related settings\n'
rg -n --hidden --glob '!**/target/**' --glob '!**/build/**' \
'setReadTimeout|setConnectTimeout|readTimeout|connectTimeout|timeout-duration|timeout\(' \
src/main/java src/main/resourcesRepository: JECT-Study/inflace-4th-server
Length of output: 18024
computeAvgUploadDays에 응답 제한을 두세요.
CompletableFuture::join()이 RestClient.builder().build()로 만든 YouTube 호출을 그대로 기다립니다. application.yml의 timeout-duration은 rate limiter 대기 제한이라 HTTP 응답 지연을 막지 못하므로, orTimeout()과 RestClient read timeout을 함께 넣어야 합니다.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@src/main/java/com/example/inflace/domain/brandcollaboration/service/BrandCollaborationService.java`
around lines 275 - 294, `computeAvgUploadDays` is waiting indefinitely on
YouTube requests, so add an actual response timeout instead of relying on the
rate-limiter setting alone. Update the async flow around
`CompletableFuture.supplyAsync(...)` and `CompletableFuture::join` to use
`orTimeout()` (or equivalent) for the upload-day computation, and configure the
`RestClient` used by `computeUploadDays` with a read timeout so slow HTTP
responses fail fast. Keep the existing exception handling/logging in
`BrandCollaborationService` to surface timeout failures clearly.
| @Bulkhead(name = "youtube-data", fallbackMethod = "youtubeDataStringListFallback") | ||
| @RateLimiter(name = "youtube-data", fallbackMethod = "youtubeDataStringListFallback") |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
RateLimiter가 내부 루프의 개별 API 호출을 정확히 제어하지 못합니다.
getMyVideoIds(라인 92)는 페이지네이션 while 루프에서 API를 여러 번 호출하지만, @RateLimiter는 메서드 호출당 1 permit만 소비합니다. getYoutubeVideos(라인 141)도 청크 루프에서 동일한 패턴입니다.
예시: 500개 영상이 있는 플레이리스트에 getMyVideoIds를 호출하면 10개 API 호출이 발생하지만 rate limiter에는 1 permit만 카운트됩니다. rate limit이 20/s일 때 실제 API 호출 속도는 200/s에 도달할 수 있어 YouTube Data API 할당량(10,000 units/day)을 50초 이내에 소진할 수 있습니다.
@Bulkhead는 동기 호출이므로 동시 HTTP 요청을 16개로 제한하는 데 효과적이지만, @RateLimiter는 버스트 속도를 실제 API 호출 단위가 아닌 메서드 호출 단위로만 제한합니다.
후속 작업에서 HTTP 클라이언트 레벨 rate limiting 또는 루프 내 호출당 rate limiter 적용을 검토하세요.
Also applies to: 139-140
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/main/java/com/example/inflace/global/client/YoutubeDataApiClient.java`
around lines 90 - 91, The current `@RateLimiter` on YoutubeDataApiClient only
limits each method invocation once, so it does not control the individual API
requests made inside the pagination/chunk loops. Review getMyVideoIds and
getYoutubeVideos and move rate limiting to the actual YouTube API call points
inside those loops, or switch to HTTP-client-level throttling, while keeping
`@Bulkhead` on the outer methods for concurrency control. Ensure the
fallbackMethod wiring still works after refactoring and that each remote call is
counted against the limiter rather than the enclosing method.
Issue
comment
Summary by CodeRabbit
New Features
Bug Fixes