Skip to content

Commit e205a87

Browse files
authored
Merge pull request #334 from let-s-record-it/develop
Merge develop into main
2 parents bc134b5 + 450f1b8 commit e205a87

File tree

12 files changed

+346
-2
lines changed

12 files changed

+346
-2
lines changed

build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ dependencies {
8989
// quartz
9090
implementation 'org.springframework.boot:spring-boot-starter-quartz:3.4.1'
9191

92+
// bucket4j
93+
implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0'
94+
implementation 'com.bucket4j:bucket4j_jdk17-redis:8.14.0'
95+
implementation 'com.bucket4j:bucket4j_jdk17-lettuce:8.15.0'
96+
9297
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
9398

9499
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'

src/main/java/com/sillim/recordit/config/cache/RedisConfig.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.sillim.recordit.config.cache;
22

3+
import io.lettuce.core.RedisClient;
34
import org.springframework.beans.factory.annotation.Value;
45
import org.springframework.context.annotation.Bean;
56
import org.springframework.context.annotation.Configuration;
@@ -25,12 +26,15 @@ public RedisConnectionFactory redisConnectionFactory() {
2526
@Bean
2627
public RedisTemplate<String, Object> redisTemplate() {
2728
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
28-
2929
redisTemplate.setConnectionFactory(redisConnectionFactory());
30-
3130
redisTemplate.setKeySerializer(new StringRedisSerializer());
3231
redisTemplate.setValueSerializer(new StringRedisSerializer());
3332

3433
return redisTemplate;
3534
}
35+
36+
@Bean
37+
public RedisClient redisClient() {
38+
return RedisClient.create("redis://" + host + ":" + port);
39+
}
3640
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.sillim.recordit.config.filter;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.sillim.recordit.global.dto.response.ErrorResponse;
5+
import com.sillim.recordit.global.exception.ErrorCode;
6+
import io.github.bucket4j.Bucket;
7+
import io.github.bucket4j.BucketConfiguration;
8+
import io.github.bucket4j.ConsumptionProbe;
9+
import io.github.bucket4j.distributed.proxy.ProxyManager;
10+
import jakarta.servlet.*;
11+
import jakarta.servlet.http.HttpServletRequest;
12+
import jakarta.servlet.http.HttpServletResponse;
13+
import java.io.IOException;
14+
import java.nio.charset.StandardCharsets;
15+
import java.util.List;
16+
import lombok.RequiredArgsConstructor;
17+
import lombok.extern.slf4j.Slf4j;
18+
import org.springframework.http.HttpHeaders;
19+
import org.springframework.http.ResponseEntity;
20+
import org.springframework.stereotype.Component;
21+
import org.springframework.util.AntPathMatcher;
22+
23+
@Slf4j
24+
@Component
25+
@RequiredArgsConstructor
26+
public class ApiThrottlingFilter implements Filter {
27+
28+
private static final List<LimitApi> LIMIT_APIS =
29+
List.of(
30+
LimitApi.pattern("/api/v1/invite/members/*"),
31+
LimitApi.pattern("POST", "/api/v1/members/*/follow"),
32+
LimitApi.pattern("POST", "/api/v1/feeds/**"));
33+
public static final int TOO_MANY_REQUEST = 429;
34+
35+
private final ProxyManager<String> proxyManager;
36+
private final BucketConfiguration bucketConfiguration;
37+
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
38+
private final ObjectMapper objectMapper;
39+
40+
@Override
41+
public void doFilter(
42+
ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
43+
throws IOException, ServletException {
44+
HttpServletRequest request = (HttpServletRequest) servletRequest;
45+
46+
String auth = request.getHeader(HttpHeaders.AUTHORIZATION);
47+
if (auth == null) {
48+
filterChain.doFilter(servletRequest, servletResponse);
49+
return;
50+
}
51+
52+
Bucket bucket = proxyManager.getProxy(auth, () -> bucketConfiguration);
53+
for (LimitApi limitApi : LIMIT_APIS) {
54+
if (antPathMatcher.match(limitApi.getUrl(), request.getRequestURI())
55+
&& (limitApi.noMethod() || limitApi.getMethod().equals(request.getMethod()))) {
56+
checkApiToken(bucket, filterChain, servletRequest, servletResponse);
57+
return;
58+
}
59+
}
60+
61+
filterChain.doFilter(servletRequest, servletResponse);
62+
}
63+
64+
private void checkApiToken(
65+
Bucket bucket,
66+
FilterChain filterChain,
67+
ServletRequest request,
68+
ServletResponse response)
69+
throws IOException, ServletException {
70+
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
71+
72+
if (probe.isConsumed()) {
73+
filterChain.doFilter(request, response);
74+
return;
75+
}
76+
77+
long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000;
78+
79+
HttpServletResponse httpResponse = (HttpServletResponse) response;
80+
httpResponse.setContentType("text/plain; charset=UTF-8");
81+
httpResponse.setStatus(TOO_MANY_REQUEST);
82+
httpResponse.setCharacterEncoding(StandardCharsets.UTF_8.name());
83+
84+
response.getWriter()
85+
.write(
86+
objectMapper.writeValueAsString(
87+
ResponseEntity.status(TOO_MANY_REQUEST)
88+
.body(
89+
ErrorResponse.from(
90+
ErrorCode.TOO_MANY_REQUEST,
91+
waitForRefill + "초 뒤에 다시 시도해주세요"))));
92+
}
93+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.sillim.recordit.config.filter;
2+
3+
import lombok.Getter;
4+
5+
@Getter
6+
public class LimitApi {
7+
8+
private final String method;
9+
private final String url;
10+
11+
private LimitApi(String method, String url) {
12+
this.method = method;
13+
this.url = url;
14+
}
15+
16+
public static LimitApi pattern(String method, String url) {
17+
return new LimitApi(method, url);
18+
}
19+
20+
public static LimitApi pattern(String url) {
21+
return new LimitApi(null, url);
22+
}
23+
24+
public boolean noMethod() {
25+
return this.method == null;
26+
}
27+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.sillim.recordit.config.neo4j;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.neo4j.driver.Driver;
5+
import org.neo4j.driver.Session;
6+
import org.springframework.boot.ApplicationArguments;
7+
import org.springframework.boot.ApplicationRunner;
8+
import org.springframework.context.annotation.Profile;
9+
import org.springframework.stereotype.Component;
10+
11+
/**
12+
* (Local 환경 한정) Spring Application 실행 시 Neo4j DB 데이터를 초기화
13+
*/
14+
@Component
15+
@RequiredArgsConstructor
16+
@Profile("local")
17+
public class LocalNeo4jInitializer implements ApplicationRunner {
18+
19+
private static final String INIT_QUERY = "MATCH (n) DETACH DELETE n";
20+
private final Driver neo4jDriver;
21+
22+
@Override
23+
public void run(ApplicationArguments args) {
24+
try (Session session = neo4jDriver.session()) {
25+
session.run(INIT_QUERY);
26+
}
27+
}
28+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.sillim.recordit.config.ratelimiter;
2+
3+
import io.github.bucket4j.BucketConfiguration;
4+
import io.github.bucket4j.distributed.ExpirationAfterWriteStrategy;
5+
import io.github.bucket4j.redis.lettuce.Bucket4jLettuce;
6+
import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager;
7+
import io.lettuce.core.RedisClient;
8+
import io.lettuce.core.api.StatefulRedisConnection;
9+
import io.lettuce.core.codec.ByteArrayCodec;
10+
import io.lettuce.core.codec.RedisCodec;
11+
import io.lettuce.core.codec.StringCodec;
12+
import java.time.*;
13+
import lombok.RequiredArgsConstructor;
14+
import org.springframework.beans.factory.annotation.Value;
15+
import org.springframework.context.annotation.Bean;
16+
import org.springframework.context.annotation.Configuration;
17+
18+
@Configuration
19+
@RequiredArgsConstructor
20+
public class RateLimiterConfig {
21+
22+
private final RedisClient redisClient;
23+
24+
@Value("${rate-limiter.capacity:5}")
25+
private int capacity;
26+
27+
@Value("${rate-limiter.refill-token-amount:5}")
28+
private int refillTokenAmount;
29+
30+
@Value("${rate-limiter.refill-duration-seconds:10}")
31+
private long refillDurationSeconds;
32+
33+
@Value("${rate-limiter.bucket-ttl-seconds:600}")
34+
private long bucketTTLSeconds;
35+
36+
@Bean
37+
public LettuceBasedProxyManager<String> proxyManager() {
38+
StatefulRedisConnection<String, byte[]> connect =
39+
redisClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));
40+
return Bucket4jLettuce.casBasedBuilder(connect)
41+
.expirationAfterWrite(
42+
ExpirationAfterWriteStrategy.fixedTimeToLive(
43+
Duration.ofSeconds(bucketTTLSeconds)))
44+
.build();
45+
}
46+
47+
@Bean
48+
public BucketConfiguration bucketConfiguration() {
49+
return BucketConfiguration.builder()
50+
.addLimit(
51+
limit ->
52+
limit.capacity(capacity)
53+
.refillIntervally(
54+
refillTokenAmount,
55+
Duration.ofSeconds(refillDurationSeconds)))
56+
.build();
57+
}
58+
}

src/main/java/com/sillim/recordit/global/exception/ErrorCode.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public enum ErrorCode {
99
INVALID_ARGUMENT("ERR_GLOBAL_001", "올바르지 않은 값이 전달되었습니다."),
1010
REQUEST_NOT_FOUND("ERR_GLOBAL_002", "요청을 찾을 수 없습니다."),
1111
INVALID_REQUEST("ERR_GLOBAL_003", "유효하지 않은 요청입니다."),
12+
TOO_MANY_REQUEST("ERR_GLOBAL_004", "너무 많은 요청을 보냈습니다."),
1213
UNHANDLED_EXCEPTION("ERR_GLOBAL_999", "예상치 못한 오류가 발생했습니다."),
1314

1415
ID_TOKEN_UNSUPPORTED("ERR_OIDC_001", "지원되지 않는 ID Token 입니다."),

src/main/java/com/sillim/recordit/member/controller/LoginController.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import lombok.extern.slf4j.Slf4j;
1414
import org.springframework.http.ResponseEntity;
1515
import org.springframework.validation.annotation.Validated;
16+
import org.springframework.web.bind.annotation.GetMapping;
1617
import org.springframework.web.bind.annotation.PostMapping;
1718
import org.springframework.web.bind.annotation.RequestBody;
1819
import org.springframework.web.bind.annotation.RequestMapping;
@@ -47,4 +48,9 @@ public ResponseEntity<Void> activateMember(
4748

4849
return ResponseEntity.noContent().build();
4950
}
51+
52+
@GetMapping("/auth/validate")
53+
public ResponseEntity<Void> validateToken() {
54+
return ResponseEntity.noContent().build();
55+
}
5056
}

src/main/resources/application-local.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ server:
111111
min-spare: 10
112112
max: 200
113113

114+
rate-limiter:
115+
capacity: 5
116+
refill-token-amount: 5
117+
refill-duration-seconds: 10
118+
bucket-ttl-seconds: 600
119+
114120
management:
115121
endpoint:
116122
health:

src/main/resources/application-prod.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,9 @@ management:
116116
web:
117117
exposure:
118118
include: health, prometheus
119+
120+
rate-limiter:
121+
capacity: ${RATE_LIMITER_CAPACITY}
122+
refill-token-amount: ${RATE_LIMITER_REFILL_TOKEN_AMOUNT}
123+
refill-duration-seconds: ${RATE_LIMITER_REFILL_DURATION_SECONDS}
124+
bucket-ttl-seconds: ${RATE_LIMITER_BUCKET_TTL_SECONDS}

0 commit comments

Comments
 (0)