-
Notifications
You must be signed in to change notification settings - Fork 1
feat: 이미지 업로드 시 요청 횟수 제한 추가 #535
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
Merged
+396
−1
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
8a3999a
feat: ApiResponseCode에 TOO_MANY_REQUESTS (429) 응답 코드 추가
dh2906 9057960
feat: Rate Limiting 기반 구조 추가
dh2906 fd83cce
feat: 이미지 업로드 API에 Rate Limiting 적용
dh2906 59b171a
test: RateLimitAspect 단위 테스트 추가
dh2906 897f2f3
refactor: Rate Limiting 코드 간소화 및 개선
dh2906 8786068
refactor: RateLimitAspect Lua 스크립트로 원자적 연산 적용
dh2906 bbe0ad0
feat: 이미지 업로드 Rate Limit을 1분 20회로 조정
dh2906 e41ddc4
chore: 코드 포맷팅
dh2906 f4926ca
refactor: RateLimitAspect 개선 - 코드리뷰 반영
dh2906 0f4c009
refactor: RateLimitAspect 성능 및 품질 개선
dh2906 7ff3bac
fix: RateLimitAspect NPE 방어 로직 추가
dh2906 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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 hidden or 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 hidden or 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
35 changes: 35 additions & 0 deletions
35
src/main/java/gg/agit/konect/global/ratelimit/annotation/RateLimit.java
This file contains hidden or 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,35 @@ | ||
| package gg.agit.konect.global.ratelimit.annotation; | ||
|
|
||
| import java.lang.annotation.ElementType; | ||
| import java.lang.annotation.Retention; | ||
| import java.lang.annotation.RetentionPolicy; | ||
| import java.lang.annotation.Target; | ||
|
|
||
| /** | ||
| * Rate Limit을 적용하기 위한 어노테이션. | ||
| * 메서드 또는 클래스 레벨에 적용할 수 있습니다. | ||
| */ | ||
| @Target({ElementType.METHOD, ElementType.TYPE}) | ||
| @Retention(RetentionPolicy.RUNTIME) | ||
| public @interface RateLimit { | ||
|
|
||
| /** | ||
| * 허용할 최대 요청 횟수. | ||
| * 기본값: 50 | ||
| */ | ||
| int maxRequests() default 50; | ||
|
|
||
| /** | ||
| * 시간 윈도우 (초 단위). | ||
| * 기본값: 600 (10분) | ||
| */ | ||
| int timeWindowSeconds() default 600; | ||
|
|
||
| /** | ||
| * Rate Limit 키를 생성할 SpEL 표현식. | ||
| * 메서드 파라미터를 참조할 수 있습니다. 예: #userId, #target | ||
| * 기본값: 빈 문자열 (메서드 시그니처 기본 키 사용) | ||
| */ | ||
| String keyExpression() default ""; | ||
|
|
||
| } |
117 changes: 117 additions & 0 deletions
117
src/main/java/gg/agit/konect/global/ratelimit/aspect/RateLimitAspect.java
This file contains hidden or 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,117 @@ | ||
| package gg.agit.konect.global.ratelimit.aspect; | ||
|
|
||
| import org.aspectj.lang.ProceedingJoinPoint; | ||
| import org.aspectj.lang.annotation.Around; | ||
| import org.aspectj.lang.annotation.Aspect; | ||
| import org.aspectj.lang.reflect.MethodSignature; | ||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||
| import org.springframework.data.redis.core.script.DefaultRedisScript; | ||
| import org.springframework.expression.spel.standard.SpelExpressionParser; | ||
| import org.springframework.expression.spel.support.StandardEvaluationContext; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.util.StringUtils; | ||
|
|
||
| import gg.agit.konect.global.ratelimit.annotation.RateLimit; | ||
| import gg.agit.konect.global.ratelimit.exception.RateLimitExceededException; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| import java.util.Collections; | ||
|
|
||
| @Slf4j | ||
| @Aspect | ||
| @Component | ||
| public class RateLimitAspect { | ||
|
|
||
| private static final String RATE_LIMIT_KEY_PREFIX = "ratelimit:"; | ||
|
|
||
| // Lua 스크립트: INCR로 원자적 증가 후, 처음 생성된 경우에만 TTL 설정 | ||
| // 이 방식으로 SETNX와 INCR 사이의 레이스 컨디션을 방지 | ||
| private static final String INCR_WITH_TTL_SCRIPT = | ||
| "local current = redis.call('INCR', KEYS[1]) " + | ||
| "if current == 1 then " + | ||
| " redis.call('EXPIRE', KEYS[1], ARGV[1]) " + | ||
| "end " + | ||
| "return current"; | ||
|
|
||
| private final StringRedisTemplate redisTemplate; | ||
| private final SpelExpressionParser parser = new SpelExpressionParser(); | ||
|
|
||
| // Lua 스크립트를 재사용하기 위해 미리 컴파일 (매 요청마다 생성 비용 제거) | ||
| private final DefaultRedisScript<Long> incrWithTtlScript; | ||
|
|
||
| public RateLimitAspect(StringRedisTemplate redisTemplate) { | ||
| this.redisTemplate = redisTemplate; | ||
| this.incrWithTtlScript = new DefaultRedisScript<>(INCR_WITH_TTL_SCRIPT, Long.class); | ||
| } | ||
|
|
||
| @Around("@within(rateLimit) || @annotation(rateLimit)") | ||
| public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { | ||
| String key = generateKey(joinPoint, rateLimit); | ||
| int maxRequests = rateLimit.maxRequests(); | ||
| int timeWindowSeconds = rateLimit.timeWindowSeconds(); | ||
|
|
||
| // Redis 장애 시 fail-open 정책: 예외 발생하면 rate limit 체크를 스킵하고 요청 처리 | ||
| long currentCount; | ||
| try { | ||
| // 미리 생성해둔 Lua 스크립트 실행 (원자적 INCR + TTL 설정) | ||
| Long count = redisTemplate.execute( | ||
| incrWithTtlScript, | ||
| Collections.singletonList(key), | ||
| String.valueOf(timeWindowSeconds) | ||
| ); | ||
| currentCount = count != null ? count : 0; | ||
| } catch (Exception e) { | ||
| log.warn("Rate limiting Redis operation failed for key={}: {}. Skipping rate limit check.", | ||
| key, e.getMessage()); | ||
| return joinPoint.proceed(); | ||
| } | ||
|
|
||
| // 제한 초과 확인 - 초과 시에만 TTL 조회 | ||
| if (currentCount > maxRequests) { | ||
| Long remainingSeconds = redisTemplate.getExpire(key); | ||
| long remaining = remainingSeconds != null && remainingSeconds > 0 | ||
| ? remainingSeconds | ||
| : timeWindowSeconds; | ||
| throw RateLimitExceededException.withRemainingTime(remaining); | ||
| } | ||
|
|
||
| return joinPoint.proceed(); | ||
| } | ||
|
|
||
| private String generateKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) { | ||
| String keyExpression = rateLimit.keyExpression(); | ||
| MethodSignature signature = (MethodSignature)joinPoint.getSignature(); | ||
| String methodKey = signature.getDeclaringTypeName() + "." + signature.getName(); | ||
|
|
||
| if (!StringUtils.hasText(keyExpression)) { | ||
| return RATE_LIMIT_KEY_PREFIX + methodKey; | ||
| } | ||
|
|
||
| StandardEvaluationContext context = new StandardEvaluationContext(); | ||
| String[] paramNames = signature.getParameterNames(); | ||
| Object[] args = joinPoint.getArgs(); | ||
|
|
||
| // -parameters 플래그 없이 컴파일된 경우 paramNames가 null일 수 있음 | ||
| // 이 경우 arg0, arg1, ... 형태의 플레이스홀더 이름 생성 | ||
| if (paramNames == null) { | ||
| paramNames = new String[args.length]; | ||
| for (int i = 0; i < args.length; i++) { | ||
| paramNames[i] = "arg" + i; | ||
| } | ||
| } | ||
|
|
||
| for (int i = 0; i < paramNames.length; i++) { | ||
| context.setVariable(paramNames[i], args[i]); | ||
| } | ||
|
dh2906 marked this conversation as resolved.
|
||
|
|
||
| try { | ||
| Object result = parser.parseExpression(keyExpression).getValue(context); | ||
| String keyValue = result != null ? result.toString() : "unknown"; | ||
| return RATE_LIMIT_KEY_PREFIX + methodKey + ":" + keyValue; | ||
| } catch (Exception e) { | ||
| log.error("SpEL expression evaluation failed for keyExpression='{}', methodKey='{}': {}", | ||
| keyExpression, methodKey, e.getMessage()); | ||
| return RATE_LIMIT_KEY_PREFIX + methodKey; | ||
|
dh2906 marked this conversation as resolved.
|
||
| } | ||
| } | ||
| } | ||
31 changes: 31 additions & 0 deletions
31
src/main/java/gg/agit/konect/global/ratelimit/exception/RateLimitExceededException.java
This file contains hidden or 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,31 @@ | ||
| package gg.agit.konect.global.ratelimit.exception; | ||
|
|
||
| import gg.agit.konect.global.code.ApiResponseCode; | ||
| import gg.agit.konect.global.exception.CustomException; | ||
|
|
||
| /** | ||
| * Rate Limit 초과 시 발생하는 예외 팩토리. | ||
| * CustomException은 상속이 불가능하므로 팩토리 메서드로 생성합니다. | ||
| */ | ||
| public final class RateLimitExceededException { | ||
|
|
||
| private static final String MESSAGE_TEMPLATE = "요청 횟수가 너무 많습니다. %d초 후 다시 시도해주세요."; | ||
|
|
||
| private RateLimitExceededException() { | ||
| // 유틸리티 클래스 | ||
| } | ||
|
|
||
| /** | ||
| * 남은 시간(초)을 포함하여 예외를 생성합니다. | ||
| * | ||
| * @param remainingSeconds 남은 시간(초) | ||
| * @return CustomException | ||
| */ | ||
| public static CustomException withRemainingTime(long remainingSeconds) { | ||
| return CustomException.of( | ||
| ApiResponseCode.TOO_MANY_REQUESTS, | ||
| String.format(MESSAGE_TEMPLATE, remainingSeconds) | ||
| ); | ||
| } | ||
|
|
||
| } |
50 changes: 50 additions & 0 deletions
50
src/test/java/gg/agit/konect/support/EmbeddedRedisConfig.java
This file contains hidden or 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,50 @@ | ||
| package gg.agit.konect.support; | ||
|
|
||
| import java.io.IOException; | ||
| import java.net.ServerSocket; | ||
|
|
||
| import org.springframework.boot.test.context.TestConfiguration; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Primary; | ||
| import org.springframework.data.redis.connection.RedisConnectionFactory; | ||
| import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; | ||
|
|
||
| import jakarta.annotation.PostConstruct; | ||
| import jakarta.annotation.PreDestroy; | ||
| import redis.embedded.RedisServer; | ||
|
|
||
| @TestConfiguration | ||
| public class EmbeddedRedisConfig { | ||
|
|
||
| private static final int DEFAULT_REDIS_PORT = 0; // 0이면 랜덤 포트 | ||
|
|
||
| private int actualPort; | ||
| private RedisServer redisServer; | ||
|
|
||
| @Bean | ||
| @Primary | ||
| public RedisConnectionFactory redisConnectionFactory() { | ||
| return new LettuceConnectionFactory("localhost", actualPort); | ||
| } | ||
|
|
||
| @PostConstruct | ||
| public void startRedis() throws IOException { | ||
| // 사용 가능한 랜덤 포트 찾기 | ||
| actualPort = findAvailablePort(); | ||
| redisServer = new RedisServer(actualPort); | ||
| redisServer.start(); | ||
| } | ||
|
|
||
| @PreDestroy | ||
| public void stopRedis() throws IOException { | ||
| if (redisServer != null && redisServer.isActive()) { | ||
| redisServer.stop(); | ||
| } | ||
| } | ||
|
|
||
| private int findAvailablePort() throws IOException { | ||
| try (ServerSocket socket = new ServerSocket(0)) { | ||
| return socket.getLocalPort(); | ||
| } | ||
| } | ||
| } |
This file contains hidden or 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
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.