Skip to content

Commit 1d4a9bd

Browse files
psychology50claude
andcommitted
feat(ai-chat): Bucket4j 기반 AI 채팅 rate limit 인터셉터 추가
- AiChatRateLimiter: 사용자별(인증 활성화 시) 또는 IP 별 분당 / 일일 호출 한도 관리. Caffeine in-memory 캐시로 Bucket 보관 (단일 인스턴스 가정). - AiChatRateLimitInterceptor: /api/v1/ai-chat/** 에 적용. 한도 초과 시 HTTP 429 + 한국어 안내 JSON 으로 즉시 차단. preHandle 단계에서 처리되어 LLM 호출 비용이 발생하지 않음. 로그에는 keyHash 만 기록 (PII 미노출). - AiChatRateLimitWebMvcConfig: WebMvcConfigurer 로 인터셉터 등록. 대응: OWASP LLM10 (Unbounded Consumption / Denial-of-Wallet). 향후 분산 환경 전환 시 bucket4j-redis backend 로 교체 예정. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7d3f175 commit 1d4a9bd

5 files changed

Lines changed: 317 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.readum.infrastructure.ai.openai.ratelimit;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import jakarta.servlet.http.HttpServletResponse;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.http.HttpStatus;
8+
import org.springframework.http.MediaType;
9+
import org.springframework.security.core.Authentication;
10+
import org.springframework.security.core.context.SecurityContextHolder;
11+
import org.springframework.stereotype.Component;
12+
import org.springframework.web.servlet.HandlerInterceptor;
13+
14+
/**
15+
* AI 채팅 rate limit interceptor.
16+
* /api/v1/ai-chat/** 엔드포인트에만 적용되며, 한도 초과 시 HTTP 429 응답을 반환한다.
17+
*
18+
* key 우선순위:
19+
* 1) Spring Security 의 인증 principal (Long userId 형태) — 인증 활성화 시
20+
* 2) X-Forwarded-For 헤더 첫 번째 IP (프록시 환경)
21+
* 3) ServletRequest.getRemoteAddr()
22+
*/
23+
@Slf4j
24+
@Component
25+
@RequiredArgsConstructor
26+
public class AiChatRateLimitInterceptor implements HandlerInterceptor {
27+
28+
private static final String ERROR_BODY = """
29+
{
30+
"error": {
31+
"message": "AI 채팅 호출 한도를 초과했습니다. 잠시 후 다시 시도해 주세요."
32+
}
33+
}
34+
""";
35+
36+
private final AiChatRateLimiter rateLimiter;
37+
38+
@Override
39+
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
40+
if (!rateLimiter.isEnabled()) {
41+
return true;
42+
}
43+
String key = resolveKey(request);
44+
if (rateLimiter.tryConsume(key)) {
45+
return true;
46+
}
47+
48+
log.info("[Guardrail] rate limit exceeded: keyHash={}", Integer.toHexString(key.hashCode()));
49+
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
50+
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
51+
response.setCharacterEncoding("UTF-8");
52+
response.getWriter().write(ERROR_BODY);
53+
response.getWriter().flush();
54+
return false;
55+
}
56+
57+
private String resolveKey(HttpServletRequest request) {
58+
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
59+
if (authentication != null && authentication.isAuthenticated()
60+
&& authentication.getPrincipal() != null
61+
&& !"anonymousUser".equals(authentication.getPrincipal())) {
62+
return "user:" + authentication.getPrincipal();
63+
}
64+
String forwarded = request.getHeader("X-Forwarded-For");
65+
if (forwarded != null && !forwarded.isBlank()) {
66+
return "ip:" + forwarded.split(",", 2)[0].trim();
67+
}
68+
String remote = request.getRemoteAddr();
69+
return "ip:" + (remote == null ? "unknown" : remote);
70+
}
71+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.readum.infrastructure.ai.openai.ratelimit;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
6+
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
7+
8+
@Configuration
9+
@RequiredArgsConstructor
10+
public class AiChatRateLimitWebMvcConfig implements WebMvcConfigurer {
11+
12+
private final AiChatRateLimitInterceptor aiChatRateLimitInterceptor;
13+
14+
@Override
15+
public void addInterceptors(InterceptorRegistry registry) {
16+
registry.addInterceptor(aiChatRateLimitInterceptor)
17+
.addPathPatterns("/api/v1/ai-chat/**");
18+
}
19+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.readum.infrastructure.ai.openai.ratelimit;
2+
3+
import com.github.benmanes.caffeine.cache.Cache;
4+
import com.github.benmanes.caffeine.cache.Caffeine;
5+
import com.readum.infrastructure.ai.openai.GuardrailProperties;
6+
import io.github.bucket4j.Bandwidth;
7+
import io.github.bucket4j.Bucket;
8+
import org.springframework.stereotype.Component;
9+
10+
import java.time.Duration;
11+
12+
/**
13+
* 사용자별 (또는 IP 별) AI 채팅 호출 rate limit 관리.
14+
*
15+
* 분당 요청 수 제한과 일일 요청 수 제한을 동시에 적용한다.
16+
* 단일 인스턴스 운영을 가정하며, in-memory(Caffeine) 캐시로 buckets 를 관리한다.
17+
* 향후 분산 환경 전환 시 bucket4j-redis 로 교체 예정.
18+
*/
19+
@Component
20+
public class AiChatRateLimiter {
21+
22+
private final GuardrailProperties properties;
23+
private final Cache<String, Bucket> buckets;
24+
25+
public AiChatRateLimiter(GuardrailProperties properties) {
26+
this.properties = properties;
27+
this.buckets = Caffeine.newBuilder()
28+
.expireAfterAccess(Duration.ofHours(2))
29+
.maximumSize(10_000)
30+
.build();
31+
}
32+
33+
public boolean isEnabled() {
34+
return properties.rateLimit().enabled();
35+
}
36+
37+
public boolean tryConsume(String key) {
38+
if (!isEnabled()) {
39+
return true;
40+
}
41+
Bucket bucket = buckets.get(key, k -> newBucket());
42+
return bucket.tryConsume(1);
43+
}
44+
45+
private Bucket newBucket() {
46+
Bandwidth perMinute = Bandwidth.builder()
47+
.capacity(properties.rateLimit().requestsPerMinute())
48+
.refillIntervally(properties.rateLimit().requestsPerMinute(), Duration.ofMinutes(1))
49+
.build();
50+
Bandwidth perDay = Bandwidth.builder()
51+
.capacity(properties.rateLimit().dailyRequests())
52+
.refillIntervally(properties.rateLimit().dailyRequests(), Duration.ofDays(1))
53+
.build();
54+
return Bucket.builder()
55+
.addLimit(perMinute)
56+
.addLimit(perDay)
57+
.build();
58+
}
59+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.readum.infrastructure.ai.openai.ratelimit;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import jakarta.servlet.http.HttpServletResponse;
5+
import org.junit.jupiter.api.AfterEach;
6+
import org.junit.jupiter.api.Tag;
7+
import org.junit.jupiter.api.Test;
8+
import org.springframework.mock.web.MockHttpServletRequest;
9+
import org.springframework.mock.web.MockHttpServletResponse;
10+
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
11+
import org.springframework.security.core.context.SecurityContextHolder;
12+
13+
import java.util.List;
14+
15+
import static org.assertj.core.api.Assertions.assertThat;
16+
import static org.mockito.ArgumentMatchers.any;
17+
import static org.mockito.BDDMockito.given;
18+
import static org.mockito.Mockito.mock;
19+
20+
@Tag("guardrail")
21+
class AiChatRateLimitInterceptorTest {
22+
23+
@AfterEach
24+
void clearContext() {
25+
SecurityContextHolder.clearContext();
26+
}
27+
28+
@Test
29+
void 한도_내_요청은_통과한다() throws Exception {
30+
AiChatRateLimiter limiter = mock(AiChatRateLimiter.class);
31+
given(limiter.isEnabled()).willReturn(true);
32+
given(limiter.tryConsume(any())).willReturn(true);
33+
AiChatRateLimitInterceptor interceptor = new AiChatRateLimitInterceptor(limiter);
34+
35+
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/ai-chat/stream");
36+
request.setRemoteAddr("203.0.113.10");
37+
MockHttpServletResponse response = new MockHttpServletResponse();
38+
39+
boolean proceeded = interceptor.preHandle(request, response, new Object());
40+
41+
assertThat(proceeded).isTrue();
42+
assertThat(response.getStatus()).isEqualTo(200);
43+
}
44+
45+
@Test
46+
void 한도_초과시_429_응답을_반환하고_체인을_중단한다() throws Exception {
47+
AiChatRateLimiter limiter = mock(AiChatRateLimiter.class);
48+
given(limiter.isEnabled()).willReturn(true);
49+
given(limiter.tryConsume(any())).willReturn(false);
50+
AiChatRateLimitInterceptor interceptor = new AiChatRateLimitInterceptor(limiter);
51+
52+
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/ai-chat/stream");
53+
request.setRemoteAddr("203.0.113.10");
54+
MockHttpServletResponse response = new MockHttpServletResponse();
55+
56+
boolean proceeded = interceptor.preHandle(request, response, new Object());
57+
58+
assertThat(proceeded).isFalse();
59+
assertThat(response.getStatus()).isEqualTo(429);
60+
assertThat(response.getContentAsString()).contains("AI 채팅 호출 한도를 초과했습니다");
61+
}
62+
63+
@Test
64+
void enabled_가_false_면_무조건_통과한다() throws Exception {
65+
AiChatRateLimiter limiter = mock(AiChatRateLimiter.class);
66+
given(limiter.isEnabled()).willReturn(false);
67+
AiChatRateLimitInterceptor interceptor = new AiChatRateLimitInterceptor(limiter);
68+
69+
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/ai-chat/stream");
70+
MockHttpServletResponse response = new MockHttpServletResponse();
71+
72+
boolean proceeded = interceptor.preHandle(request, response, new Object());
73+
74+
assertThat(proceeded).isTrue();
75+
}
76+
77+
@Test
78+
void 인증_principal_이_있으면_user_key_를_사용한다() throws Exception {
79+
AiChatRateLimiter limiter = mock(AiChatRateLimiter.class);
80+
given(limiter.isEnabled()).willReturn(true);
81+
given(limiter.tryConsume("user:42")).willReturn(true);
82+
AiChatRateLimitInterceptor interceptor = new AiChatRateLimitInterceptor(limiter);
83+
84+
SecurityContextHolder.getContext().setAuthentication(
85+
new UsernamePasswordAuthenticationToken(42L, null, List.of())
86+
);
87+
88+
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/ai-chat/stream");
89+
MockHttpServletResponse response = new MockHttpServletResponse();
90+
91+
boolean proceeded = interceptor.preHandle(request, response, new Object());
92+
93+
assertThat(proceeded).isTrue();
94+
}
95+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.readum.infrastructure.ai.openai.ratelimit;
2+
3+
import com.readum.infrastructure.ai.openai.GuardrailProperties;
4+
import org.junit.jupiter.api.Tag;
5+
import org.junit.jupiter.api.Test;
6+
7+
import static org.assertj.core.api.Assertions.assertThat;
8+
9+
@Tag("guardrail")
10+
class AiChatRateLimiterTest {
11+
12+
@Test
13+
void 분당_요청_한도를_초과하면_더_이상_허용되지_않는다() {
14+
GuardrailProperties props = new GuardrailProperties(
15+
GuardrailProperties.Input.defaults(),
16+
GuardrailProperties.Output.defaults(),
17+
GuardrailProperties.Moderation.defaults(),
18+
new GuardrailProperties.RateLimit(true, 3, 100)
19+
);
20+
AiChatRateLimiter limiter = new AiChatRateLimiter(props);
21+
22+
for (int i = 0; i < 3; i++) {
23+
assertThat(limiter.tryConsume("user:1")).as("호출 %d", i + 1).isTrue();
24+
}
25+
assertThat(limiter.tryConsume("user:1")).as("4번째 호출은 한도 초과").isFalse();
26+
}
27+
28+
@Test
29+
void 일일_한도가_분당_한도보다_먼저_소진되면_차단된다() {
30+
GuardrailProperties props = new GuardrailProperties(
31+
GuardrailProperties.Input.defaults(),
32+
GuardrailProperties.Output.defaults(),
33+
GuardrailProperties.Moderation.defaults(),
34+
new GuardrailProperties.RateLimit(true, 100, 2)
35+
);
36+
AiChatRateLimiter limiter = new AiChatRateLimiter(props);
37+
38+
assertThat(limiter.tryConsume("user:1")).isTrue();
39+
assertThat(limiter.tryConsume("user:1")).isTrue();
40+
assertThat(limiter.tryConsume("user:1")).as("일일 한도 2 를 초과").isFalse();
41+
}
42+
43+
@Test
44+
void 서로_다른_key_는_각자_독립적인_bucket_을_가진다() {
45+
GuardrailProperties props = new GuardrailProperties(
46+
GuardrailProperties.Input.defaults(),
47+
GuardrailProperties.Output.defaults(),
48+
GuardrailProperties.Moderation.defaults(),
49+
new GuardrailProperties.RateLimit(true, 1, 100)
50+
);
51+
AiChatRateLimiter limiter = new AiChatRateLimiter(props);
52+
53+
assertThat(limiter.tryConsume("user:A")).isTrue();
54+
assertThat(limiter.tryConsume("user:A")).isFalse();
55+
assertThat(limiter.tryConsume("user:B")).isTrue();
56+
}
57+
58+
@Test
59+
void enabled_가_false_면_언제나_허용한다() {
60+
GuardrailProperties props = new GuardrailProperties(
61+
GuardrailProperties.Input.defaults(),
62+
GuardrailProperties.Output.defaults(),
63+
GuardrailProperties.Moderation.defaults(),
64+
new GuardrailProperties.RateLimit(false, 1, 1)
65+
);
66+
AiChatRateLimiter limiter = new AiChatRateLimiter(props);
67+
68+
for (int i = 0; i < 50; i++) {
69+
assertThat(limiter.tryConsume("user:X")).isTrue();
70+
}
71+
assertThat(limiter.isEnabled()).isFalse();
72+
}
73+
}

0 commit comments

Comments
 (0)