Skip to content

Commit 7174f8e

Browse files
authored
✨ feat: gameStart 및 handlePlayerReady 레디스 분산락 기반 동시성 제어 적용 (#123)
* 🔧 chore: redisson v3.50.0 의존성 추가 * ✨ feat: Redis 분산락 AOP 기반 구현 * chore: Java 스타일 수정 * ✨ feat: gameStart와 handlePlayerReady에 분산 락 적용 - gameStart 중 플레이어 준비 상태 변경 방지 - 플레이어 준비 중 gameStart가 읽기 방지 * chore: Java 스타일 수정 * ✅ test: DistributedLockAspect 단위 테스트 추가 - 락 획득 성공/실패 케이스 테스트 - 인터럽트 예외 처리 테스트 - 락 해제 조건 검증 - 메서드 실행 중 예외 시 락 해제 정상 동작 확인 - SpEL 기반 동적 락 키 생성 검증 * chore: Java 스타일 수정 * ✅ test: 분산 락 통합 테스트 추가 - 멀티스레드에서 하나의 스레드만 락 획득 성공하는지 확인 - 단일 스레드 락 획득 정상 동작 테스트 - 서로 다른 키로 락 사용 시 동시 실행 가능 여부 검증 - gameStart와 handlePlayerReady 메서드 간 동일 roomId 락 충돌 테스트 - 테스트용 서비스 클래스 추가 * chore: Java 스타일 수정 * ✅ test: RedissonTestContainerConfig 추가 * chore: Java 스타일 수정 * 🐛 fix: RedissonConfig host, port 경로 재설정 * ✅ test: RoomServiceTests에 timeService 추가 * chore: Java 스타일 수정 * ♻️ chore: Redis 기본값 application.yml에 적용 * 🔧 chore: application.yml 원복 * 🐛 fix: redis host, port 설정이 되어 있지 않아 발생하는 오류 수정 * 🐛 fix: redis host, port 설정이 되어 있지 않아 발생하는 오류 수정 * chore: Java 스타일 수정 * 🗑️ remove: redissonConfig 파일 삭제 * 🗑️ remove: test application.yml 파일 빈 오버라이딩 허용 삭제 * 🗑️ remove: 플레이어 로그 삭제 * chore: Java 스타일 수정 * ♻️ refactor: 테스트 Order 제거 --------- Co-authored-by: github-actions <>
1 parent e926485 commit 7174f8e

File tree

13 files changed

+732
-7
lines changed

13 files changed

+732
-7
lines changed

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ jobs:
1111
PROJECT_DIR: backend
1212
KAKAO_CLIENT: ${{ secrets.KAKAO_CLIENT }}
1313
KAKAO_SECRET: ${{ secrets.KAKAO_SECRET }}
14+
REDIS_HOST: ${{ secrets.REDIS_HOST }}
15+
REDIS_PORT: ${{ secrets.REDIS_PORT }}
1416
steps:
1517
- uses: actions/checkout@v4
1618

backend/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies {
2929
implementation 'org.springframework.boot:spring-boot-starter-web'
3030
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
3131
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
32+
implementation 'org.redisson:redisson-spring-boot-starter:3.50.0'
3233
implementation 'org.springframework.boot:spring-boot-starter-websocket'
3334
implementation 'org.springframework.boot:spring-boot-starter-security'
3435
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

backend/src/main/java/io/f1/backend/domain/game/app/GameService.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import io.f1.backend.domain.game.dto.MessageType;
1616
import io.f1.backend.domain.game.dto.RoomEventType;
1717
import io.f1.backend.domain.game.dto.request.GameSettingChanger;
18-
import io.f1.backend.domain.game.dto.response.PlayerListResponse;
1918
import io.f1.backend.domain.game.event.GameCorrectAnswerEvent;
2019
import io.f1.backend.domain.game.event.GameTimeoutEvent;
2120
import io.f1.backend.domain.game.event.RoomUpdatedEvent;
@@ -31,6 +30,7 @@
3130
import io.f1.backend.global.exception.CustomException;
3231
import io.f1.backend.global.exception.errorcode.GameErrorCode;
3332
import io.f1.backend.global.exception.errorcode.RoomErrorCode;
33+
import io.f1.backend.global.lock.DistributedLock;
3434

3535
import lombok.RequiredArgsConstructor;
3636
import lombok.extern.slf4j.Slf4j;
@@ -59,6 +59,7 @@ public class GameService {
5959
private final RoomRepository roomRepository;
6060
private final ApplicationEventPublisher eventPublisher;
6161

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

6465
String destination = getDestination(roomId);
@@ -199,6 +200,7 @@ public void gameEnd(Room room) {
199200
destination, MessageType.ROOM_SETTING, toRoomSettingResponse(room));
200201
}
201202

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

204206
Room room = findRoom(roomId);
@@ -209,9 +211,8 @@ public void handlePlayerReady(Long roomId, String sessionId) {
209211

210212
String destination = getDestination(roomId);
211213

212-
PlayerListResponse playerListResponse = toPlayerListResponse(room);
213-
log.info(playerListResponse.toString());
214-
messageSender.sendBroadcast(destination, MessageType.PLAYER_LIST, playerListResponse);
214+
messageSender.sendBroadcast(
215+
destination, MessageType.PLAYER_LIST, toPlayerListResponse(room));
215216
}
216217

217218
public void changeGameSetting(
@@ -244,7 +245,7 @@ private void validateRoomStart(Room room, UserPrincipal principal) {
244245
// 라운드 수만큼 랜덤 Question 추출
245246
private List<Question> prepareQuestions(Room room, Quiz quiz) {
246247
Long quizId = quiz.getId();
247-
Integer round = room.getGameSetting().getRound();
248+
Integer round = room.getRound();
248249
return quizService.getRandomQuestionsWithoutAnswer(quizId, round);
249250
}
250251

backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java

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

1718
private final String code;
1819

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package io.f1.backend.global.lock;
2+
3+
import org.springframework.expression.ExpressionParser;
4+
import org.springframework.expression.spel.standard.SpelExpressionParser;
5+
import org.springframework.expression.spel.support.StandardEvaluationContext;
6+
7+
public class CustomSpringELParser {
8+
9+
private CustomSpringELParser() {}
10+
11+
public static Object getDynamicValue(
12+
String[] parameterNames, Object[] args, String keyExpression) {
13+
14+
ExpressionParser parser = new SpelExpressionParser();
15+
16+
StandardEvaluationContext context = new StandardEvaluationContext();
17+
18+
for (int i = 0; i < parameterNames.length; i++) {
19+
context.setVariable(parameterNames[i], args[i]);
20+
}
21+
22+
return parser.parseExpression(keyExpression).getValue(context, Object.class);
23+
}
24+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package io.f1.backend.global.lock;
2+
3+
import java.lang.annotation.Documented;
4+
import java.lang.annotation.ElementType;
5+
import java.lang.annotation.Retention;
6+
import java.lang.annotation.RetentionPolicy;
7+
import java.lang.annotation.Target;
8+
import java.util.concurrent.TimeUnit;
9+
10+
@Target(ElementType.METHOD)
11+
@Retention(RetentionPolicy.RUNTIME)
12+
@Documented
13+
public @interface DistributedLock {
14+
15+
String prefix();
16+
17+
String key();
18+
19+
// 시간단위를 초로 변경
20+
TimeUnit timeUnit() default TimeUnit.SECONDS;
21+
22+
// 락 점유를 위한 대기 시간
23+
long waitTime() default 5L;
24+
25+
// 락 점유 시간
26+
long leaseTime() default 3L;
27+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package io.f1.backend.global.lock;
2+
3+
import io.f1.backend.global.exception.CustomException;
4+
import io.f1.backend.global.exception.errorcode.CommonErrorCode;
5+
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
9+
import org.aspectj.lang.ProceedingJoinPoint;
10+
import org.aspectj.lang.annotation.Around;
11+
import org.aspectj.lang.annotation.Aspect;
12+
import org.aspectj.lang.reflect.MethodSignature;
13+
import org.redisson.api.RLock;
14+
import org.redisson.api.RedissonClient;
15+
import org.springframework.stereotype.Component;
16+
17+
@Slf4j
18+
@Aspect
19+
@Component
20+
@RequiredArgsConstructor
21+
public class DistributedLockAspect {
22+
23+
private static final String LOCK_KEY_FORMAT = "lock:%s:{%s}";
24+
25+
private final RedissonClient redissonClient;
26+
27+
@Around("@annotation(distributedLock)")
28+
public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock)
29+
throws Throwable {
30+
31+
String key = getLockKey(joinPoint, distributedLock);
32+
33+
RLock rlock = redissonClient.getLock(key);
34+
35+
boolean acquired = false;
36+
try {
37+
acquired =
38+
rlock.tryLock(
39+
distributedLock.waitTime(),
40+
distributedLock.leaseTime(),
41+
distributedLock.timeUnit());
42+
43+
if (!acquired) {
44+
log.warn("[DistributedLock] Lock acquisition failed: {}", key);
45+
throw new CustomException(CommonErrorCode.LOCK_ACQUISITION_FAILED);
46+
}
47+
log.info("[DistributedLock] Lock acquired: {}", key);
48+
49+
return joinPoint.proceed();
50+
} catch (InterruptedException e) {
51+
Thread.currentThread().interrupt();
52+
throw e;
53+
} finally {
54+
if (acquired && rlock.isHeldByCurrentThread()) {
55+
rlock.unlock();
56+
log.info("[DistributedLock] Lock released: {}", key);
57+
}
58+
}
59+
}
60+
61+
private String getLockKey(ProceedingJoinPoint joinPoint, DistributedLock lockAnnotation) {
62+
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
63+
64+
String keyExpr = lockAnnotation.key();
65+
String prefix = lockAnnotation.prefix();
66+
67+
Object keyValueObj =
68+
CustomSpringELParser.getDynamicValue(
69+
signature.getParameterNames(), joinPoint.getArgs(), keyExpr);
70+
String keyValue = String.valueOf(keyValueObj);
71+
72+
return String.format(LOCK_KEY_FORMAT, prefix, keyValue);
73+
}
74+
}

backend/src/main/resources/application.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ spring:
2020
redis:
2121
host: ${REDIS_HOST}
2222
port: ${REDIS_PORT}
23+
redisson:
24+
config: classpath:redisson.yaml
2325

2426
jpa:
2527
defer-datasource-initialization: true # 현재는 data.sql 에서 더미 유저 자동 추가를 위해 넣어뒀음.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
singleServerConfig:
2+
address: "redis://${REDIS_HOST}:${REDIS_PORT}"

backend/src/test/java/io/f1/backend/global/config/RedisTestContainerConfig.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import com.redis.testcontainers.RedisContainer;
44

5+
import org.redisson.Redisson;
6+
import org.redisson.api.RedissonClient;
7+
import org.redisson.config.Config;
58
import org.springframework.context.annotation.Bean;
69
import org.springframework.context.annotation.Configuration;
710
import org.springframework.data.redis.connection.RedisConnectionFactory;
@@ -12,6 +15,7 @@
1215
@Configuration
1316
@Testcontainers
1417
public class RedisTestContainerConfig {
18+
1519
@Container
1620
public static RedisContainer redisContainer =
1721
new RedisContainer(
@@ -22,8 +26,19 @@ public class RedisTestContainerConfig {
2226
}
2327

2428
@Bean
25-
RedisConnectionFactory redisConnectionFactory() {
29+
public RedisConnectionFactory redisConnectionFactory() {
2630
return new LettuceConnectionFactory(
2731
redisContainer.getHost(), redisContainer.getFirstMappedPort());
2832
}
33+
34+
@Bean
35+
public RedissonClient redissonClient() {
36+
Config config = new Config();
37+
String address =
38+
String.format(
39+
"redis://%s:%d",
40+
redisContainer.getHost(), redisContainer.getFirstMappedPort());
41+
config.useSingleServer().setAddress(address);
42+
return Redisson.create(config);
43+
}
2944
}

0 commit comments

Comments
 (0)