-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #38 from tthisag246/feature/34
[feat] Redis를 이용한 Rate Limiter 적용 (#34)
- Loading branch information
Showing
12 changed files
with
203 additions
and
0 deletions.
There are no files selected for viewing
Binary file not shown.
Binary file not shown.
Binary file not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
30 changes: 30 additions & 0 deletions
30
src/main/java/gdsc/cau/puangbe/common/config/RateLimiterConfig.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
54
src/main/java/gdsc/cau/puangbe/common/enums/RateLimitPolicy.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
9 changes: 9 additions & 0 deletions
9
src/main/java/gdsc/cau/puangbe/common/exception/RateLimiterException.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
74 changes: 74 additions & 0 deletions
74
src/main/java/gdsc/cau/puangbe/common/interceptor/RateLimiterInterceptor.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters