Skip to content
Merged
24 changes: 24 additions & 0 deletions module-app/src/main/java/module/config/AppRedisConfig.java
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 {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RateLimiter를 AOP를 활용해서 구현해주신점 좋습니다 :)

  • default를 활용한 부분도 좋네요 👍

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 {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preCheck, postCheck 행위를 "선언 및 추상화"한 부분 인상깊습니다. interface로 추상화할때 주의점은

  • 일반 애플리케이션 개발에서는 "진짜 범용 추상화"라는 것이 거의 없다는 점입니다(변화하기 때문, 라이브러리는 예외).
  • 다만, RateLimiter를 정의하는 구현체가 해당 메서드의 선언을 정확하게 동작하도록 "정의"를 잘하고 있는지?
  • 만약, 어떤 구현체는 postCheck가 필요없어 로깅만 하던지, 의미없는 리턴을 한다면 잘못된 추상화라고 할 수 있습니다 :)

Copy link
Author

Choose a reason for hiding this comment

The 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 {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lua Script를 활용해 API에 맞는 RateLimiter를 구현해주신 부분 좋습니다 👍

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 다만 해당 부분이 "하드코딩"되어 있습니다. 아시겠지만, 버그 발생시 런타임에 오류가 발생 그리고 버그 파악도 어려운 부분이 있습니다.
  • 시간이되신다면 커스텀하게 해당 부분을 책임지는 컴포넌트(클래스)를 만들어 보는 것도 고려할 수 있습니다.(eg QueryDSL)

Copy link
Author

Choose a reason for hiding this comment

The 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();

Choose a reason for hiding this comment

The 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;
Copy link

@soonhankwon soonhankwon Feb 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

만약 Redis 서버(보통 현업에서는 Caching 서버를 따로 배포해서 활용)가 중간에 문제로 다운된다면? 어떻게 될까요.

  • 주석이라던지, 명확한 가정이 필요해보입니다. 아니라면 Redis 장애에 대비한 동작이 있으면 좋겠습니다.

Copy link
Author

Choose a reason for hiding this comment

The 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
Expand Up @@ -14,6 +14,8 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import module.config.ratelimit.limiters.ShowingSearchRateLimiter;
import module.config.ratelimit.RateLimitWith;
import module.service.showing.ShowingService;

@RestController
Expand All @@ -24,6 +26,7 @@ public class ShowingController {
private final ShowingService showingService;

@GetMapping("/all")
@RateLimitWith(rateLimiter = ShowingSearchRateLimiter.class)
public ResponseEntity<List<MovieShowingResponse>> getTodayShowing(
@Valid @Size(max = 10, message = "제목 최대 길이는 255자 이내 입니다.")
@Nullable @RequestParam String title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

자바 코딩 스타일 컨벤션은 메서드는 동사를 사용함으로 reserve가 더 맞아보입니다 :)

}
}
16 changes: 16 additions & 0 deletions module-app/src/main/java/module/exception/ControllerAdvice.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)

Choose a reason for hiding this comment

The 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());
}
}
Loading