-
Notifications
You must be signed in to change notification settings - Fork 5
[SDR-433] BE: Redis를 활용한 공통으로 사용할 수 있는 Rate Limiter Service 구현 #246
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
4e940d8
3940cf9
e0062e0
23269ea
6c17f88
37ef7ed
3a72b47
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package com.jjikmuk.sikdorak.common.config; | ||
|
|
||
| import com.jjikmuk.sikdorak.common.properties.RedisProperties; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.data.redis.connection.RedisConnectionFactory; | ||
| import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; | ||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||
| import org.springframework.data.redis.serializer.StringRedisSerializer; | ||
| import org.springframework.orm.jpa.JpaTransactionManager; | ||
| import org.springframework.transaction.PlatformTransactionManager; | ||
|
|
||
| @Configuration | ||
| @RequiredArgsConstructor | ||
| public class RedisConfig { | ||
|
|
||
| private final RedisProperties redisProperties; | ||
|
|
||
| @Bean | ||
| public RedisConnectionFactory redisConnectionFactory() { | ||
| return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort()); | ||
| } | ||
|
|
||
| @Bean | ||
| public StringRedisTemplate stringRedisTemplate() { | ||
| StringRedisTemplate stringRedisTemplate = new StringRedisTemplate(); | ||
| stringRedisTemplate.setConnectionFactory(redisConnectionFactory()); | ||
| stringRedisTemplate.setKeySerializer(new StringRedisSerializer()); | ||
| stringRedisTemplate.setValueSerializer(new StringRedisSerializer()); | ||
| stringRedisTemplate.setEnableTransactionSupport(true); | ||
| return stringRedisTemplate; | ||
| } | ||
|
|
||
| @Bean | ||
| public PlatformTransactionManager transactionManager() { | ||
| return new JpaTransactionManager(); | ||
| } | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package com.jjikmuk.sikdorak.common.properties; | ||
|
|
||
| import javax.validation.constraints.NotEmpty; | ||
| import javax.validation.constraints.NotNull; | ||
| import lombok.Getter; | ||
| import org.hibernate.validator.constraints.Range; | ||
| import org.springframework.boot.context.properties.ConfigurationProperties; | ||
| import org.springframework.boot.context.properties.ConstructorBinding; | ||
| import org.springframework.validation.annotation.Validated; | ||
|
|
||
| @Getter | ||
| @Validated | ||
| @ConstructorBinding | ||
| @ConfigurationProperties(prefix = "spring.redis") | ||
| public class RedisProperties { | ||
|
Comment on lines
+11
to
+15
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. RedisProperties 객체를 사용하여 redis 설정이 존재하는지 확인합니다. |
||
|
|
||
| @NotEmpty | ||
| private final String host; | ||
|
|
||
| @NotNull | ||
| @Range(min = 0, max = 65535) | ||
| private final int port; | ||
|
|
||
| public RedisProperties(String host, int port) { | ||
| this.host = host; | ||
| this.port = port; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.jjikmuk.sikdorak.ratelimit.command.app; | ||
|
|
||
| import javax.servlet.http.HttpServletRequest; | ||
|
|
||
| public interface ApiRateLimiterService { | ||
|
|
||
| Boolean checkRateLimit(HttpServletRequest request, long windowSize, long apiMaximumNumber); | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| package com.jjikmuk.sikdorak.ratelimit.command.app; | ||
|
|
||
| import com.jjikmuk.sikdorak.ratelimit.exception.ApiLimitExceededException; | ||
| import java.text.SimpleDateFormat; | ||
| import java.util.ArrayList; | ||
| import java.util.Calendar; | ||
| import java.util.Date; | ||
| import java.util.List; | ||
| import java.util.Objects; | ||
| import java.util.concurrent.TimeUnit; | ||
| import javax.servlet.http.HttpServletRequest; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.dao.DataAccessException; | ||
| import org.springframework.data.redis.core.RedisOperations; | ||
| import org.springframework.data.redis.core.SessionCallback; | ||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||
| import org.springframework.data.redis.core.ValueOperations; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class RedisApiRateLimiterService implements ApiRateLimiterService { | ||
|
|
||
| private final StringRedisTemplate redisTemplate; | ||
|
|
||
| /** | ||
| * Redis 저장소를 활용하여 Rate Limit 여부를 확인한다. | ||
| * Redis Key Format - API:ip:yyyyMMddHHmm , e.g. /api/images/url:127.0.0.1:202212161449 | ||
| * API 사용 가능한 경우 해당 key의 값을 1 증가시킨다. | ||
| * 불가능한 경우 Exception을 발생시킨다. | ||
| * 알고리즘 : Sliding Window Counter | ||
| * | ||
| * @param request 클라이언트의 ip, servlet path를 사용한다. | ||
| * @param windowSize api 사용 빈도 검색 시간 범위 & Redis Key expire time, 단위는 '분(minute)'이다. | ||
| * @param apiMaximumNumber windowSize 내 최대 api 요청수 | ||
| * @return true : api 사용 가능 | ||
| */ | ||
| @Override | ||
| public Boolean checkRateLimit(HttpServletRequest request, long windowSize, long apiMaximumNumber) { | ||
| Date currentDate = new Date(); | ||
| String apiPathAndIpKey = getApiPathAndIpKey(request); | ||
|
|
||
| long totalApiCounts = findTotalApiCounts(apiPathAndIpKey, currentDate, windowSize); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. totalApiCounts 가 long 타입인 이유가 궁금합니다.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. int면 충분합니다.! 수정할게요 |
||
|
|
||
| // 검증 | ||
| if (totalApiCounts < apiMaximumNumber) { | ||
| upsertApiCount(apiPathAndIpKey, currentDate, windowSize); | ||
| return true; | ||
| } | ||
|
|
||
| throw new ApiLimitExceededException(); | ||
| } | ||
|
|
||
| private String getApiPathAndIpKey(HttpServletRequest request) { | ||
| String servletPath = request.getServletPath(); | ||
| String clientIp = getClientIp(request); // ip 가져올 때 ipv4 vs ipv6 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. HTTP Header 정보에서 IPv4 혹은 IPv6 정보를 가져온다고 이해했는데, 제가 이해한게 맞나요?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 네 맞아요. 개발하며 사용했던 주석을 지운다는게 깜빡했네요. |
||
|
|
||
| StringBuilder sb = new StringBuilder(); | ||
| sb.append(servletPath).append(":").append(clientIp).append(":"); | ||
|
|
||
| return sb.toString(); | ||
| } | ||
|
|
||
| /** | ||
| * 클라이언트의 ip 주소를 추출한다. | ||
| * ref : <a href="https://www.lesstif.com/software-architect/proxy-client-ip-x-forwarded-for-xff-http-header-20775886.html">...</a>. | ||
| * | ||
| * @param request Client의 HttpServletRequest | ||
| * @return Client Ip | ||
| */ | ||
| private String getClientIp(HttpServletRequest request) { | ||
| String ip = request.getHeader("X-Forwarded-For"); | ||
| String unknown = "unknown"; | ||
| if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) { | ||
| ip = request.getHeader("Proxy-Client-IP"); | ||
| } | ||
| if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) { | ||
| ip = request.getHeader("WL-Proxy-Client-IP"); | ||
| } | ||
| if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) { | ||
| ip = request.getHeader("HTTP_CLIENT_IP"); | ||
| } | ||
| if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) { | ||
| ip = request.getHeader("HTTP_X_FORWARDED_FOR"); | ||
| } | ||
| if (ip == null || ip.length() == 0 || unknown.equalsIgnoreCase(ip)) { | ||
| ip = request.getRemoteAddr(); | ||
| } | ||
|
|
||
| return ip; | ||
| } | ||
|
|
||
| /** | ||
| * 현재 시간부터 windowSize 이전까지의 시간의 api counts 총합을 리턴합니다. | ||
| * | ||
| * @param apiPathAndIpKey - /api/images/url:127.0.0.1 | ||
| * @param currentDate - 현재 시간의 Date 객체 | ||
| * @param windowSize - api 사용 빈도 검색 시간 범위 | ||
| * @return totalApiCounts | ||
| */ | ||
| private long findTotalApiCounts(String apiPathAndIpKey, Date currentDate, long windowSize) { | ||
| // 현재 시간 key 기준 검색할 key List e.g. 202212161449, 202212161448, 202212161447 ... | ||
| List<String> searchRateLimitKeys = new ArrayList<>(); | ||
| Calendar calendar = Calendar.getInstance(); | ||
| calendar.setTime(currentDate); | ||
| for (int i = 0; i < windowSize; i++) { | ||
| searchRateLimitKeys.add(apiPathAndIpKey + new SimpleDateFormat("yyyyMMddHHmm").format(calendar.getTime())); | ||
| calendar.add(Calendar.MINUTE, -1); | ||
| } | ||
|
Comment on lines
+104
to
+109
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 코멘트는 TMI 입니다! (반영해도 좋고 넘어가도 좋습니다.) 시간 관련 레거시 Java API 인 Date, SimpleDateFormat, Calendar 등의 클래스들을 대신해서 DateTimeFormatter dateTimeFormatter = new DateTimeFormatterBuilder()
.parseCaseSensitive()
.appendPattern("yyyyMMddHHmm")
.toFormatter();
LocalDateTime now = LocalDateTime.now();
now = now.minusMinutes(1);
System.out.println(now); // 2022-12-19T17:21:04.035201
System.out.println(dateTimeFormatter.format(now)); // 202212191721
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 자바의 Date 관련 클래스를 모두 설명하시오...? 반영하겠습니다. 생각하지 못했네요. :D |
||
|
|
||
| // 전체 카운트 조회 | ||
| ValueOperations<String, String> valueOperations = redisTemplate.opsForValue(); | ||
| List<String> values = valueOperations.multiGet(searchRateLimitKeys); | ||
| long totalCounts = 0L; | ||
| for (String value : Objects.requireNonNull(values)) { | ||
| if (Objects.nonNull(value)) { | ||
| totalCounts = totalCounts + Long.parseLong(value); | ||
| } | ||
| } | ||
|
|
||
| return totalCounts; | ||
| } | ||
|
|
||
| /** | ||
| * Redis Key의 value를 1 증가 시키고 expire time을 설정한다. | ||
| * | ||
| * @param apiPathAndIpKey - /api/images/url:127.0.0.1 | ||
| * @param currentDate - 현재 시간의 Date 객체 | ||
| * @param windowSize - api 사용 빈도 검색 시간 범위 & Redis Key expire time | ||
| */ | ||
| private void upsertApiCount(String apiPathAndIpKey, Date currentDate, long windowSize) { | ||
| String currentRateLimitKey = | ||
| apiPathAndIpKey + new SimpleDateFormat("yyyyMMddHHmm").format(currentDate); | ||
|
Comment on lines
+107
to
+133
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
위치 L107, L132~133
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아주 좋네요~! 적용해볼게요 |
||
|
|
||
| redisTemplate.execute(new SessionCallback<>() { | ||
| @Override | ||
| public <K, V> Object execute(RedisOperations<K, V> operations) | ||
| throws DataAccessException { | ||
|
|
||
| operations.multi(); | ||
| operations.opsForValue().increment((K) currentRateLimitKey); | ||
| operations.expire((K) currentRateLimitKey, windowSize, TimeUnit.MINUTES); | ||
| return operations.exec(); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.jjikmuk.sikdorak.ratelimit.exception; | ||
|
|
||
| import com.jjikmuk.sikdorak.common.exception.SikdorakRuntimeException; | ||
| import org.springframework.http.HttpStatus; | ||
|
|
||
| public class ApiLimitExceededException extends SikdorakRuntimeException { | ||
|
|
||
| @Override | ||
| public HttpStatus getHttpStatus() { | ||
| return HttpStatus.TOO_MANY_REQUESTS; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,12 +8,29 @@ | |
| import org.springframework.beans.factory.annotation.Autowired; | ||
| import org.springframework.boot.test.context.SpringBootTest; | ||
| import org.springframework.test.context.ActiveProfiles; | ||
| import org.springframework.test.context.DynamicPropertyRegistry; | ||
| import org.springframework.test.context.DynamicPropertySource; | ||
| import org.testcontainers.containers.GenericContainer; | ||
|
|
||
|
|
||
| @SpringBootTest(classes = AWSMockConfig.class) | ||
| @ActiveProfiles("test") | ||
| public abstract class InitIntegrationTest { | ||
|
|
||
| static final GenericContainer redis; | ||
|
|
||
| static { | ||
| redis = new GenericContainer("redis:7.0.5") | ||
| .withExposedPorts(6379); | ||
| redis.start(); | ||
| } | ||
|
|
||
| @DynamicPropertySource | ||
| public static void overrideProps(DynamicPropertyRegistry registry) { | ||
| registry.add("spring.redis.host", redis::getHost); | ||
| registry.add("spring.redis.port", () -> redis.getMappedPort(6379)); | ||
| } | ||
|
Comment on lines
+28
to
+32
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 테스트에서 |
||
|
|
||
| @Autowired | ||
| protected DatabaseConfigurator testData; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spring Data Redis 의존성 추가