-
Notifications
You must be signed in to change notification settings - Fork 37
[4주차] RateLimit 적용 및 공통 응답 작업 #90
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| dependencies { | ||
| implementation project(':application') | ||
| implementation 'org.springframework.boot:spring-boot-starter-web' | ||
| implementation 'com.google.guava:guava:33.4.0-jre' | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.example.aop; | ||
|
|
||
| import java.lang.annotation.ElementType; | ||
| import java.lang.annotation.Retention; | ||
| import java.lang.annotation.RetentionPolicy; | ||
| import java.lang.annotation.Target; | ||
|
|
||
| @Retention(RetentionPolicy.RUNTIME) | ||
| @Target(ElementType.METHOD) | ||
| public @interface MovieSearchRateLimited { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| package com.example.aop; | ||
|
|
||
| import com.example.config.ratelimit.RateLimit; | ||
| import com.example.exception.BusinessError; | ||
| import com.example.reservation.request.ReservationRequest; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.aspectj.lang.ProceedingJoinPoint; | ||
| import org.aspectj.lang.annotation.Around; | ||
| import org.aspectj.lang.annotation.Aspect; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.context.request.RequestContextHolder; | ||
| import org.springframework.web.context.request.ServletRequestAttributes; | ||
|
|
||
| @Aspect | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class RateLimitAspect { | ||
|
|
||
| private final RateLimit rateLimit; | ||
|
|
||
| @Around("@annotation(movieSearchRateLimited)") | ||
| public Object around(ProceedingJoinPoint joinPoint, MovieSearchRateLimited movieSearchRateLimited) throws Throwable { | ||
|
|
||
| HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); | ||
| String ip = request.getRemoteAddr(); | ||
|
|
||
| if (!rateLimit.isMovieSearchAllowed(ip)) { | ||
| throw BusinessError.MOVIE_SEARCH_MAX_FIND_ERROR.exception(); | ||
| } | ||
|
|
||
| return joinPoint.proceed(); | ||
| } | ||
|
|
||
| @Around("@annotation(reservationRateLimited)") | ||
| public Object around(ProceedingJoinPoint joinPoint, ReservationRateLimited reservationRateLimited) throws Throwable { | ||
| Object[] args = joinPoint.getArgs(); | ||
| for (Object arg : args) { | ||
| if (arg instanceof com.example.reservation.request.ReservationRequest) { | ||
| ReservationRequest request = (ReservationRequest) arg; | ||
| if (!rateLimit.isReservationAllowed(request.getMemberId(), request.getScreeningId())) { | ||
| throw BusinessError.RESERVATION_RATE_LIMIT_ERROR.exception(); | ||
| } | ||
|
|
||
| } | ||
| } | ||
|
|
||
| return joinPoint.proceed(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.example.aop; | ||
|
|
||
| import java.lang.annotation.ElementType; | ||
| import java.lang.annotation.Retention; | ||
| import java.lang.annotation.RetentionPolicy; | ||
| import java.lang.annotation.Target; | ||
|
|
||
| @Retention(RetentionPolicy.RUNTIME) | ||
| @Target(ElementType.METHOD) | ||
| public @interface ReservationRateLimited { | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이런 옵션들도 설정 가능하게끔 하는 것을 고려해보는 것도 좋을 것 같아요. Controller 에서 API 의 제약조건에 대해서 더 명확하게 알 수 있을 것 같습니다. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package com.example.config.advice; | ||
|
|
||
| import com.example.exception.BusinessException; | ||
| import com.example.response.ApiResponse; | ||
| import org.springframework.web.bind.annotation.ExceptionHandler; | ||
| import org.springframework.web.bind.annotation.RestControllerAdvice; | ||
|
|
||
| @RestControllerAdvice | ||
| public class ExceptionAdvice { | ||
|
|
||
| @ExceptionHandler(BusinessException.class) | ||
| public ApiResponse<Void> BusinessExceptionHandler(BusinessException e) { | ||
| return ApiResponse.businessException(e.getHttpStatus(), e.getCode(), e.getMessage()); | ||
| } | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package com.example.config.ratelimit; | ||
|
|
||
| import com.google.common.cache.Cache; | ||
| import com.google.common.cache.CacheBuilder; | ||
| import com.google.common.util.concurrent.RateLimiter; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.util.concurrent.TimeUnit; | ||
|
|
||
| @Component | ||
| public class GuavaRateLimit implements RateLimit { | ||
|
|
||
| private static final double PERMITS_PER_SECOND = 2.0; | ||
| private static final int MAX_REQUEST_PER_MINUTE = 50; | ||
|
|
||
| private final Cache<String, Boolean> blockedIpCache = CacheBuilder.newBuilder() | ||
| .expireAfterWrite(1, TimeUnit.HOURS) | ||
| .build(); | ||
|
|
||
| private final Cache<String, Integer> requestCountCache = CacheBuilder.newBuilder() | ||
| .expireAfterWrite(1, TimeUnit.MINUTES) | ||
| .build(); | ||
|
|
||
| private final Cache<String, RateLimiter> ipRateLimiterCache = CacheBuilder.newBuilder() | ||
| .expireAfterAccess(1, TimeUnit.MINUTES) | ||
| .build(); | ||
|
|
||
| private final Cache<String, Long> reservationCache = CacheBuilder.newBuilder() | ||
| .expireAfterWrite(5, TimeUnit.MINUTES) // 5분 동안 캐시 유지 | ||
| .build(); | ||
|
|
||
| @Override | ||
| public boolean isMovieSearchAllowed(String ip) { | ||
| if (isBlocked(ip)) { | ||
| return false; | ||
| } | ||
|
|
||
| RateLimiter rateLimiter = getRateLimiter(ip); | ||
| if (!rateLimiter.tryAcquire()) { | ||
| return false; | ||
| } | ||
|
|
||
| int count = incrementRequestCount(ip); | ||
| if (count > MAX_REQUEST_PER_MINUTE) { | ||
| blockIp(ip); | ||
| return false; | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| @Override | ||
| public boolean isReservationAllowed(Long memberId, Long screeningId) { | ||
|
|
||
| String key = memberId + "_" + screeningId; | ||
|
|
||
| // null이면 예약 내역이 없다는 거니까 | ||
| // 데이터를 만들어줘야지 | ||
| if (reservationCache.getIfPresent(key) == null) { | ||
| reservationCache.put(key, System.currentTimeMillis()); | ||
| return true; | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| private boolean isBlocked(String ip) { | ||
| return blockedIpCache.getIfPresent(ip) != null; | ||
| } | ||
|
|
||
| private void blockIp(String ip) { | ||
| blockedIpCache.put(ip, Boolean.TRUE); | ||
| } | ||
|
|
||
| private RateLimiter getRateLimiter(String ip) { | ||
| RateLimiter rateLimiter = ipRateLimiterCache.getIfPresent(ip); | ||
| if (rateLimiter == null) { | ||
| rateLimiter = RateLimiter.create(PERMITS_PER_SECOND); | ||
| ipRateLimiterCache.put(ip, rateLimiter); | ||
| } | ||
| return rateLimiter; | ||
| } | ||
|
|
||
| private int incrementRequestCount(String ip) { | ||
| Integer count = requestCountCache.getIfPresent(ip); | ||
| if (count == null) { | ||
| count = 0; | ||
| } | ||
| count++; | ||
| requestCountCache.put(ip, count); | ||
| return count; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.example.config.ratelimit; | ||
|
|
||
| public interface RateLimit { | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
인터페이스를 사용하여 다양한 구현체를 만드는 방식은 좋은 접근 입니다. 클라이언트 측은 인터페이스에 대한 명세만 알고있으면되고, 세부 구현체가 어떤 것인지는 몰라도 되기 때문입니다. 다만, 어떤 구현체를 선택해서 사용할지에 대한 로직도 추가적으로 필요할 것 같습니다. 다만 실무적으로 본다면 분산 애플리케이션 환경에서는 Redis 만 사용할 가능성이 높기 때문에 인터페이스 없이 바로 구현체를 활용할 수 있을 것 같습니다. 아이디어랑 접근 방식은 좋습니다 💯 |
||
| boolean isMovieSearchAllowed(String ip); | ||
|
|
||
| boolean isReservationAllowed(Long memberId, Long screeningId); | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,9 @@ | ||
| package com.example.movie; | ||
|
|
||
| import com.example.aop.MovieSearchRateLimited; | ||
| import com.example.movie.request.MovieSearchRequest; | ||
| import com.example.movie.response.MovieResponse; | ||
| import com.example.response.ApiResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
|
|
@@ -17,7 +19,8 @@ public class MovieController { | |
| private final MovieService movieService; | ||
|
|
||
| @GetMapping("/v1/movies") | ||
| public List<MovieResponse> getMovies(MovieSearchRequest request) { | ||
| return movieService.getMovies(request.toServiceRequest()); | ||
| @MovieSearchRateLimited | ||
| public ApiResponse<List<MovieResponse>> getMovies(MovieSearchRequest request) { | ||
| return ApiResponse.ok("영화 목록 조회",movieService.getMovies(request.toServiceRequest())); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 하드코딩은 제거하면 좋을 것 같습니다 ! |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package com.example.response; | ||
|
|
||
| import lombok.Getter; | ||
| import org.springframework.http.HttpStatus; | ||
|
|
||
| @Getter | ||
| public class ApiResponse<T> { | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ApiResponse 도 잘 만들어주셨습니다 👍🏿 |
||
| private final static int DEFAULT_OK_CODE = 2000; | ||
| private final static int DEFAULT_CREATE_CODE = 2001; | ||
|
|
||
| private HttpStatus status; | ||
| private int code; | ||
| private String message; | ||
| private T data; | ||
|
|
||
| public ApiResponse(HttpStatus status, int code, String message, T data) { | ||
| this.status = status; | ||
| this.code = code; | ||
| this.message = message; | ||
| this.data = data; | ||
| } | ||
|
|
||
| public static <T> ApiResponse<T> ok(String message, T data) { | ||
| return new ApiResponse<>(HttpStatus.OK, DEFAULT_OK_CODE, message, data); | ||
| } | ||
|
|
||
| public static <T> ApiResponse<T> created(String message, T data) { | ||
| return new ApiResponse<>(HttpStatus.CREATED, DEFAULT_CREATE_CODE, message, data); | ||
| } | ||
|
|
||
| public static ApiResponse<Void> businessException(HttpStatus httpstatus, int code, String message) { | ||
| return new ApiResponse<>(httpstatus != null ? httpstatus : HttpStatus.BAD_REQUEST, code, message, null); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| package com.example.config.ratelimit; | ||
|
|
||
| import org.junit.jupiter.api.DisplayName; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
|
|
||
| class GuavaRateLimitTest { | ||
|
|
||
| private final RateLimit rateLimit = new GuavaRateLimit(); | ||
|
|
||
| @Test | ||
| @DisplayName("초당 요청 제한 테스트: 연속 요청 시 per-second RateLimiter가 제한하는지 확인") | ||
| void test1() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @DisplayName 을 사용했다고 해서 test 이름을 대충 지어도 되는건 아닙니다 ! |
||
| String ip = "192.168.1.101"; // 테스트용 IP | ||
|
|
||
| int allowedCount = 0; | ||
| int totalRequests = 10; | ||
|
|
||
| for (int i = 0; i < totalRequests; i++) { | ||
| if (rateLimit.isAllowed(ip)) { | ||
| allowedCount++; | ||
| } | ||
| } | ||
|
|
||
| assertThat(allowedCount < totalRequests).isTrue(); | ||
| assertThat(allowedCount <= 3).isTrue(); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("1분 내 50회 초과 요청 시 IP 차단 테스트: 51번째 요청부터 차단된다") | ||
| void test2() throws InterruptedException { | ||
| String ip = "192.168.1.101"; | ||
|
|
||
| for (int i = 0; i < 50; i++) { | ||
| assertThat(rateLimit.isAllowed(ip)).isTrue(); | ||
| Thread.sleep(1000); | ||
| } | ||
|
|
||
| boolean allowed = rateLimit.isAllowed(ip); | ||
| assertThat(allowed).isFalse(); | ||
|
|
||
| assertThat(rateLimit.isAllowed(ip)).isFalse(); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RateLimit 어노테이션과 AOP 를 잘 활용해주셨네요 👍🏿