Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions be/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ dependencies {
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 클라이언트 사용합니다.


// Dev
compileOnly 'org.projectlombok:lombok'
Expand Down
2 changes: 1 addition & 1 deletion be/config
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
Expand Up @@ -9,6 +9,7 @@
import com.jjikmuk.sikdorak.image.exception.InvalidImagesExtensionException;
import com.jjikmuk.sikdorak.image.exception.NotFoundImageException;
import com.jjikmuk.sikdorak.image.exception.NotFoundImageMetaDataException;
import com.jjikmuk.sikdorak.ratelimit.exception.ApiLimitExceededException;
import com.jjikmuk.sikdorak.review.exception.DuplicateLikeUserException;
import com.jjikmuk.sikdorak.review.exception.InvalidReviewContentException;
import com.jjikmuk.sikdorak.review.exception.InvalidReviewImageException;
Expand Down Expand Up @@ -53,6 +54,7 @@ public enum ExceptionCodeAndMessages implements CodeAndMessages {
NOT_FOUND_ERROR_CODE("F-G001", "에러 코드를 찾을 수 없습니다.", NotFoundErrorCodeException.class),
INVALID_PAGE_PARAMETER("F-G002", "유효하지 않은 페이징 값 입니다.", InvalidPageParameterException.class),
INTERNAL_SERVER_ERROR("F-G003", "서버 에러입니다.(관리자에게 문의하세요)", SikdorakServerError.class),
API_LIMIT_EXCEEDED("F-G004", "너무 많은 요청을 하였습니다.", ApiLimitExceededException.class),

// Review
INVALID_REVIEW_CONTENT("F-R001", "유효하지 않은 리뷰 컨텐츠 입니다.", InvalidReviewContentException.class),
Expand Down
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
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 설정이 존재하는지 확인합니다.


@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);
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면 충분합니다.! 수정할게요


// 검증
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
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.

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


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
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


// 전체 카운트 조회
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
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.

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


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
Expand Up @@ -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
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에 동적으로 수정해주기 위함입니다.


@Autowired
protected DatabaseConfigurator testData;

Expand Down
Loading