Skip to content

Commit

Permalink
refactor: 후원 API 중복 요청을 방지한다. (따닥 방지) (#448)
Browse files Browse the repository at this point in the history
* chore: DatabaseCleaner.clear 에 Redis 초기화 로직을 추가한다.

* feat: DonationRedisRepository 를 생성한다.

* feat: DonationService.registerDonation 에 중복 검증 로직을 추가한다.

* refactor: ValueOperations.setIfAbsent() 의 NPE 를 방지한다.

* refactor: Redis key prefix 를 상수로 선언한다.

* refactor: valueOperations 를 DonationRedisRepository 생성 시점에서 초기화되도록 변경한다.
  • Loading branch information
pushedrumex authored Jan 5, 2024
1 parent 4a18216 commit 31880d8
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.clova.anifriends.domain.donation.repository;

public interface DonationCacheRepository {

boolean isDuplicateDonation(Long volunteerId);
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> valueOperations;

public DonationRedisRepository(RedisTemplate<String, Object> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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("존재하지 않는 봉사자입니다."));
Expand Down
7 changes: 7 additions & 0 deletions src/test/java/com/clova/anifriends/base/DatabaseCleaner.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -14,6 +16,9 @@ public class DatabaseCleaner {
private final EntityManager entityManager;
private final List<String> tableNames;

@Autowired
private RedisTemplate<String, Object> redisTemplate;

public DatabaseCleaner(EntityManager em) {
this.entityManager = em;
this.tableNames = entityManager.getMetamodel()
Expand All @@ -37,5 +42,7 @@ public void clear() {

entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY true")
.executeUpdate();

redisTemplate.getConnectionFactory().getConnection().flushAll();
}
}
Original file line number Diff line number Diff line change
@@ -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);
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,6 +42,9 @@ class DonationServiceTest {
@Mock
private PaymentRepository paymentRepository;

@Mock
private DonationCacheRepository donationCacheRepository;

@Nested
@DisplayName("registerDonation 실행 시")
class RegisterDonationTest {
Expand All @@ -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));

Expand Down

0 comments on commit 31880d8

Please sign in to comment.