배경)
특정 클라이언트에서 짧은 시간에 A기능만 과도한 요청 -> A 기능에 사용되는 DB에 많은 부하 발생 -> 이로 인해 A 기능 성능 저하 -> 특정 클라이언트의 과도한 요청으로 A 기능을 요청한 모든 클라이언트에게 응답 지연 발생
내용)
"과도한 요청" -> "Caching"을 생각했다면 아직은 섣부른 판단입니다. 해당 요청이 어떤 타입의 요청 인지를 먼저 파악해야 합니다. 그럼, "조회에 관련된 과도한 요청" -> "Caching" 이건 어떨까요? 이것도, 현재 조건만으론 아직 Caching으로 해결이라 판단하긴 이릅니다. Caching에 관련된 이야기는 현재 포스팅과 벗어나는 주제이므로 자세한 이야기는 다른 포스팅에서 하겠습니다.
자 그럼, 어떻게 해결할까요? 부하가 발생한 지점이 DB 이기 때문에 현재 상태에선 요청을 다 받아 처리하는것 보다 요청을 제한(Rate Limiting)하는 방법이 최선책입니다. 또는 처리할 수 있는 만큼까지만 허용시켜주는 방법(Throrrling)이 있습니다. 이 두가지 방법중 Redis를 사용해 Rate limiting, 요청 제한을 해보려고 합니다.
Rate limiting 기능 구현에 Redis를 사용한 이유는 Reids 의 Atomic operation(Thread safe한 처리) 때문입니다. Redis 서버에 연결된 여러 클라이언트에서 요청이 동시에 들어와도 순서대로 하나씩 처리하기 때문에 Race condition 문제를 발생시키지 않고 기능을 구현할 수 있습니다. 성능 또한 매우 좋아서 수많은 요청에 대한 처리를 잘 받아 줄 수 있을거라 판단했습니다.
Redis 소개 요약
Redis 성능 확인
@RestController
@RequestMapping("/redis/reteLimiting")
@RequiredArgsConstructor
public class LoginController {
private final RedisTemplate<String, String> redisTemplate;
private final Long LOGIN_REQUEST_LIMIT_WHILE_A_SECOND = 2L;
private final String LOGIN_REQUEST_COUNT_KEY = "loginRequestCount";
@PostMapping("/wrong/race_condition/login")
public ResponseEntity<String> wrong(String id, String password) {
String loginRequestCount = redisTemplate.opsForValue().get(LOGIN_REQUEST_COUNT_KEY); // 로그인 요청 횟수가 얼마인지 조회
if (loginRequestCount == null) {
// 조회된 값이 없다면, 1로 초기화하고 ttl을 1초로 설정(1초동안 request를 카운팅하기 위함)
redisTemplate.opsForValue().set(LOGIN_REQUEST_COUNT_KEY, String.valueOf(1));
redisTemplate.expire(LOGIN_REQUEST_COUNT_KEY, 10L, java.util.concurrent.TimeUnit.SECONDS);
} else {
// 조회된 값이 있으면 현재 요청 건수까지 더해 기준치까지 도달했는지 확인
long totalCountWhileASecond = Long.parseLong(loginRequestCount) + 1L;
if (totalCountWhileASecond > LOGIN_REQUEST_LIMIT_WHILE_A_SECOND) { // 기준치를 넘었다면 429 에러를 반환
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("Too many requests");
} else { // 기준치를 넘지 않았다면, 계산된 요청 건수를 Redis에 다시 저장
redisTemplate.opsForValue().set(LOGIN_REQUEST_COUNT_KEY, String.valueOf(totalCountWhileASecond));
}
}
if (loginProcess(id, password)) { // 로그인 처리(500ms)
return ResponseEntity.ok(id + " Login success");
} else {
return ResponseEntity.ok(id + " Login failed");
}
}
private boolean loginProcess(String id, String password) {
try {
Thread.sleep(500); // mock login process
} catch (Exception ignored) {
return false;
}
return true;
}
}
이 코드는 요청이 순차적으로만 처리된다면 문제가 없지만 Request마다 별도의 thread가 만들어 지고 각각의 thread에서 그 요청을 처리하기 때문에 race condition 문제가 있는 코드입니다. 즉, thread A가 조회한 Redis 값을 사용해 어떤 로직을 처리하는 과정중에 thread B가 Redis의 값을 변화 시켰을때 thread A가 그 값을 인지하지 못하고 이전 값을 사용하는 문제가 발생하게 됩니다.
예를 들면)
1. thread A가 LOGIN_REQUEST_COUNT_KEY로 redis 에서 조회한 값은 1
2. thread B가 LOGIN_REQUEST_COUNT_KEY로 redis 에서 조회한 값은 1
3. thread B가 LOGIN_REQUEST_COUNT_KEY로 redis 에 2 저장
4. thread A가 LOGIN_REQUEST_COUNT_KEY로 redis 에 2 저장(X) -> 여기서 저장되어야 하는 값은 3
package minssogi.study.rate_limiting.redis;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/redis/reteLimiting")
@RequiredArgsConstructor
public class LoginController {
... ...
@PostMapping("/correct/race_condition/login")
public ResponseEntity<String> correct(String id, String password) {
Long loginRequestCount = redisTemplate.opsForValue().increment(LOGIN_REQUEST_COUNT_KEY);
if (loginRequestCount.equals(1L)) {
redisTemplate.expire(LOGIN_REQUEST_COUNT_KEY, 1L, TimeUnit.SECONDS);
}
if (loginRequestCount > LOGIN_REQUEST_LIMIT_WHILE_A_SECOND) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("Too many requests");
}
if (loginProcess(id, password)) { // 로그인 처리(500ms)
return ResponseEntity.ok(id + " Login success");
} else {
return ResponseEntity.ok(id + " Login failed");
}
}
... ...
}
Redis 의 incr 명령어를 사용하면 race codition 문제를 해결할 수 있을 뿐더러 Redis에 요청하는 command 수도 줄일 수 있게 됩니다.
Redis incr command 설명
결론)
처리 가능한 요청량 이상이 순간적으로 몰릴때 모든 클라이언트가 해당 기능을 사용할 수 없게 됩니다. 뿐만아니라 외부에서 데이터(다른 서버로 요청, DB 등)를 가져와 로직을 처리하는 경우 그 트래픽이 뒷단까지 전달되어 장애가 전파되는 더 심각한 장애 상황을 만들 수 있습니다. 때문에 중요 기능의 경우 처리 가능한 양을 사전에 확인하고 Rate limiting 기능을 통해 안전하게 서비스 될 수 있도록 해야합니다. 이때 순간적으로 많은 요청을 동시에 처리(multi thread)하면서 발생 할 수 있는 race condition 문제를 잘 고려해야합니다.