Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b8cbf8d
:wrench: chore: redisson v3.50.0 의존성 추가
LimKangHyun Jul 26, 2025
4ad1312
:sparkles: feat: Redis 분산락 AOP 기반 구현
LimKangHyun Jul 26, 2025
35fe39f
chore: Java 스타일 수정
Jul 26, 2025
ce864dd
:sparkles: feat: gameStart와 handlePlayerReady에 분산 락 적용
LimKangHyun Jul 26, 2025
7f83ba9
chore: Java 스타일 수정
Jul 26, 2025
a13075b
:white_check_mark: test: DistributedLockAspect 단위 테스트 추가
LimKangHyun Jul 26, 2025
e764b1a
chore: Java 스타일 수정
Jul 26, 2025
40e0b3a
:white_check_mark: test: 분산 락 통합 테스트 추가
LimKangHyun Jul 26, 2025
66eb5e2
chore: Java 스타일 수정
Jul 26, 2025
c7da054
:white_check_mark: test: RedissonTestContainerConfig 추가
LimKangHyun Jul 26, 2025
c06e8ab
chore: Java 스타일 수정
Jul 26, 2025
f1a484c
:bug: fix: RedissonConfig host, port 경로 재설정
LimKangHyun Jul 26, 2025
00eb06b
:white_check_mark: test: RoomServiceTests에 timeService 추가
LimKangHyun Jul 26, 2025
827bdb8
chore: Java 스타일 수정
Jul 26, 2025
ffec34d
:recycle: chore: Redis 기본값 application.yml에 적용
LimKangHyun Jul 26, 2025
8b27655
:wrench: chore: application.yml 원복
LimKangHyun Jul 26, 2025
a37ad68
:bug: fix: redis host, port 설정이 되어 있지 않아 발생하는 오류 수정
LimKangHyun Jul 27, 2025
08a71c6
:bug: fix: redis host, port 설정이 되어 있지 않아 발생하는 오류 수정
LimKangHyun Jul 27, 2025
e7d524a
chore: Java 스타일 수정
Jul 27, 2025
447159a
:wastebasket: remove: redissonConfig 파일 삭제
LimKangHyun Jul 27, 2025
4fd7737
:wastebasket: remove: test application.yml 파일 빈 오버라이딩 허용 삭제
LimKangHyun Jul 27, 2025
a0af97b
:bug: fix: git conflict 해결
LimKangHyun Jul 28, 2025
d65fd83
:wastebasket: remove: 플레이어 로그 삭제
LimKangHyun Jul 28, 2025
a30ff90
chore: Java 스타일 수정
Jul 28, 2025
f79c272
:recycle: refactor: 테스트 Order 제거
LimKangHyun Jul 29, 2025
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
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ jobs:
PROJECT_DIR: backend
KAKAO_CLIENT: ${{ secrets.KAKAO_CLIENT }}
KAKAO_SECRET: ${{ secrets.KAKAO_SECRET }}
REDIS_HOST: ${{ secrets.REDIS_HOST }}
REDIS_PORT: ${{ secrets.REDIS_PORT }}
steps:
- uses: actions/checkout@v4

Expand Down
1 change: 1 addition & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.50.0'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import io.f1.backend.domain.game.dto.MessageType;
import io.f1.backend.domain.game.dto.RoomEventType;
import io.f1.backend.domain.game.dto.request.GameSettingChanger;
import io.f1.backend.domain.game.dto.response.PlayerListResponse;
import io.f1.backend.domain.game.event.GameCorrectAnswerEvent;
import io.f1.backend.domain.game.event.GameTimeoutEvent;
import io.f1.backend.domain.game.event.RoomUpdatedEvent;
Expand All @@ -31,6 +30,7 @@
import io.f1.backend.global.exception.CustomException;
import io.f1.backend.global.exception.errorcode.GameErrorCode;
import io.f1.backend.global.exception.errorcode.RoomErrorCode;
import io.f1.backend.global.lock.DistributedLock;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -59,6 +59,7 @@ public class GameService {
private final RoomRepository roomRepository;
private final ApplicationEventPublisher eventPublisher;

@DistributedLock(prefix = "room", key = "#roomId", waitTime = 0)
public void gameStart(Long roomId, UserPrincipal principal) {

String destination = getDestination(roomId);
Expand Down Expand Up @@ -196,6 +197,7 @@ public void gameEnd(Room room) {
destination, MessageType.ROOM_SETTING, toRoomSettingResponse(room));
}

@DistributedLock(prefix = "room", key = "#roomId")
public void handlePlayerReady(Long roomId, String sessionId) {

Room room = findRoom(roomId);
Expand All @@ -206,9 +208,8 @@ public void handlePlayerReady(Long roomId, String sessionId) {

String destination = getDestination(roomId);

PlayerListResponse playerListResponse = toPlayerListResponse(room);
log.info(playerListResponse.toString());
messageSender.sendBroadcast(destination, MessageType.PLAYER_LIST, playerListResponse);
messageSender.sendBroadcast(
destination, MessageType.PLAYER_LIST, toPlayerListResponse(room));
}

public void changeGameSetting(
Expand Down Expand Up @@ -241,7 +242,7 @@ private void validateRoomStart(Room room, UserPrincipal principal) {
// 라운드 수만큼 랜덤 Question 추출
private List<Question> prepareQuestions(Room room, Quiz quiz) {
Long quizId = quiz.getId();
Integer round = room.getGameSetting().getRound();
Integer round = room.getRound();
return quizService.getRandomQuestionsWithoutAnswer(quizId, round);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ public static RoomSettingResponse toRoomSettingResponse(Room room) {
return new RoomSettingResponse(
room.getRoomSetting().roomName(),
room.getRoomSetting().maxUserCount(),
room.getCurrentUserCnt(),
room.getRoomSetting().locked());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public enum CommonErrorCode implements ErrorCode {
INVALID_PAGINATION("E400006", HttpStatus.BAD_REQUEST, "page와 size는 1 이상의 정수여야 합니다."),
INTERNAL_SERVER_ERROR(
"E500001", HttpStatus.INTERNAL_SERVER_ERROR, "서버에러가 발생했습니다. 관리자에게 문의해주세요."),
INVALID_JSON_FORMAT("E400008", HttpStatus.BAD_REQUEST, "요청 형식이 올바르지 않습니다. JSON 문법을 확인해주세요.");
INVALID_JSON_FORMAT("E400008", HttpStatus.BAD_REQUEST, "요청 형식이 올바르지 않습니다. JSON 문법을 확인해주세요."),
LOCK_ACQUISITION_FAILED("E409003", HttpStatus.CONFLICT, "다른 요청이 작업 중입니다. 잠시 후 다시 시도해주세요.");

private final String code;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.f1.backend.global.lock;

import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

public class CustomSpringELParser {

private CustomSpringELParser() {}

public static Object getDynamicValue(
String[] parameterNames, Object[] args, String keyExpression) {

ExpressionParser parser = new SpelExpressionParser();

StandardEvaluationContext context = new StandardEvaluationContext();

for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}

return parser.parseExpression(keyExpression).getValue(context, Object.class);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package io.f1.backend.global.lock;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DistributedLock {

String prefix();

String key();

// 시간단위를 초로 변경
TimeUnit timeUnit() default TimeUnit.SECONDS;

// 락 점유를 위한 대기 시간
long waitTime() default 5L;

// 락 점유 시간
long leaseTime() default 3L;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package io.f1.backend.global.lock;

import io.f1.backend.global.exception.CustomException;
import io.f1.backend.global.exception.errorcode.CommonErrorCode;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {

private static final String LOCK_KEY_FORMAT = "lock:%s:{%s}";

private final RedissonClient redissonClient;

@Around("@annotation(distributedLock)")
public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock)
throws Throwable {

String key = getLockKey(joinPoint, distributedLock);

RLock rlock = redissonClient.getLock(key);

boolean acquired = false;
try {
acquired =
rlock.tryLock(
distributedLock.waitTime(),
distributedLock.leaseTime(),
distributedLock.timeUnit());

if (!acquired) {
log.warn("[DistributedLock] Lock acquisition failed: {}", key);
throw new CustomException(CommonErrorCode.LOCK_ACQUISITION_FAILED);
}
log.info("[DistributedLock] Lock acquired: {}", key);

return joinPoint.proceed();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw e;
} finally {
if (acquired && rlock.isHeldByCurrentThread()) {
rlock.unlock();
log.info("[DistributedLock] Lock released: {}", key);
}
}
}

private String getLockKey(ProceedingJoinPoint joinPoint, DistributedLock lockAnnotation) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();

String keyExpr = lockAnnotation.key();
String prefix = lockAnnotation.prefix();

Object keyValueObj =
CustomSpringELParser.getDynamicValue(
signature.getParameterNames(), joinPoint.getArgs(), keyExpr);
String keyValue = String.valueOf(keyValueObj);

return String.format(LOCK_KEY_FORMAT, prefix, keyValue);
}
}
2 changes: 2 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ spring:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
redisson:
config: classpath:redisson.yaml

jpa:
defer-datasource-initialization: true # 현재는 data.sql 에서 더미 유저 자동 추가를 위해 넣어뒀음.
Expand Down
2 changes: 2 additions & 0 deletions backend/src/main/resources/static/redisson.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
singleServerConfig:
address: "redis://${REDIS_HOST}:${REDIS_PORT}"
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.redis.testcontainers.RedisContainer;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
Expand All @@ -12,6 +15,7 @@
@Configuration
@Testcontainers
public class RedisTestContainerConfig {

@Container
public static RedisContainer redisContainer =
new RedisContainer(
Expand All @@ -22,8 +26,19 @@ public class RedisTestContainerConfig {
}

@Bean
RedisConnectionFactory redisConnectionFactory() {
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(
redisContainer.getHost(), redisContainer.getFirstMappedPort());
}

@Bean
public RedissonClient redissonClient() {
Config config = new Config();
String address =
String.format(
"redis://%s:%d",
redisContainer.getHost(), redisContainer.getFirstMappedPort());
config.useSingleServer().setAddress(address);
return Redisson.create(config);
}
}
Loading