From ec1e96e017383a8a8b4cfa834e3b0cc36f44a43c Mon Sep 17 00:00:00 2001 From: simhani1 Date: Sun, 17 Aug 2025 01:09:57 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20redisConfig=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../planetrush/core/config/RedisConfig.java | 28 ++----------------- .../member/service/MemberServiceImpl.java | 3 +- 2 files changed, 4 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/planetrush/planetrush/core/config/RedisConfig.java b/src/main/java/com/planetrush/planetrush/core/config/RedisConfig.java index 64986ec..7c9f162 100644 --- a/src/main/java/com/planetrush/planetrush/core/config/RedisConfig.java +++ b/src/main/java/com/planetrush/planetrush/core/config/RedisConfig.java @@ -1,25 +1,16 @@ package com.planetrush.planetrush.core.config; -import java.time.Duration; - import org.springframework.beans.factory.annotation.Value; -import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.cache.RedisCacheConfiguration; -import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; -import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; -import org.springframework.data.redis.serializer.RedisSerializationContext; -import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.transaction.annotation.EnableTransactionManagement; -import com.planetrush.planetrush.member.service.dto.GetMyProgressAvgDto; - @Configuration @EnableCaching @EnableRedisRepositories @@ -38,22 +29,9 @@ public RedisConnectionFactory redisConnectionFactory() { } @Bean - public RedisTemplate redisTemplate() { - RedisTemplate template = new RedisTemplate<>(); + public RedisTemplate redisTemplate() { + StringRedisTemplate template = new StringRedisTemplate(); template.setConnectionFactory(redisConnectionFactory()); return template; } - - @Bean - public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { - RedisCacheConfiguration cacheConfig = RedisCacheConfiguration.defaultCacheConfig() - .entryTtl(Duration.ofHours(24)) - .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) - .serializeValuesWith( - RedisSerializationContext.SerializationPair.fromSerializer(new Jackson2JsonRedisSerializer<>( - GetMyProgressAvgDto.class))); - return RedisCacheManager.builder(redisConnectionFactory) - .cacheDefaults(cacheConfig) - .build(); - } } \ No newline at end of file diff --git a/src/main/java/com/planetrush/planetrush/member/service/MemberServiceImpl.java b/src/main/java/com/planetrush/planetrush/member/service/MemberServiceImpl.java index 569e016..0498a1a 100644 --- a/src/main/java/com/planetrush/planetrush/member/service/MemberServiceImpl.java +++ b/src/main/java/com/planetrush/planetrush/member/service/MemberServiceImpl.java @@ -3,7 +3,6 @@ import java.util.List; import java.util.stream.Collectors; -import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -54,7 +53,7 @@ public List getPlanetCollections(CollectionSearchCond searc *

이 메서드는 반환값을 캐싱하여 관리합니다.

*

캐시 미스가 발생하는 경우에만 플라스크 서버로 API 요청을 전송하여 새로운 데이터로 캐시에 저장합니다.

*/ - @Cacheable(value = "myProgressAvgCache", key = "#memberId") + // TODO: 카페인 캐시를 이용하도록 변경 @Override public GetMyProgressAvgDto getMyProgressAvgPer(Long memberId) { Member member = memberRepository.findById(memberId) From 2031d0ddc4ec0a5902a28fe6fcc934028b96bbfc Mon Sep 17 00:00:00 2001 From: simhani1 Date: Sun, 17 Aug 2025 03:15:19 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=ED=96=89=EC=84=B1=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20api=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...catedRegisterResidentRequestException.java | 19 +++++ .../planet/service/PlanetServiceImpl.java | 15 ++++ src/main/resources/application-test.yml | 78 +++++++++++++++++++ .../planetrush/IntegrationTest.java | 7 ++ .../PlanetrushApplicationTests.java | 2 + .../planet/PlanetIntegrationTest.java | 77 +++++++++++++----- 6 files changed, 178 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/planetrush/planetrush/planet/exception/DuplicatedRegisterResidentRequestException.java create mode 100644 src/main/resources/application-test.yml diff --git a/src/main/java/com/planetrush/planetrush/planet/exception/DuplicatedRegisterResidentRequestException.java b/src/main/java/com/planetrush/planetrush/planet/exception/DuplicatedRegisterResidentRequestException.java new file mode 100644 index 0000000..ff74a9a --- /dev/null +++ b/src/main/java/com/planetrush/planetrush/planet/exception/DuplicatedRegisterResidentRequestException.java @@ -0,0 +1,19 @@ +package com.planetrush.planetrush.planet.exception; + +public class DuplicatedRegisterResidentRequestException extends RuntimeException { + + public DuplicatedRegisterResidentRequestException() { + } + + public DuplicatedRegisterResidentRequestException(String message) { + super(message); + } + + public DuplicatedRegisterResidentRequestException(String message, Throwable cause) { + super(message, cause); + } + + public DuplicatedRegisterResidentRequestException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/planetrush/planetrush/planet/service/PlanetServiceImpl.java b/src/main/java/com/planetrush/planetrush/planet/service/PlanetServiceImpl.java index 860f582..4ec4cbb 100644 --- a/src/main/java/com/planetrush/planetrush/planet/service/PlanetServiceImpl.java +++ b/src/main/java/com/planetrush/planetrush/planet/service/PlanetServiceImpl.java @@ -4,8 +4,11 @@ import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,6 +19,7 @@ import com.planetrush.planetrush.planet.domain.Planet; import com.planetrush.planetrush.planet.domain.Resident; import com.planetrush.planetrush.planet.domain.image.DefaultPlanetImg; +import com.planetrush.planetrush.planet.exception.DuplicatedRegisterResidentRequestException; import com.planetrush.planetrush.planet.exception.InvalidStartDateException; import com.planetrush.planetrush.planet.exception.PlanetNotFoundException; import com.planetrush.planetrush.planet.exception.ResidentAlreadyExistsException; @@ -48,6 +52,8 @@ @RequiredArgsConstructor public class PlanetServiceImpl implements PlanetService { + private final RedisTemplate redisTemplate; + private final MemberRepository memberRepository; private final PlanetRepository planetRepository; private final ResidentRepository residentRepository; @@ -304,6 +310,15 @@ public void registerResident(PlanetSubscriptionDto dto) { .orElseThrow(() -> new MemberNotFoundException("Member not found with ID: " + dto.getMemberId())); Planet planet = planetRepository.findByIdForUpdate(dto.getPlanetId()) .orElseThrow(() -> new PlanetNotFoundException("Planet not found with ID: " + dto.getPlanetId())); + ValueOperations ops = redisTemplate.opsForValue(); + Boolean isFirstRequest = ops.setIfAbsent( + "idemp:register-resident:" + member.getId() + ":" + planet.getId(), + "true", 10, + TimeUnit.SECONDS); + if (Boolean.FALSE.equals(isFirstRequest)) { + log.error("[REGISTER_RESIDENT] 중복 요청 발생, 회원={}, 행성={}", member.getId(), planet.getId()); + throw new DuplicatedRegisterResidentRequestException(); + } if(residentRepositoryCustom.getReadyAndInProgressResidents(member) >= 9) { throw new ResidentOverflowException("resident count overflow"); } diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..5b246a0 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,78 @@ +spring: + servlet: + multipart: + max-file-size: 50MB + config: + import: optional:file:.env[.properties] + datasource: + url: jdbc:mysql://localhost:3306/planetrush?serverTimezone=UTC&useUnicode=yes&characterEncoding=UTF-8 + driver-class-name: com.mysql.cj.jdbc.Driver + username: root + password: root + + data: + redis: + host: localhost + port: 6379 + + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + format_sql: true + +logging: + level: + org: + springframework: + web: DEBUG + com: + example: DEBUG + +jwt: + secret: + key: ${JWT_SECRET_KEY} + issuer: ${JWT_ISSUER} + salt: ${JWT_SALT} + access-token: + expiretime: ${JWT_ACCESS_TOKEN_EXPIRETIME} + refresh-token: + expiretime: ${JWT_REFRESH_TOKEN_EXPIRETIME} + +kakao: + secret: + key: ${KAKAO_SECRET_KEY} + loginurl: ${KAKAO_LOGIN_URL} + logouturl: ${KAKAO_LOGOUT_URL} + +cloud: + aws: + credentials: + access-key: ${AWS_S3_ACCESS_KEY} + secret-key: ${AWS_S3_SECRET_KEY} + region: + static: ${AWS_S3_BUCKET_REGION} + stack: + auto: false + s3: + bucket: ${AWS_S3_BUCKET_NAME} + +flask: + verifyurl: ${FLASK_VERIFY_URL} + progressavgurl: ${FLASK_PROGRESS_AVG_URL} + +notification: + mattermost: + enabled: false # mmSender를 사용할 지 여부, false면 알림이 오지 않는다 + webhook-url: ${MATTERMOST_WEBHOOK_URL} # 위의 Webhook URL을 기입 + color: ${MATTERMOST_COLOR} # attachment에 왼쪽 사이드 컬러. default=red + author-name: ${MATTERMOST_AUTHOR_NAME} # attachment의 상단에 나오는 이름 + author-icon: ${MATTERMOST_AUTHOR_ICON} # author-icon 왼쪽에 나올 아이콘의 url링크 + footer: # attachment에 하단에 나올 부분. default=현재 시간 + +scheduled-task: + change-planet-status-cron: 0 0 0 * * ? + ban-if-last-verification-older-than-three-days: 0 5 0 * * ? + planet-complete-destroy: 0 10 0 * * ? \ No newline at end of file diff --git a/src/test/java/com/planetrush/planetrush/IntegrationTest.java b/src/test/java/com/planetrush/planetrush/IntegrationTest.java index 94de8de..66c2109 100644 --- a/src/test/java/com/planetrush/planetrush/IntegrationTest.java +++ b/src/test/java/com/planetrush/planetrush/IntegrationTest.java @@ -1,7 +1,14 @@ package com.planetrush.planetrush; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public abstract class IntegrationTest { + + @LocalServerPort + private int port; + } diff --git a/src/test/java/com/planetrush/planetrush/PlanetrushApplicationTests.java b/src/test/java/com/planetrush/planetrush/PlanetrushApplicationTests.java index 2f6abc7..67c9b04 100644 --- a/src/test/java/com/planetrush/planetrush/PlanetrushApplicationTests.java +++ b/src/test/java/com/planetrush/planetrush/PlanetrushApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles(profiles = "test") class PlanetrushApplicationTests { @Test diff --git a/src/test/java/com/planetrush/planetrush/planet/PlanetIntegrationTest.java b/src/test/java/com/planetrush/planetrush/planet/PlanetIntegrationTest.java index 9841a85..bc6db75 100644 --- a/src/test/java/com/planetrush/planetrush/planet/PlanetIntegrationTest.java +++ b/src/test/java/com/planetrush/planetrush/planet/PlanetIntegrationTest.java @@ -3,10 +3,13 @@ import static org.assertj.core.api.Assertions.*; import java.util.List; +import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.IntStream; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -25,33 +28,21 @@ import com.planetrush.planetrush.planet.domain.PlanetStatus; import com.planetrush.planetrush.planet.domain.Resident; import com.planetrush.planetrush.planet.exception.PlanetDestroyedException; -import com.planetrush.planetrush.planet.repository.DefaultPlanetImgRepository; import com.planetrush.planetrush.planet.repository.PlanetRepository; import com.planetrush.planetrush.planet.repository.ResidentRepository; -import com.planetrush.planetrush.planet.repository.custom.PlanetRepositoryCustom; -import com.planetrush.planetrush.planet.repository.custom.ResidentRepositoryCustom; import com.planetrush.planetrush.planet.service.PlanetServiceImpl; import com.planetrush.planetrush.planet.service.dto.PlanetSubscriptionDto; -import com.planetrush.planetrush.verification.repository.custom.VerificationRecordRepositoryCustom; public class PlanetIntegrationTest extends IntegrationTest { @Autowired - private PlanetServiceImpl planetService; + PlanetServiceImpl planetService; @Autowired - private MemberRepository memberRepository; + MemberRepository memberRepository; @Autowired - private PlanetRepository planetRepository; + PlanetRepository planetRepository; @Autowired - private ResidentRepository residentRepository; - @Autowired - private DefaultPlanetImgRepository defaultPlanetImgRepository; - @Autowired - private PlanetRepositoryCustom planetRepositoryCustom; - @Autowired - private ResidentRepositoryCustom residentRepositoryCustom; - @Autowired - private VerificationRecordRepositoryCustom verificationRecordRepositoryCustom; + ResidentRepository residentRepository; @BeforeEach void setUp() { @@ -72,9 +63,9 @@ void clear() { memberRepository.deleteAll(); } - @DisplayName("가입이 탈퇴보다 먼저 요청될 경우 한 명의 거주자만 남는다.") + @DisplayName("가입 및 탈퇴 요청의 순서가 보장된다.") @RepeatedTest(100) - void should_() throws InterruptedException { + void should_guarantee_order_between_register_and_delete_requests() throws InterruptedException { // GIVEN List members = memberRepository.findAll(); Member member1 = members.get(0); @@ -173,9 +164,9 @@ void should_leave_one_resident_when_register_before_delete() { assertThat(planet.getStatus()).isEqualTo(PlanetStatus.READY); } - @DisplayName("행성 탈퇴가 가입보다 먼저 요청될 경우 한 명의 거주자만 남는다.") + @DisplayName("행성 탈퇴가 가입보다 먼저 요청될 경우 거주자는 0명이고 행성은 파괴된다.") @Test - void register_and_delete() throws InterruptedException { + void should_destroy_planet_when_delete_before_register() { // GIVEN List members = memberRepository.findAll(); Member member1 = members.get(0); @@ -205,4 +196,50 @@ void register_and_delete() throws InterruptedException { assertThat(planet.getCurrentParticipants()).isEqualTo(0); assertThat(planet.getStatus()).isEqualTo(PlanetStatus.DESTROYED); } + + @DisplayName("10초 이내로 중복된 행성 가입 요청은 멱등성을 보장한다.") + @Test + void should_ensure_idempotency_when_duplicate_register_resident_within_ten_seconds() { + // GIVEN + List members = memberRepository.findAll(); + Member member = members.get(1); + + List planets = planetRepository.findAll(); + Planet planet = planets.get(0); + + PlanetSubscriptionDto registerDto = PlanetSubscriptionDto.builder() + .planetId(planet.getId()) + .memberId(member.getId()) + .build(); + + // WHEN + int loop = 10; + ExecutorService executor = Executors.newFixedThreadPool(loop); + CountDownLatch startLatch = new CountDownLatch(1); + Callable task = () -> { + startLatch.await(); + planetService.registerResident(registerDto); + return null; + }; + + List> futures = IntStream.range(0, loop) + .mapToObj(i -> executor.submit(task)) + .toList(); + startLatch.countDown(); + + int successCnt = 0; + int failedCnt = 0; + for (Future future : futures) { + try { + future.get(); + successCnt++; + } catch (Exception e) { + failedCnt++; + } + } + + // THEN + assertThat(successCnt).isEqualTo(1); + assertThat(failedCnt).isEqualTo(loop - 1); + } } \ No newline at end of file From eda1da68d82246f5d98180c71fbfc06d81d692f9 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Sun, 17 Aug 2025 03:21:20 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=ED=96=89=EC=84=B1=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20api=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...licatedDeleteResidentRequestException.java | 19 ++++++++++++++++ .../planet/service/PlanetServiceImpl.java | 22 ++++++++++++++----- 2 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/planetrush/planetrush/planet/exception/DuplicatedDeleteResidentRequestException.java diff --git a/src/main/java/com/planetrush/planetrush/planet/exception/DuplicatedDeleteResidentRequestException.java b/src/main/java/com/planetrush/planetrush/planet/exception/DuplicatedDeleteResidentRequestException.java new file mode 100644 index 0000000..6b9352e --- /dev/null +++ b/src/main/java/com/planetrush/planetrush/planet/exception/DuplicatedDeleteResidentRequestException.java @@ -0,0 +1,19 @@ +package com.planetrush.planetrush.planet.exception; + +public class DuplicatedDeleteResidentRequestException extends RuntimeException { + + public DuplicatedDeleteResidentRequestException() { + } + + public DuplicatedDeleteResidentRequestException(String message) { + super(message); + } + + public DuplicatedDeleteResidentRequestException(String message, Throwable cause) { + super(message, cause); + } + + public DuplicatedDeleteResidentRequestException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/planetrush/planetrush/planet/service/PlanetServiceImpl.java b/src/main/java/com/planetrush/planetrush/planet/service/PlanetServiceImpl.java index 4ec4cbb..4891d6d 100644 --- a/src/main/java/com/planetrush/planetrush/planet/service/PlanetServiceImpl.java +++ b/src/main/java/com/planetrush/planetrush/planet/service/PlanetServiceImpl.java @@ -19,6 +19,7 @@ import com.planetrush.planetrush.planet.domain.Planet; import com.planetrush.planetrush.planet.domain.Resident; import com.planetrush.planetrush.planet.domain.image.DefaultPlanetImg; +import com.planetrush.planetrush.planet.exception.DuplicatedDeleteResidentRequestException; import com.planetrush.planetrush.planet.exception.DuplicatedRegisterResidentRequestException; import com.planetrush.planetrush.planet.exception.InvalidStartDateException; import com.planetrush.planetrush.planet.exception.PlanetNotFoundException; @@ -312,11 +313,11 @@ public void registerResident(PlanetSubscriptionDto dto) { .orElseThrow(() -> new PlanetNotFoundException("Planet not found with ID: " + dto.getPlanetId())); ValueOperations ops = redisTemplate.opsForValue(); Boolean isFirstRequest = ops.setIfAbsent( - "idemp:register-resident:" + member.getId() + ":" + planet.getId(), + "idempotent:register-resident:" + "member:" + member.getId() + "planet:" + planet.getId(), "true", 10, TimeUnit.SECONDS); if (Boolean.FALSE.equals(isFirstRequest)) { - log.error("[REGISTER_RESIDENT] 중복 요청 발생, 회원={}, 행성={}", member.getId(), planet.getId()); + log.error("[IDEMPOTENT] 행성 가입 중복 요청 발생, 회원={}, 행성={}", member.getId(), planet.getId()); throw new DuplicatedRegisterResidentRequestException(); } if(residentRepositoryCustom.getReadyAndInProgressResidents(member) >= 9) { @@ -339,11 +340,22 @@ public void registerResident(PlanetSubscriptionDto dto) { @Transactional @Override public void deleteResident(PlanetSubscriptionDto dto) { - Resident resident = residentRepository.findByMemberIdAndPlanetId(dto.getMemberId(), dto.getPlanetId()) - .orElseThrow(() -> new ResidentNotFoundException( - "Resident not found member id: " + dto.getMemberId() + " and planet id: " + dto.getPlanetId())); + Member member = memberRepository.findById(dto.getMemberId()) + .orElseThrow(() -> new MemberNotFoundException("Member not found with ID: " + dto.getMemberId())); Planet planet = planetRepository.findByIdForUpdate(dto.getPlanetId()) .orElseThrow(() -> new PlanetNotFoundException("Planet not found with ID: " + dto.getPlanetId())); + ValueOperations ops = redisTemplate.opsForValue(); + Boolean isFirstRequest = ops.setIfAbsent( + "idempotent:delete-resident:" + "member:" + member.getId() + "planet:" + planet.getId(), + "true", 10, + TimeUnit.SECONDS); + if (Boolean.FALSE.equals(isFirstRequest)) { + log.error("[IDEMPOTENT] 행성 탈퇴 중복 요청 발생, 회원={}, 행성={}", member.getId(), planet.getId()); + throw new DuplicatedDeleteResidentRequestException(); + } + Resident resident = residentRepository.findByMemberIdAndPlanetId(member.getId(), planet.getId()) + .orElseThrow(() -> new ResidentNotFoundException( + "Resident not found member id: " + member.getId() + " and planet id: " + planet.getId())); planet.participantLeave(); residentRepository.delete(resident); } From 88cf850eeaa9d0cf636c36175142b1e8c07dc3a3 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Sun, 17 Aug 2025 03:23:13 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=ED=96=89=EC=84=B1=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20api=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../planet/PlanetIntegrationTest.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/test/java/com/planetrush/planetrush/planet/PlanetIntegrationTest.java b/src/test/java/com/planetrush/planetrush/planet/PlanetIntegrationTest.java index bc6db75..bc00e6f 100644 --- a/src/test/java/com/planetrush/planetrush/planet/PlanetIntegrationTest.java +++ b/src/test/java/com/planetrush/planetrush/planet/PlanetIntegrationTest.java @@ -242,4 +242,50 @@ void should_ensure_idempotency_when_duplicate_register_resident_within_ten_secon assertThat(successCnt).isEqualTo(1); assertThat(failedCnt).isEqualTo(loop - 1); } + + @DisplayName("10초 이내로 중복된 행성 탈퇴 요청은 멱등성을 보장한다.") + @Test + void should_ensure_idempotency_when_duplicate_delete_resident_within_ten_seconds() { + // GIVEN + List members = memberRepository.findAll(); + Member member = members.get(0); + + List planets = planetRepository.findAll(); + Planet planet = planets.get(0); + + PlanetSubscriptionDto deleteDto = PlanetSubscriptionDto.builder() + .planetId(planet.getId()) + .memberId(member.getId()) + .build(); + + // WHEN + int loop = 10; + ExecutorService executor = Executors.newFixedThreadPool(loop); + CountDownLatch startLatch = new CountDownLatch(1); + Callable task = () -> { + startLatch.await(); + planetService.deleteResident(deleteDto); + return null; + }; + + List> futures = IntStream.range(0, loop) + .mapToObj(i -> executor.submit(task)) + .toList(); + startLatch.countDown(); + + int successCnt = 0; + int failedCnt = 0; + for (Future future : futures) { + try { + future.get(); + successCnt++; + } catch (Exception e) { + failedCnt++; + } + } + + // THEN + assertThat(successCnt).isEqualTo(1); + assertThat(failedCnt).isEqualTo(loop - 1); + } } \ No newline at end of file