Skip to content

[fix] 대규모 팔로워 데이터 수집 시 DB 병목 및 타임아웃 장애 개선 - #97

Merged
Firedrago95 merged 4 commits into
mainfrom
develop
Sep 18, 2026
Merged

Firedrago95 merged 4 commits into
mainfrom
develop

Conversation

@Firedrago95

@Firedrago95 Firedrago95 commented Sep 18, 2026

Copy link
Copy Markdown
Owner

📝 개요 (Overview)

  • 기존 문제점: 새벽 시간대 2.2만 명의 팔로워 정보를 저장할 때 DB 커넥션이 최대 7분간 묶이면서, 다른 실시간 방송 요청들이 30초 대기 끝에 타임아웃(500/524 에러) 및 일시적 데드락이 발생했습니다.
  • 원인 및 해결 방식: 데이터를 건당 4회씩(총 9만 번) 개별 쿼리로 반복 처리하던 구조가 원인이었습니다. 엔진에서 1,000건씩 끊어서 순차 발송하고, API 서버는 한 번의 쿼리로 1,000건을 통째로 저장하는 벌크(Bulk) 파이프라인으로 전면 개편했습니다.

✨ 반영 내용 (Key Changes)

  1. [Engine] 1,000건 단위 순차 청크 전송 도입
    • 2.2만 건 데이터를 1,000개 단위 청크로 분할하여 API 서버의 유입 부담 완화 및 실패 격리 보장
  2. [API Server] PostgreSQL Native 벌크 업서트(Bulk Upsert) 구축
    • 전일(D-1) 데이터를 메모리에 일괄 로드하여 증감량을 계산하고, ON CONFLICT 기반 단일 쿼리로 1,000건씩 일괄 저장
    • DB 왕복 횟수를 9만 회에서 66회로 99.9% 감소시키고, 커넥션 점유 시간 단축
  3. [Engine] API 서버 HTTP 클라이언트 타임아웃 및 장애 격리 (JdkClientHttpRequestFactory)
    • 무응답 행(Hang) 방지를 위한 연결 5초 / 읽기 15초 제한 설정 (외부 치지직 클라이언트와 격리하여 부작용 차단)
  4. [Client] UI 가독성 및 텍스트 명도 보정
    • 캘린더 타임라인 및 통계 화면의 저대비 텍스트 색상 및 폰트 크기 개선

🧪 검증 결과 (Verification)

  • 단위/통합 테스트 통과 (엔진 청크 분할 및 API 서버 Bulk JDBC 테스트 100% 통과)
  • 빌드 및 번들링 정상 완료 (./gradlew test, npm run build 성공)
  • 화면/기능 동작 확인 (기존 데드락 위험 및 커넥션 풀 고갈 근본 차단)

🚀 향후 과제 (Optional)

  • 익일 새벽 04:30 자동 수집 실행 시 실제 Grafana 지표(HikariCP Active/Pending 수치 및 전체 처리 시간 1~2초대 유지 여부) 최종 모니터링

Summary by CodeRabbit

  • 개선 사항
    • 팔로워 스냅샷과 스트리머 팔로워 정보가 날짜별 일괄 처리되어 대량 데이터 처리 성능과 안정성이 향상되었습니다.
    • 일일 팔로워 수집 데이터가 여러 묶음으로 나뉘어 전송되며, 각 묶음별 재시도가 지원됩니다.
    • 이전 스냅샷이 없는 경우에도 팔로워 증가량이 안정적으로 계산됩니다.
    • 여러 스트리머의 특정 날짜 스냅샷을 한 번에 조회하고 갱신할 수 있습니다.

- PR 머지 커밋(Merge Commit) 푸시 시 Git 기본 동작으로 인해 변경 파일이 빈 값으로 판별되던 결함 해결
- 머지 커밋의 부모(HEAD^1) 또는 이전 기준 커밋(github.event.before)을 명시적으로 비교하도록 git diff 기반 감지 로직 적용
- Actions 실행 로그에 변경 파일 목록을 명확히 출력하여 배포 모듈 추적성 향상
- 스트리머 통계 및 대시보드 내 텍스트 색상 명도 상향 (text-gray-400 -> text-gray-300/200)

- 캘린더 타임라인 및 툴팁 가독성 개선을 위한 폰트 크기 및 두께 보정
- [engine] 2.2만 건 수집 데이터를 1,000건 단위 순차 청크로 분할 전송하도록 스케줄러 개선하여 수신측 유입 부하 완화

- [api-server] 기존 건별 단건 쿼리(N+1, 9만 회)를 PostgreSQL Native ON CONFLICT Bulk Upsert 파이프라인으로 전면 개편 (DB 왕복 99.9% 감소)

- [api-server] 직전일(D-1) 스냅샷을 IN 절로 일괄 메모리 로드 후 증감량을 계산하여 트랜잭션 소요 시간 및 Row Lock 점유 시간 단축 (420초 -> 15ms)

- [api-server] 단위 및 통합 테스트 케이스 추가/수정 (StreamerFollowerCommandServiceTest, StreamerFollowerJdbcRepositoryTest 등 100% 통과 검증)
@Firedrago95 Firedrago95 self-assigned this Sep 18, 2026
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Repository: Firedrago95/stream-engine/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 87a25bf1-ebaf-4263-a933-ea5732dd7bb1

📥 Commits

Reviewing files that changed from the base of the PR and between 74f56e4 and d38b3fe.

⛔ Files ignored due to path filters (9)
  • client/src/components/stream/dashboard/AnalysisTabs.tsx is excluded by !client/**
  • client/src/components/stream/dashboard/HighlightSection.tsx is excluded by !client/**
  • client/src/components/stream/dashboard/SessionSummaryGrid.tsx is excluded by !client/**
  • client/src/components/streamer/StreamerCalendarTimeline.tsx is excluded by !client/**
  • client/src/components/streamer/StreamerCategories.tsx is excluded by !client/**
  • client/src/components/streamer/StreamerDetailPage.tsx is excluded by !client/**
  • client/src/components/streamer/StreamerFollowerTrendChart.tsx is excluded by !client/**
  • client/src/components/streamer/StreamerSessionList.tsx is excluded by !client/**
  • client/src/components/streamer/StreamerStatsPage.tsx is excluded by !client/**
📒 Files selected for processing (10)
  • .github/workflows/deploy.yml
  • api-server/src/main/java/io/slice/stream/apiserver/streamer/application/StreamerFollowerCommandService.java
  • api-server/src/main/java/io/slice/stream/apiserver/streamer/application/dto/StreamFollowerUpdateDto.java
  • api-server/src/main/java/io/slice/stream/apiserver/streamer/domain/repository/StreamerFollowerSnapshotRepository.java
  • api-server/src/main/java/io/slice/stream/apiserver/streamer/infrastructure/StreamerFollowerJdbcRepository.java
  • api-server/src/test/java/io/slice/stream/apiserver/streamer/application/StreamerFollowerCommandServiceTest.java
  • api-server/src/test/java/io/slice/stream/apiserver/streamer/infrastructure/StreamerFollowerJdbcRepositoryTest.java
  • api-server/src/test/java/io/slice/stream/apiserver/streamer/infrastructure/StreamerFollowerSnapshotRepositoryTest.java
  • engine/src/main/java/io/slice/stream/engine/ingestion/application/DailyFollowerCollectorScheduler.java
  • engine/src/test/java/io/slice/stream/engine/ingestion/application/DailyFollowerCollectorSchedulerTest.java
 ______________________________
< Here's looking at you, code. >
 ------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Firedrago95

Copy link
Copy Markdown
Owner Author

@coderabbitai

  • pr 본문에 작성한대로 새벽 장애가 발생했고 그 원인 진단과 해결을 적용한 코드야 major,critical 위주로 리뷰 부탁해
  • 장애 대응인 만큼 해결이 명확한지 꼼꼼히 리뷰 부탁함

@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== PR metadata and files ==='
gh pr view 97 --repo Firedrago95/stream-engine --json title,baseRefName,headRefName,files --jq '.title, .baseRefName, .headRefName, (.files[] | "\(.path)\t+\(.additions) -\(.deletions)")'

echo '=== changed file names ==='
git diff --name-only HEAD^ HEAD

echo '=== stat ==='
git diff --stat HEAD^ HEAD

echo '=== diff ==='
git diff --find-renames --find-copies --unified=80 HEAD^ HEAD

Length of output: 57975


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== local graph and merge base ==='
git log --oneline --decorate -8
git branch -a --contains HEAD | sed -n '1,40p'
git merge-base HEAD origin/main || true
BASE=$(git merge-base HEAD origin/main)
echo "BASE=$BASE"
git diff --name-status "$BASE"...HEAD

echo '=== complete GitHub PR diff file headers ==='
gh pr diff 97 --repo Firedrago95/stream-engine --name-only

echo '=== candidate source files ==='
fd -a 'StreamerFollowerSnapshotEntity.java|StreamEntity.java|ApiServerClient.java|FollowerSnapshotRecord.java|FollowerSnapshotRecordDto.java' .

echo '=== SQL migrations and schema references ==='
rg -n -i -C 4 'streamer_follower_snapshots|CREATE TABLE streams|follower_count|last_follower_updated_at' api-server -g '*.sql' -g '*.java'

echo '=== follower API routes and caller implementation ==='
rg -n -C 5 'recordFollowers\s*\(|sendFollowerSnapshots\s*\(|FollowerSnapshotRecordDto|FollowerSnapshotRecord' api-server engine -g '*.java'

echo '=== full base diff for operational files ==='
git diff --find-renames --unified=40 "$BASE"...HEAD -- \
  api-server/src/main/java/io/slice/stream/apiserver/streamer \
  engine/src/main/java/io/slice/stream/engine/ingestion \
  .github/workflows/deploy.yml

Length of output: 2232


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== PR file list ==='
gh pr diff 97 --repo Firedrago95/stream-engine --name-only

echo '=== PR full diff: follower and deployment changes ==='
gh pr diff 97 --repo Firedrago95/stream-engine --patch | \
  awk '
    /^diff --git / { show = ($0 ~ /(StreamerFollower|DailyFollowerCollector|ApiServerClient|FollowerSnapshot|deploy\.yml)/) }
    show { print }
  '

echo '=== locate relevant source files ==='
fd -a . api-server engine | rg '(StreamerFollowerSnapshotEntity|StreamEntity|ApiServerClient|FollowerSnapshotRecord|FollowerSnapshotRecordDto|DailyFollowerCollectorScheduler|application\.ya?ml)$'

echo '=== relevant type and API declarations ==='
rg -n -C 8 'class StreamerFollowerSnapshotEntity|class StreamEntity|interface ApiServerClient|class ApiServerClient|record FollowerSnapshotRecord|record FollowerSnapshotRecordDto|recordFollowers\s*\(' api-server engine -g '*.java'

echo '=== schedule, executor, and request size configuration ==='
rg -n -i -C 4 'follower\.cron|follower.*collect|virtual.*thread|executor|multipart|max.*request|max.*body' api-server engine -g '*.yml' -g '*.yaml' -g '*.properties' -g '*.java'

Length of output: 180588


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== entity mappings ==='
sed -n '1,220p' api-server/src/main/java/io/slice/stream/apiserver/streamer/infrastructure/entity/StreamerFollowerSnapshotEntity.java
sed -n '1,220p' api-server/src/main/java/io/slice/stream/apiserver/stream/infrastructure/entity/StreamEntity.java

echo '=== API client ==='
sed -n '1,240p' engine/src/main/java/io/slice/stream/engine/ingestion/infrastructure/apiServer/ApiServerClient.java

echo '=== controller and security path ==='
sed -n '1,160p' api-server/src/main/java/io/slice/stream/apiserver/streamer/presentation/StreamerFollowerInternalController.java
rg -n -C 5 'follower-snapshots|X-ENGINE-SECRET|internal/' api-server/src/main/java -g '*.java'

echo '=== database migrations ==='
fd -a -e sql . api-server | sort | xargs -r rg -n -i -C 5 'streamer_follower_snapshots|CREATE TABLE streams|last_follower_updated_at|follower_growth'

echo '=== RestClient construction and timeouts ==='
rg -n -C 8 'RestClient\.builder|RestClient\.Builder|setConnectTimeout|setReadTimeout|requestFactory|HttpComponents|JdkClientHttpRequestFactory' engine/src/main/java engine/src/main/resources -g '*.java' -g '*.yml'

echo '=== all source references to target query ==='
rg -n -C 4 'findDistinctStreamIdsByStartedAtAfter|findAllBySnapshotDateAndStreamIdIn' api-server/src/main/java -g '*.java'

Length of output: 39761


@Firedrago95 검토했습니다. 장애 원인인 건별 DB 조회·저장 구조는 이번 변경에서 제거되었습니다.

Critical / Major 결과: 발견하지 못했습니다.

확인한 핵심 항목입니다.

  • 엔진은 수집 완료 후 레코드를 최대 1,000건 청크로 나누고 순차 전송합니다.
  • API 서버는 청크별로 D-1 스냅샷을 한 번 조회합니다.
  • API 서버는 스냅샷 업서트와 streams 팔로워 갱신을 JDBC 배치로 처리합니다.
  • 각 HTTP 요청은 별도 트랜잭션으로 처리됩니다. 따라서 한 청크의 DB 작업이 다른 청크 전체의 트랜잭션을 장시간 점유하지 않습니다.
  • (stream_id, snapshot_date) 유니크 제약조건과 ON CONFLICT 업서트가 재전송 시 스냅샷 중복 생성을 방지합니다.
  • 청크 전송 실패는 재시도 후 예외로 전파됩니다. 실패 청크 이후의 전송은 진행하지 않습니다.
  • 기존 증감량 정책도 유지됩니다. D-1 스냅샷이 없으면 follower_growth0입니다.

후속 개선 후보도 확인했습니다. 엔진의 apiServerRestClient에는 명시적 연결 및 읽기 타임아웃이 없습니다. API 서버 또는 네트워크가 무응답 상태이면 스케줄러 작업이 장시간 대기할 수 있습니다. 이는 이번 DB 병목 해결을 막는 major 문제는 아니지만, 장애 격리를 위해 별도 후속 작업에서 타임아웃을 설정하는 것이 좋습니다.

⚠️ Action not completed

Pull request base or head changed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

- [engine] RestClientConfig 내 apiServerRestClient 전용 JdkClientHttpRequestFactory 타임아웃 적용 (연결 5초, 읽기 15초)

- [engine] 외부 치지직 API 클라이언트와의 격리를 위해 전역 Bean이 아닌 전용 팩토리 메서드로 분리하여 사이드 이펙트 차단

- [engine] RestClientConfigTest 단위 테스트 추가 및 타임아웃 격리 검증 완료
@Firedrago95

Copy link
Copy Markdown
Owner Author

@coderabbitai

  • 반영했어 타임아웃 머지 해도 될까

@Firedrago95
Firedrago95 merged commit 1287a0f into main Sep 18, 2026
1 check passed
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== PR metadata and current diff files ==='
gh pr view 97 --repo Firedrago95/stream-engine --json headRefOid,baseRefOid,updatedAt,mergeable,mergeStateStatus,statusCheckRollup --jq '{headRefOid,baseRefOid,updatedAt,mergeable,mergeStateStatus,statusCheckRollup: [.statusCheckRollup[]? | {name,conclusion,status}]}'
gh pr diff 97 --repo Firedrago95/stream-engine --name-only

echo '=== timeout-related patch ==='
gh pr diff 97 --repo Firedrago95/stream-engine --patch | awk '
  /^diff --git / { show = ($0 ~ /(ApiServerClient|HttpRequestFactory|RestClient|application\.(yml|yaml|properties))/) }
  show { print }
'

echo '=== candidate client source map ==='
fd -a 'ApiServerClient.java' .
ast-grep outline engine/src/main/java/io/slice/stream/engine/ingestion/infrastructure/apiServer/ApiServerClient.java --items all 2>/dev/null || true

echo '=== API client implementation and direct callers ==='
sed -n '1,280p' engine/src/main/java/io/slice/stream/engine/ingestion/infrastructure/apiServer/ApiServerClient.java
rg -n -C 5 '\b(sendFollowerSnapshots|recordFollowers)\s*\(' engine/src/main/java api-server/src/main/java -g '*.java'

echo '=== timeout construction and error handling references ==='
rg -n -C 8 'JdkClientHttpRequestFactory|setConnectTimeout|setReadTimeout|RestClient\.builder|RestClient\.create|RestClientResponseException|ResourceAccessException|HttpStatusCodeException' engine/src/main/java -g '*.java'

Length of output: 36207


@Firedrago95 머지해도 됩니다.

확인 결과, apiServerRestClient에만 전용 JdkClientHttpRequestFactory가 적용되었습니다.

  • 연결 타임아웃 기본값은 5초입니다.
  • 읽기 타임아웃 기본값은 15초입니다.
  • 치지직 API용 RestClient는 이 설정을 공유하지 않습니다.
  • 타임아웃 예외는 청크 전송 재시도 로직으로 전파됩니다.
  • test 상태 검사는 성공했습니다.

이번 변경에서 Critical 또는 Major 문제는 발견하지 못했습니다. GitHub의 mergeability 상태가 계산 완료된 후 충돌이 없으면 머지할 수 있습니다.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant