-
Notifications
You must be signed in to change notification settings - Fork 37
[5주차] rate limit 적용 #103
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
[5주차] rate limit 적용 #103
Changes from all commits
90d5c3b
1a5bddd
b2ddd8a
19f200f
9253439
3dd75a9
7a65bb6
1deea31
90d80a6
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 |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package module.config; | ||
|
|
||
| import org.redisson.Redisson; | ||
| import org.redisson.api.RedissonClient; | ||
| import org.redisson.config.Config; | ||
| import org.redisson.spring.data.connection.RedissonConnectionFactory; | ||
| import org.springframework.beans.factory.annotation.Autowired; | ||
| import org.springframework.beans.factory.annotation.Qualifier; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.data.redis.connection.RedisConnectionFactory; | ||
| import org.springframework.data.redis.core.RedisTemplate; | ||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||
|
|
||
| @Configuration | ||
| public class AppRedisConfig { | ||
|
|
||
| @Bean | ||
| StringRedisTemplate stringRedisTemplate( RedisConnectionFactory connectionFactory) { | ||
| return new StringRedisTemplate(connectionFactory); | ||
| } | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package module.config.ratelimit; | ||
|
|
||
| import java.lang.annotation.ElementType; | ||
| import java.lang.annotation.Retention; | ||
| import java.lang.annotation.RetentionPolicy; | ||
| import java.lang.annotation.Target; | ||
|
|
||
| import module.config.ratelimit.limiters.RateLimiter; | ||
|
|
||
| @Target(ElementType.METHOD) | ||
| @Retention(RetentionPolicy.RUNTIME) | ||
| public @interface RateLimitWith { | ||
| Class<? extends RateLimiter> rateLimiter(); | ||
| boolean postProcess() default false; | ||
| boolean preProcess() default true; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| package module.config.ratelimit; | ||
|
|
||
| import java.lang.reflect.Method; | ||
|
|
||
| import org.aspectj.lang.ProceedingJoinPoint; | ||
| import org.aspectj.lang.annotation.Around; | ||
| import org.aspectj.lang.annotation.Aspect; | ||
| import org.aspectj.lang.reflect.MethodSignature; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.context.request.RequestContextHolder; | ||
| import org.springframework.web.context.request.ServletRequestAttributes; | ||
| import org.springframework.web.util.ContentCachingRequestWrapper; | ||
| import org.springframework.web.util.ContentCachingResponseWrapper; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import module.config.ratelimit.limiters.RateLimiter; | ||
| import module.config.ratelimit.limiters.RateLimiterFactory; | ||
|
|
||
| @Aspect | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class RateLimiterAop { | ||
|
|
||
| private final RateLimiterFactory rateLimiterFactory; | ||
|
|
||
| @Around("@annotation(module.config.ratelimit.RateLimitWith)") | ||
| public Object rateLimiter(final ProceedingJoinPoint joinPoint) throws Throwable { | ||
| MethodSignature signature = (MethodSignature)joinPoint.getSignature(); | ||
| Method method = signature.getMethod(); | ||
|
|
||
| // 설정된 RateLimiter 조회 | ||
| RateLimitWith rateLimitWith = method.getAnnotation(RateLimitWith.class); | ||
|
|
||
| // HttpServletRequest 조회 및 캐싱 | ||
| ServletRequestAttributes attributes = | ||
| (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); | ||
| ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(attributes.getRequest()); | ||
|
|
||
| RateLimiter rateLimiter = rateLimiterFactory.getRateLimiter(rateLimitWith.rateLimiter()); | ||
|
|
||
| if(rateLimitWith.preProcess()){ | ||
| if(!rateLimiter.preCheck(joinPoint.getArgs(), wrappedRequest)){ | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| Object result = joinPoint.proceed(); | ||
|
|
||
| ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(attributes.getResponse()); | ||
| if(rateLimitWith.postProcess()){ | ||
| rateLimiter.postCheck(joinPoint.getArgs(), wrappedResponse); | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package module.config.ratelimit.limiters; | ||
|
|
||
| import org.springframework.web.util.ContentCachingRequestWrapper; | ||
| import org.springframework.web.util.ContentCachingResponseWrapper; | ||
|
|
||
| public interface RateLimiter { | ||
|
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. preCheck, postCheck 행위를 "선언 및 추상화"한 부분 인상깊습니다. interface로 추상화할때 주의점은
Author
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. 진짜 범용 추상화 라는 것이 없다는 점, 잘못된 추상화에 대해 알려주셔서 너무 감사합니다. 앞으로 프로그래밍을 하며 많이 참고할 것 같습니다. 해당 부분 좀 더 고민하여 더 좋은 코드로 만들어보겠습니다 |
||
| boolean preCheck(Object[] args, ContentCachingRequestWrapper request); | ||
| default void postCheck(Object[] args, ContentCachingResponseWrapper response){} | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package module.config.ratelimit.limiters; | ||
|
|
||
| import org.springframework.context.ApplicationContext; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class RateLimiterFactory { | ||
|
|
||
| private final ApplicationContext applicationContext; | ||
|
|
||
| public RateLimiter getRateLimiter(Class<? extends RateLimiter> T){ | ||
| return applicationContext.getBean(T); | ||
| } | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| package module.config.ratelimit.limiters; | ||
|
|
||
| import java.util.Collections; | ||
|
|
||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||
| import org.springframework.data.redis.core.script.DefaultRedisScript; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.util.ContentCachingRequestWrapper; | ||
|
|
||
| import exception.showing.TooManyRequestException; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class ShowingSearchRateLimiter implements module.config.ratelimit.limiters.RateLimiter { | ||
|
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. Lua Script를 활용해 API에 맞는 RateLimiter를 구현해주신 부분 좋습니다 👍 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.
Author
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. 흥미로운 시도일 것 같습니다!! 시도해보겠습니다! 감사합니다. |
||
|
|
||
| private static final String LIMIT_PER_SECOND = "2"; | ||
| private static final String LIMIT_PER_MINUTE = "50"; | ||
| private static final DefaultRedisScript<Boolean> LUA_SCRIPT = new DefaultRedisScript( | ||
| """ | ||
| local prefix = KEYS[1] | ||
| local ip = ARGV[1] | ||
| local LIMIT_PER_SECOND = tonumber(ARGV[2]) | ||
| local LIMIT_PER_MINUTE = tonumber(ARGV[3]) | ||
|
|
||
| local blocked_ip_key = prefix .. ':BlockedIp:' .. ip | ||
| local request_counter_key = prefix .. ':PerMinuteRequestCounter:' .. ip | ||
| local per_second_counter_key = prefix .. ':PerSecondRequestCounter:' .. ip | ||
|
|
||
| if redis.call('EXISTS', blocked_ip_key) == 1 then | ||
| return false | ||
| end | ||
|
|
||
| local per_second_count = redis.call('INCR', per_second_counter_key) | ||
| if per_second_count == 1 then | ||
| redis.call('EXPIRE', per_second_counter_key, 1) end | ||
| if per_second_count > LIMIT_PER_SECOND then | ||
| return false | ||
| end | ||
|
|
||
| local current_count = redis.call('INCR', request_counter_key) | ||
| if current_count == 1 then | ||
| redis.call('EXPIRE', request_counter_key, 60) | ||
| end | ||
| if current_count > LIMIT_PER_MINUTE then | ||
| redis.call('SET', blocked_ip_key, 1) | ||
| redis.call('EXPIRE', blocked_ip_key, 3600) | ||
| return false | ||
| end | ||
|
|
||
| return true | ||
| """, Boolean.class); | ||
|
|
||
| private final StringRedisTemplate redisTemplate; | ||
| private final String KEY_PREFIX = "hanghaeho:showingSearch"; | ||
|
|
||
| @Override | ||
| public boolean preCheck(Object[] args, ContentCachingRequestWrapper request){ | ||
| String ip = request.getRemoteAddr(); | ||
| boolean isAllowed = Boolean.TRUE.equals(redisTemplate.execute( | ||
| LUA_SCRIPT, | ||
| Collections.singletonList(KEY_PREFIX), | ||
| ip, LIMIT_PER_SECOND, LIMIT_PER_MINUTE | ||
| )); | ||
|
|
||
| if(!isAllowed){ | ||
| throw new TooManyRequestException(); | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| package module.config.ratelimit.limiters; | ||
|
|
||
| import org.springframework.data.redis.core.RedisTemplate; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.util.ContentCachingRequestWrapper; | ||
| import org.springframework.web.util.ContentCachingResponseWrapper; | ||
|
|
||
| import dto.ticket.TicketReservationRequest; | ||
| import exception.ticket.NoReservationInfoException; | ||
| import exception.ticket.TwoManyReservationRequestException; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class TicketReservationRateLimiter implements RateLimiter { | ||
|
|
||
| private final String KEY_PREFIX = "hanghaeho:ticket_reservation_complete:"; | ||
| private final RedisTemplate<String, String> redisTemplate; | ||
|
|
||
| @Override | ||
| public boolean preCheck(Object[] args, ContentCachingRequestWrapper request) { | ||
| TicketReservationRequest ticketReservationRequest = getTicketReservationRequest(args); | ||
| Long showingId = ticketReservationRequest.getShowingId(); | ||
| String username = ticketReservationRequest.getUsername(); | ||
|
|
||
| if(isBlocked(username,showingId)){ | ||
| throw new TwoManyReservationRequestException(); | ||
|
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. Two -> Too 오타가 있네요 :) |
||
| } else { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public void postCheck(Object[] args, ContentCachingResponseWrapper response) { | ||
| if(response.getStatus() != HttpStatus.OK.value()){ | ||
| return; | ||
| } | ||
|
|
||
| TicketReservationRequest ticketReservationRequest = getTicketReservationRequest(args); | ||
|
|
||
| Long showingId = ticketReservationRequest.getShowingId(); | ||
| String username = ticketReservationRequest.getUsername(); | ||
|
|
||
| addBlock(username, showingId); | ||
| } | ||
|
|
||
| public boolean isBlocked(String user, Long showingId) { | ||
| String key = keyFormatter(user, showingId); | ||
| return redisTemplate.opsForValue().get(key) != null; | ||
|
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 서버(보통 현업에서는 Caching 서버를 따로 배포해서 활용)가 중간에 문제로 다운된다면? 어떻게 될까요.
Author
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 도입에 급급했던 것 같습니다. 장애에 대한 대비 해보겠습니다. |
||
| } | ||
|
|
||
| public void addBlock(String username, Long showingId) { | ||
| String key = keyFormatter(username, showingId); | ||
| redisTemplate.opsForValue().set(key, "complete", 300000); | ||
| } | ||
|
|
||
| public TicketReservationRequest getTicketReservationRequest(Object[] args){ | ||
| for(Object o : args){ | ||
| if(o instanceof TicketReservationRequest){ | ||
| return (TicketReservationRequest) o; | ||
| } | ||
| } | ||
| throw new NoReservationInfoException(); | ||
| } | ||
|
|
||
| public String keyFormatter(String username, Long showingId){ | ||
| return KEY_PREFIX + username + ":" + showingId; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,8 @@ | |
| import jakarta.validation.constraints.Pattern; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import module.config.ratelimit.RateLimitWith; | ||
| import module.config.ratelimit.limiters.TicketReservationRateLimiter; | ||
| import module.service.ticket.TicketService; | ||
|
|
||
| @Slf4j | ||
|
|
@@ -43,12 +45,13 @@ public ResponseEntity<List<TicketResponse>> getUserTicket( | |
| } | ||
|
|
||
| @PostMapping(value = "/reservation") | ||
| @RateLimitWith(rateLimiter = TicketReservationRateLimiter.class, postProcess = true) | ||
| public ResponseEntity<String> reservation( | ||
| @RequestBody TicketReservationRequest request | ||
| ) { | ||
| Long showingId = request.getShowingId(); | ||
| String username = request.getUsername(); | ||
| List<TicketDTO> ticketList = request.getTicketList(); | ||
| return ResponseEntity.ok(ticketService.reservationWithFunctional(showingId, username, ticketList)); | ||
| return ResponseEntity.ok(ticketService.reservation(showingId, username, ticketList)); | ||
|
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. 자바 코딩 스타일 컨벤션은 메서드는 동사를 사용함으로 reserve가 더 맞아보입니다 :) |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,11 +9,13 @@ | |
|
|
||
| import exception.common.TryLockFailedException; | ||
| import exception.showing.ShowingNotFoundException; | ||
| import exception.showing.TooManyRequestException; | ||
| import exception.ticket.InvalidAgeForMovieException; | ||
| import exception.ticket.InvalidSeatConditionException; | ||
| import exception.ticket.InvalidTicketException; | ||
| import exception.ticket.NotOnSaleTicketException; | ||
| import exception.ticket.TooManyReservationException; | ||
| import exception.ticket.TwoManyReservationRequestException; | ||
| import exception.user.UserNotFoundException; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
|
|
@@ -83,4 +85,18 @@ protected ResponseEntity<String> tryLockFailedException(TryLockFailedException e | |
| return ResponseEntity.status(HttpStatus.NO_CONTENT) | ||
| .body(e.getMessage()); | ||
| } | ||
|
|
||
| @ExceptionHandler(TooManyRequestException.class) | ||
| @ResponseStatus(HttpStatus.NO_CONTENT) | ||
| protected ResponseEntity<String> tooManyRequestException(TooManyRequestException e){ | ||
| return ResponseEntity.status(HttpStatus.BAD_REQUEST) | ||
|
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. Too many request에 알맞게 429 상태코드를 리턴하도록 해주시면 더 적합할듯 합니다 :) |
||
| .body(e.getMessage()); | ||
| } | ||
|
|
||
| @ExceptionHandler(TwoManyReservationRequestException.class) | ||
| @ResponseStatus(HttpStatus.BAD_REQUEST) | ||
| protected ResponseEntity<String> tooManyReservationRequestException(TwoManyReservationRequestException e){ | ||
| return ResponseEntity.status(HttpStatus.BAD_REQUEST) | ||
| .body(e.getMessage()); | ||
| } | ||
| } | ||
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.
RateLimiter를 AOP를 활용해서 구현해주신점 좋습니다 :)