
서비스를 운영하다 보면 보안상 가장 고민되는 지점 중 하나가 바로 '인증되지 않은 사용자의 요청' 이다. 특히 파일 업로드 API는 악의적인 사용자의 타겟이 되기 쉽다. 주변에서도 왕왕 그러 악의적인 접근 때문에 aws 요금을 폭탄맞았다는 경우도 왕왕 들었다.
오늘은 최근 프로젝트 코드 리뷰 중 발견한 보안 취약점을 해결하기 위해, Bucket4j를 도입하여 트래픽 제한(Rate Limiting)을 적용한 과정을 공유하려 한다.
1️⃣ 현재 필자의 상황 (왜 트래픽 제한이 필요한가)
현재 개발 중인 프로젝트의 판매자 회원가입 로직은 다음과 같다.
- 판매자가 회원가입 폼을 작성하며 사업자등록증 등 증빙 서류를 업로드한다.
- 이 서류는 S3의 temp/ 경로에 임시 저장된다. (아직 가입 전이므로 비로그인 상태)
- 회원가입 완료 시 해당 파일을 정식 경로로 이동시킨다.
여기서 문제는 1번(임시 업로드) 단계다. 아직 회원가입 전이라 인증(Authentication)이 불가능한 엔드포인트인데, 누군가 악의적으로 10MB 크기의 파일을 초당 수백 번씩 업로드한다면 어떻게 될까?
- S3 비용 폭증: 대역폭 비용과 저장 비용이 기하급수적으로 늘어남
- 서버 자원 고갈: 파일 처리 프로세스가 CPU와 메모리를 점유하여 정상적인 서비스 이용이 어려워짐
2️⃣ Bucket4j란 무엇인가?
Bucket4j는 자바 기반의 Rate Limiting(속도 제한) 라이브러리다. 흔히 알고 있는 Token Bucket 알고리즘을 사용하여 애플리케이션의 요청 흐름을 제어한다.
- Token Bucket 알고리즘: 일정한 속도로 토큰이 채워지는 '바구니(Bucket)'가 있고, 요청이 올 때마다 토큰을 하나씩 소비함, 토큰이 없으면 요청은 거절됨
- 특징: 매우 가볍고, 외부 인프라(Redis 등) 없이도 동작하며, 유연한 설정이 가능합니다.
3️⃣ 왜 Bucket4j인가?
트래픽 제어를 위해 여러 대안(Redis, Resilience4j , Nginx 등)이 있었지만, 현재 프로젝트 상황에 비추어 Bucket4j를 선택한 이유다.
- 서버 단일 환경: 현재 우리 서버는 1대다. Redis 같은 분산 저장소 없이 메모리 내(In-memory)에서 동작하는 Bucket4j만으로도 충분한 효과를 낼 수 있다. (현재 서비스에서 Redis는 사용하고 있지 않음)
- 예상 트래픽 규모: 초기 서비스 단계이며 하루 업로드 건수가 1,000건 미만으로 예상하고 있다. 복잡한 인프라 설정보다는 가볍고 빠르게 적용할 수 있는 라이브러리가 적합하다고 판단했다.
- 세밀한 제어: Spring Security 설정이나 Interceptor 단계에서 특정 IP별로 버킷을 할당하여 유연하게 요청을 제한할 수 있다.
| 라이브러리 | 장점 | 단점 |
| Bucket4j | 간단, 다양한 알고리즘, Redis 지원 | Java 전용 |
| Resilience4j | Circuit Breaker 등 다기능 | Rate Limiting은 부가 기능 |
| Spring Cloud Gateway | API Gateway 레벨 제어 | 마이크로서비스용 |
4️⃣ 서비스 성장 시 고려할 만한 다음 단계
서비스가 커지고 사용자가 많아지면 단일 서버 메모리에 의존하는 방식은 한계가 온다. 추후 다음과 같은 선택지를 고려할 예정이다. (물론 이 정도 상황까지 오면 증말 해피해피 한 상황일 것 ^^)
- Redis + Bucket4j (Distributed): 서버가 여러 대로 늘어나면 각 서버가 트래픽 정보를 공유해야 한다. Bucket4j는 Redis를 백엔드로 사용할 수 있는 기능을 지원하여 분산 환경에서도 속도 제한을 유지할 수 있다.
- API Gateway 레벨의 제한: Spring Cloud Gateway나 Nginx 레벨에서 요청을 사전에 차단하는 방식이다. 애플리케이션 로직에 도달하기 전 인프라 단에서 막아주므로 서버 부하를 더 확실히 줄일 수 있다. (마이크로서비스에서)
- Cloud Native 솔루션: AWS WAF(Web Application Firewall) 등을 사용하여 비정상적인 IP 패턴을 자동으로 차단하는 방식을 도입할 수 있다.
5️⃣ 구현 예시
// build.gradle에 의존성 추가
implementation 'com.bucket4j:bucket4j-core:8.7.0'
implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
// RateLimitingService.java
@Service
public class RateLimitingService {
private final Cache<String, Bucket> cache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10))
.build();
public Bucket resolveBucket(String key, int requestsPerMinute) {
return cache.get(key, k -> createNewBucket(requestsPerMinute));
}
private Bucket createNewBucket(int requestsPerMinute) {
Bandwidth limit = Bandwidth.builder()
.capacity(requestsPerMinute)
.refillGreedy(requestsPerMinute, Duration.ofMinutes(1))
.build();
return Bucket.builder()
.addLimit(limit)
.build();
}
}
// RateLimitInterceptor.java
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
@Autowired
private RateLimitingService rateLimitingService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String endpoint = request.getRequestURI();
// 특정 엔드포인트에만 적용
if (endpoint.startsWith("/api/v1/sellers/documents/temp")) {
String clientIp = getClientIP(request);
String key = "temp-upload:" + clientIp;
// IP당 분당 5회로 제한
Bucket bucket = rateLimitingService.resolveBucket(key, 5);
if (bucket.tryConsume(1)) {
return true;
} else {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(
"{\"error\":\"Too many requests. Please try again later.\"}"
);
return false;
}
}
return true;
}
private String getClientIP(HttpServletRequest request) {
String xfHeader = request.getHeader("X-Forwarded-For");
if (xfHeader == null) {
return request.getRemoteAddr();
}
return xfHeader.split(",")[0].trim();
}
}
// WebMvcConfig.java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private RateLimitInterceptor rateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/api/v1/sellers/documents/temp");
}
}
6️⃣ 마치며
보안은 '기능 구현'만큼이나 중요하다. 이걸 회사 외에 사이드 프로젝트를 하면서 내가 담당하는 부분이 늘어나며 더 여실히 느끼고 있다.. 특히 비용과 직결되는 파일 업로드 기능에서는 Bucket4j와 같은 도구를 통해 최소한의 방어선을 구축하는 것이 필수적이라는 것을 이번 리뷰를 통해 다시 한번 배웠다.
AI를 통해 코딩을 하면서 뭐가 더 현재 상황에 적합한 방법인지 내가 결정할 수 있는 능력과, 보안이 얼마나 중요한지 깨닫고 있는 요즘이다.
'Project > 사이드 프로젝트 기록' 카테고리의 다른 글
| [Spring/JPA] refresh token 통합 테스트 중 데이터 불일치 문제 삽질기 (@Modifying의 flushAutomatically 옵션) (2) | 2026.01.27 |
|---|