api를 공개하게 되면 유저가 마음대로 요청을 할 수 있다.
프론트 쪽에서는 쓰로틀링으로 막아주긴하지만, 백엔드에서는
추가적으로 요청량을 제한 해야한다.
요청량을 제한하는건 ip기반으로 하게되면,
로드밸런서에서 막아줄수도있고 , nginx 에서도 막아 줄 수 있다.
두둥 프로젝트에서는 유저 아이디 기반으로 요청량을 측정하고 싶어서
서버 내부서 제한을 걸기로 했다.
그 방법을 공유하고자 한다.
목차
1. 문제점
2. buket4j
3. 적용하기
3.1. Bucket4j 와 레디스 jcache 로 연결하기
3.2. ProxyManager 로 버킷 만들기
3.3 인터셉터에 적용하기
3.3.1. 인터셉터에서 유저정보를 불러올려면? SecurityContextHodler
3.3.2 인터셉터에 적용해보자.
1. 문제점
두둥 백엔드 서비스에서는 주문, 재고감소 등 로직간의 동시성 이슈를 해소하기위해서
Redisson 기반 분산락을 사용중이다.
Redisson 기반 분산락에는 타임아웃이 존재하는데,
waitTime 이 지나게 되면 락으로 진입 자체를 못하게 된다.
boolean available = rLock.tryLock(waitTime, leaseTime, timeUnit);
락이라는건 공유자원에 접근 할때 한번에 하나 씩 집어넣는것이므로,
악성유저가 락을 쓰는 api를 계속 요청할 경우 처리하는데 시간이걸리므로 서버 에러가 발생하게 된다.
클라이언트가 작업중이다가... 실제로 계속 api 콜하면서 500번대 응답이 엄청 생긴적이있다.
그래서 쓰로틀링을 유저 아이디 기반으로 서버 쪽에도 넣어야 할것 같아서
Rate Limit 을 적용하기로 했다.
2. bucket4j
구현 방식에는 여러 방법이 있을것 같은데,
토큰 알고리즘 을 이용해서 적용하는 방식이다.
버킷 알고리즘에 관련해서는 다른 블로그를 참고하면 좋을것같다.
제일 고민했던 포인트는
위와같은 고민이었는데 , 지금은 서버가 한대라 상관없지만 분산 서버 환경에서는
토큰이 얼마나남아있는지 서버 어플리케이션간에 공유할 수 있는 구성이였으면 했다.
그와중에 jcache api 사용가능하고 , redis 연동이 가능한 ( Redisson 이 jcache 를 지원함 ) , bucket4j 를 선택했다.
즉 우리는 jcache api 를 사용해서 레디스를 토큰을 여러 서버에서 공유할 수 있는 메모리 저장소로 사용할 예정이다.
3. 적용하기
3.1. Bucket4j 와 레디스 jcache 로 연결하기
위 두 문서를 살펴보면 될것같다.
블로그는 예시 코드일뿐이지 항상 공식 문서 읽는게 좋다.
/** for bucket4j */
// JCache 의 CacheManager를 생성한다.
@Bean
public CacheManager cacheManager(RedissonClient redissonClient) {
CacheManager manager = Caching.getCachingProvider().getCacheManager();
Cache<Object, Object> bucket4j = manager.getCache("bucket4j");
// 테스트 코드에서 깨져서 null check 하고 createCache 를해야함
if (bucket4j == null) {
manager.createCache("bucket4j", RedissonConfiguration.fromInstance(redissonClient));
}
return manager;
}
/** for bucket4j */
@Bean
ProxyManager<String> proxyManager(CacheManager cacheManager) {
// JCacheProxyManager 를 생성한다.
return new JCacheProxyManager<>(cacheManager.getCache("bucket4j"));
}
Redisson 클라이언트가 JCache api 를 지원하고,
JCache 의 ChaceManager를 만든뒤에 해당 캐시 매니저로
Bucket4j 의 JCacheProxyManager을 생성한다.
우린 ProxyManager<String> proxyManger을 빈으로 등록하고 사용할 것이다.
레디스를 캐시 저장소로 활용 하였으니, 다중 서버에서도 토큰 여부를 같이 판단 할 수 있게 되었다.
3.2. ProxyManager 로 버킷 만들기
@Component
@RequiredArgsConstructor
public class UserRateLimiter {
// autowiring dependencies
private final ProxyManager<String> buckets;
@Value("${throttle.overdraft}")
private long overdraft;
@Value("${throttle.greedyRefill}")
private long greedyRefill;
public Bucket resolveBucket(String key) {
Supplier<BucketConfiguration> configSupplier = getConfigSupplierForUser();
return buckets.builder().build(key, configSupplier); // 1
}
private Supplier<BucketConfiguration> getConfigSupplierForUser() {
Refill refill = Refill.greedy(greedyRefill, Duration.ofMinutes(1));
Bandwidth limit = Bandwidth.classic(overdraft, refill);
return () -> (BucketConfiguration.builder().addLimit(limit).build()); //2
}
}
우선 우린 유저 아이디 기반 Rate Limit 를 걸길 원하고 있으니 ( 두둥 서버에서는 익명 유저일경우에는 아이피 기반으로 걸어버렸다 )
//1 버킷을 유저아이디를 키값으로 만들고, 있으면 리턴을 해준다 build 라고 해서 매번 새로운 버킷을 만들지 않느다.
//2 BucketConfiguration으로 Bandwith 를설정한다. 전략이 여러가지가 있다.
필자는 그리디하게 1분에 몇개를 채우는 정도로 설정해놨다.
전략이 더많으니 공식문서에서 확인하길 바란다.
3.3 인터셉터에 적용하기
3.3.1. 인터셉터에서 유저정보를 불러올려면? SecurityContextHodler
Rate Limit 을 적용하는데는 정말 자유로울 것 같다.
Aop 에 적용해도 되고 , 필터 단에 넣어도 되고,
두둥 프로젝트에서는 우선 글로벌하게 rate limit을 걸고 싶고, 유저 아이디 기반으로 걸고 싶어서
인터셉터에 위치시켰다.
요청이 들어올때에 시큐리티 context에서 유저 아이디 정보를 가져온다.
시큐리티 컨텍스트에서도 사용자에 관련된 정보를 불러올 수 있다.
// 두둥의 시큐리티 유틸 클래스
public class SecurityUtils {
private static SimpleGrantedAuthority anonymous = new SimpleGrantedAuthority("ROLE_ANONYMOUS");
private static SimpleGrantedAuthority swagger = new SimpleGrantedAuthority("ROLE_SWAGGER");
private static List<SimpleGrantedAuthority> notUserAuthority = List.of(anonymous, swagger);
public static Long getCurrentUserId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
throw SecurityContextNotFoundException.EXCEPTION;
}
if (authentication.isAuthenticated()
&& !CollectionUtils.containsAny(
authentication.getAuthorities(), notUserAuthority)) {
return Long.valueOf(authentication.getName());
}
// 스웨거 유저일시 익명 유저 취급
// 익명유저시 userId 0 반환
return 0L;
}
}
위처럼 요청마다 쓰레드 로컬에 인증 관련정보가 저장되어서 요청마다 SecurityContextHodler에서
인증정보를 가져올 수 있는데, ( 마치 트랜잭션 쓰레드 로컬과 비슷하다 )
익명 유저도 값을 꺼내올 수 있다는 조건이있어,
(익명 유저는 비 인증 api 인 경우 시큐리티 필터를 통과하면 익명 유저이다 )
익명유저와 스웨거 유저( 스웨거에 비밀번호를 걸기위해서 Basic auth 를 설정한 상태임 ) 이면 유저아이디 0을 리턴하도록 했다.
3.3.2 인터셉터에 적용해보자.
@Component
@RequiredArgsConstructor
@Slf4j
public class ThrottlingInterceptor implements HandlerInterceptor {
private final UserRateLimiter userRateLimiter;
private final IPRateLimiter ipRateLimiter;
private final ObjectMapper objectMapper;
private final SlackThrottleErrorSender slackThrottleErrorSender;
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws IOException {
Long userId = SecurityUtils.getCurrentUserId();
Bucket bucket;
if (userId == 0L) {
// 익명 유저 ip 기반처리
String remoteAddr = request.getRemoteAddr();
bucket = ipRateLimiter.resolveBucket(remoteAddr);
} else {
// 비 익명 유저 유저 아이디 기반 처리
bucket = userRateLimiter.resolveBucket(userId.toString());
}
if (bucket.tryConsume(1)) {
return true;
}
// 슬랙 알림 메시지 발송.
// limit is exceeded
ContentCachingRequestWrapper cachingRequest = (ContentCachingRequestWrapper) request;
slackThrottleErrorSender.execute(cachingRequest, userId);
responseTooManyRequestError(request, response);
return false;
}
간단하다
익명 유저와 스웨거 유저일 경우 ip 기반으로,
비 익명 유저일 경우 유저 아이디 기반으로 Rate Limit 을 적용했다.
익명 유저는 비인증 api 일경우 시큐리티는 익명유저로판단한다.
추가적으로 두둥에서는 슬랙으로 알림도 전송한다.
이글에서 중요한점은 redis client인 redisson 이 jcahce api를 지원한다는점,
이를 이용해서 bucket4j 를 연동 할 수 있다는점.
redis 를 활용해서 토큰의 공유 저장소로 활용해서 분산 서버에 적용가능하다는 점.
이 세가지 정도일것같다.
이글의 소스는 위 레포지토레이서 참고 가능하다.
굳이 서버에서도 Rate Limit 처리를 안해도
aws로드밸런서나 nginx에도 처리가 가능하니,
유저아이디 기반으로 처리할게 아니라면
다른 방식을 추천 드리고싶다.
토큰 공유저장소로 레디스는 속도가 빠르긴 하지만, 진짜 몸집이 커지게되면 위 라인 블로그와 같은 방식을 고려해야할것같다.
'스프링' 카테고리의 다른 글
[스프링] spring batch 도커로 세팅하기 with jenkins (0) | 2023.03.07 |
---|---|
[스프링] spring thymeleaf to pdf 이미지,한글 적용하기 (1) | 2023.03.06 |
[스프링] spring 프록시 환경에서 HttpContentCache 적용 (0) | 2023.03.06 |
[스프링] spring swagger 같은 코드 여러 에러 응답 예시 만들기 (3) | 2023.03.06 |
[스프링] 공통 응답 형식 만들기 ResponseBodyAdvice (0) | 2023.03.05 |