-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
12 changed files
with
234 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
13 changes: 13 additions & 0 deletions
13
backend/src/main/java/reviewme/config/RequestLimitProperties.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
34
backend/src/main/java/reviewme/config/RequestLimitRedisConfig.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
50 changes: 50 additions & 0 deletions
50
backend/src/main/java/reviewme/global/RequestLimitInterceptor.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
12 changes: 12 additions & 0 deletions
12
backend/src/main/java/reviewme/global/exception/TooManyRequestException.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
78 changes: 78 additions & 0 deletions
78
backend/src/test/java/reviewme/global/RequestLimitInterceptorTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters