[SDR-433] BE: Redis를 활용한 공통으로 사용할 수 있는 Rate Limiter Service 구현#246
[SDR-433] BE: Redis를 활용한 공통으로 사용할 수 있는 Rate Limiter Service 구현#246
Conversation
- 테스트에서 Testcontainers를 활용하여 Redis 띄우기 - ApiRateLimiterService 인터페이스 / RedisApiRateLimiterService 구현체 포맷
| implementation 'org.springframework.boot:spring-boot-starter-data-jpa' | ||
| runtimeOnly 'com.h2database:h2' | ||
| runtimeOnly 'mysql:mysql-connector-java' | ||
| implementation 'org.springframework.boot:spring-boot-starter-data-redis' |
There was a problem hiding this comment.
Spring Data Redis 의존성 추가
- Lettuce Redis 클라이언트 사용합니다.
| @Getter | ||
| @Validated | ||
| @ConstructorBinding | ||
| @ConfigurationProperties(prefix = "spring.redis") | ||
| public class RedisProperties { |
There was a problem hiding this comment.
RedisProperties 객체를 사용하여 redis 설정이 존재하는지 확인합니다.
| @Test | ||
| @DisplayName("클라이언트가 api 요청 최대 이하로 요청한다면, api에 요청할 수 있다.") | ||
| void api_request_less_than_maximum_number() throws InterruptedException { | ||
| // given | ||
| MockHttpServletRequest request = new MockHttpServletRequest(); | ||
| request.setServletPath("/api/images/url"); | ||
| int threadCount = 10; | ||
| ExecutorService executorService = Executors.newFixedThreadPool(16); | ||
| CountDownLatch latch = new CountDownLatch(threadCount); | ||
|
|
||
| // when | ||
| for (int i = 0; i < threadCount; i++) { | ||
| executorService.submit(() -> { | ||
| try { | ||
| apiRateLimiterService.checkRateLimit(request, 5L, threadCount + 1L); | ||
| } finally { | ||
| latch.countDown(); | ||
| } | ||
| }); | ||
| } | ||
| latch.await(); | ||
|
|
||
| Boolean result = apiRateLimiterService.checkRateLimit(request, 5L, threadCount + 1L); | ||
|
|
||
| // then | ||
| assertThat(result).isTrue(); | ||
| } |
There was a problem hiding this comment.
테스트는 멀티 스레드를 활용하여 1명의 클라이언트의 10개의 요청을 진행합니다.
| @DynamicPropertySource | ||
| public static void overrideProps(DynamicPropertyRegistry registry) { | ||
| registry.add("spring.redis.host", redis::getHost); | ||
| registry.add("spring.redis.port", () -> redis.getMappedPort(6379)); | ||
| } |
There was a problem hiding this comment.
테스트에서 @DynamicPropertySource 을 사용한 이유는
Redis Container의 port 주소가 동적으로 매핑됩니다. 다시 말해 6379으로 설정해도 자동으로 바뀌게 됩니다.
이를 위해 property에 동적으로 수정해주기 위함입니다.
0dfb9cb to
3a72b47
Compare
|
[sikdorak] Kudos, SonarCloud Quality Gate passed! |
|
|
||
| private String getApiPathAndIpKey(HttpServletRequest request) { | ||
| String servletPath = request.getServletPath(); | ||
| String clientIp = getClientIp(request); // ip 가져올 때 ipv4 vs ipv6 |
There was a problem hiding this comment.
HTTP Header 정보에서 IPv4 혹은 IPv6 정보를 가져온다고 이해했는데, 제가 이해한게 맞나요?
There was a problem hiding this comment.
네 맞아요. 개발하며 사용했던 주석을 지운다는게 깜빡했네요.
| searchRateLimitKeys.add(apiPathAndIpKey + new SimpleDateFormat("yyyyMMddHHmm").format(calendar.getTime())); | ||
| calendar.add(Calendar.MINUTE, -1); | ||
| } | ||
|
|
||
| // 전체 카운트 조회 | ||
| 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); |
There was a problem hiding this comment.
searchRateLimitKey 를 만드는 로직이 apiKey + yyyyMMddHHmm 으로 보이네요.
이 로직이 서로 다른 메소드에 각각 코딩되어있는데 이를 로직을 한 곳에서 관리하는게 나중에 파악하거나 유지보수하기 좋을 것 같아요.
그래서 key 를 만드는 메소드를 만들어 대체하거나,KeyGenerator 같은 클래스를 만들어 위임하는건 어떨까 생각합니다.
위치 L107, L132~133
| Date currentDate = new Date(); | ||
| String apiPathAndIpKey = getApiPathAndIpKey(request); | ||
|
|
||
| long totalApiCounts = findTotalApiCounts(apiPathAndIpKey, currentDate, windowSize); |
There was a problem hiding this comment.
totalApiCounts 가 long 타입인 이유가 궁금합니다.
(int 의 최대 크기인 21억 정도면 apiCount 로 충분하지 않을까 생각이 들어서요)
| 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); | ||
| } |
There was a problem hiding this comment.
이 코멘트는 TMI 입니다! (반영해도 좋고 넘어가도 좋습니다.)
시간 관련 레거시 Java API 인 Date, SimpleDateFormat, Calendar 등의 클래스들을 대신해서
Java8 에서 추가된 DateTimeFormatter, LocalDateTime 등을 활용할 수 있을 것 같습니다.
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)); // 202212191721There was a problem hiding this comment.
자바의 Date 관련 클래스를 모두 설명하시오...?
반영하겠습니다. 생각하지 못했네요. :D
jinan159
left a comment
There was a problem hiding this comment.
Rate limit 구현에 많은 고뇌가 있으셨을 것 같네요..!
보고 많이 배워갑니다~👍
| @Bean // 만약 PlatformTransactionManager 등록이 안되어 있다면 해야함, 되어있다면 할 필요 없음 | ||
| public PlatformTransactionManager transactionManager() { | ||
| return new JpaTransactionManager(); | ||
| } |
There was a problem hiding this comment.
Jpa 트랜잭션 매니저가 등록이 안되어 있어서 별도로 등록하는걸까요!?








❗️ 이슈 번호
SDR-433
📝 구현 내용
Rate Limit 기능 구현
API:ip:yyyyMMddHHmm, e.g./api/images/url:127.0.0.1:202212161449사용 방법
ApiRateLimiterService.checkRateLimit()메서드를 사용하면 됩니다.트랜잭션 이슈
삽질 기록문서에 초안으로 작성되어있습니다. 이후 수정하여 wiki에 추가하겠습니다. (https://kukim.tistory.com/184)테스트
🙇🏻♂️ 리뷰어에게 부탁합니다!
💡 참고 자료