Conversation
WalkthroughSpring Retry 의존성 및 설정을 추가하고, 시즌 기반 순위 시스템(도메인·레포지토리·서비스·컨트롤러·스케줄러·스냅샷 배치)을 도입하며, 학습 세션 종료 검증과 StudySessionResponse 확장이 포함되었습니다. Changes
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 분 Possibly related PRs
Suggested labels
Suggested reviewers
시
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
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: 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 기준의 경계"라는 의미가 더 명확해집니다.
src/main/java/com/gpt/geumpumtabackend/rank/dto/response/SeasonRankingResponse.java
Show resolved
Hide resolved
src/main/java/com/gpt/geumpumtabackend/rank/repository/SeasonRepository.java
Outdated
Show resolved
Hide resolved
src/main/java/com/gpt/geumpumtabackend/rank/repository/UserRankingRepository.java
Show resolved
Hide resolved
src/main/java/com/gpt/geumpumtabackend/rank/scheduler/SeasonTransitionScheduler.java
Outdated
Show resolved
Hide resolved
src/main/java/com/gpt/geumpumtabackend/rank/service/SeasonSnapshotService.java
Show resolved
Hide resolved
There was a problem hiding this comment.
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.
…ansitionScheduler.java Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
There was a problem hiding this comment.
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주입을 검토해 주세요.
🚀 1. 개요
📝 2. 주요 변경 사항
1. 도메인 모델 및 시즌 관리 (Domain & Logic)
Season 엔티티: 4개 시즌(학기/방학)의 생명주기(Active/Ended)와 기간을 관리.
SeasonRankingSnapshot 엔티티: 시즌 종료 시 확정된 유저의 전체/학과별 랭킹 및 공부 시간을 영구 저장 (불변 데이터).
자동 시즌 전환: 스케줄러를 통해 종료일 다음 날 00:05에 자동으로 다음 시즌 생성 및 이전 시즌 종료 처리.
2. 랭킹 집계 로직 고도화 (Ranking Strategy)
3. 대용량 데이터 처리 최적화 (Performance)
4. 스케줄러 및 캐시 전략 (Scheduler & Cache)
🤔 고민한 부분
1. 대용량 스냅샷 데이터 처리 최적화 (JPA vs JDBC)
문제
해결
2. 스케줄러 실행 순서 보장 (Data Consistency)
문제
해결
Cron 시간차를 이용한 순차 실행 별도의 복잡한 이벤트 의존성 없이, 물리적인 시간 간격을 두어 데이터 정합성을 보장했습니다.
일간 랭킹 생성 (어제 데이터 확정)
월간 랭킹 생성 (지난달 데이터 확정)
시즌 전환 및 스냅샷 생성 (모든 데이터 준비 완료 후 실행)
고민 : 일간,월간,시즌 랭킹 스케줄러가 순서대로 집계되고 있는 상황
일간~시즌 스케줄러 동작 기간에 들어온 공부 세션을 어떻게 처리해야할지 고민
Summary by CodeRabbit
새로운 기능
기능 개선
버그 수정
성능
✏️ Tip: You can customize this high-level summary in your review settings.