Skip to content
Merged
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 build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ dependencies {
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'
testImplementation 'com.github.codemonstur:embedded-redis:1.4.3'
testRuntimeOnly 'com.h2database:h2'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import gg.agit.konect.domain.upload.enums.UploadTarget;
import gg.agit.konect.domain.upload.service.UploadService;
import gg.agit.konect.global.auth.annotation.UserId;
import gg.agit.konect.global.ratelimit.annotation.RateLimit;
import lombok.RequiredArgsConstructor;

@RestController
Expand All @@ -16,6 +17,7 @@ public class UploadController implements UploadApi {

private final UploadService uploadService;

@RateLimit(maxRequests = 20, timeWindowSeconds = 60, keyExpression = "#userId")
Comment thread
dh2906 marked this conversation as resolved.
@Override
public ResponseEntity<ImageUploadResponse> uploadImage(
@UserId Integer userId,
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/gg/agit/konect/global/code/ApiResponseCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ public enum ApiResponseCode {
// 413 Payload Too Large (요청 본문 크기 초과)
PAYLOAD_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기가 제한을 초과했습니다."),

// 429 Too Many Requests (요청 횟수 초과)
TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "요청 횟수가 너무 많습니다. 잠시 후 다시 시도해주세요."),

// 500 Internal Server Error (서버 오류)
CLIENT_ABORTED(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다."),
FAILED_UPLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."),
Expand Down
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 "";

}
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]);
}
Comment thread
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;
Comment thread
dh2906 marked this conversation as resolved.
}
}
}
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 src/test/java/gg/agit/konect/support/EmbeddedRedisConfig.java
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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import({TestSecurityConfig.class, TestJpaConfig.class, TestClaudeConfig.class})
@Import({TestSecurityConfig.class, TestJpaConfig.class, TestClaudeConfig.class, EmbeddedRedisConfig.class})
@TestPropertyConfig
@Transactional // 각 테스트 메서드 종료 시 자동 롤백하여 fork 내 데이터 격리 보장
public abstract class IntegrationTestSupport {
Expand Down
Loading
Loading