diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e488aca..65690414 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/backend/build.gradle b/backend/build.gradle index 692fafd2..603ccce5 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -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' diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java index 6e74acc4..182d5626 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java @@ -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; @@ -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; @@ -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); @@ -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); @@ -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( @@ -241,7 +242,7 @@ private void validateRoomStart(Room room, UserPrincipal principal) { // 라운드 수만큼 랜덤 Question 추출 private List prepareQuestions(Room room, Quiz quiz) { Long quizId = quiz.getId(); - Integer round = room.getGameSetting().getRound(); + Integer round = room.getRound(); return quizService.getRandomQuestionsWithoutAnswer(quizId, round); } diff --git a/backend/src/main/java/io/f1/backend/domain/game/mapper/RoomMapper.java b/backend/src/main/java/io/f1/backend/domain/game/mapper/RoomMapper.java index 48b76ad6..0880a4d8 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/mapper/RoomMapper.java +++ b/backend/src/main/java/io/f1/backend/domain/game/mapper/RoomMapper.java @@ -48,7 +48,6 @@ public static RoomSettingResponse toRoomSettingResponse(Room room) { return new RoomSettingResponse( room.getRoomSetting().roomName(), room.getRoomSetting().maxUserCount(), - room.getCurrentUserCnt(), room.getRoomSetting().locked()); } diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java index a998d9c5..2ec59944 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java @@ -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; diff --git a/backend/src/main/java/io/f1/backend/global/lock/CustomSpringELParser.java b/backend/src/main/java/io/f1/backend/global/lock/CustomSpringELParser.java new file mode 100644 index 00000000..24879497 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/lock/CustomSpringELParser.java @@ -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); + } +} diff --git a/backend/src/main/java/io/f1/backend/global/lock/DistributedLock.java b/backend/src/main/java/io/f1/backend/global/lock/DistributedLock.java new file mode 100644 index 00000000..93627874 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/lock/DistributedLock.java @@ -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; +} diff --git a/backend/src/main/java/io/f1/backend/global/lock/DistributedLockAspect.java b/backend/src/main/java/io/f1/backend/global/lock/DistributedLockAspect.java new file mode 100644 index 00000000..f0a3f96b --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/lock/DistributedLockAspect.java @@ -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); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index c14341b2..91c0d6f7 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -20,6 +20,8 @@ spring: redis: host: ${REDIS_HOST} port: ${REDIS_PORT} + redisson: + config: classpath:redisson.yaml jpa: defer-datasource-initialization: true # 현재는 data.sql 에서 더미 유저 자동 추가를 위해 넣어뒀음. diff --git a/backend/src/main/resources/static/redisson.yaml b/backend/src/main/resources/static/redisson.yaml new file mode 100644 index 00000000..a66ddc3b --- /dev/null +++ b/backend/src/main/resources/static/redisson.yaml @@ -0,0 +1,2 @@ +singleServerConfig: + address: "redis://${REDIS_HOST}:${REDIS_PORT}" \ No newline at end of file diff --git a/backend/src/test/java/io/f1/backend/global/config/RedisTestContainerConfig.java b/backend/src/test/java/io/f1/backend/global/config/RedisTestContainerConfig.java index e091b706..17ede401 100644 --- a/backend/src/test/java/io/f1/backend/global/config/RedisTestContainerConfig.java +++ b/backend/src/test/java/io/f1/backend/global/config/RedisTestContainerConfig.java @@ -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; @@ -12,6 +15,7 @@ @Configuration @Testcontainers public class RedisTestContainerConfig { + @Container public static RedisContainer redisContainer = new RedisContainer( @@ -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); + } } diff --git a/backend/src/test/java/io/f1/backend/global/lock/DistributedLockAspectTests.java b/backend/src/test/java/io/f1/backend/global/lock/DistributedLockAspectTests.java new file mode 100644 index 00000000..58b0c877 --- /dev/null +++ b/backend/src/test/java/io/f1/backend/global/lock/DistributedLockAspectTests.java @@ -0,0 +1,281 @@ +package io.f1.backend.global.lock; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.f1.backend.global.exception.CustomException; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; + +import java.util.concurrent.TimeUnit; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@ExtendWith(MockitoExtension.class) +class DistributedLockAspectTests { + + @InjectMocks DistributedLockAspect distributedLockAspect; + + @Mock RedissonClient redissonClient; + + @Mock RLock rLock; + + @Mock ProceedingJoinPoint joinPoint; + + @Mock MethodSignature methodSignature; + + @Mock DistributedLock distributedLockAnnotation; + + private final String TEST_PREFIX = "room"; + private final String TEST_KEY = "#roomId"; + private final String TEST_ROOM_ID = "12345"; + private final long WAIT_TIME = 5L; + private final long LEASE_TIME = 3L; + private final String EXPECTED_LOCK_KEY = "lock:room:{12345}"; + private final Object EXPECTED_RETURN_VALUE = "success"; + + @BeforeEach + void setUp() { + // 모든 테스트에서 공통으로 사용되는 기본 설정만 유지 + when(distributedLockAnnotation.prefix()).thenReturn(TEST_PREFIX); + when(distributedLockAnnotation.waitTime()).thenReturn(WAIT_TIME); + when(distributedLockAnnotation.leaseTime()).thenReturn(LEASE_TIME); + when(distributedLockAnnotation.timeUnit()).thenReturn(TimeUnit.SECONDS); + + when(joinPoint.getSignature()).thenReturn(methodSignature); + when(methodSignature.getParameterNames()).thenReturn(new String[] {"roomId"}); + when(joinPoint.getArgs()).thenReturn(new Object[] {TEST_ROOM_ID}); + } + + @DisplayName("락 획득 성공 시 정상적으로 메서드가 실행되고, 락 해제가 호출되는지 확인") + @Test + void testLock_Success() throws Throwable { + // Given + when(distributedLockAnnotation.key()).thenReturn(TEST_KEY); + when(redissonClient.getLock(EXPECTED_LOCK_KEY)).thenReturn(rLock); + when(rLock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)).thenReturn(true); + when(rLock.isHeldByCurrentThread()).thenReturn(true); + when(joinPoint.proceed()).thenReturn(EXPECTED_RETURN_VALUE); + + try (MockedStatic mockedParser = + Mockito.mockStatic(CustomSpringELParser.class)) { + mockedParser + .when( + () -> + CustomSpringELParser.getDynamicValue( + any(String[].class), any(Object[].class), anyString())) + .thenReturn(TEST_ROOM_ID); + + // When + Object result = distributedLockAspect.lock(joinPoint, distributedLockAnnotation); + + // Then + assertAll( + () -> assertEquals(EXPECTED_RETURN_VALUE, result), + () -> verify(redissonClient, times(1)).getLock(EXPECTED_LOCK_KEY), + () -> verify(rLock, times(1)).tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS), + () -> verify(joinPoint, times(1)).proceed(), + () -> verify(rLock, times(1)).isHeldByCurrentThread(), + () -> verify(rLock, times(1)).unlock()); + } + } + + @DisplayName("락 획득 실패 시 CustomException(LOCK_ACQUISITION_FAILED)이 발생하는지 확인") + @Test + void testLock_FailToAcquireLock() throws Throwable { + // Given + when(distributedLockAnnotation.key()).thenReturn(TEST_KEY); + when(redissonClient.getLock(EXPECTED_LOCK_KEY)).thenReturn(rLock); + // 무조건 false 반환 하도록 강제 + when(rLock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)).thenReturn(false); + + try (MockedStatic mockedParser = + Mockito.mockStatic(CustomSpringELParser.class)) { + mockedParser + .when( + () -> + CustomSpringELParser.getDynamicValue( + any(String[].class), any(Object[].class), anyString())) + .thenReturn(TEST_ROOM_ID); + + // When & Then + CustomException exception = + assertThrows( + CustomException.class, + () -> distributedLockAspect.lock(joinPoint, distributedLockAnnotation)); + + assertAll( + () -> assertNotNull(exception), + () -> assertEquals("다른 요청이 작업 중입니다. 잠시 후 다시 시도해주세요.", exception.getMessage()), + () -> verify(redissonClient, times(1)).getLock(EXPECTED_LOCK_KEY), + () -> verify(rLock, times(1)).tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS), + () -> verify(joinPoint, never()).proceed(), + () -> verify(rLock, never()).unlock()); + } + } + + @DisplayName("락 대기 중 인터럽트 발생 시 InterruptedException이 전파되는지 확인") + @Test + void testLock_InterruptedException() throws Throwable { + // Given + when(distributedLockAnnotation.key()).thenReturn(TEST_KEY); + when(redissonClient.getLock(EXPECTED_LOCK_KEY)).thenReturn(rLock); + when(rLock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)) + .thenThrow(new InterruptedException("Thread interrupted")); + + try (MockedStatic mockedParser = + Mockito.mockStatic(CustomSpringELParser.class)) { + mockedParser + .when( + () -> + CustomSpringELParser.getDynamicValue( + any(String[].class), any(Object[].class), anyString())) + .thenReturn(TEST_ROOM_ID); + + // When & Then + InterruptedException exception = + assertThrows( + InterruptedException.class, + () -> distributedLockAspect.lock(joinPoint, distributedLockAnnotation)); + + assertAll( + () -> assertNotNull(exception), + () -> assertEquals("Thread interrupted", exception.getMessage()), + () -> verify(redissonClient, times(1)).getLock(EXPECTED_LOCK_KEY), + () -> verify(rLock, times(1)).tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS), + () -> verify(joinPoint, never()).proceed(), + () -> verify(rLock, never()).unlock()); + } + } + + @DisplayName("락을 획득하지 않은 스레드가 unlock하지 않는지 확인") + @Test + void testLock_NotHeldByCurrentThread() throws Throwable { + // Given + when(distributedLockAnnotation.key()).thenReturn(TEST_KEY); + when(redissonClient.getLock(EXPECTED_LOCK_KEY)).thenReturn(rLock); + when(rLock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)).thenReturn(true); + when(rLock.isHeldByCurrentThread()).thenReturn(false); // 현재 스레드가 락을 보유하지 않도록 강제 + when(joinPoint.proceed()).thenReturn(EXPECTED_RETURN_VALUE); + + try (MockedStatic mockedParser = + Mockito.mockStatic(CustomSpringELParser.class)) { + mockedParser + .when( + () -> + CustomSpringELParser.getDynamicValue( + any(String[].class), any(Object[].class), anyString())) + .thenReturn(TEST_ROOM_ID); + + // When + Object result = distributedLockAspect.lock(joinPoint, distributedLockAnnotation); + + // Then + assertAll( + () -> assertEquals(EXPECTED_RETURN_VALUE, result), + () -> verify(redissonClient, times(1)).getLock(EXPECTED_LOCK_KEY), + () -> verify(rLock, times(1)).tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS), + () -> verify(joinPoint, times(1)).proceed(), + () -> verify(rLock, times(1)).isHeldByCurrentThread(), + () -> verify(rLock, never()).unlock() // unlock 호출되지 않아야 함 + ); + } + } + + @DisplayName("메서드 실행 중 예외 발생 시에도 락이 정상적으로 해제되는지 확인") + @Test + void testLock_ExceptionDuringExecution() throws Throwable { + // Given + RuntimeException testException = new RuntimeException("Test exception"); + when(distributedLockAnnotation.key()).thenReturn(TEST_KEY); + when(redissonClient.getLock(EXPECTED_LOCK_KEY)).thenReturn(rLock); + when(rLock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)).thenReturn(true); + when(rLock.isHeldByCurrentThread()).thenReturn(true); + when(joinPoint.proceed()).thenThrow(testException); + + try (MockedStatic mockedParser = + Mockito.mockStatic(CustomSpringELParser.class)) { + mockedParser + .when( + () -> + CustomSpringELParser.getDynamicValue( + any(String[].class), any(Object[].class), anyString())) + .thenReturn(TEST_ROOM_ID); + + // When & Then + RuntimeException exception = + assertThrows( + RuntimeException.class, + () -> distributedLockAspect.lock(joinPoint, distributedLockAnnotation)); + + assertAll( + () -> assertEquals(testException, exception), + () -> verify(redissonClient, times(1)).getLock(EXPECTED_LOCK_KEY), + () -> verify(rLock, times(1)).tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS), + () -> verify(joinPoint, times(1)).proceed(), + () -> verify(rLock, times(1)).isHeldByCurrentThread(), + () -> verify(rLock, times(1)).unlock() // 예외 발생해도 unlock 호출되어야 함 + ); + } + } + + @DisplayName("SpEL 파싱을 통한 동적 키 생성이 정상적으로 작동하는지 확인") + @Test + void testGetLockKey_WithSpELExpression() throws Throwable { + // Given + String roomId = "room123"; + String expectedLockKey = "lock:room:{room123}"; + + when(distributedLockAnnotation.prefix()).thenReturn("room"); + when(distributedLockAnnotation.key()).thenReturn("#roomId"); + when(methodSignature.getParameterNames()).thenReturn(new String[] {"roomId"}); + when(joinPoint.getArgs()).thenReturn(new Object[] {roomId}); + + when(redissonClient.getLock(expectedLockKey)).thenReturn(rLock); + when(rLock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)).thenReturn(true); + when(rLock.isHeldByCurrentThread()).thenReturn(true); + when(joinPoint.proceed()).thenReturn(EXPECTED_RETURN_VALUE); + + try (MockedStatic mockedParser = + Mockito.mockStatic(CustomSpringELParser.class)) { + mockedParser + .when( + () -> + CustomSpringELParser.getDynamicValue( + new String[] {"roomId"}, + new Object[] {roomId}, + "#roomId")) + .thenReturn(roomId); + + // When + Object result = distributedLockAspect.lock(joinPoint, distributedLockAnnotation); + + // Then + assertAll( + () -> assertEquals(EXPECTED_RETURN_VALUE, result), + () -> verify(redissonClient, times(1)).getLock(expectedLockKey), + () -> verify(rLock, times(1)).unlock()); + } + } +} diff --git a/backend/src/test/java/io/f1/backend/global/lock/DistributedLockIntegrationTest.java b/backend/src/test/java/io/f1/backend/global/lock/DistributedLockIntegrationTest.java new file mode 100644 index 00000000..aa369548 --- /dev/null +++ b/backend/src/test/java/io/f1/backend/global/lock/DistributedLockIntegrationTest.java @@ -0,0 +1,294 @@ +package io.f1.backend.global.lock; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.f1.backend.global.config.RedisTestContainerConfig; +import io.f1.backend.global.exception.CustomException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.stereotype.Service; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +@SpringBootTest +@Import({RedisTestContainerConfig.class, DistributedLockIntegrationTest.TestLockService.class}) +class DistributedLockIntegrationTest { + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", RedisTestContainerConfig.redisContainer::getHost); + registry.add( + "spring.data.redis.port", + () -> RedisTestContainerConfig.redisContainer.getFirstMappedPort()); + } + + @Autowired private TestLockService testLockService; + + private final Long ROOM_ID = 1L; + + @DisplayName("멀티스레드 환경에서 하나의 스레드만 락 획득에 성공하고, 나머지는 모두 실패하는지 검증") + @Test + void testDistributedLock_WhenMultipleThreads_OnlyOneSuccess() throws Exception { + // Given: 5개의 쓰레드로 구성된 고정된 쓰레드 풀과 동기화를 위한 CountDownLatch 준비 + int threadCount = 5; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // When: 여러 스레드가 동시에 락 획득 시도 + for (int i = 0; i < threadCount; i++) { + executorService.submit( + () -> { + try { + testLockService.executeWithLock(ROOM_ID); + successCount.incrementAndGet(); + } catch (IllegalStateException e) { + // 락 획득 실패로 인한 예외는 예상된 동작 + failCount.incrementAndGet(); + } catch (Exception e) { + // 기타 예외는 실패로 간주 + failCount.incrementAndGet(); + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + // Then: 하나의 스레드만 락 획득에 성공하고, 나머지는 모두 실패하는지 검증 + assertAll( + () -> assertEquals(1, successCount.get(), "락 획득에 성공한 스레드는 1개여야 합니다"), + () -> + assertEquals( + threadCount - 1, + failCount.get(), + "락 획득에 실패한 스레드는 " + (threadCount - 1) + "개여야 합니다")); + } + + @DisplayName("단일 스레드에서 락 획득이 정상적으로 동작하는지 검증") + @Test + void testDistributedLock_SingleThread_Success() { + // Given & When & Then + String result = testLockService.executeWithLock(ROOM_ID); + assertEquals("락 획득 및 실행 성공 : " + ROOM_ID, result); + } + + @DisplayName("다른 키로 락을 사용할 때 동시 실행이 가능한지 검증") + @Test + void testDistributedLock_DifferentKeys_BothSuccess() throws Exception { + // Given + int threadCount = 2; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + // When: 서로 다른 키로 락 획득 시도 + executorService.submit( + () -> { + try { + testLockService.executeWithLock(1L); + successCount.incrementAndGet(); + } catch (CustomException e) { + failCount.incrementAndGet(); + e.getMessage(); + } finally { + latch.countDown(); + } + }); + + executorService.submit( + () -> { + try { + testLockService.executeWithLock(2L); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + e.getMessage(); + } finally { + latch.countDown(); + } + }); + + latch.await(); + executorService.shutdown(); + + // Then: 서로 다른 키이므로 둘 다 성공해야 함 + assertAll( + () -> assertEquals(2, successCount.get(), "서로 다른 키로 락을 사용하면 둘 다 성공해야 한다"), + () -> assertEquals(0, failCount.get(), "락 획득을 실패한 스레드는 없어야 한다")); + } + + @DisplayName("gameStart 중에는 handlePlayerReady가 동일한 roomId로 락을 획득할 수 없어야 한다") + @Test + void testHandlePlayerReadyFailsWhenGameStartHoldsLock() throws Exception { + // Given + ExecutorService executorService = Executors.newFixedThreadPool(2); + CountDownLatch latch = new CountDownLatch(2); + CountDownLatch gameStartLocked = new CountDownLatch(1); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + AtomicReference successMethod = new AtomicReference<>(); + + // Thread A: gameStart 락 선점 + executorService.submit( + () -> { + try { + testLockService.gameStartSimulate(ROOM_ID, gameStartLocked); + successMethod.set("gameStart"); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + e.getMessage(); + } finally { + latch.countDown(); + } + }); + + // Thread A: gameStart가 락 획득한 후에 handlePlayerReady 시도 + executorService.submit( + () -> { + try { + gameStartLocked.await(); + testLockService.handlePlayerReadySimulate(ROOM_ID, gameStartLocked); + successMethod.set("handlePlayerReady"); + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + e.getMessage(); + } finally { + latch.countDown(); + } + }); + + latch.await(); + executorService.shutdown(); + + // Then + assertAll( + () -> assertEquals(1, successCount.get(), "성공한 스레드는 1개여야 한다."), + () -> assertEquals(1, failCount.get(), "실패한 스레드는 1개여야 한다."), + () -> assertEquals("gameStart", successMethod.get(), "gameStart 락을 획득해야 한다")); + } + + @DisplayName("handlePlayerReady 중에는 gameStart가 동일한 roomId로 락을 획득할 수 없어야 한다") + @Test + void testGameStartFailsWhenHandlePlayerReadyHoldsLock() throws Exception { + // Given + ExecutorService executorService = Executors.newFixedThreadPool(2); + CountDownLatch latch = new CountDownLatch(2); + CountDownLatch handlePlayerReadyLocked = + new CountDownLatch(1); // handlePlayerReady가 락 획득 신호용 + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + AtomicReference successMethod = new AtomicReference<>(); + + // Thread B: handlePlayerReady가 락 먼저 획득 + executorService.submit( + () -> { + try { + testLockService.handlePlayerReadySimulate(ROOM_ID, handlePlayerReadyLocked); + successMethod.set("handlePlayerReady"); + successCount.incrementAndGet(); + } catch (CustomException e) { + failCount.incrementAndGet(); + e.getMessage(); + } finally { + handlePlayerReadyLocked.countDown(); // 락 획득 알림 + latch.countDown(); + } + }); + + // Thread A: handlePlayerReady가 락 획득한 후에 gameStart 시도 + executorService.submit( + () -> { + try { + handlePlayerReadyLocked.await(); // handlePlayerReady 락 획득 신호 기다림 + testLockService.gameStartSimulate(ROOM_ID, handlePlayerReadyLocked); + successMethod.set("gameStart"); + successCount.incrementAndGet(); + } catch (CustomException e) { + failCount.incrementAndGet(); + e.getMessage(); + } catch (Exception e) { + failCount.incrementAndGet(); + + } finally { + latch.countDown(); + } + }); + + latch.await(); + executorService.shutdown(); + + // Then + assertAll( + () -> assertEquals(1, successCount.get(), "성공한 스레드는 1개여야 한다."), + () -> assertEquals(1, failCount.get(), "실패한 스레드는 1개여야 한다."), + () -> + assertEquals( + "handlePlayerReady", + successMethod.get(), + "handlePlayerReady만 락을 획득해야 한다")); + } + + /** 테스트용 서비스 클래스 */ + @Service + static class TestLockService { + + @DistributedLock(prefix = "room", key = "#roomId", waitTime = 0) + public String executeWithLock(Long roomId) { + // 락이 획득된 상태에서 실행되는 비즈니스 로직 시뮬레이션 + try { + Thread.sleep(100); // 짧은 작업 시간 시뮬레이션 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("작업 중단", e); + } + return "락 획득 및 실행 성공 : " + roomId; + } + + @DistributedLock(prefix = "room", key = "#roomId", waitTime = 0) + public String gameStartSimulate(Long roomId, CountDownLatch lockAcquiredSignal) { + String threadName = Thread.currentThread().getName(); + lockAcquiredSignal.countDown(); + try { + Thread.sleep(300); // 락 점유 시간 시뮬레이션 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return "gameStart 실행 스레드 : " + threadName; + } + + @DistributedLock(prefix = "room", key = "#roomId", waitTime = 0) + public String handlePlayerReadySimulate(Long roomId, CountDownLatch lockAcquiredSignal) { + String threadName = Thread.currentThread().getName(); + lockAcquiredSignal.countDown(); + try { + Thread.sleep(300); // 락 점유 시간 시뮬레이션 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return "handlePlayerReady 실행 스레드 : " + threadName; + } + } +} diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index bf62a914..7b21edf1 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -1,4 +1,5 @@ spring: + datasource: url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_LOWER=TRUE username: sa