From fb98cf86697e6237f4708986272138bda7a51df6 Mon Sep 17 00:00:00 2001 From: simhani1 Date: Wed, 17 Dec 2025 13:43:02 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20planetService=20=EB=A9=B1=EB=93=B1?= =?UTF-8?q?=ED=82=A4,=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC?= =?UTF-8?q?=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../planet/service/IdempotencyManager.java | 50 +++++++++++++++++++ .../planet/service/IdempotencyTTL.java | 19 +++++++ .../planet/service/PlanetServiceImpl.java | 44 ++++++---------- ...PlanetPolicy.java => PlanetValidator.java} | 20 +++++--- 4 files changed, 97 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/planetrush/planetrush/planet/service/IdempotencyManager.java create mode 100644 src/main/java/com/planetrush/planetrush/planet/service/IdempotencyTTL.java rename src/main/java/com/planetrush/planetrush/planet/service/{PlanetPolicy.java => PlanetValidator.java} (71%) diff --git a/src/main/java/com/planetrush/planetrush/planet/service/IdempotencyManager.java b/src/main/java/com/planetrush/planetrush/planet/service/IdempotencyManager.java new file mode 100644 index 0000000..9869d83 --- /dev/null +++ b/src/main/java/com/planetrush/planetrush/planet/service/IdempotencyManager.java @@ -0,0 +1,50 @@ +package com.planetrush.planetrush.planet.service; + +import static com.planetrush.planetrush.planet.service.IdempotencyTTL.*; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class IdempotencyManager { + + private final RedisTemplate redisTemplate; + + public boolean tryRegisterResident(Long memberId, Long planetId) { + String key = generateKey(REGISTER_RESIDENT.getPrefix(), memberId, planetId); + return Boolean.TRUE.equals( + redisTemplate.opsForValue().setIfAbsent( + key, + "true", + REGISTER_RESIDENT.amount, + REGISTER_RESIDENT.unit + ) + ); + } + + public boolean tryDeleteResident(Long memberId, Long planetId) { + String key = generateKey(DELETE_RESIDENT.getPrefix(), memberId, planetId); + return Boolean.TRUE.equals( + redisTemplate.opsForValue().setIfAbsent( + key, + "true", + DELETE_RESIDENT.amount, + DELETE_RESIDENT.unit + ) + ); + } + + private String generateKey(String prefix, Long memberId, Long planetId) { + return new StringBuffer() + .append(prefix) + .append(":") + .append(memberId) + .append(":") + .append(planetId) + .toString(); + } +} + diff --git a/src/main/java/com/planetrush/planetrush/planet/service/IdempotencyTTL.java b/src/main/java/com/planetrush/planetrush/planet/service/IdempotencyTTL.java new file mode 100644 index 0000000..68335b6 --- /dev/null +++ b/src/main/java/com/planetrush/planetrush/planet/service/IdempotencyTTL.java @@ -0,0 +1,19 @@ +package com.planetrush.planetrush.planet.service; + +import java.util.concurrent.TimeUnit; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum IdempotencyTTL { + + REGISTER_RESIDENT("idempotent:register-resident", 10, TimeUnit.SECONDS), + DELETE_RESIDENT("idempotent:delete-resident", 10, TimeUnit.SECONDS), + ; + + final String prefix; + final int amount; + final TimeUnit unit; +} 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 b4dac6d..a73aa54 100644 --- a/src/main/java/com/planetrush/planetrush/planet/service/PlanetServiceImpl.java +++ b/src/main/java/com/planetrush/planetrush/planet/service/PlanetServiceImpl.java @@ -1,16 +1,11 @@ package com.planetrush.planetrush.planet.service; -import static com.planetrush.planetrush.planet.service.PlanetPolicy.*; - import java.time.LocalDate; 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; @@ -52,7 +47,8 @@ @RequiredArgsConstructor public class PlanetServiceImpl implements PlanetService { - private final RedisTemplate redisTemplate; + private final IdempotencyManager idempotencyManager; + private final PlanetValidator planetValidator; private final MemberRepository memberRepository; private final PlanetRepository planetRepository; @@ -306,25 +302,20 @@ private GetMainPlanetListDto convertToDto(GetMainPlanetListVo vo) { @Transactional @Override public void registerResident(PlanetSubscriptionDto dto) { + if (!idempotencyManager.tryRegisterResident(dto.getMemberId(), dto.getPlanetId())) { + log.warn("[IDEMPOTENT] 행성 가입 중복 요청, member={}, planet={}", dto.getMemberId(), dto.getPlanetId()); + throw new DuplicatedRegisterResidentRequestException(); + } 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:register-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 DuplicatedRegisterResidentRequestException(); - } - validateResidentLimit(member, residentRepositoryCustom); - validateDuplicateResident(member, planet, residentRepository); + planetValidator.checkMaxResidentLimit(member); + planetValidator.checkDuplicatedRegister(member, planet); planet.addParticipant(); residentRepository.save(Resident.isNotCreator(member, planet)); } - + /** * {@inheritDoc} * @@ -334,19 +325,14 @@ public void registerResident(PlanetSubscriptionDto dto) { @Transactional @Override public void deleteResident(PlanetSubscriptionDto dto) { + if (!idempotencyManager.tryDeleteResident(dto.getMemberId(), dto.getPlanetId())) { + log.error("[IDEMPOTENT] 행성 탈퇴 중복 요청 발생, 회원={}, 행성={}", dto.getMemberId(), dto.getPlanetId()); + throw new DuplicatedDeleteResidentRequestException(); + } 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())); @@ -362,8 +348,8 @@ public void deleteResident(PlanetSubscriptionDto dto) { public void registerPlanet(RegisterPlanetDto dto) { Member member = memberRepository.findById(dto.getMemberId()) .orElseThrow(() -> new MemberNotFoundException("Member not found with ID: " + dto.getMemberId())); - validateStartDateWithinTwoWeeks(dto.getStartDate()); - validateResidentLimit(member, residentRepositoryCustom); + planetValidator.checkStartDate(dto.getStartDate()); + planetValidator.checkMaxResidentLimit(member); Planet planet = planetRepository.save(Planet.builder() .name(dto.getName()) .category(Category.valueOf(dto.getCategory())) diff --git a/src/main/java/com/planetrush/planetrush/planet/service/PlanetPolicy.java b/src/main/java/com/planetrush/planetrush/planet/service/PlanetValidator.java similarity index 71% rename from src/main/java/com/planetrush/planetrush/planet/service/PlanetPolicy.java rename to src/main/java/com/planetrush/planetrush/planet/service/PlanetValidator.java index 1a2e135..6e1f18b 100644 --- a/src/main/java/com/planetrush/planetrush/planet/service/PlanetPolicy.java +++ b/src/main/java/com/planetrush/planetrush/planet/service/PlanetValidator.java @@ -4,6 +4,7 @@ import java.time.temporal.ChronoUnit; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; import com.planetrush.planetrush.member.domain.Member; import com.planetrush.planetrush.planet.domain.Planet; @@ -13,18 +14,23 @@ import com.planetrush.planetrush.planet.repository.ResidentRepository; import com.planetrush.planetrush.planet.repository.custom.ResidentRepositoryCustom; +import lombok.RequiredArgsConstructor; + @Component -public final class PlanetPolicy { +@RequiredArgsConstructor +public class PlanetValidator { + private final ResidentRepository residentRepository; + private final ResidentRepositoryCustom residentRepositoryCustom; private static final int MAX_RESIDENT_LIMIT = 9; private static final int MAX_CHALLENGE_START_OFFSET = 14; /** * 가입 가능한 최대 챌린지 수를 초과하는지 검사합니다. * @param member - * @param residentRepositoryCustom */ - public static void validateResidentLimit(Member member, ResidentRepositoryCustom residentRepositoryCustom) { + @Transactional(readOnly = true) + public void checkMaxResidentLimit(Member member) { if (residentRepositoryCustom.getReadyAndInProgressResidents(member) >= MAX_RESIDENT_LIMIT) { throw new ResidentOverflowException("resident count overflow"); } @@ -34,9 +40,9 @@ public static void validateResidentLimit(Member member, ResidentRepositoryCustom * 동일한 행성에 중복 가입을 방지합니다. * @param member * @param planet - * @param residentRepository */ - public static void validateDuplicateResident(Member member, Planet planet, ResidentRepository residentRepository) { + @Transactional(readOnly = true) + public void checkDuplicatedRegister(Member member, Planet planet) { residentRepository.findByMemberIdAndPlanetId(member.getId(), planet.getId()) .ifPresent(resident -> { throw new ResidentAlreadyExistsException("resident already exists: " + resident.getId()); @@ -47,8 +53,8 @@ public static void validateDuplicateResident(Member member, Planet planet, Resid * 2주 이내로 시작하는지 검사합니다. * @param startDate */ - public static void validateStartDateWithinTwoWeeks(LocalDate startDate) { - if(ChronoUnit.DAYS.between(LocalDate.now(), startDate) > MAX_CHALLENGE_START_OFFSET) { + public void checkStartDate(LocalDate startDate) { + if (LocalDate.now().minus(MAX_CHALLENGE_START_OFFSET, ChronoUnit.DAYS).isAfter(startDate)) { throw new InvalidStartDateException("Start date must be within 14 days from today."); } }