diff --git a/docs/ai/erd.md b/docs/ai/erd.md index 0e5f2b24..bd50b076 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -245,7 +245,7 @@ AI_RESPONSE metadata는 `requestMessageId`, `intent`, `placeRecommendation`, `co | `route:lock:{origin}:{dest}:{travelMode}` | Routes API single-flight lock | 5초 | Redis `SET NX EX` 기반 lock. 동시 cache miss 시 Google Routes API 대표 호출자 1개만 선출 | | `route:no-route:{origin}:{dest}:{travelMode}` | Routes API 경로 없음 신호 | 5초 | 대표 호출자가 경로 없음(204)을 확인했음을 대기 요청에 전달하는 짧은 조정 신호 | | `rate-limit:http:*` | HTTP API rate limit bucket | Bucket4j refill 완료 후 제거 | user, room, inviteCode, refresh token hash 기준 Bucket4j Redis bucket. 전체 API/auth/ws IP 제한은 Caddy에서 처리 | -| `rate-limit:stomp:chat:user:{userId}` | 채팅/장소 공유 STOMP rate limit bucket | Bucket4j refill 완료 후 제거 | `/chat`, `/place` 메시지 전송에 `5/sec + 60/min/user` 적용 | +| `rate-limit:stomp:chat:user:{userId}` | 채팅/장소 공유 STOMP rate limit bucket | Bucket4j refill 완료 후 제거 | `/chat`, `/place` 메시지 전송에 `4/sec + 60/min/user` 적용 | | `ai:room:{roomId}:queue` | 방별 AI 요청 대기 큐 | 처리 완료/취소 시 제거 | Redis List. 값은 `{AI_REQUEST message id}:{userId}`. 방별 `QUEUED` 요청 처리 순서를 보존한다 | | `ai:room:{roomId}:queued-count` | 방별 AI 요청 대기 슬롯 수 | 30분 TTL 갱신, 처리/취소 시 감소 | Redis String counter. 메시지 저장 전 예약한 슬롯을 포함하며 방별 `QUEUED` 요청 최대 3개 제한에 사용 | | `ai:room:{roomId}:pending-users` | 방별 AI 요청 pending 사용자 집합 | 30분 TTL 갱신, 처리 완료/취소 시 제거 | Redis Set. 같은 사용자의 같은 방 내 `QUEUED`/`PROCESSING` 요청 1개 제한에 사용 | diff --git a/docs/ai/features.md b/docs/ai/features.md index cec279aa..1f05401d 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -174,7 +174,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | `[x]` | 시스템 메시지 | 입장 승인 같은 멤버십 변경 이벤트를 `messageType=SYSTEM`, `senderId=NULL` 메시지로 저장 후 `/topic/rooms/{roomId}/messages`와 `/topic/rooms/{roomId}/members` 양쪽으로 브로드캐스트. MongoDB 문서와 STOMP/REST 응답 모두에서 `content` 키는 포함되지 않으며, metadata는 식별자만 가진다. `MEMBER_JOINED`/`MEMBER_LEFT`/`MEMBER_KICKED`는 `{ eventType, userId }`, `HOST_DELEGATED`는 `{ eventType, previousHostUserId, newHostUserId }`. 표시 문구는 클라이언트가 `eventType`과 userId를 멤버 맵에서 lookup해 조립한다. WebSocket 접속/해제 presence 이벤트는 채팅 히스토리에 저장하지 않음 | MongoDB messages | | `[x]` | 읽음 처리 (WS) | 클라이언트가 `/app/rooms/{roomId}/messages/read`로 `lastReadMessageId`를 전송하면 `room_members.last_read_message_id`를 업데이트. 후퇴 방지(새 ID > 기존 ID만 허용). 실패 시 로그만 남기고 무시. 읽음 cursor는 현재 `_id` 기준을 유지한다 | room_members | | `[x]` | 안읽은 메시지 카운트 조회 | `GET /rooms/{roomId}/messages/unread-count`로 `lastReadMessageId` 이후 SYSTEM 제외 메시지 수 반환. 페이지 로딩/재접속 시 호출하여 배지 카운트 초기화, 이후 실시간 메시지는 프론트 로컬 증가. unread count는 현재 `_id` 기준을 유지한다 | room_members, MongoDB messages | -| `[x]` | 채팅 도배 방지 (Flood Protection) | Redis 기반 Bucket4j로 사용자별 `5/sec + 60/min` token bucket 제한을 `/chat`, `/place` 전송에 적용한다. 초과 시 `CHAT_RATE_LIMIT_EXCEEDED` 에러와 다음 토큰 충전까지의 시간(`retryAfterMs`)을 `/user/queue/errors`로 전달한다. Redis/Bucket4j backend 장애 시에는 fail-open으로 전송을 허용한다. 서버 사이드 cooldown과 점진 증가 정책은 사용하지 않는다 | Redis | +| `[x]` | 채팅 도배 방지 (Flood Protection) | Redis 기반 Bucket4j로 사용자별 `4/sec + 60/min` token bucket 제한을 `/chat`, `/place` 전송에 적용한다. 초과 시 `CHAT_RATE_LIMIT_EXCEEDED` 에러와 다음 토큰 충전까지의 시간(`retryAfterMs`)을 `/user/queue/errors`로 전달한다. Redis/Bucket4j backend 장애 시에는 fail-open으로 전송을 허용한다. 서버 사이드 cooldown과 점진 증가 정책은 사용하지 않는다 | Redis | --- diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 85039680..b9d6beab 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -150,7 +150,7 @@ app: duration: 1m chat: limits: - - capacity: 5 + - capacity: 4 duration: 1s - capacity: 60 duration: 1m diff --git a/src/test/java/com/howaboutus/backend/common/config/properties/RateLimitPropertiesTest.java b/src/test/java/com/howaboutus/backend/common/config/properties/RateLimitPropertiesTest.java new file mode 100644 index 00000000..70ebffe0 --- /dev/null +++ b/src/test/java/com/howaboutus/backend/common/config/properties/RateLimitPropertiesTest.java @@ -0,0 +1,46 @@ +package com.howaboutus.backend.common.config.properties; + +import static org.assertj.core.api.Assertions.*; + +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.ConfigurationPropertySource; +import org.springframework.boot.context.properties.source.ConfigurationPropertySources; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.MutablePropertySources; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ClassPathResource; + +import com.howaboutus.backend.common.ratelimit.RateLimitBandwidth; + +class RateLimitPropertiesTest { + + @Test + @DisplayName("기본 채팅 도배 방지 정책은 사용자별 4/sec + 60/min이다") + void defaultChatPolicyUsesFourPerSecondAndSixtyPerMinute() throws Exception { + RateLimitProperties properties = loadDefaultProperties(); + + assertThat(properties.getBandwidths("chat")) + .containsExactly( + new RateLimitBandwidth(4, Duration.ofSeconds(1)), + new RateLimitBandwidth(60, Duration.ofMinutes(1)) + ); + } + + private RateLimitProperties loadDefaultProperties() throws Exception { + YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); + List> loadedSources = loader.load("application", new ClassPathResource("application.yaml")); + MutablePropertySources sources = new MutablePropertySources(); + loadedSources.forEach(sources::addLast); + + Iterable configurationSources = ConfigurationPropertySources.from(sources); + return new Binder(configurationSources) + .bind("app.rate-limit", Bindable.of(RateLimitProperties.class)) + .orElseThrow(() -> new IllegalStateException("app.rate-limit properties are not bound")); + } +} diff --git a/src/test/java/com/howaboutus/backend/common/ratelimit/Bucket4jRateLimitServiceTest.java b/src/test/java/com/howaboutus/backend/common/ratelimit/Bucket4jRateLimitServiceTest.java index 64894c5c..5305442f 100644 --- a/src/test/java/com/howaboutus/backend/common/ratelimit/Bucket4jRateLimitServiceTest.java +++ b/src/test/java/com/howaboutus/backend/common/ratelimit/Bucket4jRateLimitServiceTest.java @@ -34,7 +34,7 @@ void setUp() { void returnsFailOpenDecisionWhenBackendFails() { RateLimitPlan plan = new RateLimitPlan( "rate-limit:stomp:chat:user:1", - List.of(new RateLimitBandwidth(5, Duration.ofSeconds(1))) + List.of(new RateLimitBandwidth(4, Duration.ofSeconds(1))) ); given(proxyManager.getProxy(eq(plan.key()), any())).willThrow(new RuntimeException("redis down")); diff --git a/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java b/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java index 826fd372..c0cbf127 100644 --- a/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java +++ b/src/test/java/com/howaboutus/backend/common/ratelimit/HttpRateLimitPolicyResolverTest.java @@ -32,7 +32,7 @@ class HttpRateLimitPolicyResolverTest { Map.entry("join-user", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null)), Map.entry("write-user", new RateLimitProperties.PolicyDto(60, Duration.ofMinutes(1), null)), Map.entry("chat", new RateLimitProperties.PolicyDto(null, null, List.of( - new RateLimitProperties.BandwidthDto(5, Duration.ofSeconds(1)), + new RateLimitProperties.BandwidthDto(4, Duration.ofSeconds(1)), new RateLimitProperties.BandwidthDto(60, Duration.ofMinutes(1)) ))) )); diff --git a/src/test/java/com/howaboutus/backend/messages/service/MessageRateLimiterTest.java b/src/test/java/com/howaboutus/backend/messages/service/MessageRateLimiterTest.java index 54862f7c..df9b0f3f 100644 --- a/src/test/java/com/howaboutus/backend/messages/service/MessageRateLimiterTest.java +++ b/src/test/java/com/howaboutus/backend/messages/service/MessageRateLimiterTest.java @@ -39,7 +39,7 @@ class MessageRateLimiterTest { java.util.Map.entry("join-user", new RateLimitProperties.PolicyDto(10, Duration.ofMinutes(1), null)), java.util.Map.entry("write-user", new RateLimitProperties.PolicyDto(60, Duration.ofMinutes(1), null)), java.util.Map.entry("chat", new RateLimitProperties.PolicyDto(null, null, java.util.List.of( - new RateLimitProperties.BandwidthDto(5, Duration.ofSeconds(1)), + new RateLimitProperties.BandwidthDto(4, Duration.ofSeconds(1)), new RateLimitProperties.BandwidthDto(60, Duration.ofMinutes(1)) ))) )); @@ -53,7 +53,7 @@ void setUp() { } @Test - @DisplayName("채팅 메시지는 사용자별 5/sec + 60/min Redis bucket으로 검사한다") + @DisplayName("채팅 메시지는 사용자별 4/sec + 60/min Redis bucket으로 검사한다") void checksChatBucketWithSecondAndMinuteLimits() { long userId = 1L; given(rateLimitService.tryConsume(any())).willReturn(RateLimitDecision.allowed(4)); @@ -67,7 +67,7 @@ void checksChatBucketWithSecondAndMinuteLimits() { assertThat(plan.key()).isEqualTo("rate-limit:stomp:chat:user:1"); assertThat(plan.limits()) .containsExactly( - new com.howaboutus.backend.common.ratelimit.RateLimitBandwidth(5, Duration.ofSeconds(1)), + new com.howaboutus.backend.common.ratelimit.RateLimitBandwidth(4, Duration.ofSeconds(1)), new com.howaboutus.backend.common.ratelimit.RateLimitBandwidth(60, Duration.ofMinutes(1)) ); }