From 31880d8184d455cfb9aedab98325f458a3078da5 Mon Sep 17 00:00:00 2001 From: minjungkim <97938489+pushedrumex@users.noreply.github.com> Date: Fri, 5 Jan 2024 16:52:31 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=ED=9B=84=EC=9B=90=20API=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=9A=94=EC=B2=AD=EC=9D=84=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=ED=95=9C=EB=8B=A4.=20(=EB=94=B0=EB=8B=A5=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80)=20(#448)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: DatabaseCleaner.clear 에 Redis 초기화 로직을 추가한다. * feat: DonationRedisRepository 를 생성한다. * feat: DonationService.registerDonation 에 중복 검증 로직을 추가한다. * refactor: ValueOperations.setIfAbsent() 의 NPE 를 방지한다. * refactor: Redis key prefix 를 상수로 선언한다. * refactor: valueOperations 를 DonationRedisRepository 생성 시점에서 초기화되도록 변경한다. --- .../exception/DonationDuplicateException.java | 11 +++ .../repository/DonationCacheRepository.java | 6 ++ .../repository/DonationRedisRepository.java | 25 +++++++ .../donation/service/DonationService.java | 11 +++ .../anifriends/base/DatabaseCleaner.java | 7 ++ .../service/DonationIntegrationTest.java | 75 +++++++++++++++++++ .../donation/service/DonationServiceTest.java | 6 ++ 7 files changed, 141 insertions(+) create mode 100644 src/main/java/com/clova/anifriends/domain/donation/exception/DonationDuplicateException.java create mode 100644 src/main/java/com/clova/anifriends/domain/donation/repository/DonationCacheRepository.java create mode 100644 src/main/java/com/clova/anifriends/domain/donation/repository/DonationRedisRepository.java create mode 100644 src/test/java/com/clova/anifriends/domain/donation/service/DonationIntegrationTest.java diff --git a/src/main/java/com/clova/anifriends/domain/donation/exception/DonationDuplicateException.java b/src/main/java/com/clova/anifriends/domain/donation/exception/DonationDuplicateException.java new file mode 100644 index 000000000..0c535e362 --- /dev/null +++ b/src/main/java/com/clova/anifriends/domain/donation/exception/DonationDuplicateException.java @@ -0,0 +1,11 @@ +package com.clova.anifriends.domain.donation.exception; + +import com.clova.anifriends.global.exception.BadRequestException; +import com.clova.anifriends.global.exception.ErrorCode; + +public class DonationDuplicateException extends BadRequestException { + + public DonationDuplicateException(String message) { + super(ErrorCode.BAD_REQUEST, message); + } +} diff --git a/src/main/java/com/clova/anifriends/domain/donation/repository/DonationCacheRepository.java b/src/main/java/com/clova/anifriends/domain/donation/repository/DonationCacheRepository.java new file mode 100644 index 000000000..78e49d1f5 --- /dev/null +++ b/src/main/java/com/clova/anifriends/domain/donation/repository/DonationCacheRepository.java @@ -0,0 +1,6 @@ +package com.clova.anifriends.domain.donation.repository; + +public interface DonationCacheRepository { + + boolean isDuplicateDonation(Long volunteerId); +} diff --git a/src/main/java/com/clova/anifriends/domain/donation/repository/DonationRedisRepository.java b/src/main/java/com/clova/anifriends/domain/donation/repository/DonationRedisRepository.java new file mode 100644 index 000000000..3e60fc3d4 --- /dev/null +++ b/src/main/java/com/clova/anifriends/domain/donation/repository/DonationRedisRepository.java @@ -0,0 +1,25 @@ +package com.clova.anifriends.domain.donation.repository; + +import java.util.concurrent.TimeUnit; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Repository; + +@Repository +public class DonationRedisRepository implements DonationCacheRepository { + + public static final int TIMEOUT = 1; + public static final String DONATION_KEY = "donation:"; + + private final ValueOperations valueOperations; + + public DonationRedisRepository(RedisTemplate redisTemplate) { + this.valueOperations = redisTemplate.opsForValue(); + } + + public boolean isDuplicateDonation(Long volunteerId) { + String key = DONATION_KEY + volunteerId; + return Boolean.FALSE.equals( + valueOperations.setIfAbsent(key, true, TIMEOUT, TimeUnit.SECONDS)); + } +} diff --git a/src/main/java/com/clova/anifriends/domain/donation/service/DonationService.java b/src/main/java/com/clova/anifriends/domain/donation/service/DonationService.java index 42e914ead..03505a44c 100644 --- a/src/main/java/com/clova/anifriends/domain/donation/service/DonationService.java +++ b/src/main/java/com/clova/anifriends/domain/donation/service/DonationService.java @@ -2,6 +2,8 @@ import com.clova.anifriends.domain.donation.Donation; import com.clova.anifriends.domain.donation.dto.response.PaymentRequestResponse; +import com.clova.anifriends.domain.donation.exception.DonationDuplicateException; +import com.clova.anifriends.domain.donation.repository.DonationCacheRepository; import com.clova.anifriends.domain.payment.Payment; import com.clova.anifriends.domain.payment.repository.PaymentRepository; import com.clova.anifriends.domain.shelter.Shelter; @@ -19,10 +21,13 @@ public class DonationService { private final ShelterRepository shelterRepository; private final VolunteerRepository volunteerRepository; private final PaymentRepository paymentRepository; + private final DonationCacheRepository donationCacheRepository; @Transactional public PaymentRequestResponse registerDonation(Long volunteerId, Long shelterId, Integer amount) { + checkDuplicate(volunteerId); + Shelter shelter = getShelter(shelterId); Volunteer volunteer = getVolunteer(volunteerId); Donation donation = new Donation(shelter, volunteer, amount); @@ -32,6 +37,12 @@ public PaymentRequestResponse registerDonation(Long volunteerId, Long shelterId, return PaymentRequestResponse.of(payment); } + private void checkDuplicate(Long volunteerId) { + if (donationCacheRepository.isDuplicateDonation(volunteerId)) { + throw new DonationDuplicateException("중복된 요청입니다."); + } + } + private Volunteer getVolunteer(Long volunteerId) { return volunteerRepository.findById(volunteerId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 봉사자입니다.")); diff --git a/src/test/java/com/clova/anifriends/base/DatabaseCleaner.java b/src/test/java/com/clova/anifriends/base/DatabaseCleaner.java index 024f28a18..9cb4af779 100644 --- a/src/test/java/com/clova/anifriends/base/DatabaseCleaner.java +++ b/src/test/java/com/clova/anifriends/base/DatabaseCleaner.java @@ -4,6 +4,8 @@ import jakarta.persistence.Table; import jakarta.persistence.metamodel.Type; import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -14,6 +16,9 @@ public class DatabaseCleaner { private final EntityManager entityManager; private final List tableNames; + @Autowired + private RedisTemplate redisTemplate; + public DatabaseCleaner(EntityManager em) { this.entityManager = em; this.tableNames = entityManager.getMetamodel() @@ -37,5 +42,7 @@ public void clear() { entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY true") .executeUpdate(); + + redisTemplate.getConnectionFactory().getConnection().flushAll(); } } diff --git a/src/test/java/com/clova/anifriends/domain/donation/service/DonationIntegrationTest.java b/src/test/java/com/clova/anifriends/domain/donation/service/DonationIntegrationTest.java new file mode 100644 index 000000000..63ae03c1f --- /dev/null +++ b/src/test/java/com/clova/anifriends/domain/donation/service/DonationIntegrationTest.java @@ -0,0 +1,75 @@ +package com.clova.anifriends.domain.donation.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchException; +import static org.awaitility.Awaitility.await; + +import com.clova.anifriends.base.BaseIntegrationTest; +import com.clova.anifriends.domain.donation.exception.DonationDuplicateException; +import com.clova.anifriends.domain.shelter.Shelter; +import com.clova.anifriends.domain.shelter.support.ShelterFixture; +import com.clova.anifriends.domain.volunteer.Volunteer; +import com.clova.anifriends.domain.volunteer.support.VolunteerFixture; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +public class DonationIntegrationTest extends BaseIntegrationTest { + + @Autowired + private DonationService donationService; + + @Nested + @DisplayName("registerDonation 실행 시") + class RegisterDonationTest { + + @Test + @DisplayName("성공: 1초 간격으로 두 번 후원 요청 시") + void registerDonation() { + // given + Volunteer volunteer = VolunteerFixture.volunteer(); + Shelter shelter = ShelterFixture.shelter(); + int amount = 100_000; + + volunteerRepository.save(volunteer); + shelterRepository.save(shelter); + + // when && then + donationService.registerDonation(volunteer.getVolunteerId(), shelter.getShelterId(), + amount); + + await().pollDelay(Duration.ofSeconds(1)).untilAsserted(() -> { + assertThat(catchException( + () -> donationService.registerDonation(volunteer.getVolunteerId(), + shelter.getShelterId(), amount))).isNull(); + }); + } + + @Test + @DisplayName("예외(DonationDuplicateException): 1초 안에 연속 두 번 후원 요청 시") + void exceptionWhenDuplicateDonation() { + // given + Volunteer volunteer = VolunteerFixture.volunteer(); + Shelter shelter = ShelterFixture.shelter(); + int amount = 100_000; + + volunteerRepository.save(volunteer); + shelterRepository.save(shelter); + + // when + Exception exception = catchException(() -> { + donationService.registerDonation(volunteer.getVolunteerId(), shelter.getShelterId(), + amount); + donationService.registerDonation(volunteer.getVolunteerId(), shelter.getShelterId(), + amount); + }); + + // then + assertThat(exception).isInstanceOf(DonationDuplicateException.class); + } + + } + +} diff --git a/src/test/java/com/clova/anifriends/domain/donation/service/DonationServiceTest.java b/src/test/java/com/clova/anifriends/domain/donation/service/DonationServiceTest.java index 391dfd3d8..79e77f900 100644 --- a/src/test/java/com/clova/anifriends/domain/donation/service/DonationServiceTest.java +++ b/src/test/java/com/clova/anifriends/domain/donation/service/DonationServiceTest.java @@ -8,6 +8,7 @@ import com.clova.anifriends.domain.donation.Donation; import com.clova.anifriends.domain.donation.dto.response.PaymentRequestResponse; +import com.clova.anifriends.domain.donation.repository.DonationCacheRepository; import com.clova.anifriends.domain.donation.support.fixture.DonationFixture; import com.clova.anifriends.domain.payment.Payment; import com.clova.anifriends.domain.payment.repository.PaymentRepository; @@ -41,6 +42,9 @@ class DonationServiceTest { @Mock private PaymentRepository paymentRepository; + @Mock + private DonationCacheRepository donationCacheRepository; + @Nested @DisplayName("registerDonation 실행 시") class RegisterDonationTest { @@ -55,6 +59,8 @@ void registerDonation() { Payment payment = new Payment(donation); PaymentRequestResponse expected = PaymentRequestResponse.of(payment); + when(donationCacheRepository.isDuplicateDonation(anyLong())).thenReturn( + false); when(shelterRepository.findById(anyLong())).thenReturn(Optional.of(shelter)); when(volunteerRepository.findById(anyLong())).thenReturn(Optional.of(volunteer));