Skip to content

Commit

Permalink
[BE] feat: 중복 요청 방지 기능 구현 (#781)
Browse files Browse the repository at this point in the history
* build: 의존성 추가

* chore: 개발 환경 redis 설정

* feat: 레디스 설정을 빈으로 등록

* feat: 중복 요청 처리 인터셉터 생성

- URI, IP, UserAgent 를 기준으로 1초에 3개보다 많은 요청이 오는 경우, 429를 응답한다. 즉, 4번째 요청부터 거부된다.

* feat: 중복 요청 처리 인터셉터 등록

* feat: 예외 핸들러 추가

* test: 중복 예외 처리 인터셉터 테스트 작성

* refactor: value를 업데이트할 때 만료 시간을 다시 설정하도록 수정

* style: 가독성을 위한 개행 수정

* style: 파일 끝 개행

* refactor: ConfigurationProperties 적용

* refactor: 변수명 변경

* refactor: 기존 상수 적용

* refactor: RedisTemplate 타입과 중복 요청 감지 로직 변경

- RedisTemplate<String, Object> -> RedisTemplate<String, Long>

* refactor: 로그 메세지 수정

* chore: 사용하지 않는 예외 삭제

* refactor: 로그 레벨 수정

- info -> warn

* refactor: 중복 요청 검증 로직 개션

- if null 분기 제거

* test: 깨진 테스트 봉합

* test: 의미 없어진 테스트 삭제

- 명시적으로 null 인 경우 1로 초기화했던 이전 코드와 달리, setIfAbsent를 통해서 값이 초기화하는 지금은 '1로 초기화되었는지' 검증하기가 어렵다.

* refactor: 불필요한 지역변수 할당 삭제

- increment 가 증가한 결과를 바로 반환한다. 이를 사용하도록 수정했다.

* test: redisTemplate 등록으로 깨지는 테스트 봉합

- RedisTemplate과 ValueOperations를 모킹한다.
- 구체적인 인자를 지정하지 않은 stub 문을 수정한다.

* refactor: 예외 이름 변경

* refactor: 요청 제한 설정을 properties로 이동

* refactor: 예외 메세지 수정

- 테드 의견 반영

* chore: 안쓰는 import 문 제거

* test: 테스트 데이터 경계값으로 변경

* refactor: 제한 만료 시간 설정 로직 변경

* refactor: frequency -> requestCount 완전 대체
  • Loading branch information
nayonsoso authored Oct 15, 2024
1 parent 8c3c5ae commit 2bd0197
Show file tree
Hide file tree
Showing 12 changed files with 234 additions and 1 deletion.
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
13 changes: 13 additions & 0 deletions backend/src/main/java/reviewme/config/RequestLimitProperties.java
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
) {
}
34 changes: 34 additions & 0 deletions backend/src/main/java/reviewme/config/RequestLimitRedisConfig.java
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
50 changes: 50 additions & 0 deletions backend/src/main/java/reviewme/global/RequestLimitInterceptor.java
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

0 comments on commit 2bd0197

Please sign in to comment.