Skip to content

Commit

Permalink
Merge pull request #38 from tthisag246/feature/34
Browse files Browse the repository at this point in the history
[feat] Redis를 이용한 Rate Limiter 적용 (#34)
  • Loading branch information
win-luck authored Aug 28, 2024
2 parents 79eb54a + 729a4f4 commit 2630a97
Show file tree
Hide file tree
Showing 12 changed files with 203 additions and 0 deletions.
Binary file modified .gradle/8.8/checksums/checksums.lock
Binary file not shown.
Binary file modified .gradle/8.8/checksums/md5-checksums.bin
Binary file not shown.
Binary file modified .gradle/8.8/checksums/sha1-checksums.bin
Binary file not shown.
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,13 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'

// Rabbit MQ
implementation 'org.springframework.boot:spring-boot-starter-amqp'

// bucket4j
implementation 'com.bucket4j:bucket4j_jdk17-redis-common:8.13.1'
implementation 'com.bucket4j:bucket4j_jdk17-lettuce:8.13.1'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package gdsc.cau.puangbe.common.config;

import io.github.bucket4j.distributed.ExpirationAfterWriteStrategy;
import io.github.bucket4j.redis.lettuce.Bucket4jLettuce;
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.codec.ByteArrayCodec;
import io.lettuce.core.codec.RedisCodec;
import io.lettuce.core.codec.StringCodec;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;

@Configuration
@RequiredArgsConstructor
public class RateLimiterConfig {

private final RedisClient redisClient;

@Bean
public LettuceBasedProxyManager<String> proxyManager() {
StatefulRedisConnection<String, byte[]> connection = redisClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));
return Bucket4jLettuce.casBasedBuilder(connection)
.expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofHours(1))) // 1시간마다 리필
.build();
}
}
12 changes: 12 additions & 0 deletions src/main/java/gdsc/cau/puangbe/common/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package gdsc.cau.puangbe.common.config;

import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
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;
Expand All @@ -10,6 +13,10 @@

@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
String host;
@Value("${spring.data.redis.port}")
Integer port;

// String key, Long value(requestId)를 다루기 위한 RedisTemplate입니다. (현재 유저의 요청의 대기열 역할을 수행하게 됩니다)
// opsForSet().add()를 통해 requestId를 추가할 수 있으며, 이 때 key는 별도로 만든 키로 설정합니다. (RequestQueue 등)
Expand All @@ -33,4 +40,9 @@ public RedisTemplate<String, Long> redisTemplate(RedisConnectionFactory connecti
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}

@Bean
public RedisClient redisClient() {
return RedisClient.create(RedisURI.builder().withHost(host).withPort(port).build());
}
}
9 changes: 9 additions & 0 deletions src/main/java/gdsc/cau/puangbe/common/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
package gdsc.cau.puangbe.common.config;

import gdsc.cau.puangbe.common.config.resolver.PuangUserArgumentResolver;
import gdsc.cau.puangbe.common.interceptor.RateLimiterInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

private final PuangUserArgumentResolver puangUserArgumentResolver;
private final RateLimiterInterceptor rateLimiterInterceptor;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(puangUserArgumentResolver);
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimiterInterceptor);
}
}
54 changes: 54 additions & 0 deletions src/main/java/gdsc/cau/puangbe/common/enums/RateLimitPolicy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package gdsc.cau.puangbe.common.enums;

import gdsc.cau.puangbe.common.exception.RateLimiterException;
import gdsc.cau.puangbe.common.util.ResponseCode;
import io.github.bucket4j.Bandwidth;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.time.Duration;
import java.util.Arrays;

@Getter
@RequiredArgsConstructor
public enum RateLimitPolicy {
GENERAL("general") {
@Override
public Bandwidth getLimit() {
return Bandwidth.builder()
.capacity(50)
.refillIntervally(50, Duration.ofSeconds(1))
.build();
}
// 버킷 용량: 토큰 50개
// 1초에 토큰 50개씩 리필 (버킷 용량 내에서)
// 1시간마다 버킷 전체 리필(RateLimiterConfig에 설정됨)
},

HEAVY("heavy") {
@Override
public Bandwidth getLimit() {
return Bandwidth.builder()
.capacity(1)
.refillIntervally(1, Duration.ofSeconds(1))
.build();
}
// 버킷 용량: 토큰 1개
// 1초에 토큰 1개씩 리필 (버킷 용량 내에서)
// 1시간마다 버킷 전체 리필(RateLimiterConfig에 설정됨)
},

;

public abstract Bandwidth getLimit();

private final String planName;

public static Bandwidth resolvePlan(final String targetPlan) {
return Arrays.stream(RateLimitPolicy.values())
.filter(policy -> policy.getPlanName().equals(targetPlan))
.map(RateLimitPolicy::getLimit)
.findFirst()
.orElseThrow(() -> new RateLimiterException(ResponseCode.RATE_LIMITER_POLICY_ERROR));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,10 @@ public APIResponse<Void> handleAuthException(AuthException e) {
log.info("AuthException: {}", e.getMessage());
return APIResponse.fail(e.getResponseCode(), e.getMessage());
}

@ExceptionHandler(RateLimiterException.class)
public APIResponse<Void> handleRateLimiterException(RateLimiterException e) {
log.info("RateLimiterException: {}", e.getMessage());
return APIResponse.fail(e.getResponseCode(), e.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package gdsc.cau.puangbe.common.exception;

import gdsc.cau.puangbe.common.util.ResponseCode;

public class RateLimiterException extends BaseException {
public RateLimiterException(ResponseCode responseCode) {
super(responseCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package gdsc.cau.puangbe.common.interceptor;

import gdsc.cau.puangbe.auth.external.JwtProvider;
import gdsc.cau.puangbe.common.enums.RateLimitPolicy;
import gdsc.cau.puangbe.common.exception.RateLimiterException;
import gdsc.cau.puangbe.common.util.ResponseCode;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.BucketConfiguration;
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import java.time.Duration;

@Component
@RequiredArgsConstructor
public class RateLimiterInterceptor implements HandlerInterceptor {
private final LettuceBasedProxyManager<String> proxyManager;
private final RedisTemplate<String, Long> redisTemplate;
private final JwtProvider jwtProvider;

private final String LIMITER_PREFIX = "rate-limiter-count:";
private final String BLOCKED_PREFIX = "rate-limiter-blocked:";

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// key 생성 (kakaoId + API 엔트포인트)
String accessToken = jwtProvider.getTokenFromAuthorizationHeader(request.getHeader("Authorization"));
String kakaoId = jwtProvider.getKakaoIdFromExpiredToken(accessToken);
String servletPath = request.getServletPath();
String key = kakaoId + servletPath;

if (isBlocked(key)) {
throw new RateLimiterException(ResponseCode.RATE_LIMITER_TOO_MANY_REQUESTS);
}

// key에 해당하는 bucket 로드. 없으면 생성
Bucket bucket = proxyManager.getProxy(LIMITER_PREFIX + key, () -> getRateLimitPolicy(servletPath));

if (!bucket.tryConsume(1)) {
blockClient(key);
throw new RateLimiterException(ResponseCode.RATE_LIMITER_TOO_MANY_REQUESTS);
}
return true;
}

private BucketConfiguration bucketConfiguration(String bucketPlan) {
return BucketConfiguration.builder()
.addLimit(RateLimitPolicy.resolvePlan(bucketPlan))
.build();
}

private BucketConfiguration getRateLimitPolicy(String contextPath) {
switch (contextPath) {
case "/api/photo-request":
return bucketConfiguration("heavy");

default:
return bucketConfiguration("general");
}
}

private void blockClient(String key) {
redisTemplate.opsForValue().set(BLOCKED_PREFIX + key, 0L, Duration.ofMinutes(5));
}

private boolean isBlocked(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(BLOCKED_PREFIX + key));
}
}
4 changes: 4 additions & 0 deletions src/main/java/gdsc/cau/puangbe/common/util/ResponseCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ public enum ResponseCode {
USER_ALREADY_EXISTS(HttpStatus.CONFLICT, false, "이미 존재하는 사용자입니다."),
URL_ALREADY_UPLOADED(HttpStatus.CONFLICT, false, "이미 url이 업로드 되었습니다."),

// 429 Too Many Requests
RATE_LIMITER_TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, false, "호출 허용량 초과입니다. 잠시 후 다시 시도해 주세요."),

// 500 Internal Server Error
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, "서버에 오류가 발생하였습니다."),
JSON_PARSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, "JSON 파싱 오류가 발생하였습니다."),
EMAIL_SEND_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, "이메일 발송에 오류가 발생하였습니다."),
RATE_LIMITER_POLICY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, "정의되지 않은 정책입니다."),

// 200 OK
USER_LOGIN_SUCCESS(HttpStatus.OK, true, "사용자 로그인 성공"),
Expand Down

0 comments on commit 2630a97

Please sign in to comment.