Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
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 @@ -31,6 +31,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 +60,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 All @@ -82,6 +84,9 @@ public void gameStart(Long roomId, UserPrincipal principal) {

timerService.startTimer(room, START_DELAY);

PlayerListResponse playerListResponse = toPlayerListResponse(room);
log.info(playerListResponse.toString());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

디버깅용 로그인가요 ㅎㅎ

Copy link
Collaborator Author

@LimKangHyun LimKangHyun Jul 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 gameStart에 락을 안걸었을 때, gameStart가 플레이어의 레디를 전부 읽고나서, handlePlayerReady가 동작해서 실제로 false인 상태로 게임이 시작되는지 보려고 적어놨었습니다!
지금은 필요없을것 같은데 로그 정리해서 커밋해 놓았습니다!


messageSender.sendBroadcast(
destination, MessageType.GAME_START, toGameStartResponse(questions));
messageSender.sendBroadcast(
Expand Down Expand Up @@ -190,6 +195,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 Down Expand Up @@ -218,9 +224,7 @@ public void changeGameSetting(
broadcastGameSetting(room);

RoomUpdatedEvent roomUpdatedEvent =
new RoomUpdatedEvent(
room,
quizService.getQuizWithQuestionsById(room.getGameSetting().getQuizId()));
new RoomUpdatedEvent(room, quizService.getQuizWithQuestionsById(room.getQuizId()));

eventPublisher.publishEvent(roomUpdatedEvent);
}
Expand All @@ -242,7 +246,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
@@ -0,0 +1,27 @@
package io.f1.backend.global.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

@Value("${spring.data.redis.host}")
private String redisHost;

@Value("${spring.data.redis.port}")
private int redisPort;

@Bean
public RedissonClient redissonClient() {
Config config = new Config();
final String address = "redis://%s:%d".formatted(redisHost, redisPort);

config.useSingleServer().setAddress(address);
return Redisson.create(config);
}
}
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,7 @@ class RoomServiceTests {
void setUp() {
MockitoAnnotations.openMocks(this); // @Mock 어노테이션이 붙은 필드들을 초기화합니다.

roomService =
new RoomService(
timerService, quizService, roomRepository, eventPublisher, messageSender);
roomService = new RoomService(quizService, roomRepository, eventPublisher, messageSender);

SecurityContextHolder.clearContext();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package io.f1.backend.global.config;

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.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Configuration
@Testcontainers
public class RedissonTestContainerConfig {

@Container
public static RedisContainer redisContainer =
new RedisContainer(
RedisContainer.DEFAULT_IMAGE_NAME.withTag(RedisContainer.DEFAULT_TAG));

static {
redisContainer.start();
}

@Bean
public RedissonClient redissonClient() {
Config config = new Config();
String address =
String.format(
"redis://%s:%d",
RedissonTestContainerConfig.redisContainer.getHost(),
RedissonTestContainerConfig.redisContainer.getFirstMappedPort());

config.useSingleServer().setAddress(address);

return Redisson.create(config);
}
}
Loading
Loading