Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, "코스 상세 조회에 성공했습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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);

Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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),
)
}
62 changes: 62 additions & 0 deletions src/main/kotlin/org/runnect/server/ranking/dto/RankingDtos.kt
Original file line number Diff line number Diff line change
@@ -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<RankingEntryResponse>,
)

// 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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String>()
.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<String, String>()
.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<Long>): Map<String, String?> {
if (userIds.isEmpty()) return emptyMap()

val fields = userIds.map { it.toString() }
val values = stringRedisTemplate.opsForHash<String, String>().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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading