티스토리 뷰
인증/가입 관련 API는 외부에 공개되어 있고, 인증이 필요 없는 경우가 많아 공격 시도에 자주 노출되는 영역이다. 특히 응답 구조가 단순하고 처리 비용이 낮은 API는 자동화된 반복 요청의 대상이 되기 쉽다. 이번 글에서는 토이 프로젝트 서비스에 10분 단위 호출 제한을 도입하기까지 어떤 선택지를 검토했고, 왜 해당 방식을 최종적으로 채택했는지, 또 어떻게 구현했는지를 정리했다.
1. 왜 이 고민을 했는가
진행하고 있는 토이 프로젝트에서 /auth/available/user_id API는 응답이 boolean 하나뿐이다.
/**
* 사용자 아이디 사용 가능 여부
*
* @param userId 사용자 아이디
* @return true = 사용 가능, false = 사용 불가
*/
@GetMapping("/available/user_id")
@Operation(summary = "사용자 아이디 사용 가능 여부", description = "응답 true = 사용 가능, false = 사용 불가")
public ApiResponse<Boolean> availableUserId(@RequestParam String userId) {
return ApiResponse.success(authService.availableUserId(userId));
}
즉, 호출 비용이 거의 없고, 인증이 필요 없으며 반복 호출로 사용자 존재 여부를 열거할 수 있다. 이는 다음과 같은 공격의 전제가 된다.
- 계정 정보 수집
- 타겟 계정 brute force
- credential stuffing 사전 단계
따라서 문제는 단순하다. 이 API를 10분에 몇 번까지만 호출 가능하게 만들면 안전하지 않을까? 다만, Rate Limit은 자동화 공격 비용을 증가시키는 수단이지 완전한 방어는 아니다. 이 문제를 해결하기 위해 다음과 같은 요구사항을 설정했다.
IP 기준 10분 내 N회 호출 제한
2. Rate Limit은 원래 어디에서 해야 할까?
실무에서는 아래와 같이 보통 3단계로 방어한다.
- WAF / API Gateway
- Reverse Proxy (Nginx 등)
- Application 레벨
이번 검토는 Application 레벨 Rate Limit이다. 이건 최후 방어선이지만, 토이 프로젝트라 인프라 레벨 제어가 없는 상황에서, 최소한 애플리케이션에서라도 방어선을 둬야 한다는 관점에서 접근했다.
3. Rate Limiting 알고리즘 비교
여기서 윈도우는 요청 횟수를 집계하는 시간 구간이다.
예를 들어 10분당 20회 제한이라면 10분이 하나의 집계 단위가 되는 것이다.
3.1. Fixed Window (고정 윈도우)
시간을 일정 단위로 잘라서 카운트를 세고, 각 구간마다 카운트를 별도로 관리한다.
00:00 ~ 00:10 구간 1
00:10 ~ 00:20 구간 2
00:20 ~ 00:30 구간 3
Fixed Window는 구현이 단순하고 Redis INCR + TTL로 쉽게 구현 가능하다.
3.1.1. 문제점 Double Burst
- 00:09:59에 20회 호출 → 허용
- 00:10:01에 다시 20회 호출 → 허용
실제로는 2초 사이 40회 허용되고, 이는 고정 윈도우의 경계 문제다.
3.2. Sliding Window (슬라이딩 윈도우)
“현재 시점 기준 과거 10분 동안 몇 번 호출했는가?”를 계산해서, 위에서 설명한 고정 윈도우의 경계 문제는 거의 발생하지 않는다. 그리고 구현 방식에 따라서 요청 로그를 전부 저장하는 Sliding Log 방식, 근사 계산을 사용하는 Counter 방식으로 나뉘며, 정확도는 높지만 저장 비용과 정리 전략이 필요하다는 문제가 있다.
3.3. Token Bucket (토큰 버킷)
버킷에 토큰이 있고 요청 시 하나 소비하고, 시간이 지나면 토큰이 보충된다.
- 버킷 용량 = 최대 burst
- refill 전략 = 평균 허용 속도
고정 윈도우처럼 경계가 딱 끊기지 않지만, refill 정책에 따라 체감 동작이 달라질 수 있다.
4. 라이브러리 및 방식 비교
동시성, 경계 처리, 만료 관리까지 모두 책임져야 하기 때문에, 직접 윈도우 로직을 구현하는 방식은 제외했다.
4.1. Bucket4j
- 알고리즘: Token Bucket
- 분산 지원: Redis 확장 가능
- 기본 저장소: 인메모리
특징
- refill 전략 선택 가능
- burst 제어 명확
- 동시성 안전
- Spring Filter/Interceptor와 결합 쉬움
장점
- Redis 없이 시작 가능
- 검증된 알고리즘
- 추후 Redis 확장 가능
4.2. Resilience4j RateLimiter
- 알고리즘: 주기 기반 허용 모델 (limitRefreshPeriod + limitForPeriod)
- 내부 구현: AtomicRateLimiter
- 분산 지원: 기본 제공 아님
특징
- Spring 친화적
- 설정 기반 구성 가능
- AOP 적용 용이
한계
- 기본은 엔드포인트 단위 제한
- IP 기반 Key 분리는 추가 구현 필요
4.3. Redis 기반 구현
- 알고리즘: Fixed Window (INCR + TTL), Sliding Window (Sorted Set + Lua)
장점
- 다중 인스턴스 대응
- TTL로 자동 만료
주의
- INCR + EXPIRE는 race condition 가능 → 원자적 처리 필요
- Redis 인프라 필요
5. 현재 프로젝트 컨텍스트
- Spring Boot 기반
- Stateless JWT
- Redis 미도입
- 단일 인스턴스
6. 최종 선택은 Bucket4j (인메모리)
선정 이유는 다음과 같다.
6.1. 요구사항과 정합성
- 10분 정책 refill 전략으로 표현 가능
- burst 제어 가능
- 경계 double burst 문제 최소화
6.2. 구현 복잡도
- 윈도우 직접 구현 불필요
- 동시성 처리 라이브러리 위임
- 유지보수 부담 낮음
6.3. 확장성
- 현재는 인메모리로 충분
- 추후 Redis 도입 시 bucket4j-redis로 전환 가능
6.4. 대안이 불리한 이유
- Resilience4j는 IP Key 기반 확장 코드 필요
- Redis는 현 시점 오버엔지니어링
- 직접 구현은 유지보수 리스크 높음
7. 구현
7.1. 의존성 추가
implementation 'com.bucket4j:bucket4j_jdk17-core:8.16.1'
implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
여기서 Caffeine는 IP별 버킷 인스턴스를 캐시하고, 오래 쓰이지 않는 키를 만료시켜 메모리를 정리하는 라이브러리다.
7.2. 응답 코드 정의
다른 API 에러와 형식을 맞추기 위해 공통 응답 코드 enum에 TOO_MANY_REQUESTS를 추가했다.
TOO_MANY_REQUESTS("BPLTE429", "요청이 너무 많습니다.", "10분당 호출 한도를 초과했습니다. 잠시 후 다시 시도해주세요."),
이 코드로 ApiResponse.error(ResponseCodeGeneral.TOO_MANY_REQUESTS)를 쓰면, 현재 사용하고 있는 ApiResponse 구조를 유지하면서 응답을 받게 된다.
7.3. Rate Limit 필터
대상은 /auth/available/user_id 하나다. OncePerRequestFilter를 상속해, 해당 경로와 메소드에만 동작하도록 했다.
7.3.1. 적용 대상
shouldNotFilter에서 GET이면서 URI /auth/available/user_id 일 때만 필터 로직을 타도록 한다. 나머지 요청은 그대로 통과시킨다.
private static final String PATH_AVAILABLE_USER_ID = "/auth/available/user_id";
@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
String fullPath = request.getContextPath() + PATH_AVAILABLE_USER_ID;
return !("GET".equalsIgnoreCase(request.getMethod()) && fullPath.equals(request.getRequestURI()));
}
7.3.2. 클라이언트 식별(키)
Rate limit은 IP 단위로 적용한다. 해당 토이 프로젝트는 현재 프록시나 로드밸런서 없이 단일 API 서버로 동작하므로, 실제로는 request.getRemoteAddr()가 그대로 클라이언트 IP로 쓰인다.
다만 나중에 Nginx 등 리버스 프록시를 앞단에 두면 getRemoteAddr()는 프록시 IP가 되므로, 그때를 대비해 X-Forwarded-For, X-Real-IP를 먼저 보고, 없을 때만 getRemoteAddr()를 쓰는 순서로 구현해 두었다. 지금 구조에서는 헤더가 없으니 결국 getRemoteAddr() 하나의 경로만 타게 된다.
🚨 X-Forwarded-For는 신뢰 가능한 프록시 환경에서만 사용해야 한다.
그렇지 않으면 클라이언트가 헤더를 위조할 수 있다.
private static String resolveClientKey(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
int comma = forwarded.indexOf(',');
String first = comma > 0 ? forwarded.substring(0, comma).trim() : forwarded.trim();
if (!first.isBlank()) return first;
}
String realIp = request.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) return realIp.trim();
String remote = request.getRemoteAddr();
return remote != null ? remote : "unknown";
}
7.3.3. 버킷 생성 및 소비
IP별 버킷은 Caffeine 캐시에 넣는다. maximumSize(10_000), expireAfterAccess(15분) 으로 오래 쓰이지 않는 IP의 버킷은 자동 제거해 메모리를 제한한다.
버킷 정책은 용량을 10으로 하여 10분마다 10개 greedy refill이다.
현재 설정은 10분 단위로 10개의 토큰이 한 번에 보충되는 방식이다. 따라서 동작 특성은 “부드러운 분산 허용”보다는 “구간 단위 허용”에 가깝다. 만약 요청을 분산시키고 싶다면 refillIntervally(1, 1분) 형태로 설정할 수도 있다.
private static Bucket createBucket() {
return Bucket.builder()
.addLimit(limit -> limit.capacity(10).refillGreedy(10, Duration.ofMinutes(10)))
.build();
}
7.3.4. 응답
한도 초과 시에는 429를 반환하되 해당 프로젝트에서 사용하고 있는 응답 클래스에 매핑시킨다.
Retry-After는 헤더에 Bucket4j의 getNanosToWaitForRefill() 을 초 단위로 변환해 넣어, 클라이언트가 언제 재시도할 수 있는지 알 수 있게 한다.
7.3.5. 필터 구현부 전체
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import io.github.bucket4j.Bucket;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.bplte.core.api.core.ApiResponse;
import org.bplte.core.api.core.message.ResponseCodeGeneral;
import org.jspecify.annotations.NonNull;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
/**
* 특정 경로에 대해 IP 단위 호출 횟수 제한을 적용하는 필터
*
* <p>
* 토큰 버킷 알고리즘(Bucket4j)으로 한도를 적용하고, IP별 버킷은 인메모리 Caffeine 캐시에 보관한다.
* 오래 사용되지 않는 IP의 버킷은 만료되어 메모리 사용량이 제한된다.
* </p>
*/
@Slf4j
@Component
@Order(1)
public class AuthAvailableUserIdRateLimitFilter extends OncePerRequestFilter {
private static final String PATH_AVAILABLE_USER_ID = "/auth/available/user_id";
private static final int CAPACITY = 10;
private static final Duration REFILL_PERIOD = Duration.ofMinutes(10);
private static final int CACHE_MAX_SIZE = 10_000;
private static final long CACHE_EXPIRE_MINUTES = 15;
private final Cache<String, Bucket> bucketCache;
public AuthAvailableUserIdRateLimitFilter() {
this.bucketCache = Caffeine.newBuilder()
.maximumSize(CACHE_MAX_SIZE)
.expireAfterAccess(Duration.ofMinutes(CACHE_EXPIRE_MINUTES))
.build();
}
@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
String fullPath = request.getContextPath() + PATH_AVAILABLE_USER_ID;
return !("GET".equalsIgnoreCase(request.getMethod()) && fullPath.equals(request.getRequestURI()));
}
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
String clientKey = resolveClientKey(request);
Bucket bucket = bucketCache.get(clientKey, k -> createBucket());
var probe = bucket.tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
filterChain.doFilter(request, response);
return;
}
log.warn("Rate limit exceeded for {}, clientKey: {}", request.getContextPath() + PATH_AVAILABLE_USER_ID, clientKey);
response.setStatus(429);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
long secondsToWait = Math.max(1, TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()));
response.setHeader("Retry-After", String.valueOf(secondsToWait));
ApiResponse<Object> errorResponse = ApiResponse.error(ResponseCodeGeneral.TOO_MANY_REQUESTS);
ObjectMapper objectMapper = new ObjectMapper();
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
private static String resolveClientKey(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
int comma = forwarded.indexOf(',');
String first = comma > 0 ? forwarded.substring(0, comma).trim() : forwarded.trim();
if (!first.isBlank()) {
return first;
}
}
String realIp = request.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) {
return realIp.trim();
}
String remote = request.getRemoteAddr();
return remote != null ? remote : "unknown";
}
private static Bucket createBucket() {
return Bucket.builder()
.addLimit(limit -> limit.capacity(CAPACITY).refillGreedy(CAPACITY, REFILL_PERIOD))
.build();
}
}
7.4. Spring Security 설정
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(jwtAuthenticationEntryPoint))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**", "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(authAvailableUserIdRateLimitFilter, JwtAuthenticationFilter.class)
return http.build();
}
/auth/available/user_id 는 이미지 requestMatchers 의 /auth/** 에 포함되어 인증 없이 허용하고 있다. 그리고 중요한 부분이 필터 순서다. RateLimitFilter가 JwtAuthenticationFilter 보다 먼저 실행하도록 했고, 초과 시 바로 429를 반환해서 필터(jwtAuthenticationFilter)와 컨트롤러까지 닿지 않도록 하여 부하를 줄였다.
8. 구현 확인
Postman을 이용해서 /auth/available/user_id?userId=testId 로 10번 정도 호출하니 정확히 제한이 걸렸다.
{
"resultCode": "BPLTE429",
"resultMessage": "요청이 너무 많습니다.",
"resultDetailMessage": "10분당 호출 한도를 초과했습니다. 잠시 후 다시 시도해주세요.",
"data": null
}
서버 로그는 아래와 같다.
2026-02-22 00:01:26.668 WARN [http-nio-8081-exec-4] org.bplte.core.api.config.filter.AuthAvailableUserIdRateLimitFilter : Rate limit exceeded for /bplte/core/auth/available/user_id, clientKey: 0:0:0:0:0:0:0:1
9. 마무리 하며
이번 Rate Limit 적용은 가장 강력한 보안 설계를 한 것이 아니라, 현재 프로젝트 상황에서 가장 현실적인 선택을 한 과정이었다.
- 단일 인스턴스
- Redis 미도입
- 인프라 레벨 제어 없음
- 토이 프로젝트 수준 레벨
이 조건에서는 인메모리 기반 Token Bucket이 복잡도 대비 효과가 가장 좋았다. 구현은 단순하지만 동시성은 안전하고, 나중에 Redis로 확장할 여지도 남겨둘 수 있다. 물론 Rate Limit이 모든 문제를 해결해주지는 않는다. 분산 IP 기반 공격이나 장기적인 계정 열거에는 한계가 있다. 운영 환경이라면 WAF나 API Gateway 레벨에서 먼저 막는 것이 더 적절할 것이다.
- Total
- Today
- Yesterday
- docker
- MySQL
- 도커
- 쿠버네티스
- MongoDB
- k8s
- springsecurity
- 파이썬
- BeautifulSoup
- oracle
- 스프링부트
- 젠킨스
- 스프링시큐리티
- 마리아디비
- 쿼리
- springboot
- Minikube
- Spring
- 크롤링
- kubernetes
- nodejs
- java
- JavaScript
- jenkins
- mariadb
- 스프링
- 톰캣
- AWS
- 자바스크립트
- 오라클
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |