Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] feat: 중복 요청 방지 기능 구현 #781

Merged
merged 29 commits into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
aadfab0
build: 의존성 추가
nayonsoso Oct 7, 2024
0c91821
chore: 개발 환경 redis 설정
nayonsoso Oct 7, 2024
cc5253a
feat: 레디스 설정을 빈으로 등록
nayonsoso Oct 7, 2024
20e266a
feat: 중복 요청 처리 인터셉터 생성
nayonsoso Oct 7, 2024
d06ff0f
feat: 중복 요청 처리 인터셉터 등록
nayonsoso Oct 7, 2024
d9eb2d3
feat: 예외 핸들러 추가
nayonsoso Oct 7, 2024
b5fc73d
test: 중복 예외 처리 인터셉터 테스트 작성
nayonsoso Oct 7, 2024
5008973
refactor: value를 업데이트할 때 만료 시간을 다시 설정하도록 수정
nayonsoso Oct 7, 2024
8544e68
style: 가독성을 위한 개행 수정
nayonsoso Oct 7, 2024
5386fc6
style: 파일 끝 개행
nayonsoso Oct 14, 2024
c6e0288
refactor: ConfigurationProperties 적용
nayonsoso Oct 14, 2024
ad41a00
refactor: 변수명 변경
nayonsoso Oct 14, 2024
1b0800c
refactor: 기존 상수 적용
nayonsoso Oct 15, 2024
650d745
refactor: RedisTemplate 타입과 중복 요청 감지 로직 변경
nayonsoso Oct 15, 2024
953979d
refactor: 로그 메세지 수정
nayonsoso Oct 15, 2024
fb1acdd
chore: 사용하지 않는 예외 삭제
nayonsoso Oct 15, 2024
a57be0f
refactor: 로그 레벨 수정
nayonsoso Oct 15, 2024
ca3df64
refactor: 중복 요청 검증 로직 개션
nayonsoso Oct 15, 2024
1212bbc
test: 깨진 테스트 봉합
nayonsoso Oct 15, 2024
82cf806
test: 의미 없어진 테스트 삭제
nayonsoso Oct 15, 2024
08ae904
refactor: 불필요한 지역변수 할당 삭제
nayonsoso Oct 15, 2024
10eb99b
test: redisTemplate 등록으로 깨지는 테스트 봉합
nayonsoso Oct 15, 2024
3d1e623
refactor: 예외 이름 변경
nayonsoso Oct 15, 2024
87a4294
refactor: 요청 제한 설정을 properties로 이동
nayonsoso Oct 15, 2024
2c434ea
refactor: 예외 메세지 수정
nayonsoso Oct 15, 2024
5c28d2e
chore: 안쓰는 import 문 제거
nayonsoso Oct 15, 2024
ddd7c72
test: 테스트 데이터 경계값으로 변경
nayonsoso Oct 15, 2024
cf7e628
refactor: 제한 만료 시간 설정 로직 변경
nayonsoso Oct 15, 2024
f1a9d38
refactor: frequency -> requestCount 완전 대체
nayonsoso Oct 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies {
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package reviewme.config;

import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "request-limit")
public record RequestLimitProperties(
long threshold,
Duration duration,
String host,
int port
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package reviewme.config;

import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.serializer.GenericToStringSerializer;

@Configuration
@EnableConfigurationProperties(RequestLimitProperties.class)
@RequiredArgsConstructor
public class RequestLimitRedisConfig {

private final RequestLimitProperties requestLimitProperties;

@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(
requestLimitProperties.host(), requestLimitProperties.port()
);
}

@Bean
public RedisTemplate<String, Long> requestLimitRedisTemplate() {
RedisTemplate<String, Long> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class));

return redisTemplate;
}
}
10 changes: 10 additions & 0 deletions backend/src/main/java/reviewme/config/WebConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import reviewme.global.RequestLimitInterceptor;
import reviewme.reviewgroup.controller.ReviewGroupSessionResolver;
import reviewme.reviewgroup.service.ReviewGroupService;

Expand All @@ -13,9 +16,16 @@
public class WebConfig implements WebMvcConfigurer {

private final ReviewGroupService reviewGroupService;
private final RedisTemplate<String, Long> redisTemplate;
private final RequestLimitProperties requestLimitProperties;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new ReviewGroupSessionResolver(reviewGroupService));
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new RequestLimitInterceptor(redisTemplate, requestLimitProperties));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import org.springframework.web.servlet.resource.NoResourceFoundException;
import reviewme.global.exception.BadRequestException;
import reviewme.global.exception.DataInconsistencyException;
import reviewme.global.exception.TooManyRequestException;
import reviewme.global.exception.FieldErrorResponse;
import reviewme.global.exception.NotFoundException;
import reviewme.global.exception.UnauthorizedException;
Expand Down Expand Up @@ -50,6 +51,11 @@ public ProblemDetail handleDataConsistencyException(DataInconsistencyException e
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getErrorMessage());
}

@ExceptionHandler(TooManyRequestException.class)
public ProblemDetail handleDuplicateRequestException(TooManyRequestException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.TOO_MANY_REQUESTS, ex.getErrorMessage());
}

@ExceptionHandler(Exception.class)
public ProblemDetail handleException(Exception ex) {
log.error("Internal server error has occurred", ex);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package reviewme.global;

import static org.springframework.http.HttpHeaders.USER_AGENT;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import reviewme.config.RequestLimitProperties;
import reviewme.global.exception.TooManyRequestException;

@Component
@EnableConfigurationProperties(RequestLimitProperties.class)
@RequiredArgsConstructor
public class RequestLimitInterceptor implements HandlerInterceptor {

private final RedisTemplate<String, Long> redisTemplate;
private final RequestLimitProperties requestLimitProperties;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!HttpMethod.POST.matches(request.getMethod())) {
return true;
}

String key = generateRequestKey(request);
ValueOperations<String, Long> valueOperations = redisTemplate.opsForValue();
valueOperations.setIfAbsent(key, 0L, requestLimitProperties.duration());
redisTemplate.expire(key, requestLimitProperties.duration());

long requestCount = valueOperations.increment(key);
if (requestCount > requestLimitProperties.threshold()) {
throw new TooManyRequestException(key);
}
return true;
}

private String generateRequestKey(HttpServletRequest request) {
String requestURI = request.getRequestURI();
String remoteAddr = request.getRemoteAddr();
String userAgent = request.getHeader(USER_AGENT);

return String.format("RequestURI: %s, RemoteAddr: %s, UserAgent: %s", requestURI, remoteAddr, userAgent);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package reviewme.global.exception;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class TooManyRequestException extends ReviewMeException {

public TooManyRequestException(String requestKey) {
super("짧은 시간 안에 너무 많은 동일한 요청이 일어났어요. 잠시 후 다시 시도해주세요.");
log.warn("Too many request received - request: {}", requestKey);
}
}
6 changes: 6 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,9 @@ cors:
allowed-origins:
- http://localhost
- https://localhost

request-limit:
threshold: 3
duration: 1s
host: localhost
port: 6379
17 changes: 17 additions & 0 deletions backend/src/test/java/reviewme/api/ApiTest.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package reviewme.api;

import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyHeaders;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris;
Expand All @@ -15,8 +17,11 @@
import org.apache.http.HttpHeaders;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.http.MediaType;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
Expand Down Expand Up @@ -73,6 +78,12 @@ public abstract class ApiTest {
@MockBean
protected ReviewGroupLookupService reviewGroupLookupService;

@MockBean
protected RedisTemplate<String, Long> redisTemplate;

@Mock
protected ValueOperations<String, Long> valueOperations;

@MockBean
protected ReviewSummaryService reviewSummaryService;

Expand Down Expand Up @@ -100,6 +111,12 @@ public abstract class ApiTest {
}
};

@BeforeEach
void setUpRedisConfig() {
given(redisTemplate.opsForValue()).willReturn(valueOperations);
given(valueOperations.increment(anyString())).willReturn(1L);
}

@BeforeEach
void setUpRestDocs(WebApplicationContext context, RestDocumentationContextProvider provider) {
UriModifyingOperationPreprocessor uriModifier = modifyUris()
Expand Down
2 changes: 1 addition & 1 deletion backend/src/test/java/reviewme/api/ReviewApiTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class ReviewApiTest extends ApiTest {
@Test
void 리뷰_그룹_코드가_올바르지_않은_경우_예외가_발생한다() {
BDDMockito.given(reviewRegisterService.registerReview(any(ReviewRegisterRequest.class)))
.willThrow(new ReviewGroupNotFoundByReviewRequestCodeException(anyString()));
.willThrow(new ReviewGroupNotFoundByReviewRequestCodeException("ABCD1234"));

FieldDescriptor[] requestFieldDescriptors = {
fieldWithPath("reviewRequestCode").description("리뷰 요청 코드"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package reviewme.global;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.springframework.http.HttpHeaders.USER_AGENT;

import jakarta.servlet.http.HttpServletRequest;
import java.time.Duration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import reviewme.config.RequestLimitProperties;
import reviewme.global.exception.TooManyRequestException;

class RequestLimitInterceptorTest {

private final HttpServletRequest request = mock(HttpServletRequest.class);
private final RedisTemplate<String, Long> redisTemplate = mock(RedisTemplate.class);
private final ValueOperations<String, Long> valueOperations = mock(ValueOperations.class);
private final RequestLimitProperties requestLimitProperties = mock(RequestLimitProperties.class);
private final RequestLimitInterceptor interceptor = new RequestLimitInterceptor(redisTemplate, requestLimitProperties);
private final String requestKey = "RequestURI: /api/v2/reviews, RemoteAddr: localhost, UserAgent: Postman";

@BeforeEach
void setUp() {
given(request.getMethod()).willReturn("POST");
given(request.getRequestURI()).willReturn("/api/v2/reviews");
given(request.getRemoteAddr()).willReturn("localhost");
given(request.getHeader(USER_AGENT)).willReturn("Postman");

given(redisTemplate.opsForValue()).willReturn(valueOperations);
given(requestLimitProperties.duration()).willReturn(Duration.ofSeconds(1));
given(requestLimitProperties.threshold()).willReturn(3L);
}

@Test
void POST_요청이_아니면_통과한다() {
// given
HttpServletRequest request = mock(HttpServletRequest.class);
given(request.getMethod()).willReturn("GET");

// when
boolean result = interceptor.preHandle(request, null, null);

// then
assertThat(result).isTrue();
}

@Test
void 특정_POST_요청이_처음이_아니며_최대_빈도보다_작을_경우_빈도를_1증가시킨다() {
// given
long requestCount = 1;
given(valueOperations.get(anyString())).willReturn(requestCount);

// when
boolean result = interceptor.preHandle(request, null, null);

// then
assertThat(result).isTrue();
verify(valueOperations).increment(requestKey);
}

@Test
void 특정_POST_요청이_처음이_아니며_최대_빈도보다_클_경우_예외를_발생시킨다() {
// given
long maxRequestCount = 3;
given(valueOperations.increment(anyString())).willReturn(maxRequestCount + 1);

// when & then
assertThatThrownBy(() -> interceptor.preHandle(request, null, null))
.isInstanceOf(TooManyRequestException.class);
}
}
6 changes: 6 additions & 0 deletions backend/src/test/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,9 @@ logging:
cors:
allowed-origins:
- https://allowed-domain.com

request-limit:
threshold: 3
duration: 1s
host: localhost
port: 6379