Redis를 사용해 요청 횟수를 제한해 보자

Minseok Jeon·2023년 3월 12일
11

Redis

목록 보기
1/1
post-thumbnail

배경)

특정 클라이언트에서 짧은 시간에 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 성능 확인

Rate limiting wrong code)

@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

Rate limiting correct case code)

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 문제를 잘 고려해야합니다.

profile
개발 천재 밍코천

0개의 댓글