최근 '모띠클' 이라는 아카이빙 서비스를 개인 프로젝트로 진행을 하고 있습니다. 더불어 아침마다 책을 두 권씩 읽고 있는데, 그중 '가상 면접 사례로 배우는 대규모 시스템 설계 기초' 책을 읽고 있습니다.
어제도 여느날과 마찬가지로 책을 읽다가 '4장. 처리율 제한 장치의 설계' 부분을 읽게 되었습니다. 해당 파트에서는 챕터명 그대로 미들웨어를 활용해서 처리율을 제한을 두어 시스템에 부하를 덜어주는 것에 대하여 알려줍니다.
해당 파트를 읽다가 내 프로젝트에도 접목을 시켜볼 수 있을 것 같았습니다. 그래서 해당 부분을 공부 해보고 프로젝트에 접목시켜 본 것을 남겨보려고 합니다.
들어가기 앞서
서론에서 보았듯이 개인 프로젝트의 특정 API 호출에 트래픽을 제한해보려고 한다.
내 프로젝트에서 트래픽 제한을 걸어두면 좋은 부분은 우선 두 가지이다.
1. 공개 여부 수정
2. 스크랩
두 부분 모두 화면상에서 토글/버튼으로 간편하게 클릭만으로 상태를 변경할 수 있다.
사용자 입장에서 간편하게 사용하길 위해서 저런 형식으로 화면을 구성했지만, 사용자가 악의를 품고 or 뭔가 느린 화면에 기다리지 못하고 계속 해서 클릭을 한다면 그만큼 서버로 요청이 들어오게 될 것이다.
그래서 나는 저 두 부분을 트래픽을 제한 시키고자 하였다.
Bucket4j ?
트래픽을 제한하기 위해서 스프링부트에서 제공하는 다양한 라이브러리가 있다.
- RateLimiter (guava, https://github.com/google/guava)
- RateLimitJ (https://github.com/mokies/ratelimitj)
- Bucket4j (https://github.com/vladimir-bukhtoyarov/bucket4j)
이중 Bucket4j를 사용한 이유는 Bucket4j는 기본적으로 토큰을 기반으로한 알고리즘을 사용한다. 이외에도 처리율 제한 알고리즘을 지원할 수 있다는 유연성과 다양성을 고려했다.
또 인메모리 기반으로 처리율을 제한하기에 빠르게 동작한다. 하지만 가장 중요한 것은 해당 라이브러리를 사용해 스프링에서 손쉽게 사용할 수 있다는 편의성이 가장 큰 이유이다.
좋은만큼 단점도 있는데 라이브러리 형태로 제공되어 단일 서버에 종속될 수 밖에 없다. 따라서 분산 환경에서 사용하기 위해서는 Redis같은 별도의 서버를 구성해야한다.
적용 시켜보자
mvn에 들어가 bucker4j Core를 검색 후 본인 프로젝트에 맞는 버전을 받는다.
1. 의존성 주입
implementation 'com.github.vladimir-bukhtoyarov:bucket4j-core:7.6.0'
2. Bucket4jConfig Class 작성
@Configuration
public class Bucket4jConfig {
private static final int CAPACITY = 4; // 총 크기 = 4
private static final int DURATION = 3; // 3초마다 충전
private static final int REFILL_TOKENS_COUNT = 4; // 한번 충전시 4개의 토큰 충전
@Bean
public Bucket bucket() {
Refill refill = Refill.intervally(
REFILL_TOKENS_COUNT,
Duration.ofSeconds(DURATION));
Bandwidth limit = Bandwidth.classic(CAPACITY, refill);
//총 크기가 4이며 3초마다 4개의 토큰을 충전하는 Bucket 생성
return Bucket.builder()
.addLimit(limit)
.build();
}
}
사실상 이 부분만 적절하게 작성해주면 사용법의 70%는 끝났다고 봐도 무방하다.
이제 각 값들을 살펴보면
- Refill : Refill.intervally() 메서드를 사용하여 재생산 주기를 설정한다. 위의 설정에서는 3초마다 4개의 토큰이 재생산되도록 설정되어 있다.
- Bandwidth : Bandwidth.classic() 메서드를 사용하여 버킷의 용량(CAPACITY)과 재생산(refill) 주기를 기반으로 Bandwidth 객체를 생성한다
- classic Bandwidth는 일정한 속도로 토큰을 재충전하고, 한 번에 처리할 수 있는 최대 요청 수를 제한하는 데 사용된다.
이는 전통적인 방식의 트래픽 제한을 구현하는 데 유용하다.
- classic Bandwidth는 일정한 속도로 토큰을 재충전하고, 한 번에 처리할 수 있는 최대 요청 수를 제한하는 데 사용된다.
3. BucketUtils 클래스
@Component
@Slf4j
@RequiredArgsConstructor
public class BucketUtils {
private static final int CONSUME_BUCKET_COUNT = 1;
private final Bucket bucket;
public void checkRequestBucketCount() {
if (bucket.tryConsume(CONSUME_BUCKET_COUNT)) {
return;
}
log.warn("POST/PATCH:CREATE/UPDATE:TOO_MANY_REQUESTS");
throw new ExcessiveRequestException(ErrorCode.TOO_MANY_REQUESTS);
}
}
필자는 Bucket이 필요한 부분이 여러곳 이라고 판단하여 Util클래스를 따로 만들어서 사용을 하고 있다.
- bucket.tryConsume(CONSUME_BUCKET_COUNT)
- 이 부분은 Bucket 객체의 tryConsume() 메서드를 호출하여 요청을 소비하려는 시도를 나타낸다.
- tryConsume() 메서드는 버킷에서 토큰을 소비하는데, 소비가 성공하면 true를 반환하고, 버킷에 충분한 토큰이 없어서 소비할 수 없는 경우에는 false를 반환한다.
- 이 코드에서는 CONSUME_BUCKET_COUNT 만큼의 토큰을 소비하려는 시도를 하고 있다. 소비에 성공하면 조건문이 참이 되어 메서드를 종료하게 된다.
필자는 커스텀 에러(ExcessiveRequestException)를 만들어 GlobalExceptionHandler에서 전역적으로 예외를 잡아주고 있고, 해당 오류시 TOO_MANY_REQUESTS(429) Http 코드로 넘겨주게 만들어 뒀다.
4. 트래픽 제한이 필요한 곳에 Bucket 적용
@Service
@Slf4j
@RequiredArgsConstructor
@Transactional
public class ArticleService {
/**
* 중략..
*/
private final BucketUtils bucketUtils;
/**
* 중략..
*/
public ArticleInfoRes modify(Long articleId, Long memberId, ArticleModifyReq req) {
bucketUtils.checkRequestBucketCount(); // Bucket 적용
Article article = checkArticleWriterAndRequester(articleId, memberId);
Article modifiedArticle = ArticleModifyReq.toArticle(req);
article.updateInfo(modifiedArticle);
return ArticleInfoRes.from(article);
}
우선 간단하게 보면 공개 여부 수정에 사용되는 클래스와 메서드이다.
적용 방법은 간단하게 해당 클래스에 위에서 만든 BucketUtils 클래스를 주입받고 필요한 기능의 메서드에 적용시켜주기만 하면된다.
이렇게 하면 토큰이 없는 상태에서 요청 (짧은 시간에 너무 많은 요청을 보낸 경우)을 보내면 BucketUtils 클래스에서 만든 checkRequestBucketCount() 메서드에서 확인 후 에러를 반환할 것이다.
확인해보자
토큰이 생기기 전에 해당 요청을 계속해서 보내게 되면 이렇게 429 에러 코드를 포함하여 에러 메세지를 반환한다.
마찬가지로 프론트 단에서도 짧은 시간에 많은 요청을 보내게 되면 사용자에게 알려줄 수 있도록 처리를 하였다.
마치며
이번 기능을 추가하면서는 무언가 스스로 뿌듯한(?) 마음이 많이 들었다. 물론 기술적인 것을 하나 더 익혔다는 점에서도 뿌듯하지만, 예전에는 책을 보아도 눈으로만 보는 듯한 느낌이 있었고 내가 진짜 이해한 것인지 헷갈리는 부분이 많았다.
하지만 이번에는 책의 내용을 내 프로젝트와 내 경험을 연관지어서 읽을 수 있게 되었고, 그로 인해 프로젝트의 레벨을 한층 더 높게 만든 것 같다.