Skip to content

feat: 시즌 기반 랭킹 시스템 구현 및 대용량 스냅샷 배치 처리 최적화#67

Merged
Juhye0k merged 13 commits intodevfrom
ranking
Jan 19, 2026
Merged

feat: 시즌 기반 랭킹 시스템 구현 및 대용량 스냅샷 배치 처리 최적화#67
Juhye0k merged 13 commits intodevfrom
ranking

Conversation

@Juhye0k
Copy link
Contributor

@Juhye0k Juhye0k commented Jan 18, 2026

🚀 1. 개요

  • 학기 단위(1학기/여름방학/2학기/겨울방학)로 장기 랭킹을 집계하는 시즌 시스템을 도입하고, 시즌 종료 시 확정된 랭킹을 영구 보존하기 위한 기능을 구현했습니다.
  • 대량의 랭킹 데이터를 안정적으로 저장하기 위해 JDBC Batch Update와 Retry 전략을 적용하여 성능과 안정성을 확보했습니다.

📝 2. 주요 변경 사항

1. 도메인 모델 및 시즌 관리 (Domain & Logic)

  • Season 엔티티: 4개 시즌(학기/방학)의 생명주기(Active/Ended)와 기간을 관리.

  • SeasonRankingSnapshot 엔티티: 시즌 종료 시 확정된 유저의 전체/학과별 랭킹 및 공부 시간을 영구 저장 (불변 데이터).

  • 자동 시즌 전환: 스케줄러를 통해 종료일 다음 날 00:05에 자동으로 다음 시즌 생성 및 이전 시즌 종료 처리.

2. 랭킹 집계 로직 고도화 (Ranking Strategy)

  • 하이브리드 집계: 완료된 월간 랭킹 + 현재 월의 일간 랭킹 + 실시간 금일 세션을 합산하여 실시간 시즌 랭킹 제공.
  • 동점자 처리 통일: Java 로직 내에서 MySQL RANK() 함수와 동일한 로직(1등, 1등, 3등...)을 구현하여 일관성 확보.

3. 대용량 데이터 처리 최적화 (Performance)

  • JDBC Batch Insert: JPA saveAll()의 성능 한계를 극복하기 위해 JdbcTemplate 기반의 Batch Insert 구현
  • Retry & Recovery: DB 일시적 장애나 Deadlock 상황에 대비해 @retryable(최대 3회) 적용.
  • DB URL 튜닝: rewriteBatchedStatements=true 옵션을 적용하여 대량 Insert 속도 극대화.

4. 스케줄러 및 캐시 전략 (Scheduler & Cache)

  • 실행 순서 보장: 데이터 누락 방지를 위해 실행 시각 조정 (일간(00:05) → 월간(00:02) → 시즌전환(00:05)).
  • Caffeine Cache: activeSeason 조회에 캐시를 적용하되, 시즌 전환 시점에는 캐시를 강제 클리어(evict)하여 정합성 보장.

🤔 고민한 부분

1. 대용량 스냅샷 데이터 처리 최적화 (JPA vs JDBC)

문제

  • 시즌 종료 시 약 몇만 건의 사용자 랭킹 스냅샷을 생성해야 합니다.
  • Identity 전략에서는 PK를 알지 못하기 때문에 INSERT 이후 ID를 받아와야 합니다.
  • 기존 JPA saveAll() 방식은 엔티티마다 INSERT 쿼리가 생성되고 영속성 컨텍스트 관리 비용이 발생하여 시간이 오래걸립니다.

해결

  • JdbcTemplate을 활용한 Bulk Insert 구현.
  • 튜닝: batchSize: 2000으로 설정 (메모리 사용량과 네트워크 왕복 횟수의 최적 균형점).
  • DB URL 옵션: rewriteBatchedStatements=true를 추가하여 드라이버 수준에서 패킷 최적화.
  • 안정성: @retryable을 적용하여 일시적 DB 연결 장애 시 자동 복구(Max 3회).

2. 스케줄러 실행 순서 보장 (Data Consistency)

문제

  • 시즌 랭킹 스냅샷은 월간 랭킹 + 일간 랭킹 + 실시간 세션을 합산하여 생성됩니다. 만약 시즌 전환(00:00:00)이 월간 랭킹 생성(00:02:00)보다 먼저 실행되면, 마지막 달(12월) 데이터가 누락된 채로 스냅샷이 생성되는 문제가 발생합니다.

해결

  • Cron 시간차를 이용한 순차 실행 별도의 복잡한 이벤트 의존성 없이, 물리적인 시간 간격을 두어 데이터 정합성을 보장했습니다.

  • 일간 랭킹 생성 (어제 데이터 확정)

  • 월간 랭킹 생성 (지난달 데이터 확정)

  • 시즌 전환 및 스냅샷 생성 (모든 데이터 준비 완료 후 실행)

고민 : 일간,월간,시즌 랭킹 스케줄러가 순서대로 집계되고 있는 상황
일간~시즌 스케줄러 동작 기간에 들어온 공부 세션을 어떻게 처리해야할지 고민

Summary by CodeRabbit

  • 새로운 기능

    • 시즌별 랭킹 조회(현재/종료, 전체/학과별) 및 관련 API 제공
    • 시즌 전환 자동 스케줄러와 종료 시즌 스냅샷 생성·배치 저장 지원
  • 기능 개선

    • 실시간 랭킹 병합·재정렬 로직 도입
    • 응답에 오늘 공부 중 여부(isStudying) 포함
    • 활성 시즌 캐시 도입으로 조회 최적화
  • 버그 수정

    • 공부 세션 종료 시간 검증 추가(종료시간 유효성 확인)
  • 성능

    • DB 연결 최적화 파라미터 적용 및 대량 삽입 배치 처리 개선

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 18, 2026

Walkthrough

Spring Retry 의존성 및 설정을 추가하고, 시즌 기반 순위 시스템(도메인·레포지토리·서비스·컨트롤러·스케줄러·스냅샷 배치)을 도입하며, 학습 세션 종료 검증과 StudySessionResponse 확장이 포함되었습니다.

Changes

Cohort / File(s) 변경 요약
빌드 설정
\build.gradle``
org.springframework.retry:spring-retry, org.springframework:spring-aspects 의존성 추가
재시도 설정
\src/main/java/.../global/config/retry/RetryConfig.java``
@Configuration, @EnableRetry 적용하여 Spring Retry 활성화
캐시 설정
\src/main/java/.../global/config/cache/CacheConfig.java``
Caffeine 기반 CacheManager 빈 추가(wifiValidation, activeSeason)
예외 타입
\src/main/java/.../global/exception/ExceptionType.java``
시즌/학습 관련 예외 상수 추가 (INVALID_END_TIME, NO_ACTIVE_SEASON, SEASON_NOT_FOUND, SEASON_NOT_ENDED, SEASON_ALREADY_ENDED, SEASON_INVALID_DATE_RANGE)
시즌 도메인 및 열거형
\src/main/java/.../rank/domain/Season.java`, `.../SeasonType.java`, `.../SeasonStatus.java`, `.../RankType.java`, `.../SeasonRankingSnapshot.java``
Season 엔티티(검증·end 메서드), 관련 enum, 스냅샷 엔티티 추가
레포지토리 (시즌/스냅샷/유저/학습)
\src/main/java/.../rank/repository/SeasonRepository.java`, `.../SeasonRankingSnapshotRepository.java`, `.../UserRankingRepository.java`, `src/main/java/.../study/repository/StudySessionRepository.java``
시즌 기간 조회 쿼리, 스냅샷 리포지토리, 복합 집계(JPQL) 메서드 및 부서별 집계 쿼리 추가/수정
서비스: 시즌 관리/순위/스냅샷 배치
\src/main/java/.../rank/service/SeasonService.java`, `.../SeasonRankService.java`, `.../SeasonSnapshotService.java`, `.../SeasonSnapshotBatchService.java``
활성 시즌 조회/전환, 현재·종료 시즌 순위 산출, 스냅샷 생성·배치 저장, @Retryable/recover 구현
컨트롤러 / API / DTO
\src/main/java/.../rank/api/SeasonRankApi.java`, `.../rank/controller/SeasonRankController.java`, `.../rank/dto/response/SeasonRankingResponse.java`, `.../rank/dto/PersonalRankingTemp.java`, `.../rank/dto/response/PersonalRankingEntryResponse.java``
시즌 순위 API(인터페이스·컨트롤러) 추가, 응답 레코드·DTO 변환기 추가, PersonalRankingTemp 생성자 확장(Department 처리)
스케줄러
\src/main/java/.../rank/scheduler/RankingSchedulerService.java`, `.../SeasonTransitionScheduler.java``
기존 스케줄 cron 조정 및 시즌 전환·스냅샷 생성 스케줄러 추가
학습 도메인/서비스/응답
\src/main/java/.../study/domain/StudySession.java`, `.../dto/response/StudySessionResponse.java`, `.../service/StudySessionService.java`, `.../study/repository/StudySessionRepository.java``
endStudySession 검증 추가(INVALID_END_TIME), StudySessionResponse에 isStudying 필드 추가 및 서비스에서 계산, 부서별 기간 집계 메서드 추가
설정 파일
\src/main/resources/application-dev.yml`, `.../application-local.yml`, `.../application-prod.yml`, `src/test/resources/application-test.yml`, `src/test/resources/application-unit-test.yml``
MySQL JDBC 파라미터(rewriteBatchedStatements, cachePrepStmts, useServerPrepStmts) 추가; unit-test 프로파일에서 스케줄링 비활성화 설정 추가; 테스트 YAML 공백 조정

Sequence Diagram(s)

sequenceDiagram
    actor Client as Client
    participant Controller as SeasonRankController
    participant RankService as SeasonRankService
    participant SeasonSvc as SeasonService
    participant Repo as UserRankingRepository/StudySessionRepository
    participant Cache as Cache
    participant DB as Database

    Client->>Controller: GET /api/v1/rank/season/current
    Controller->>RankService: getCurrentSeasonRanking()
    RankService->>SeasonSvc: getActiveSeason()
    SeasonSvc->>Cache: 조회(activeSeason)
    alt 캐시 히트
        Cache-->>SeasonSvc: Season
    else 캐시 미스
        SeasonSvc->>Repo: findByDateRange(today)
        Repo->>DB: JPQL 실행
        DB-->>Repo: Season 반환
        Repo-->>SeasonSvc: Season
        SeasonSvc->>Cache: 캐시 저장
    end
    SeasonSvc-->>RankService: Season
    RankService->>Repo: 월별/월간/일간 집계 조회
    Repo->>DB: 집계 쿼리 실행
    DB-->>Repo: PersonalRankingTemp 리스트
    Repo-->>RankService: 집계 결과
    RankService->>RankService: mergeAndRank 처리
    RankService-->>Controller: SeasonRankingResponse
    Controller-->>Client: 200 OK + payload
Loading
sequenceDiagram
    participant Scheduler as SeasonTransitionScheduler
    participant SeasonSvc as SeasonService
    participant SnapshotSvc as SeasonSnapshotService
    participant Cache as Cache
    participant DB as Database

    Scheduler->>Scheduler: 매일 00:05 실행
    Scheduler->>SeasonSvc: getActiveSeasonNoCache()
    SeasonSvc->>DB: findByDateRange(today)
    DB-->>SeasonSvc: Season
    SeasonSvc-->>Scheduler: Season
    Scheduler->>Scheduler: 오늘 > endDate + 1 ?
    alt 조건 만족
        Scheduler->>Cache: activeSeason 캐시 삭제
        Scheduler->>SeasonSvc: transitionToNextSeason(season)
        SeasonSvc->>DB: 새 Season 저장, 기존 Season 상태 종료
        Scheduler->>SnapshotSvc: createSeasonSnapshot(seasonId)
        SnapshotSvc->>DB: 배치 INSERT(JdbcTemplate)
        DB-->>SnapshotSvc: 저장 결과
        SnapshotSvc-->>Scheduler: 저장 건수 반환
    else 불만족
        Scheduler->>Scheduler: 종료
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 분

Possibly related PRs

Suggested labels

enhancement

Suggested reviewers

  • kon28289

🐰 계절 바뀌어도 코드엔 리트라이
스케줄은 새벽에 조용히 일어나
스냅샷 쌓아 순위 매기고
배치로 묶어 저장하면 뿌듯하구나
당근 하나로 팀이 웃네 🥕

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive PR 설명이 저장소의 템플릿 구조를 따르고 있으나, 필수 섹션인 '3. 스크린샷 (API 테스트 결과)' 섹션이 작성되지 않았습니다. 스크린샷 또는 API 테스트 결과를 추가하여 구현의 동작을 시각적으로 검증할 수 있도록 작성해주세요.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 주요 변경 사항을 명확하게 요약하고 있으며, 시즌 기반 랭킹 시스템 구현과 스냅샷 배치 처리 최적화라는 핵심 내용을 잘 반영하고 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/com/gpt/geumpumtabackend/study/domain/StudySession.java (1)

43-49: endTime 파라미터에 대한 null 체크 누락

endTime이 null로 전달될 경우 endTime.isBefore(startTime) 호출 시 NullPointerException이 발생합니다. 의도한 BusinessException 대신 NPE가 발생하면 클라이언트에게 명확한 에러 메시지를 전달하기 어렵습니다.

🛠️ null 체크 추가 제안
 public void endStudySession(LocalDateTime endTime) {
-    if(endTime.isBefore(startTime))
+    if (endTime == null || endTime.isBefore(startTime)) {
         throw new BusinessException(ExceptionType.INVALID_END_TIME);
+    }
     this.endTime = endTime;
     status = StudyStatus.FINISHED;
     this.totalMillis = Duration.between(this.startTime, this.endTime).toMillis();
 }
🤖 Fix all issues with AI agents
In
`@src/main/java/com/gpt/geumpumtabackend/rank/dto/response/SeasonRankingResponse.java`:
- Around line 19-23: In SeasonRankingResponse.of(Season season,
List<PersonalRankingTemp> rankings) add null protection for the rankings
parameter before streaming; e.g., treat a null rankings as an empty list (use
Collections.emptyList() or
Optional.ofNullable(rankings).orElse(Collections.emptyList())) so the call to
PersonalRankingEntryResponse::of cannot cause an NPE when rankings is null;
update the mapping that produces rankingEntries accordingly.

In
`@src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRepository.java`:
- Around line 14-21: The custom JPQL query in SeasonRepository (method
findByDateRange) uses a non-supported LIMIT clause causing runtime errors;
replace it with a Spring Data derived query that returns a single result (e.g.,
rename/replace findByDateRange to a derived method such as
findFirstByStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByCreatedAtDesc
and keep the parameter name date/LocalDate) so Spring applies the 1-row limit
without JPQL LIMIT and remove the `@Query` annotation.

In
`@src/main/java/com/gpt/geumpumtabackend/rank/repository/UserRankingRepository.java`:
- Around line 37-55: The JPQL queries compare the Enum field ur.rankingType to
String literals; update each query to compare against the Enum instead (either
by using a typed parameter or the Enum literal) to improve type-safety and JPA
compatibility: for example, in the method
calculateSeasonRankingFromMonthlyRankings replace WHERE ur.rankingType =
'MONTHLY' with WHERE ur.rankingType = :rankingType and add a
`@Param`("rankingType") RankingType rankingType (or use the fully-qualified enum
literal like WHERE ur.rankingType =
com.gpt.geumpumtabackend.rank.domain.RankingType.MONTHLY); apply the same change
to the other three repository queries that currently use 'DAILY'/'MONTHLY' so
all comparisons use enum parameters or enum literals (update method signatures
to accept the param when using :rankingType).

In
`@src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java`:
- Around line 32-34: 현재 SeasonTransitionScheduler의 전환 조건이 오늘 ==
activeSeason.getEndDate().plusDays(1)로 고정되어 있어 해당 날짜에 스케줄러가 실패하면 영구히 전환이 누락됩니다;
activeSeason.getEndDate() 비교를 완화하여 오늘이 종료일+1보다 이전인 경우에만 리턴하도록 변경하세요 (예: if
(today.isBefore(activeSeason.getEndDate().plusDays(1))) return;). 이 변경은
SeasonTransitionScheduler 내 해당 조건문(참조: activeSeason.getEndDate(), today 변수)을
수정하면 됩니다.

In
`@src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonSnapshotService.java`:
- Around line 46-99: The existsBySeasonId check plus subsequent
batchService.saveBatchWithJdbc calls are not atomic and can lead to duplicate
SeasonRankingSnapshot rows under concurrency; add a DB-level unique constraint
on (season_id, user_id, rank_type, department) and remove reliance on
existsBySeasonId as the sole guard, then make the snapshot creation idempotent
by attempting the batch insert and catching/ignoring
duplicate-key/DataIntegrityViolationException (or use an upsert/ON CONFLICT DO
NOTHING strategy) in the code that calls batchService.saveBatchWithJdbc and for
department inserts; keep SeasonRankingSnapshot builder usage and snapshotAt
logic but handle duplicate-key exceptions around batchService.saveBatchWithJdbc
(or implement repository-level upsert) to prevent race-condition duplicates.
♻️ Duplicate comments (2)
src/main/java/com/gpt/geumpumtabackend/rank/repository/UserRankingRepository.java (2)

81-101: 위 enum 비교 이슈와 동일합니다.
중복 코멘트는 생략합니다.


106-126: 위 enum 비교/일자 경계 이슈와 동일합니다.
중복 코멘트는 생략합니다.

🧹 Nitpick comments (6)
src/main/java/com/gpt/geumpumtabackend/study/service/StudySessionService.java (1)

37-39: 존재 여부 조회는 existsBy...로 경량화하는 편이 좋습니다.

현재는 엔티티를 로딩하므로 불필요한 비용이 발생합니다. Spring Data의 existsByUser_IdAndStatus로 바꾸는 것을 권장합니다.

♻️ 제안 변경(diff)
-        boolean isStudying = studySessionRepository.findByUser_IdAndStatus(userId, StudyStatus.STARTED).isPresent();
+        boolean isStudying = studySessionRepository.existsByUser_IdAndStatus(userId, StudyStatus.STARTED);
src/main/java/com/gpt/geumpumtabackend/rank/domain/SeasonRankingSnapshot.java (1)

14-45: 조회 패턴에 맞는 인덱스 추가를 권장합니다.

season_id + rank_type (+ department) 기반 조회가 반복될 예정이므로 JPA 인덱스를 추가하면 스냅샷 테이블이 커졌을 때 성능에 유리합니다.

♻️ 제안 변경(diff)
-@Entity
+@Entity
+@Table(indexes = {
+    `@Index`(name = "idx_season_ranktype_dept", columnList = "season_id, rank_type, department"),
+    `@Index`(name = "idx_season_user", columnList = "season_id, user_id")
+})
src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonSnapshotBatchService.java (1)

35-64: 중복 배칭 로직 정리 권장
JdbcTemplate가 내부에서 batchSize로 분할하므로, 현재의 subList 루프는 중복입니다. 전체 리스트를 한 번에 넘기는 방식으로 단순화 가능한지 확인해 주세요.

♻️ 단순화 예시
-        for (int i = 0; i < snapshots.size(); i += batchSize) {
-            int end = Math.min(i + batchSize, snapshots.size());
-            List<SeasonRankingSnapshot> batch = snapshots.subList(i, end);
-
-            int[][] updateCounts = jdbcTemplate.batchUpdate(sql, batch, batchSize,
-                (ps, snapshot) -> {
-                    ps.setLong(1, snapshot.getSeasonId());
-                    ps.setLong(2, snapshot.getUserId());
-                    ps.setString(3, snapshot.getRankType().name());
-                    ps.setInt(4, snapshot.getFinalRank());
-                    ps.setLong(5, snapshot.getFinalTotalMillis());
-                    if (snapshot.getDepartment() != null) {
-                        ps.setString(6, snapshot.getDepartment().name());
-                    } else {
-                        ps.setNull(6, java.sql.Types.VARCHAR);
-                    }
-                    ps.setTimestamp(7, Timestamp.valueOf(snapshot.getSnapshotAt()));
-                    ps.setTimestamp(8, Timestamp.valueOf(now));
-                    ps.setTimestamp(9, Timestamp.valueOf(now));
-                });
-
-            for (int[] batchUpdateCounts : updateCounts) {
-                totalSaved += batchUpdateCounts.length;
-            }
-        }
+        int[][] updateCounts = jdbcTemplate.batchUpdate(sql, snapshots, batchSize,
+            (ps, snapshot) -> {
+                ps.setLong(1, snapshot.getSeasonId());
+                ps.setLong(2, snapshot.getUserId());
+                ps.setString(3, snapshot.getRankType().name());
+                ps.setInt(4, snapshot.getFinalRank());
+                ps.setLong(5, snapshot.getFinalTotalMillis());
+                if (snapshot.getDepartment() != null) {
+                    ps.setString(6, snapshot.getDepartment().name());
+                } else {
+                    ps.setNull(6, java.sql.Types.VARCHAR);
+                }
+                ps.setTimestamp(7, Timestamp.valueOf(snapshot.getSnapshotAt()));
+                ps.setTimestamp(8, Timestamp.valueOf(now));
+                ps.setTimestamp(9, Timestamp.valueOf(now));
+            });
+
+        for (int[] batchUpdateCounts : updateCounts) {
+            totalSaved += batchUpdateCounts.length;
+        }
         return totalSaved;
src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonRankService.java (1)

41-120: 현재 시각을 한 번만 캡처해 경계 오차 예방
LocalDate.now()/LocalDateTime.now()를 여러 번 호출하면 자정 경계에서 today와 now가 달라질 수 있습니다. now를 한 번 캡처해 파생 값을 쓰는 방식이 더 안전합니다.

♻️ 적용 예시
     public SeasonRankingResponse getCurrentSeasonRanking() {
         Season activeSeason = seasonService.getActiveSeason();
 
-        LocalDate seasonStart = activeSeason.getStartDate();
-        LocalDate today = LocalDate.now();
+        LocalDateTime now = LocalDateTime.now();
+        LocalDate seasonStart = activeSeason.getStartDate();
+        LocalDate today = now.toLocalDate();
         LocalDate currentMonthStart = today.withDayOfMonth(1);
@@
         List<PersonalRankingTemp> todayRanking = studySessionRepository
             .calculateCurrentPeriodRanking(
                 today.atStartOfDay(),
                 todayEnd,
-                LocalDateTime.now()
+                now
             );
@@
     public SeasonRankingResponse getCurrentSeasonDepartmentRanking(Department department) {
         Season activeSeason = seasonService.getActiveSeason();
 
-        LocalDate seasonStart = activeSeason.getStartDate();
-        LocalDate today = LocalDate.now();
+        LocalDateTime now = LocalDateTime.now();
+        LocalDate seasonStart = activeSeason.getStartDate();
+        LocalDate today = now.toLocalDate();
         LocalDate currentMonthStart = today.withDayOfMonth(1);
@@
         List<PersonalRankingTemp> todayRanking = studySessionRepository
             .calculateCurrentPeriodDepartmentRanking(
                 today.atStartOfDay(),
                 todayEnd,
-                LocalDateTime.now(),
+                now,
                 department.name()
             );
src/main/java/com/gpt/geumpumtabackend/rank/api/SeasonRankApi.java (1)

60-65: userId 파라미터 바인딩 방식 확인이 필요합니다.
@AssignUserId가 메서드 레벨에만 있어 기본 설정에서는 userId가 요청 파라미터로 해석될 수 있습니다. 실제로 ArgumentResolver/AOP로 안전하게 주입되는지 확인하고, 필요하면 @RequestAttribute/커스텀 파라미터 어노테이션으로 명시해 주세요.

src/main/java/com/gpt/geumpumtabackend/rank/repository/UserRankingRepository.java (1)

58-76: 파라미터 명 명확화 검토

today 파라미터는 atStartOfDay()로 "오늘 00:00"을 전달하고 있으며, 쿼리의 < :today 조건은 의도적으로 오늘 데이터를 제외합니다. 오늘 데이터는 별도로 studySessionRepository.calculateCurrentPeriodRanking()에서 처리되므로 현재 경계 조건은 정확합니다.

다만 코드 가독성을 위해 파라미터를 todayStart로 명명하면 "00:00 기준의 경계"라는 의미가 더 명확해집니다.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/test/resources/application-test.yml`:
- Around line 6-9: The scheduler classes are not conditional on the
spring.task.scheduling.enabled property so test config disabling scheduling is
ignored; update each scheduler (SeasonTransitionScheduler,
RankingSchedulerService, RefreshTokenDeleteScheduler) by adding
`@ConditionalOnProperty`(name = "spring.task.scheduling.enabled", havingValue =
"true", matchIfMissing = true) at the class level or on the `@Scheduled` method(s)
to ensure they are disabled when the property is false, and import the
annotation from org.springframework.boot.autoconfigure.condition; alternatively
apply the annotation to the specific methods if you prefer finer-grained
control.

Juhye0k and others added 2 commits January 19, 2026 09:41
…ansitionScheduler.java

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In
`@src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java`:
- Around line 25-26: processSeasonTransition in SeasonTransitionScheduler can
run concurrently across instances; add a DB-backed guard so only one instance
performs the season transition/snapshot: create a TransitionLock (or use an
existing table) with a unique key (e.g., name='season_transition') and implement
transactional acquire-and-check logic in the transition service method called by
processSeasonTransition — start a transaction, attempt to insert or update the
lock row using the unique constraint (or SELECT ... FOR UPDATE) to claim
ownership, abort/return if the claim fails, then perform the transition/snapshot
and mark the lock released (or leave timestamped record) before commit; annotate
the service method with `@Transactional` and reference
SeasonTransitionScheduler.processSeasonTransition and the transition service
(e.g., SeasonTransitionService.performTransition) so the locking code is
colocated with the transition logic.
- Around line 44-48: The current flow in SeasonTransitionScheduler calls
seasonService.transitionToNextSeason(activeSeason) before
snapshotService.createSeasonSnapshot(endedSeasonId), which risks permanently
missing a snapshot if snapshot creation fails; change the flow to create the
snapshot first (call snapshotService.createSeasonSnapshot(endedSeasonId) before
seasonService.transitionToNextSeason(activeSeason)), or alternatively make the
two actions atomic by performing the transition inside a transaction or by
adding a compensating rollback: after transition, if
snapshotService.createSeasonSnapshot fails, call
seasonService.revertTransition(activeSeason or endedSeasonId) or schedule
reliable retries for snapshot creation; update SeasonTransitionScheduler to use
the chosen approach ensuring snapshot creation is guaranteed before or
retried/rolled back if transition already occurred.
🧹 Nitpick comments (1)
src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java (1)

27-32: 타임존 의존으로 전환 날짜 오차 가능

LocalDate.now()는 서버 기본 타임존을 사용합니다. 운영 환경이 KST가 아닐 경우 전환일이 하루 앞/뒤로 어긋날 수 있으니, 설정된 타임존 사용 또는 Clock 주입을 검토해 주세요.

@Juhye0k Juhye0k requested a review from kon28289 January 19, 2026 02:01
@Juhye0k Juhye0k self-assigned this Jan 19, 2026
Copy link
Contributor

@kon28289 kon28289 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고하셨습니다!

@Juhye0k Juhye0k merged commit 4b653af into dev Jan 19, 2026
4 checks passed
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.

2 participants