diff --git a/build.gradle b/build.gradle index c17600b..25a206a 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,9 @@ plugins { id 'jacoco' id 'org.springframework.boot' version '2.7.14' id 'io.spring.dependency-management' version '1.0.15.RELEASE' + id 'org.jetbrains.kotlin.jvm' version '1.9.24' + id 'org.jetbrains.kotlin.plugin.spring' version '1.9.24' + id 'org.jetbrains.kotlin.plugin.lombok' version '1.9.24' } group = 'org.runnect' @@ -12,6 +15,20 @@ java { sourceCompatibility = '11' } +compileKotlin { + kotlinOptions { + freeCompilerArgs = ['-Xjsr305=strict', '-java-parameters'] + jvmTarget = '11' + } +} + +compileTestKotlin { + kotlinOptions { + freeCompilerArgs = ['-Xjsr305=strict', '-java-parameters'] + jvmTarget = '11' + } +} + configurations { compileOnly { extendsFrom annotationProcessor @@ -76,6 +93,11 @@ dependencies { // Swagger (OpenAPI) implementation 'org.springdoc:springdoc-openapi-ui:1.7.0' + // Kotlin + implementation 'org.jetbrains.kotlin:kotlin-reflect' + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin' + testImplementation 'org.mockito.kotlin:mockito-kotlin:4.1.0' + } tasks.named('test') { diff --git a/src/main/java/org/runnect/server/common/constant/SuccessStatus.java b/src/main/java/org/runnect/server/common/constant/SuccessStatus.java index e6bb3a9..81c0d77 100644 --- a/src/main/java/org/runnect/server/common/constant/SuccessStatus.java +++ b/src/main/java/org/runnect/server/common/constant/SuccessStatus.java @@ -15,6 +15,8 @@ public enum SuccessStatus { LOGIN_SUCCESS(HttpStatus.OK, "로그인에 성공했습니다."), GET_RECORD_SUCCESS(HttpStatus.OK, "활동 기록 조회 성공"), + GET_RECORD_RANKING_SUCCESS(HttpStatus.OK, "코스 기록 랭킹 조회 성공"), + GET_MY_RECORD_RANKING_SUCCESS(HttpStatus.OK, "내 코스 기록 랭킹 조회 성공"), GET_COURSE_LIST_BY_USER_SUCCESS(HttpStatus.OK, "내가 그린 코스 리스트 조회에 성공했습니다."), GET_SCRAP_COURSE_BY_USER_SUCCESS(HttpStatus.OK, "스크랩한 코스 조회 성공"), GET_COURSE_DETAIL_SUCCESS(HttpStatus.OK, "코스 상세 조회에 성공했습니다."), diff --git a/src/main/java/org/runnect/server/record/service/RecordService.java b/src/main/java/org/runnect/server/record/service/RecordService.java index bfc3e42..2d50b47 100644 --- a/src/main/java/org/runnect/server/record/service/RecordService.java +++ b/src/main/java/org/runnect/server/record/service/RecordService.java @@ -13,6 +13,7 @@ import org.runnect.server.course.repository.CourseRepository; import org.runnect.server.publicCourse.entity.PublicCourse; import org.runnect.server.publicCourse.repository.PublicCourseRepository; +import org.runnect.server.ranking.service.RecordRankingService; import org.runnect.server.record.dto.request.CreateRecordRequestDto; import org.runnect.server.record.dto.request.DeleteRecordsRequestDto; import org.runnect.server.record.dto.request.UpdateRecordRequestDto; @@ -36,6 +37,8 @@ import org.runnect.server.user.service.UserStampService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; @Slf4j @Service @@ -48,6 +51,7 @@ public class RecordService { private final PublicCourseRepository publicCourseRepository; private final UserStampService userStampService; private final RecordHealthDataRepository recordHealthDataRepository; + private final RecordRankingService recordRankingService; @Transactional public CreateRecordResponseDto createRecord(Long userId, CreateRecordRequestDto request) { @@ -83,6 +87,10 @@ public CreateRecordResponseDto createRecord(Long userId, CreateRecordRequestDto recordRepository.save(record); + if (publicCourse != null) { + registerRankingUpdateAfterCommit(publicCourse.getId(), userId, record.getId(), time); + } + user.updateCreatedRecord(); userStampService.createStampByUser(user, StampType.r); @@ -94,6 +102,23 @@ public CreateRecordResponseDto createRecord(Long userId, CreateRecordRequestDto } + // 랭킹(Redis) 갱신은 DB 트랜잭션이 실제로 커밋된 뒤에만 실행한다. + // 커밋 전에 실행하면, 이후 로직(스탬프 적립 등)이 실패해 롤백될 때 + // 존재하지 않는 recordId를 가리키는 랭킹 엔트리가 Redis에 남을 수 있다. + private void registerRankingUpdateAfterCommit(Long publicCourseId, Long userId, Long recordId, Time time) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + recordRankingService.updateBestRecord(publicCourseId, userId, recordId, time); + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + recordRankingService.updateBestRecord(publicCourseId, userId, recordId, time); + } + }); + } + public GetRecordResponseDto getRecordByUser(Long userId) { UserResponse user = UserResponse.of(userId); diff --git a/src/main/kotlin/org/runnect/server/ranking/controller/RecordRankingController.kt b/src/main/kotlin/org/runnect/server/ranking/controller/RecordRankingController.kt new file mode 100644 index 0000000..bb37a1c --- /dev/null +++ b/src/main/kotlin/org/runnect/server/ranking/controller/RecordRankingController.kt @@ -0,0 +1,40 @@ +package org.runnect.server.ranking.controller + +import org.runnect.server.common.constant.SuccessStatus +import org.runnect.server.common.dto.ApiResponseDto +import org.runnect.server.common.resolver.userId.UserId +import org.runnect.server.ranking.service.RecordRankingService +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api") +class RecordRankingController( + private val recordRankingService: RecordRankingService, +) { + + @GetMapping("course/{courseId}/ranking") + @ResponseStatus(HttpStatus.OK) + fun getRanking( + @PathVariable courseId: Long, + @RequestParam(defaultValue = "20") limit: Long, + ) = ApiResponseDto.success( + SuccessStatus.GET_RECORD_RANKING_SUCCESS, + recordRankingService.getRanking(courseId, limit), + ) + + @GetMapping("course/{courseId}/ranking/me") + @ResponseStatus(HttpStatus.OK) + fun getMyRanking( + @UserId userId: Long, + @PathVariable courseId: Long, + ) = ApiResponseDto.success( + SuccessStatus.GET_MY_RECORD_RANKING_SUCCESS, + recordRankingService.getMyRanking(courseId, userId), + ) +} diff --git a/src/main/kotlin/org/runnect/server/ranking/dto/RankingDtos.kt b/src/main/kotlin/org/runnect/server/ranking/dto/RankingDtos.kt new file mode 100644 index 0000000..8d0bf80 --- /dev/null +++ b/src/main/kotlin/org/runnect/server/ranking/dto/RankingDtos.kt @@ -0,0 +1,62 @@ +package org.runnect.server.ranking.dto + +import org.runnect.server.record.entity.Record + +data class RankingEntryResponse( + val rank: Int, + val userId: Long, + val nickname: String, + val recordId: Long, + val time: String, + val pace: String, +) { + companion object { + fun of(rank: Int, userId: Long, record: Record) = RankingEntryResponse( + rank = rank, + userId = userId, + nickname = record.runnectUser.nickname, + recordId = record.id, + time = record.time.toString(), + pace = record.pace.toString(), + ) + } +} + +data class RankingListResponse( + val totalCount: Long, + val entries: List, +) + +// data가 null이면 클라이언트(ResultCall)에서 "null body = 에러"로 취급하기 때문에, +// "아직 이 코스를 완주한 기록이 없음"도 hasRecord=false인 정상 200 응답으로 표현한다. +data class MyRankingResponse( + val hasRecord: Boolean, + val rank: Int?, + val userId: Long, + val nickname: String?, + val recordId: Long?, + val time: String?, + val pace: String?, +) { + companion object { + fun of(rank: Int, userId: Long, record: Record) = MyRankingResponse( + hasRecord = true, + rank = rank, + userId = userId, + nickname = record.runnectUser.nickname, + recordId = record.id, + time = record.time.toString(), + pace = record.pace.toString(), + ) + + fun notFound(userId: Long) = MyRankingResponse( + hasRecord = false, + rank = null, + userId = userId, + nickname = null, + recordId = null, + time = null, + pace = null, + ) + } +} diff --git a/src/main/kotlin/org/runnect/server/ranking/service/RecordRankingService.kt b/src/main/kotlin/org/runnect/server/ranking/service/RecordRankingService.kt new file mode 100644 index 0000000..f05bdea --- /dev/null +++ b/src/main/kotlin/org/runnect/server/ranking/service/RecordRankingService.kt @@ -0,0 +1,94 @@ +package org.runnect.server.ranking.service + +import org.runnect.server.ranking.dto.MyRankingResponse +import org.runnect.server.ranking.dto.RankingEntryResponse +import org.runnect.server.ranking.dto.RankingListResponse +import org.runnect.server.record.repository.RecordRepository +import org.springframework.data.redis.connection.RedisZSetCommands.ZAddArgs +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Service +import java.nio.charset.StandardCharsets +import java.sql.Time + +/** + * 코스별 완주 기록 랭킹. + * + * 랭킹 정렬 기준은 Redis Sorted Set(ranking:record:{courseId})이 유일한 소스다. + * ZADD 옵션 LT(기존 score보다 작을 때만 반영) + CH(실제 변경 여부 반환)를 사용해 + * "동시에 들어온 완주 기록 중 개인 최고기록만 남기는" 비교-후-갱신을 Redis 내부에서 + * 원자적으로 처리한다. 애플리케이션에서 GET → 비교 → SET을 나눠서 하면 그 사이에 + * 동시 요청이 끼어드는 Lost Update 창이 생기는데, ZADD LT는 그 창을 없앤다. + * + * userId → recordId 매핑(record 상세를 다시 읽기 위한 보조 인덱스)은 별도 Hash에 + * eventual하게 둔다. 랭킹 score 자체의 정합성과는 무관한 부가 정보라 원자성이 필요 없다. + */ +@Service +class RecordRankingService( + private val stringRedisTemplate: StringRedisTemplate, + private val recordRepository: RecordRepository, +) { + + fun updateBestRecord(courseId: Long, userId: Long, recordId: Long, time: Time): Boolean { + val timeSeconds = time.toLocalTime().toSecondOfDay().toDouble() + + val updated = stringRedisTemplate.execute { connection -> + connection.zAdd( + rankingKey(courseId).toByteArray(StandardCharsets.UTF_8), + timeSeconds, + userId.toString().toByteArray(StandardCharsets.UTF_8), + ZAddArgs.empty().lt().ch(), + ) + } ?: false + + if (updated) { + stringRedisTemplate.opsForHash() + .put(recordIndexKey(courseId), userId.toString(), recordId.toString()) + } + + return updated + } + + fun getRanking(courseId: Long, limit: Long): RankingListResponse { + val zSetOps = stringRedisTemplate.opsForZSet() + val totalCount = zSetOps.zCard(rankingKey(courseId)) ?: 0L + val topTuples = zSetOps.rangeWithScores(rankingKey(courseId), 0, limit - 1) ?: emptySet() + + val userIds = topTuples.mapNotNull { it.value?.toLongOrNull() } + val recordIdByUserId = fetchRecordIdsByUserId(courseId, userIds) + val recordsById = recordRepository.findByIdIn(recordIdByUserId.values.mapNotNull { it?.toLongOrNull() }) + .associateBy { it.id } + + val entries = topTuples.mapIndexedNotNull { index, tuple -> + val userId = tuple.value?.toLongOrNull() ?: return@mapIndexedNotNull null + val recordId = recordIdByUserId[userId.toString()]?.toLongOrNull() ?: return@mapIndexedNotNull null + val record = recordsById[recordId] ?: return@mapIndexedNotNull null + + RankingEntryResponse.of(rank = index + 1, userId = userId, record = record) + } + + return RankingListResponse(totalCount = totalCount, entries = entries) + } + + fun getMyRanking(courseId: Long, userId: Long): MyRankingResponse { + val rank = stringRedisTemplate.opsForZSet().rank(rankingKey(courseId), userId.toString()) + ?: return MyRankingResponse.notFound(userId) + val recordId = stringRedisTemplate.opsForHash() + .get(recordIndexKey(courseId), userId.toString()) + ?.toLongOrNull() ?: return MyRankingResponse.notFound(userId) + val record = recordRepository.findById(recordId).orElse(null) + ?: return MyRankingResponse.notFound(userId) + + return MyRankingResponse.of(rank = rank.toInt() + 1, userId = userId, record = record) + } + + private fun fetchRecordIdsByUserId(courseId: Long, userIds: List): Map { + if (userIds.isEmpty()) return emptyMap() + + val fields = userIds.map { it.toString() } + val values = stringRedisTemplate.opsForHash().multiGet(recordIndexKey(courseId), fields) + return fields.zip(values).toMap() + } + + private fun rankingKey(courseId: Long) = "ranking:record:$courseId" + private fun recordIndexKey(courseId: Long) = "ranking:record:$courseId:record" +} diff --git a/src/test/java/org/runnect/server/record/service/RecordServiceTest.java b/src/test/java/org/runnect/server/record/service/RecordServiceTest.java index 726814c..13308ba 100644 --- a/src/test/java/org/runnect/server/record/service/RecordServiceTest.java +++ b/src/test/java/org/runnect/server/record/service/RecordServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -47,6 +48,8 @@ import org.runnect.server.user.service.UserStampService; import org.springframework.beans.BeanUtils; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; @ExtendWith(MockitoExtension.class) class RecordServiceTest { @@ -63,13 +66,15 @@ class RecordServiceTest { private UserStampService userStampService; @Mock private RecordHealthDataRepository recordHealthDataRepository; + @Mock + private org.runnect.server.ranking.service.RecordRankingService recordRankingService; private RecordService recordService; @BeforeEach void setUp() { recordService = new RecordService(recordRepository, userRepository, courseRepository, - publicCourseRepository, userStampService, recordHealthDataRepository); + publicCourseRepository, userStampService, recordHealthDataRepository, recordRankingService); } private RunnectUser buildUser(Long id) { @@ -161,6 +166,7 @@ class CreateRecord { assertThat(user.getCreatedRecord()).isEqualTo(1L); verify(userStampService).createStampByUser(user, StampType.r); verify(publicCourseRepository, never()).findById(any()); + verify(recordRankingService, never()).updateBestRecord(anyLong(), anyLong(), anyLong(), any()); } @Test @@ -184,6 +190,40 @@ class CreateRecord { recordService.createRecord(1L, request); verify(courseRepository, never()).findById(any()); + verify(recordRankingService).updateBestRecord(20L, 1L, 100L, java.sql.Time.valueOf("00:25:00")); + } + + @Test + @DisplayName("트랜잭션이 활성화된 상태면 랭킹 갱신은 커밋 이후로 지연되고, 커밋 전에는 호출되지 않는다") + void 트랜잭션_커밋_이후에_랭킹이_갱신된다() { + RunnectUser user = buildUser(1L); + Course course = buildCourse(10L, user); + PublicCourse publicCourse = PublicCourse.builder() + .course(course) + .title("공개 코스") + .description("설명") + .build(); + ReflectionTestUtils.setField(publicCourse, "id", 20L); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(publicCourseRepository.findById(20L)).thenReturn(Optional.of(publicCourse)); + stubSaveSetsCreatedAt(); + + CreateRecordRequestDto request = createRecordRequestDto(null, 20L, "00:25:00", "00:05:30"); + + TransactionSynchronizationManager.initSynchronization(); + try { + recordService.createRecord(1L, request); + + verify(recordRankingService, never()).updateBestRecord(anyLong(), anyLong(), anyLong(), any()); + + TransactionSynchronizationManager.getSynchronizations() + .forEach(TransactionSynchronization::afterCommit); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + + verify(recordRankingService).updateBestRecord(20L, 1L, 100L, java.sql.Time.valueOf("00:25:00")); } @Test diff --git a/src/test/kotlin/org/runnect/server/ranking/service/RecordRankingServiceTest.kt b/src/test/kotlin/org/runnect/server/ranking/service/RecordRankingServiceTest.kt new file mode 100644 index 0000000..5fd4630 --- /dev/null +++ b/src/test/kotlin/org/runnect/server/ranking/service/RecordRankingServiceTest.kt @@ -0,0 +1,158 @@ +package org.runnect.server.ranking.service + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.runnect.server.course.entity.Course +import org.runnect.server.record.entity.Record +import org.runnect.server.record.repository.RecordRepository +import org.runnect.server.user.entity.RunnectUser +import org.runnect.server.user.entity.SocialType +import org.springframework.data.redis.core.DefaultTypedTuple +import org.springframework.data.redis.core.HashOperations +import org.springframework.data.redis.core.RedisCallback +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.data.redis.core.ZSetOperations +import org.springframework.test.util.ReflectionTestUtils +import java.sql.Time +import java.util.Optional + +@ExtendWith(MockitoExtension::class) +class RecordRankingServiceTest { + + private val stringRedisTemplate: StringRedisTemplate = mock(StringRedisTemplate::class.java) + private val recordRepository: RecordRepository = mock(RecordRepository::class.java) + + private val service = RecordRankingService(stringRedisTemplate, recordRepository) + + @Test + fun `기존 기록보다 빠르면 hash 인덱스에 recordId가 갱신된다`() { + val hashOps: HashOperations = mock(HashOperations::class.java) as HashOperations + whenever(stringRedisTemplate.execute(any>())).thenReturn(true) + whenever(stringRedisTemplate.opsForHash()).thenReturn(hashOps) + + val updated = service.updateBestRecord(1L, 10L, 100L, Time.valueOf("00:25:00")) + + assertThat(updated).isTrue() + verify(hashOps).put("ranking:record:1:record", "10", "100") + } + + @Test + fun `기존 기록보다 느리면 hash 인덱스를 건드리지 않는다`() { + whenever(stringRedisTemplate.execute(any>())).thenReturn(false) + + val updated = service.updateBestRecord(1L, 10L, 100L, Time.valueOf("00:30:00")) + + assertThat(updated).isFalse() + verify(stringRedisTemplate, never()).opsForHash() + } + + @Test + fun `getRanking은 점수 오름차순 순서대로 순위를 매기고 recordId로 상세 정보를 조인한다`() { + val zSetOps: ZSetOperations = mock(ZSetOperations::class.java) as ZSetOperations + val hashOps: HashOperations = mock(HashOperations::class.java) as HashOperations + whenever(stringRedisTemplate.opsForZSet()).thenReturn(zSetOps) + whenever(stringRedisTemplate.opsForHash()).thenReturn(hashOps) + + whenever(zSetOps.zCard("ranking:record:1")).thenReturn(2L) + val tuples: Set> = linkedSetOf( + DefaultTypedTuple("10", 1500.0), + DefaultTypedTuple("11", 1620.0) + ) + whenever(zSetOps.rangeWithScores("ranking:record:1", 0, 1)).thenReturn(tuples) + whenever(hashOps.multiGet("ranking:record:1:record", listOf("10", "11"))) + .thenReturn(listOf("100", "101")) + + val record100 = buildRecord(100L, buildUser(10L, "런너A")) + val record101 = buildRecord(101L, buildUser(11L, "런너B")) + whenever(recordRepository.findByIdIn(listOf(100L, 101L))).thenReturn(listOf(record100, record101)) + + val response = service.getRanking(courseId = 1L, limit = 2) + + assertThat(response.totalCount).isEqualTo(2L) + assertThat(response.entries).hasSize(2) + assertThat(response.entries[0].rank).isEqualTo(1) + assertThat(response.entries[0].nickname).isEqualTo("런너A") + assertThat(response.entries[1].rank).isEqualTo(2) + assertThat(response.entries[1].nickname).isEqualTo("런너B") + } + + @Test + fun `getMyRanking은 0-based rank를 1-based로 변환해서 반환한다`() { + val zSetOps: ZSetOperations = mock(ZSetOperations::class.java) as ZSetOperations + val hashOps: HashOperations = mock(HashOperations::class.java) as HashOperations + whenever(stringRedisTemplate.opsForZSet()).thenReturn(zSetOps) + whenever(stringRedisTemplate.opsForHash()).thenReturn(hashOps) + + whenever(zSetOps.rank("ranking:record:1", "10")).thenReturn(0L) + whenever(hashOps.get("ranking:record:1:record", "10")).thenReturn("100") + + val record = buildRecord(100L, buildUser(10L, "런너A")) + whenever(recordRepository.findById(100L)).thenReturn(Optional.of(record)) + + val myRanking = service.getMyRanking(courseId = 1L, userId = 10L) + + assertThat(myRanking.hasRecord).isTrue() + assertThat(myRanking.rank).isEqualTo(1) + assertThat(myRanking.nickname).isEqualTo("런너A") + } + + @Test + fun `랭킹에 없는 유저면 getMyRanking은 hasRecord=false를 반환한다`() { + val zSetOps: ZSetOperations = mock(ZSetOperations::class.java) as ZSetOperations + whenever(stringRedisTemplate.opsForZSet()).thenReturn(zSetOps) + whenever(zSetOps.rank("ranking:record:1", "99")).thenReturn(null) + + val myRanking = service.getMyRanking(courseId = 1L, userId = 99L) + + assertThat(myRanking.hasRecord).isFalse() + assertThat(myRanking.rank).isNull() + } + + private fun buildUser(id: Long, nickname: String): RunnectUser { + val user = RunnectUser.builder() + .nickname(nickname) + .socialId("social-$id") + .email("user$id@runnect.io") + .provider(SocialType.KAKAO) + .build() + ReflectionTestUtils.setField(user, "id", id) + return user + } + + private fun buildCourse(id: Long, owner: RunnectUser): Course { + val course = Course.builder() + .runnectUser(owner) + .title("코스 제목") + .departureRegion("경기") + .departureCity("시흥시") + .departureTown("정왕동") + .departureDetail("정왕본동") + .departureName("정왕역") + .distance(5.2f) + .image("https://image.example/course.png") + .path(null) + .build() + ReflectionTestUtils.setField(course, "id", id) + return course + } + + private fun buildRecord(id: Long, owner: RunnectUser): Record { + val course = buildCourse(id + 1000, owner) + val record = Record.builder() + .runnectUser(owner) + .course(course) + .title("퇴근길 러닝") + .pace(Time.valueOf("00:05:30")) + .time(Time.valueOf("00:25:00")) + .build() + ReflectionTestUtils.setField(record, "id", id) + return record + } +}