프로젝트를 진행하면서 구현하게 되는 대부분은 특정 서비스를 수행하기 위한 api 설계가 대부분이었던 것 같습니다. (개발 레벨)
하지만 현업에 계신 개발자님들의 이야기를 종종 듣고 나면,
실제 서비스에서 개발만큼 중요한 부분이 운영적인 부분이라고 느꼈습니다.
개발한 애플리케이션이 항상 의도대로 동작한다고 단정 지을 수 없고
유저의 예측할 수 없는 여러 동작에 대한 경우의 수를 세놓고 그에 대한 대비책을 만드는 것이 훌륭한 개발자로 도달하기 위해 필요되는 역량이라고 생각했습니다.
우선 프로젝트에 구현하려고 하는 부분은 다음과 같습니다.
rate limit
threshold 초과시 알림 메시지그리고 이를 프로젝트 세팅에 맞게 구체화하여 다음과 같은 요구사항을 만들었습니다.
1초에 10번 이상의 api 호출 시 1회의
rate limit
발생으로 간주하며,
만약 1시간 안에 10번 이상의rate limit
을 발생 시킨 클라이언트가 있다면
해당 클라이언트에 대한 알림 메시지를 서버측에 발송합니다.
뿐만아니라 조금 더 응용한다면 더 많은 서비스 및 시스템을 구축할 수 있습니다.
(유저의 레벨에 따른 번역 기능 횟수 제한하기, 500 에러 발생시 알림등..)
rate limit
이란 시스템의 소비 가능한 리소스의 속도 및 수량을 제한하는 기술로 ,
일정 시간 동안 애플리케이션에 대한 요청의 수를 제한하는 것입니다.
rate limit
은 다음과 같은 상황에서 사용이 용이합니다.
api 남용 및 많은 사용량 처리
Dos 공격 방지
rate limit
은 클라이언트의 요청을 제한함으로서 이 같은 문제를 해결하거나 완화할 수 있습니다.
먼저 rate limit
을 구현하기 위해서 저는 Bucket4j 라이브러리를 활용하기로 결정했습니다.
rate limit
을 지원하는 라이브러리로서 고효율적인 rate limit
을 구성하고 있습니다.Spring boot 에서 bucket4j
를 활용하기 위해서는 다음과 같은 의존성 주입이 필요합니다.
implementation group: 'com.github.vladimir-bukhtoyarov', name: 'bucket4j-core', version: '7.0.0'
이제 본격적으로 구현을 해보겠습니다.
먼저 rate limit
관련한 bucket 생성에 필요한 상수에 대해서 별도의 클래스로 관리하였습니다.
RateLimitBucketConstants
/**
* @apiNote # BUCKET_CAPACITY : 버킷의 총 크기 (용량)
* # BUCKET_TOKENS : 시간당 버킷안에 충전되는 토큰의 수
* # CALLS_IN_SECONDS : 버킷 충전 시간
* # REQUEST_COST_IN_TOKENS : 1회 요청당 소비되는 토큰 수
*/
public class RateLimitBucketConstants {
public static final Long BUCKET_CAPACITY = 10L;
public static final Long BUCKET_TOKENS = 10L;
public static final Duration CALLS_IN_SECONDS = Duration.ofSeconds(1);
public static final Integer REQUEST_COST_IN_TOKENS = 1;
}
해당 상수에 대한 설정이 반영된다면 bucket에 1초에 10번 토큰이 채워지며
토큰의 수는 10을 초과할 수 없게 됩니다.
로그인/ 비로그인을 포함한 rate limit
체크를 하기 위해서는 클라이언트의 IP 정보가 필요하다고 생각하여 다음과 같이 클라이언트의 IP에 대한 유틸 클래스를 생성하였습니다.
ClientIpUtil
public class ClientIpUtil {
private static final String[] IP_HEADER_CANDIDATES = {
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_X_FORWARDED_FOR",
"HTTP_X_FORWARDED",
"HTTP_X_CLUSTER_CLIENT_IP",
"HTTP_CLIENT_IP",
"HTTP_FORWARDED_FOR",
"HTTP_FORWARDED",
"HTTP_VIA",
};
/**
* @param request current HTTP request
* @return (String) client Ip
* @apiNote 'X-Forwarded-For' 참고: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
*/
public static String getClientIp(HttpServletRequest request) {
return Arrays.stream(IP_HEADER_CANDIDATES)
.map(request::getHeader)
.filter(ipAddress -> ipAddress != null && !ipAddress.isEmpty() && !"unknown".equalsIgnoreCase(ipAddress))
.map(ipAddress -> ipAddress.split(",")[0])
.findFirst()
.orElseGet(request::getRemoteAddr);
}
}
IP 추출에 대해서는 해당 레퍼런스 를 참조했습니다.
rate limit
을 체크할 수 있는 RateLimitingInterceptor
을 만들어 주었습니다.
package com.sns.yourconnection.common.interceptor;
import static com.sns.yourconnection.utils.constants.RateLimitBucketConstants.*;
...(생략)
@Component
@RequiredArgsConstructor
@Slf4j
public class RateLimitingInterceptor implements HandlerInterceptor {
private final Map<String, Bucket> cache = new ConcurrentHashMap<>();
private final RateLimitService rateLimitService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response, Object handler) {
String clientIp = ClientIpUtil.getClientIp(request);
Bucket bucket = cache.computeIfAbsent(clientIp, key -> newBucket());
ConsumptionProbe consumptionProbe = bucket.tryConsumeAndReturnRemaining(
REQUEST_COST_IN_TOKENS);
if (isRateLimitExceeded(request, response, clientIp, consumptionProbe)) {
return false;
}
return true;
}
/**
* @apiNote rate limit 발생여부에 따른 각각의 success, error response 를 생성 및 반환 합니다. *
* 'rateLimitService.isLimitReachedThreshold(...)' 특정 IP에 대한 rate limit 허용치 초과 여부를 check 합니다.*
*/
private boolean isRateLimitExceeded(HttpServletRequest request, HttpServletResponse response,
String clientIp, ConsumptionProbe consumptionProbe) {
if (!consumptionProbe.isConsumed()) {
float waitForRefill =
RateLimitRefillChecker.getRoundedSecondsToWaitForRefill(consumptionProbe);
RateLimitResponse.errorResponse(
response, BUCKET_CAPACITY, CALLS_IN_SECONDS, waitForRefill);
log.warn(
"rate limit exceeded for client IP :{} Refill in {} seconds Request "
+ "details: method = {} URI = {}",
clientIp, waitForRefill, request.getMethod(), request.getRequestURI());
// 만약에 1시간에 10번 이상의 Rate Limit 에러를 발생시키는 유저가 있다면 메시지 알림.
rateLimitService.isLimitReachedThreshold(clientIp);
return true;
}
RateLimitResponse.successResponse(
response, consumptionProbe.getRemainingTokens(), BUCKET_CAPACITY, CALLS_IN_SECONDS);
log.info("remaining token: {}", consumptionProbe.getRemainingTokens());
return false;
}
private Bucket newBucket() {
return Bucket.builder()
.addLimit(Bandwidth.classic(
BUCKET_CAPACITY, Refill.intervally(
BUCKET_TOKENS, CALLS_IN_SECONDS)))
.build();
}
}
인터셉터를 사용한 이유
rate limit
발생 여부를 체크해야 하므로 때문에 인터셉터를 활용했습니다.ConcurrentHashMap 사용 이유
rate limit
발생시 클라이언트에 대한 버킷 토큰을 카운트하고 소비시키기 위해서는 Thread-safe 해야 했습니다.ConcurrentHashMap
은 동시성(Concurrency) 환경에서 안전하게 사용할 수 있는 해시 맵 구현이며 여러 스레드가 동시에 맵에 접근하더라도 일관된 상태를 유지할 수 있습니다.ConcurrentHashMap
에 대해서는 [ConcurrentHashMap 동기화 방식] 를 참조했습니다.
(상황에 따라서는 ConcurrentHashMap
대신에 Redis
를 활용해 보는 것도 가능할 것 같습니다.)
버킷 생성 및 소비
Bucket bucket = cache.computeIfAbsent(clientIp, key -> newBucket());
ConsumptionProbe consumptionProbe = bucket.tryConsumeAndReturnRemaining(
REQUEST_COST_IN_TOKENS);
clientIp
에 대한 버킷을 불러오고 버킷을 REQUEST_COST_IN_TOKENS
만큼 consume 시킵니다. clientIp
에 대한 버킷이 존재하지 않다면 새로운 버킷을 생성합니다.RateLimit 발생 여부 체크
private boolean isRateLimitExceeded(HttpServletRequest request, HttpServletResponse response,
String clientIp, ConsumptionProbe consumptionProbe) {
if (!consumptionProbe.isConsumed()) {
...(생략)
// 만약에 1시간에 10번 이상의 Rate Limit 에러를 발생시키는 유저가 있다면 알림 메시지
rateLimitService.isLimitReachedThreshold(clientIp);
return true;
}
버킷에 더 이상 남아있는 토큰의 수가 없을때 (모든 버킷을 소비했을 때)
해당 조건식 if (!consumptionProbe.isConsumed())
을 만족하게 됩니다.
rateLimitService
서비스 레이어를 통해 해당 클라이언트가 1시간에 10번 이상의 rate limit
을 발생 시켰는지 확인합니다.
다음과 같이 서비스 레이어를 만들어 주었습니다.
RateLimitService
@Slf4j
@Service
@RequiredArgsConstructor
public class RateLimitService {
private Map<String, RequestInfo> rateLimitErrorCounter = new ConcurrentHashMap<>();
private final NotificationService notificationService;
public boolean isLimitReachedThreshold(String clientIp) {
RequestInfo requestInfo = rateLimitErrorCounter.computeIfAbsent(clientIp,
key -> new RequestInfo());
if (!isApplyHandling(clientIp, requestInfo)) {
requestInfo.saveCount();
return false;
}
return true;
}
private boolean isApplyHandling(String clientIp, RequestInfo requestInfo) {
if (requestInfo.isWithinTimeWindow()) {
int count = requestInfo.incrementAndGetCount();
log.info("[Rate limit count] client IP : {} limit count : {} ", clientIp, count);
checkAndResetIfLimitExceeded(clientIp, requestInfo, count);
return true;
}
return false;
}
private void checkAndResetIfLimitExceeded(String clientIp, RequestInfo requestInfo, int count) {
if (count >= 10) {
requestInfo.resetCount();
notificationService.sendMessage(
String.format(" Rate limit is occurred 10 or more times for this client IP: %s",
clientIp));
}
}
private static class RequestInfo {
private AtomicInteger count = new AtomicInteger(0);
private LocalDateTime lastRequestTime;
public int incrementAndGetCount() {
return this.count.incrementAndGet();
}
public boolean isWithinTimeWindow() {
LocalDateTime now = LocalDateTime.now();
if (lastRequestTime == null || ChronoUnit.HOURS.between(lastRequestTime, now) >= 1) {
lastRequestTime = now;
return false;
}
return true;
}
public void saveCount() {
count.set(1);
lastRequestTime = LocalDateTime.now();
}
public void resetCount() {
count.set(0);
lastRequestTime = LocalDateTime.now();
}
}
}
역시나 클라이언트에 대한 카운트 원자성을 보장 받기 위해서 ConcurrentHashMap
을 활용했습니다.
최초 rate limit
발생 후 1시간이 지났다면 해당 클라이언트에 대한 카운트를 초기화하기 위해서 카운트와 마지막 발생 시간의 정보를 담고 있는RequestInfo
객체를 내부 클래스로 생성해 주었습니다.
만약 클라이언트가 1시간 안에 10번 이상의 rate limit
을 발생시켰다면 notificationService
를 통해 알림 메시지를 전송합니다.
(notificationService
에 대해서는 조금 뒤에 다루겠습니다.)
rate limit
관련되서 조금 더 세분화 된 response 가 필요하다고 생각하여
RateLimitResponse
클래스를 생성해 주었습니다.
public class RateLimitResponse {
/**
* @apiNote 'X-RateLimit-RetryAfter', 'X-RateLimit-Limit', 'X-RateLimit-Remaining' 참고:
* https://sendbird.com/docs/chat/v3/platform-api/application/understanding-rate-limits/rate-limits
* @apiNote `Retry-After` 참고:
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
*/
public static void successResponse(HttpServletResponse response,
long remainingTokens, Long bucketCapacity, Duration callsInSeconds) {
response.setHeader("X-RateLimit-Remaining",
Long.toString(remainingTokens));
response.setHeader("X-RateLimit-Limit",
bucketCapacity + ";w=" + callsInSeconds.getSeconds());
}
public static void errorResponse(HttpServletResponse response,
Long bucketCapacity, Duration callsInSeconds, float waitForRefill) {
response.setHeader("X-RateLimit-RetryAfter",
Float.toString(waitForRefill));
response.setHeader("X-RateLimit-Limit",
bucketCapacity + ";w=" + callsInSeconds.getSeconds());
response.setStatus(ErrorCode.TOO_MANY_REQUESTS.getHttpStatus().value());
}
}
관련된 response 메시지는 다음의 레퍼런스를 참고했습니다.
이제 에러에 대한 알림 메시지를 전송할 notificationService
를 구현해 보겠습니다.
NotificationService
public interface NotificationService {
void sendMessage(String message);
}
저는 텔레그램을 통해 알림 메시지를 전송하게 만들었습니다.
(텔레그램에 대한 구체적인 세팅을 생략하겠습니다.)
TelegramService
@Slf4j
@Service
@RequiredArgsConstructor
public class TelegramService implements NotificationService {
private final TelegramProperties properties;
private final Environment environment;
private final RestTemplate restTemplate;
@Override
public void sendMessage(String message) {
message = environment.getProperty("spring.config.activate.on-profile") + message;
try {
sendTelegram(properties, message);
log.info(message);
} catch (Exception e) {
throw new TelegramException(ErrorCode.TELEGRAM_SEND_ERROR);
}
}
private void sendTelegram(TelegramProperties properties, String message) {
try {
final String url = properties.getUrl();
final String chatId = properties.getChatId();
final HttpHeaders headers = new HttpHeaders();
HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();
CloseableHttpClient httpClient = getHttpClient();
requestFactory.setHttpClient(httpClient);
headers.set("Accept", MediaType.APPLICATION_JSON_VALUE);
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url)
.queryParam("chat_id", chatId)
.queryParam("parse_mode", "HTML")
.queryParam("disable_web_page_preview", "true")
.queryParam("text", message);
final HttpEntity<?> entity = new HttpEntity<>(headers);
restTemplate.exchange(
builder.build()
.encode()
.toUri(), HttpMethod.GET, entity, String.class);
} catch (Exception e) {
throw new TelegramException(ErrorCode.TELEGRAM_SEND_ERROR);
}
}
private CloseableHttpClient getHttpClient() {
CloseableHttpClient httpClient = HttpClients.custom()
.setSSLHostnameVerifier(
new NoopHostnameVerifier())
.build();
return httpClient;
}
}
이렇게 Properties 값으로 세팅한 텔레그램 채팅 방에 에러 발생시 관련 메시지를 전송하도록 만들어 주었습니다.
결과값 확인
이후 의도적으로 rate limit을 발생시켰고 결과를 확인했습니다.
이로서 Rate limit Handling 및 모니터링을 구현해 보았습니다.😃
긴 글 읽어주셔서 감사합니다 :)
프로젝트에 전체 코드는 [프로젝트 깃 허브 링크]에서 확인하실 수 있습니다.
개선점이나 더 좋은 것들이 있다면 같이 공유해요!