
사용자가 기존 데이터를 읽어 새로운 버전을 복사 생성하는 기능에서,
동일 사용자가 너무 빠르게 버튼을 눌러 동일한 데이터가 두 개 생성되는 문제가 발생했다.
이로 인해 불필요한 중복 요청과 함께 동일한 데이터가 공존하는 문제가 있었다.
기존 해결 방법 - DB Lock 사용

초기에 이 문제를 해결하기 위해 데이터베이스에서 해당 Row에 Lock을 설정하여,
새로운 데이터가 커밋되기 전까지 동일한 데이터가 생성되지 않도록 조치했다.
하지만 이 방법에는 단점이 있었다
- Lock 해제: 빠른 커밋이 발생하는 상황에서는 따닥 현상 해결을 보장하지 않음
- 비정상적인 사용 패턴: 동일한 사용자가 짧은 시간내에 같은 CUD API를 여러 번 호출하는 것은 정상 요청이라고 보기 어려움
- 새로운 데이터를 생성하는 요청에서는 Lock으로 해결할 수 없음
따라서, 보다 효율적인 해결책으로 사용자별 API Rate Limiter를 도입하게 되었다.
기존 문제 분석
발생 원인
- 사용자 인터페이스(UI)에서 버튼을 여러 번 누르는 경우
이 문제를 해결하기 위해 Rate Limiter 를 적용하기로 결정했다.
FE가 아닌 BE에서 Rate Limiting을 적용한 이유
프론트엔드에서도 중복 요청을 방지하는 기능을 구현할 수 있지만, 서버에서 이를 관리하는 것이 더 적절한 이유는 다음과 같다.
- 신뢰성: 클라이언트 측에서 Rate Limiting을 적용하면 사용자가 이를 우회하는 것이 가능하다. 하지만 백엔드에서 적용하면 서버 단에서 확실하게 제한할 수 있다.
- 일관성: 다양한 클라이언트(웹, 모바일 등)에서 동일한 정책을 적용해야 하는 경우, 백엔드에서 중앙 집중적으로 관리하는 것이 유지보수 측면에서 더 용이하다.
- 구조적 문제: 당시 FE 팀이 쳐내는 기능이 많았고, FE 팀에서 기능을 변경하는 경우가 많아 기존 구조를 수정하기 어려웠다.
Bucket4j 사용
API 요청 제한을 위해 다양한 라이브러리를 검토한 결과, 가볍고 요청 제한 기능만 제공하는 Bucket4j를 선택했다.
dependencies {
implementation("com.bucket4j:bucket4j-core:$8.10.1")
}
요청 제한기 기능 구현

구현 코드
사용자별로 초당 1번의 요청만 허용하는 로직을 구현했다.
/**
* 사용자 별 HTTP API 요청 제한기
* 1초에 1번만 요청할 수 있다.
*/
@Component
class UserApiRateLimiter {
private val _buckets: MutableMap<UserApiRequest, Bucket> = ConcurrentHashMap()
fun acquire(request: UserApiRequest) {
val bucket = this._buckets.computeIfAbsent(request) { createNewBucket() }
val tryConsume = bucket.tryConsume(1)
if (!tryConsume) {
throw TooManyRequestException("허용된 요청 수를 초과했습니다.")
}
}
private fun createNewBucket(): Bucket {
val bandwidth = BandwidthBuilder.builder()
.capacity(1)
.refillIntervally(1, Duration.ofSeconds(1))
.build()
return Bucket.builder()
.addLimit(bandwidth)
.build()
}
}
/**
* 사용자가 요청한 API
*/
data class UserApiRequest(
val memberId: Long,
val method: String,
val api: String,
)
data class TooManyRequestException(
override val message: String
) : Exception(message)
사용자별로 요청 제한을 관리해야하기 때문에 요청ID, 요청 API를 담을 수 있는 UserApiRequest 객체를 키로 잡는다.
고려 사항
현재 예제에서는 ConcurrentHashMap을 사용하여 요청을 저장하고 있지만, Caffeine Cache, Redis 같은 인메모리나 외부 저장소와 연동하여 사용할 수 있게 구현체들을 제공하고있다.
https://github.com/bucket4j/bucket4j
GitHub - bucket4j/bucket4j: Java rate limiting library based on token-bucket algorithm.
Java rate limiting library based on token-bucket algorithm. - bucket4j/bucket4j
github.com
제한기 필터 구현
@Component
class UserRateLimiterFilter(
private val userApiRateLimiter: UserApiRateLimiter,
private val objectMapper: ObjectMapper,
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val authentication = SecurityContextHolder.getContext().authentication
val isAuthenticated = authentication.isAuthenticated
// 인증되지 않았거나 익명 사용자라면 생략한다.
if (!isAuthenticated || authentication is AnonymousAuthenticationToken) {
filterChain.doFilter(request, response)
return
}
val principal = authentication.principal as MemberPrincipal
try {
val apiRequest = UserApiRequest(principal.memberId, request.requestURI)
userApiRateLimiter.acquire(apiRequest) // 유저별 토큰 소비
} catch (e: TooManyRequestException) {
response.errorResponse(e) // 토큰 소비 시 예외가 발생하면 429 응답
return
}
filterChain.doFilter(request, response)
}
override fun shouldNotFilter(request: HttpServletRequest): Boolean {
return request.method == HttpMethod.GET.name()
}
private fun HttpServletResponse.errorResponse(e: TooManyRequestException) {
val errorResponse = ApiResponse.error(e.message)
val errorJson = objectMapper.writeValueAsString(errorResponse)
this.contentType = MediaType.APPLICATION_JSON_VALUE
this.status = HttpStatus.TOO_MANY_REQUESTS.value()
this.characterEncoding = "UTF-8"
this.writer.write(errorJson)
}
}
필터 체인 등록
사용자 식별이 가능해야 하므로 UserRateLimiter를 Spring Security 필터 체인에 추가하여 JWT 인증 이후 적용되도록 설정했다.
@Bean
fun filterChain(
http: HttpSecurity,
jwtFilter: JwtFilter,
userRateLimiterFilter: UserRateLimterFilter
): SecurityFilterChain {
http {
...
addFilterBefore<AuthorizationFilter>(jwtFilter)
addFilterAfter<JwtFilter>(userRateLimiterFilter)
}
return http.build()
}
결론
Bucket4j를 사용하여 API Rate Limiting을 적용함으로써
- DB Lock 없이도
- 비정상적인 중복 요청을 전역으로 방지하며,
- 사용자로 부터 발생할 수 있는 사이드 이펙트를 방지할 수 있게되었다.