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
2 changes: 1 addition & 1 deletion docs/ai/erd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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개 제한에 사용 |
Expand Down
2 changes: 1 addition & 1 deletion docs/ai/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ app:
duration: 1m
chat:
limits:
- capacity: 5
- capacity: 4
duration: 1s
- capacity: 60
duration: 1m
Original file line number Diff line number Diff line change
@@ -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<PropertySource<?>> loadedSources = loader.load("application", new ClassPathResource("application.yaml"));
MutablePropertySources sources = new MutablePropertySources();
loadedSources.forEach(sources::addLast);

Iterable<ConfigurationPropertySource> 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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
)))
));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
)))
));
Expand All @@ -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));
Expand All @@ -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))
);
}
Expand Down