Skip to content

[SDR-433] BE: Redis를 활용한 공통으로 사용할 수 있는 Rate Limiter Service 구현#246

Open
ku-kim wants to merge 7 commits intodevfrom
BE-feat/SDR-433-Rate-limiter
Open

[SDR-433] BE: Redis를 활용한 공통으로 사용할 수 있는 Rate Limiter Service 구현#246
ku-kim wants to merge 7 commits intodevfrom
BE-feat/SDR-433-Rate-limiter

Conversation

@ku-kim
Copy link
Copy Markdown
Member

@ku-kim ku-kim commented Dec 16, 2022

❗️ 이슈 번호

SDR-433



📝 구현 내용

  • Rate Limit 기능 구현

    • 선택 알고리즘 : Sliding Window Counter
    • 작동 방식 : Redis 저장소를 활용함
      • Redis의 Set 자료구조 사용
      • Redis Key Format - API:ip:yyyyMMddHHmm , e.g. /api/images/url:127.0.0.1:202212161449
        • API, 유저의 ip, 요청 일시를 기준으로 Redis key를 만듭니다. 만료시간은 각자 api에 맞게 조절합니다.
          • 단, 유저 ip로 유저 식별하고 있기 때문에 UID로 변경할 수 있습니다.
        • 현재 시간이 202212161449 이고 window 사이즈가 5분이라면, 202212161445 ~ 202212161449 의 Redis key들의 카운드들을 모두 조회하여 api count 제한 조건이 넘는지 체크합니다.
  • 사용 방법

    • Rate Limit 기능이 필요한 Controller Api에서 ApiRateLimiterService.checkRateLimit() 메서드를 사용하면 됩니다.
  • 트랜잭션 이슈

    • 레디스에서 트랜잭션 사용하는 문제가 있어 SessionCallBack() 이란 방식으로 구현했습니다. 이에 대한 초안 문서는 삽질 기록 문서에 초안으로 작성되어있습니다. 이후 수정하여 wiki에 추가하겠습니다. (https://kukim.tistory.com/184)
  • 테스트

    • TestContainers + Redis 활용
    • [] 기존에 사용하던 테스트 서버(헤로쿠) 이전이 안되어 CI 테스트가 깨지고 있습니다. 추후 변경이 필요합니다.



🙇🏻‍♂️ 리뷰어에게 부탁합니다!

  • 해당 PR 이후 PresignedURL API에 기능을 추가하려 합니다.
  • 공통적으로 사용할 Rate Limiter를 구현했기에 리뷰, 댓글 생성 API에 적용할 수 있다고 생각하는데 어떻게 생각하시나요? 리뷰, 댓글 말고 더 적용할 곳이 있을까요?
  • Redis에 관한 레포지토리가 없고 서비스에서 레디스를 접근하고 있습니다. 이후 Spring Data Redis Repository로 옮기거나 해당 로직을 별도로 빼야하나 고민중입니다.



💡 참고 자료

Comment thread be/build.gradle
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'
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spring Data Redis 의존성 추가

  • Lettuce Redis 클라이언트 사용합니다.

Comment on lines +11 to +15
@Getter
@Validated
@ConstructorBinding
@ConfigurationProperties(prefix = "spring.redis")
public class RedisProperties {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RedisProperties 객체를 사용하여 redis 설정이 존재하는지 확인합니다.

Comment on lines +36 to +62
@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();
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트는 멀티 스레드를 활용하여 1명의 클라이언트의 10개의 요청을 진행합니다.

Comment on lines +28 to +32
@DynamicPropertySource
public static void overrideProps(DynamicPropertyRegistry registry) {
registry.add("spring.redis.host", redis::getHost);
registry.add("spring.redis.port", () -> redis.getMappedPort(6379));
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트에서 @DynamicPropertySource 을 사용한 이유는
Redis Container의 port 주소가 동적으로 매핑됩니다. 다시 말해 6379으로 설정해도 자동으로 바뀌게 됩니다.
이를 위해 property에 동적으로 수정해주기 위함입니다.

@ku-kim ku-kim changed the title [SDR-433] BE: 공통으로 사용할 수 있는 Rate Limiter Service 구현 [SDR-433] BE: Redis를 활용한 공통으로 사용할 수 있는 Rate Limiter Service 구현 Dec 16, 2022
@sonarqubecloud
Copy link
Copy Markdown

[sikdorak] Kudos, SonarCloud Quality Gate passed!    Quality Gate passed

Bug A 0 Bugs
Vulnerability A 0 Vulnerabilities
Security Hotspot A 0 Security Hotspots
Code Smell A 0 Code Smells

75.0% 75.0% Coverage
0.0% 0.0% Duplication


private String getApiPathAndIpKey(HttpServletRequest request) {
String servletPath = request.getServletPath();
String clientIp = getClientIp(request); // ip 가져올 때 ipv4 vs ipv6
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTP Header 정보에서 IPv4 혹은 IPv6 정보를 가져온다고 이해했는데, 제가 이해한게 맞나요?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 맞아요. 개발하며 사용했던 주석을 지운다는게 깜빡했네요.

Comment on lines +107 to +133
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);
Copy link
Copy Markdown
Collaborator

@jinan159 jinan159 Dec 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

searchRateLimitKey 를 만드는 로직이 apiKey + yyyyMMddHHmm 으로 보이네요.
이 로직이 서로 다른 메소드에 각각 코딩되어있는데 이를 로직을 한 곳에서 관리하는게 나중에 파악하거나 유지보수하기 좋을 것 같아요.
그래서 key 를 만드는 메소드를 만들어 대체하거나,KeyGenerator 같은 클래스를 만들어 위임하는건 어떨까 생각합니다.

위치 L107, L132~133

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아주 좋네요~! 적용해볼게요

Date currentDate = new Date();
String apiPathAndIpKey = getApiPathAndIpKey(request);

long totalApiCounts = findTotalApiCounts(apiPathAndIpKey, currentDate, windowSize);
Copy link
Copy Markdown
Collaborator

@jinan159 jinan159 Dec 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totalApiCounts 가 long 타입인 이유가 궁금합니다.
(int 의 최대 크기인 21억 정도면 apiCount 로 충분하지 않을까 생각이 들어서요)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

int면 충분합니다.! 수정할게요

Comment on lines +104 to +109
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);
}
Copy link
Copy Markdown
Collaborator

@jinan159 jinan159 Dec 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 코멘트는 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)); // 202212191721

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

자바의 Date 관련 클래스를 모두 설명하시오...?

반영하겠습니다. 생각하지 못했네요. :D

Copy link
Copy Markdown
Collaborator

@jinan159 jinan159 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rate limit 구현에 많은 고뇌가 있으셨을 것 같네요..!
보고 많이 배워갑니다~👍

Copy link
Copy Markdown
Collaborator

@kobe-ham kobe-ham left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다! 레디스 도입 👍

Comment on lines +35 to +38
@Bean // 만약 PlatformTransactionManager 등록이 안되어 있다면 해야함, 되어있다면 할 필요 없음
public PlatformTransactionManager transactionManager() {
return new JpaTransactionManager();
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jpa 트랜잭션 매니저가 등록이 안되어 있어서 별도로 등록하는걸까요!?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants