Bucket4jλ‘ νΈλν½ μ ννκΈ°(Redis & MariaDB)
κ°μ
μ΅κ·Ό μ 무 νλ‘μ νΈμμ νΉμ (μκΈμ΄ λΆκ°λλ) λ‘μ§μ λν΄ μλ³ μ¬μ©λμ μ ννλ κΈ°λ₯μ΄ μΆκ°λμ΄μΌ νμ΅λλ€. μ΄μ κ΄λ ¨νμ¬ μ²λ¦¬μ¨ μ ν κΈ°μ μ μμ보μλλ° Bucket4j, Guava, RateLimitj, Resilience4j λ± λ€μν λΌμ΄λΈλ¬λ¦¬κ° μλ κ±Έ μκ² λμκ³ , νλ‘μ νΈ νκ²½μΈ Spring Boot 2.7.x, MariaDB(nosql & in-momoryλΆμ¬), Java 11μ μλ§μ Bucket4jλ₯Ό μ ννκ² λμμ΅λλ€.
λ€μμ κ³ λ €ν λΌμ΄λΈλ¬λ¦¬λ€μ νΉμ§κ³Ό κ·Έ μ ν μ΄μ μ λλ€.
- Guava: λ€μν ν΅μ¬ Java λΌμ΄λΈλ¬λ¦¬λ₯Ό μ 곡νμ§λ§, λ¨μν Rate Limiting κΈ°λ₯λ§μ μν΄ μ¬μ©νκΈ°μλ λ€μ λ¬΄κ±°μ΄ λλμ΄λ€.
- Resilience4j: μν· λΈλ μ΄μ»€λ₯Ό μ 곡νλ λΌμ΄λΈλ¬λ¦¬λ‘ Rate Limiter κΈ°λ₯λ ν¬ν¨λμ΄ μμ§λ§, λ§μ°¬κ°μ§λ‘ λ¨μν Rate Limiting κΈ°λ₯λ§μ μν΄ μ¬μ©νκΈ°μλ λ€μ λ¬΄κ±°μ΄ λλμ΄λ€.
- RateLimitj: μ¬λΌμ΄λ© μλμ° μκ³ λ¦¬μ¦μ κΈ°λ°μΌλ‘ νλ Rate Limiter ꡬν체μ΄λ€. κ·Έλ¬λ 곡μ λ¬Έμμ λ μ΄μ μ λ°μ΄νΈκ° μμ κ²μ΄λΌκ³ λͺ μλμ΄ μκ³ , Bucket4jλ₯Ό λ체μ λ‘ κΆμ₯νκ³ μλ€.
- Bucket4j: ν ν° λ²ν· μκ³ λ¦¬μ¦μ κΈ°λ°μΌλ‘ νλ©°, lock-freeν ꡬνμΌλ‘ λ©ν° μ€λ λ© νκ²½μμμ νμ₯μ±μ΄ μ°μνλ©°, Rate Limitingλ§μ λͺ©μ μΌλ‘ μ 곡λλ λΌμ΄λΈλ¬λ¦¬μ΄λ€.
λ°λΌμ μ΄ κΈμμλ Bucket4jλ₯Ό μ¬μ©νμ¬ νΈλν½ μ νμ ꡬννλ λ°©λ²μ λν΄ μ 리ν©λλ€.
ν ν° λ²ν· μκ³ λ¦¬μ¦ μ΄λ?
ν ν° λ²ν· μκ³ λ¦¬μ¦(Token Bucket Algorithm)μ λ€νΈμν¬ νΈλν½ λ° λ°μ΄ν° μ μ‘ μλλ₯Ό μ μ΄νλ λ° λ리 μ¬μ©λλ κΈ°λ²μ λλ€. κ³ μ λ μλλ‘ ν ν°μ΄ λ²ν·μ μ±μμ§λ©°, κ° μμ²μ λ²ν€μμ νλ μ΄μμ ν ν°μ μλΉν©λλ€. λ²ν·μ μΆ©λΆν ν ν°μ΄ μλ κ²½μ°, μμ²μ λκΈ°νκ±°λ κ±°λΆλλ©° μ΄ λ°©μμ νΈλν½μ μ μ°νκ² μ μ΄νκ³ νΌν¬ μκ°μ λν λΆνλ₯Ό κ΄λ¦¬νλλ° ν¨κ³Όμ μ λλ€. μ£Όμ νΉμ§κ³Ό λμ μ리λ λ€μκ³Ό κ°μ΅λλ€.
- ν ν°μ μμ±κ³Ό μλΉ: λ²ν·μλ μΌμ ν μλλ‘ ν ν°μ΄ μΆκ°λλ€. μλ₯Ό λ€μ΄, 1λΆμ 100κ°μ ν ν°μ΄ μμ±λ μ μμΌλ©° μμ²μ΄ λ€μ΄μ¬ λλ§λ€ λ²ν·μμ ν ν°μ νλ λλ κ·Έ μ΄μ μλΉνλ€.
- μμ² μ ν: λ²ν·μ ν ν°μ΄ μΆ©λΆνμ§ μμΌλ©΄ μλ‘μ΄ μμ²μ μ²λ¦¬λμ§ μκ³ λ²λ €μ§λ€.
- ν ν°μ μ¬μΆ©μ : μΌμ μκ°μ΄ μ§λλ©΄ ν ν°μ΄ λ€μ μμ±λμ΄ λ²ν·μ μΆκ°λλ€. μλ₯Ό λ€μ΄, μ΄κΈ°μ 120κ°μ ν ν°μΌλ‘ μμνμ¬ 1λΆμ 100κ°μ© μ¬μΆ©μ λλ λ°©μμ΄λ€.
Bucket4j μ μ©
Bucket4jλ λ‘컬 λ©λͺ¨λ¦¬ μΈμλ, JDBC(MySQL, PostgreSQL, Oracle, MSSQL)μ Redisμ κ°μ΄ λ€μν λΆμ° νκ²½μ λ°μ΄ν°λ² μ΄μ€λ₯Ό μ§μν©λλ€.
μλλ MariaDBμ Redisμ Bucket4jλ₯Ό μ μ©ν μμ μ½λλ€μ λλ€. λ‘컬 λ©λͺ¨λ¦¬(μΊμ)λ₯Ό μ¬μ©ν μ½λμ λν΄μλ μΈν°λ·μ λ€μν μμ κ° μμΌλ―λ‘, νμνλ€λ©΄ ν΄λΉ μλ£λ€μ μ°Έμ‘°νμλ©΄ μ’μ κ² κ°μ΅λλ€. μ€μ μλΉμ€ νκ²½μμλ λκ° μ¬λ¬ μΈμ€ν΄μ€κ° λμμ μ΄μλλ―λ‘, μ΄μ²λΌ λΆμ° λ°μ΄ν°λ² μ΄μ€μ Bucket4jλ₯Ό μ μ©νλ κ²μ΄ νμν©λλ€.
- Bucket4jλ₯Ό MariaDBμ μ μ©ν μμ : Bucket4j-mariadb
- Bucket4jλ₯Ό Redisμ μ μ©ν μμ : Bucket4j-redis
Bucket4j-mariadb
1. table μμ±
λ¨Όμ μλμ κ°μ΄ 'bucket'μ΄λΌλ μ΄λ¦μ ν μ΄λΈμ μμ±ν©λλ€. 곡μ λ¬Έμμμλ ν μ΄λΈ μ΄λ¦μ 'buckets'λ‘ μ§μ νμμ§λ§, μ€μ λ‘ BucketTableSetting.getDefault() λ©μλλ₯Ό νΈμΆνλ©΄ ν μ΄λΈ μ΄λ¦μ΄ 'bucket'μΌλ‘ λ°νλλ―λ‘, ν μ΄λΈ μ΄λ¦μ 'bucket'μΌλ‘ μ€μ νλ κ²μ΄ νΈλ¦¬ν©λλ€.
create table bucket
(
id bigint not null
primary key,
state blob null
);
2. dependency
'bucket4j-mysql' μμ‘΄μ±μ μΆκ°ν©λλ€.
dependencies {
...
// bucket4j
implementation 'com.bucket4j:bucket4j-mysql:8.7.0'
}
3. APIRateLimiter
@Slf4j
@Component
public class APIRateLimiter {
// λ²ν·μ μ©λμ μ€μ , μ΄λ λ²ν·μ λ΄κΈΈ μ μλ ν ν°μ μ΅λ μλ₯Ό μλ―Έ
private static final int CAPACITY = 3;
// ν ν°μ΄ μΌλ§λ λΉ λ₯΄κ² μ¬μΆ©μ λ μ§ μ€μ , μ΄λ μ§μ λ μκ° λμ λ²ν·μ μΆκ°λ ν ν°μ μλ₯Ό μλ―Έ
private static final int REFILL_AMOUNT = 3;
// ν ν°μ΄ μ¬μΆ©μ λλ λΉλλ₯Ό μ€μ
private static final Duration REFILL_DURATION = Duration.ofSeconds(5);
// MySQLSelectForUpdateBasedProxyManager κ°μ²΄λ₯Ό μμ±, μ΄ κ°μ²΄λ λ²ν·μ μμ± λ° κ΄λ¦¬λ₯Ό λ΄λΉ
private final MySQLSelectForUpdateBasedProxyManager<Long> proxyManager;
// λμΌν API ν€μ λν μμ²μ μ²λ¦¬νκΈ° μν΄ λ²ν·μ μ¬μ¬μ©νκΈ° μν΄ λ²ν·μ μ μ₯νλ 맡μ μμ±
private final ConcurrentMap<String, BucketProxy> buckets = new ConcurrentHashMap<>();
/**
* APIRateLimiter μμ±μ.
*
* @param dataSource λ°μ΄ν° μμ€
*/
public APIRateLimiter(DataSource dataSource) {
// λ²ν· ν
μ΄λΈ μ€μ μ κ°μ Έμ΄
var tableSettings = BucketTableSettings.getDefault();
// SQL νλ‘μ μ€μ μ μμ±
var sqlProxyConfiguration = SQLProxyConfiguration.builder()
.withTableSettings(tableSettings)
.build(dataSource);
// SQL νλ‘μ μ€μ μ μ΄μ©ν΄ MySQLSelectForUpdateBasedProxyManager κ°μ²΄λ₯Ό μμ±
proxyManager = new MySQLSelectForUpdateBasedProxyManager<>(sqlProxyConfiguration);
}
/**
* API ν€μ ν΄λΉνλ λ²ν·μ κ°μ Έμ€κ±°λ, μμ κ²½μ° μλ‘ μμ±νλ λ©μλ.
*
* @param apiKey API ν€
* @return ν΄λΉ API ν€μ λμνλ λ²ν·
*/
private BucketProxy getOrCreateBucket(String apiKey) {
return buckets.computeIfAbsent(apiKey, key -> {
// API ν€μ ν΄μ μ½λλ₯Ό λ²ν· IDλ‘ μ¬μ©
Long bucketId = (long) key.hashCode();
// λ²ν· μ€μ μ μμ±
var bucketConfiguration = createBucketConfiguration();
// λ²ν· IDμ λ²ν· μ€μ μ μ΄μ©ν΄ λ²ν·μ μμ±νκ³ , μ΄λ₯Ό λ°ν
return proxyManager.builder().build(bucketId, bucketConfiguration);
});
}
/**
* λ²ν· μ€μ μ μμ±νλ λ©μλ.
*
* @return μμ±λ λ²ν· μ€μ
*/
private BucketConfiguration createBucketConfiguration() {
return BucketConfiguration.builder()
// λ²ν·μ λν μ ν(μ©λκ³Ό μ¬μΆ©μ μλ)μ μ€μ
.addLimit(Bandwidth.builder().capacity(CAPACITY).refillIntervally(REFILL_AMOUNT, REFILL_DURATION).build())
.build();
}
/**
* API ν€μ ν΄λΉνλ λ²ν·μμ ν ν°μ μλΉνλ €κ³ μλνλ λ©μλ.
*
* @param apiKey API ν€
* @return ν ν° μλΉ μ±κ³΅ μ¬λΆ
*/
public boolean tryConsume(String apiKey) {
// API ν€μ ν΄λΉνλ λ²ν·μ κ°μ Έμ΄
BucketProxy bucket = getOrCreateBucket(apiKey);
// λ²ν·μμ ν ν°μ μλΉνλ €κ³ μλνκ³ , κ·Έ κ²°κ³Όλ₯Ό λ°ν
boolean consumed = bucket.tryConsume(1);
log.info("API Key: {}, Consumed: {}, Time: {}", apiKey, consumed, LocalDateTime.now());
return consumed;
}
}
λ§μ½ μ¬κΈ°μ 컀μ€ν ν ν μ΄λΈ νΉμ 컬λΌμ μ§μ νκ±°λ, μ€ν€λ§λ₯Ό μ§μ νκ³ μΆλ€λ©΄ λ€μκ³Ό κ°μ΄ μ μνλ©΄ λ©λλ€.
var tableSettings = BucketTableSettings.customSettings("test.bucket","id", "state");
Bucket4j-redis
Bucket4jλ₯Ό Redisμ μ μ©νλ λ°©λ²μ MariaDBμ μ μ©νλ λ°©λ²κ³Ό κ±°μ λμΌν©λλ€. Bucket4jλ Redisμ Redisson, Jedis, Lettuceλ₯Ό λͺ¨λ μ§μνλ©°, μλ μμ μμλ Lettuceλ₯Ό μ¬μ©νμμ΅λλ€.
1. dependency
μμ‘΄μ±μ μΆκ°ν©λλ€.
dependencies {
...
// bucket4j
implementation 'com.bucket4j:bucket4j-redis:8.7.0'
}
2. APIRateLimiter
@Slf4j
@Component
public class APIRateLimiter {
// λ²ν·μ μ©λμ μ€μ , μ΄λ λ²ν·μ λ΄κΈΈ μ μλ ν ν°μ μ΅λ μλ₯Ό μλ―Έ
private static final int CAPACITY = 3;
// ν ν°μ΄ μΌλ§λ λΉ λ₯΄κ² μ¬μΆ©μ λ μ§ μ€μ , μ΄λ μ§μ λ μκ° λμ λ²ν·μ μΆκ°λ ν ν°μ μλ₯Ό μλ―Έ
private static final int REFILL_AMOUNT = 3;
// ν ν°μ΄ μ¬μΆ©μ λλ λΉλλ₯Ό μ€μ
private static final Duration REFILL_DURATION = Duration.ofSeconds(5);
// LettuceBasedProxyManager κ°μ²΄λ₯Ό μμ±, μ΄ κ°μ²΄λ λ²ν·μ μμ± λ° κ΄λ¦¬λ₯Ό λ΄λΉ
private final LettuceBasedProxyManager<String> proxyManager;
// λμΌν API ν€μ λν μμ²μ μ²λ¦¬νκΈ° μν΄ λ²ν·μ μ¬μ¬μ©νκΈ° μν΄ λ²ν·μ μ μ₯νλ 맡μ μμ±
private final ConcurrentMap<String, Bucket> buckets = new ConcurrentHashMap<>();
/**
* APIRateLimiter μμ±μ.
*
* @param redisClient Redis ν΄λΌμ΄μΈνΈ
*/
public APIRateLimiter(RedisClient redisClient) {
// Redis μ°κ²°μ μμ±
StatefulRedisConnection<String, byte[]> connection = redisClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));
// Redis μ°κ²°μ μ΄μ©ν΄ LettuceBasedProxyManager κ°μ²΄λ₯Ό μμ±
this.proxyManager = LettuceBasedProxyManager.builderFor(connection)
.withExpirationStrategy(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofSeconds(100)))
.build();
}
/**
* API ν€μ ν΄λΉνλ λ²ν·μ κ°μ Έμ€κ±°λ, μμ κ²½μ° μλ‘ μμ±νλ λ©μλ.
*
* @param apiKey API ν€
* @return ν΄λΉ API ν€μ λμνλ λ²ν·
*/
private Bucket getOrCreateBucket(String apiKey) {
return buckets.computeIfAbsent(apiKey, key -> {
// λ²ν· μ€μ μ μμ±
BucketConfiguration configuration = createBucketConfiguration();
// λ²ν· IDμ λ²ν· μ€μ μ μ΄μ©ν΄ λ²ν·μ μμ±νκ³ , μ΄λ₯Ό λ°ν
return proxyManager.builder().build(key, configuration);
});
}
/**
* λ²ν· μ€μ μ μμ±νλ λ©μλ.
*
* @return μμ±λ λ²ν· μ€μ
*/
private BucketConfiguration createBucketConfiguration() {
return BucketConfiguration.builder()
// λ²ν·μ λν μ ν(μ©λκ³Ό μ¬μΆ©μ μλ)μ μ€μ
.addLimit(Bandwidth.simple(CAPACITY, REFILL_DURATION).withInitialTokens(REFILL_AMOUNT))
.build();
}
/**
* API ν€μ ν΄λΉνλ λ²ν·μμ ν ν°μ μλΉνλ €κ³ μλνλ λ©μλ.
*
* @param apiKey API ν€
* @return ν ν° μλΉ μ±κ³΅ μ¬λΆ
*/
public boolean tryConsume(String apiKey) {
// API ν€μ ν΄λΉνλ λ²ν·μ κ°μ Έμ΄
Bucket bucket = getOrCreateBucket(apiKey);
// λ²ν·μμ ν ν°μ μλΉνλ €κ³ μλνκ³ , κ·Έ κ²°κ³Όλ₯Ό λ°ν
boolean consumed = bucket.tryConsume(1);
log.info("API Key: {}, Consumed: {}, Time: {}", apiKey, consumed, LocalDateTime.now());
return consumed;
}
}
ν μ€νΈ
μλμ κ°μ΄ AOP(Aspect-Oriented Programming)λ₯Ό μ μ©νμ¬ μ½λμ κ°λ μ±μ λμ΄κ³ μ€λ³΅μ μ κ±°ν μ μμ΅λλ€.
RateLimit
λ¨Όμ , κ° λ©μλμ μ μ©ν μ¬μ©μ μ μ μ λ Έν μ΄μ μΈ RateLimitμ μμ±ν©λλ€. μ΄ μ λ Έν μ΄μ μ API ν€λ₯Ό μ§μ νλ 'key'λΌλ μμ±μ κ°μ§λλ€.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimit {
String key();
}
RateLimitingAspect
λ€μμΌλ‘, RateLimit μ λ Έν μ΄μ μ μ²λ¦¬ν Aspect ν΄λμ€μΈ RateLimitingAspectλ₯Ό μμ±ν©λλ€. μ΄ ν΄λμ€λ APIRateLimiter κ°μ²΄λ₯Ό μ¬μ©νμ¬ API ν€μ ν΄λΉνλ λ²ν·μμ ν ν°μ μλΉνλ €κ³ μλν©λλ€. ν ν°μ΄ μΆ©λΆνλ©΄ μμ²μ΄ μ±κ³΅μ μΌλ‘ μ²λ¦¬λκ³ , κ·Έλ μ§ μμΌλ©΄ μμΈκ° λ°μν©λλ€.
@Aspect
@Component
public class RateLimitingAspect {
private final APIRateLimiter apiRateLimiter;
@Autowired
public RateLimitingAspect(APIRateLimiter apiRateLimiter) {
this.apiRateLimiter = apiRateLimiter;
}
@Around("@annotation(rateLimit)")
public Object rateLimit(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
if (apiRateLimiter.tryConsume(rateLimit.key())) {
return joinPoint.proceed();
} else {
throw new RuntimeException("Rate limit exceeded for key: " + rateLimit.key());
}
}
}
RateLimitingController
@RestController
@Slf4j
public class RateLimitingController {
@Autowired
private RateLimitingService rateLimitingService;
@GetMapping("/test")
public String test() {
return rateLimitingService.run();
}
}
RateLimitingService
@Service
public class RateLimitingService {
@RateLimit(key = "someUniqueKey")
public String run() {
return "μμ² μ±κ³΅";
}
}
μ 리
Bucket4j λΌμ΄λΈλ¬λ¦¬λ₯Ό νμ©νλ©΄, API νΈμΆμ μ²λ¦¬μ¨μ μμ½κ² μ νν μ μμ΅λλ€. λν λ€μν κ³³μμ μ μ©νκ³ λΆκ° κΈ°λ₯μ λ£μ μ μλλ°, Baeldungμ Bucket4j κ°μ΄λλ₯Ό 보면 μΈν°μ ν°λ₯Ό μ΄μ©νμ¬ μ²λ¦¬μ¨μ μ νν μλ μκ³ , μλμ²λΌ μ¬μ©μμ μκΈμ μ λ°λΌ API νΈμΆ μ νμ λ€λ₯΄κ² μ μ©νλ κ²λ κ°λ₯ν©λλ€.
enum PricingPlan {
// νμκ°μ 20λ² μ¬μ© κ°λ₯
FREE {
Bandwidth getLimit() {
return Bandwidth.classic(20, Refill.intervally(20, Duration.ofHours(1)));
}
},
// νμκ°μ 40λ² μ¬μ© κ°λ₯
BASIC {
Bandwidth getLimit() {
return Bandwidth.classic(40, Refill.intervally(40, Duration.ofHours(1)));
}
},
// νμκ°μ 100λ² μ¬μ© κ°λ₯
PROFESSIONAL {
Bandwidth getLimit() {
return Bandwidth.classic(100, Refill.intervally(100, Duration.ofHours(1)));
}
};
//..
}
Bucket4jλ μ΄μ κ°μ΄ λ€μν νκ²½μμ API νΈμΆ μ νμ ꡬννλ λ° μ μ©νκ² μ¬μ©λ μ μμ΅λλ€. λ°λΌμ, μλΉμ€μ μ±λ₯μ ν₯μμν€κ³ μλ²μ λΆνλ₯Ό κ΄λ¦¬νλ λ° νμν κΈ°λ₯μ μ½κ³ λΉ λ₯΄κ² ꡬνν μ μμ΅λλ€.