새로운 내용을 공부할 때
새로운 내용의 공부를 시작할 때 용어의 정의를 이해하지 못하거나 정확하게 알지 못한다면 그 용어가 포함된 문장을 이해하지 못합니다.
작은 단어 하나가 내용을 이해하지 못하게 하기 때문에 용어를 정확하게 이해하는 것이 중요합니다.
TIL) RateLimit 감각깨우기(2) 코드 구현
시스템 안정성과 과도한 트레픽을 제어하고 보안 및 사용자의 빠른 응답을 제공하는 것이 백엔드 개발자의 중요한 숙제라고 생각이 들었습니다.
Ratelimit(속도 제한)은 API서버에서 과도한 요청으로 서버를 보호하고 제한된 자원을 공정하게 분배하기 위한 기술이므로 학습하려고 합니다.
상황정의
서버에 로그인 API가 있다.
같은 사용자가 1분 안에 5번 이상 로그인 요청을 보내면 더 이상 허용하지 않고 차단해야 한다.
코드 레벨로 구현해보면서 해당 단계에서 발생할 수 있는 문제를 개선하려고 합니다.
1단계
회원 정보를 관리하기 위해서 HashMap 자료구조를 선택하기로 했습니다.
이유는 관리할 회원 데이터가 많지 않으며 해시 충돌이 발생되어 성능 저하가 발생하더라도 테스트 코드 환경에서 O(1)로 동작할 확률이 높기 때문입니다.
그리고 회원이 접속한 시간을 저장하는 시계열 데이터를 관리할 자료구조는 Deque를 선택했습니다.
이유는 Queue
자료구조를 사용해도 되지만 메서드 명이 더 의미있는 Deque
를 선택했습니다.
코드
public class LoginRateLimiterV1 implements RateLimiterCustom {
private long limitTime = 1_000L;
private final static Map<String, Deque<Long>> rateLimiters = new HashMap<>();
@Override
public boolean setLimiterTime(long time) {
if (time <= 0) {
return false;
}
limitTime = time;
return true;
}
@Override
public long getLimiterTime() {
return limitTime;
}
@Override
public boolean isAllowed(String id) {
Deque<Long> limiterByUserId = rateLimiters.get(id);
if (limiterByUserId == null) {
LinkedList<Long> deque = new LinkedList<>();
deque.addLast(System.currentTimeMillis());
rateLimiters.put(id, deque);
return true;
}
if (limiterByUserId.size() < 5) {
limiterByUserId.addLast(System.currentTimeMillis());
return true;
}
Long firstTimeMillis = limiterByUserId.peekFirst();
Long lastTimeMillis = System.currentTimeMillis();
if (lastTimeMillis - firstTimeMillis < limitTime) {
return false;
}
limiterByUserId.removeFirst();
limiterByUserId.addLast(lastTimeMillis);
return true;
}
public int size(String id) {
return rateLimiters.get(id).size();
}
}
테스트 코드 작성
간단하게 5회, 6회 경계 테스트를 해보려고 합니다.
class LoginRateLimiterV1TestSelf {
@Test
@DisplayName("로그인 시도 횟수가 5회일 경우 제한되지 않습니다.")
void testLoginRateLimiterV1() {
LoginRateLimiterV1 loginRateLimiterV1 = new LoginRateLimiterV1();
loginRateLimiterV1.setLimiterTime(1000);
String userId = "UserId";
loginRateLimiterV1.isAllowed(userId);
loginRateLimiterV1.isAllowed(userId);
loginRateLimiterV1.isAllowed(userId);
loginRateLimiterV1.isAllowed(userId);
Assertions.assertThat(loginRateLimiterV1.isAllowed(userId)).isTrue();
}
@Test
@DisplayName("로그인 시도 횟수가 6회일 경우 제한")
void testLoginRateLimiterV2() {
LoginRateLimiterV1 loginRateLimiterV1 = new LoginRateLimiterV1();
loginRateLimiterV1.setLimiterTime(1000);
String userId = "UserId";
loginRateLimiterV1.isAllowed(userId);
loginRateLimiterV1.isAllowed(userId);
loginRateLimiterV1.isAllowed(userId);
loginRateLimiterV1.isAllowed(userId);
loginRateLimiterV1.isAllowed(userId);
boolean allowed = loginRateLimiterV1.isAllowed(userId);
Assertions.assertThat(loginRateLimiterV1.size(userId)).isEqualTo(5);
Assertions.assertThat(allowed).isFalse();
}
}
테스트 결과는 성공입니다.
사실 이 테스트는 함정이 있었습니다.
@Test
@DisplayName("처음 생성한 인스턴스이지만 정적 필드를 공유해서 이 테스트가 실패한다.")
void testLoginRateLimiterV3() {
LoginRateLimiterV1 loginRateLimiterV1 = new LoginRateLimiterV1();
boolean allowed = loginRateLimiterV1.isAllowed(userId);
Assertions.assertThat(loginRateLimiterV1.size(userId)).isEqualTo(1);
Assertions.assertThat(allowed).isTrue();
}
정적 필드는 컴파일에 초기화됩니다.
-
primitive ( 원시 ), String (문자열) 은 컴파일 되면 리터럴로 입력되어있습니다.
JIT나 그외 성능 개선 및 불필요한 호출을 최적화하기 위해서 입니다.
-
HashMap, 사용자 정의 객체는 컴파일되면 정적 메모리 주소에 공간이 할당 됩니다.
즉, 테스트 코드는 실행이 될 때 정적 필드는 한 번 초기화 이후 모두 공유하게 됩니다.
static final이나 static 과 같이 인스턴스가 공유하게 되는 경우에는 테스트가 순서에 따라 깨지거나 잘못된 결과를 출력할 수 있습니다.
코드에서 확인 할 수 있는 문제
문제점 : 동시성
동시성 문제가 발생됩니다.
동시성 문제란, 여러 쓰레드 또는 프로세스가 동시에 접근하면 안되는, 공유 자원을 수정하는 코드영역(임계 영역)을 실행하면서 발생되는 문제입니다.
- HashMap
- Deque
- isAllowed
모두 동시성 문제가 발생될 수 있으므로 코드를 쓰레드 안정성이 높도록 동시성이 보장되는 방식으로 변경해야합니다.
문제점 : 메모리 누수
다양한 사용자가 한꺼번에 요청을 보내고 무한대로 즐기게 된다면 운영체제는 서버를 지키기 위해서 JVM을 종료시키게 됩니다.
문제점: 정적필드로 테스트끼리 영향을 미치게 됩니다.
위에서 말했던 내용으로 테스트가 잘못된 결과를 보여주게 됩니다.
문제점 : 제어하기 힘든 시간을 기반으로 테스트를 한다
특히 System.currentTimeMillis()
는 실행되는 코드는 추상화되어 있지 않으므로 개발자가 제어할 수 없습니다.4
댓글남기기