네트워크 시스템에서 처리율 제한 장치는 클라이언트 또는 서비스가 보내는 트래픽의 처리율을 제어하기 위한 장치이다.
처리율 제한 장치가 필요한 이유
면접관과의 소통을 통해 알아낼 수 있는 내용은 다음과 같다.
1. 클라이언트측 제한 장치인지, 서버측 제한 장치인지
2. IP주소를 기준으로 제한할지, 사용자 ID 기준으로 제한할지
3. 시스템 규모는 어느 정도인지
4. 분산 환경에서 동작해야 하는지
5. 처리율 제한 장치는 독립된 서비스인지, 애플리케이션 코드에 포함되는지
클라이언트측에 위치하면 얼마든지 위변조 당할 수 있기 때문에 클라이언트측은 좋지 않다.
서버측에서는 애플리케이션에서 각각 사용량 제한을 처리 할 수 있다. 이러한 경우에는 애플리케이션에서 각각 처리하므로 사용량 제한에 필요한 데이터를 Redis와 같은 데이터 저장소에 저장해야 한다.
MSA의 경우, 처리율 제한 정치는 보통API 게이트웨이라 불리는 컴포넌트에 구현된다. API 게이트웨이는 다음과 같은 기능을 제공하는 서비스다.
처리율 제한 알고리즘은 다음과 같다. 알고리즘에 대해서는 따로 정리하지 않는다.
얼마나 많은 요청이 접수되었는지를 추적할 수 있는 카운터를 추적 대상별로 두고(사용자별로 추적 or IP 등), 이 카운터의 값이 어떤 한도를 넘어서면 한도를 넘어 도착한 요청은 거부하는 것이다.
처리율 제한 처리는 API 게이트웨이와 같은 미들웨어에서 하고, 카운터는 레디스에서 관리한다.
레디스를 사용하는 경우에 동시성 문제가 있을 수 있다. 동시성 문제는 레디스의 원자적 연산인 INCR, EXPIRE를 사용하면 해결 가능하다.
@Service
public class RateLimiterService {
private final StringRedisTemplate redisTemplate;
public boolean isAllowed(String userId, int limit, int windowSeconds) {
String key = "rate:" + userId;
Long count = redisTemplate.opsForValue().increment(key);
// 메모리에 키가 없었으면 1을 반환함. 따라서 count가 1이면 ttl 설정 필요.
if (count == 1) {
redisTemplate.expire(key, Duration.ofSeconds(windowSeconds));
}
return count <= limit;
}
}
어떤 요청이 한도 제한에 걸리면 API는 HTTP 429 응답을 클라이언트에게 보낸다. 경우에 따라서는 한도 제한에 걸린 메시지를 나중에 처리하기 위해 큐에 보관할 수 있다.
분산 환경에서 처리율 제한 장치를 구현할 때는 경쟁 조건과 동기화 이슈를 고려해야 한다.
경쟁 조건 해결 방법
루아 스크립트를 활용하는 예시 코드는 다음과 같다.
String script = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, ttl)
end
return count <= limit
""";
Boolean allowed = redisTemplate.execute(
new DefaultRedisScript<>(script, Boolean.class),
Collections.singletonList(key),
limit, windowSeconds
);
동기화 이슈 해결 방법
처리율 제한 장치를 여러 개 사용해야 하는 경우를 대비하여 카운터는 처리율 조절 장치에 관리하지 않고, 레디스와 같은 중앙 집중형 데이터 저장소를 사용해야 한다.
처리율 제한 장치가 효과적으로 동작하고 있는지 보기 위해 데이터를 모아야 한다. 모니터링을 통해 확인하려는 것은 다음 두가지다.