112호. 실전 라라벨 1편 - 의도하지 않았던 API Throttle free

2020-01-10

다양한 관점과 경험을 제공해드리기 위해 실력있는 개발자분들을 모셔 컨텐츠를 함께 제공하기로 하였습니다. 오늘은 그 첫 번째 컨텐츠 입니다. 재밋게 즐겨주시고 앞으로도 기대해 주세요~



API 개발을 하다 보면 외부에서의 크롤링, 검색봇의 방문 등 전혀 의도치 않은 외부의 접근 시도를 막아야 할 때가 있습니다.

특정 서버의 과도한 요청을 막기 위해 만들어진 기능이 API throttle 입니다.


https://github.com/illuminate/routing/blob/master/Middleware/ThrottleRequests.php


그리고 이 기능은 라라벨을 API 개발에 사용하게 되면 기본적으로 장착되어 있습니다.

특정 외부 서버에서 우리 서버로 API 호출을 출력 제어를 하게 하는 (정확히는 API 호출 접근 개수를 제한하는) 미들웨어입니다.


/app/Http/Kernel.php


namespace App\Http;

...

class Kernel extends HttpKernel
{
...
/**
* The application's route middleware groups.
*
* @var array
*/
protected $middlewareGroups = [
'web' => [
...
],

'api' => [
'throttle:60,1',
'bindings',
],
];

/**
* The application's route middleware.
*
* These middleware may be assigned to groups or used individually.
*
* @var array
*/
protected $routeMiddleware = [
...
'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
...

}

라라벨을 사용하게 되면 위의 코드와 같이 기본적으로 api 미들웨어 그룹에 포함되어있기 때문에 별 생각 없이 쓰게 됩니다. 그러니깐, 요 기능을 인지하지 못해도 라라벨 API를 개발하고 있으면, 해당 기능을 자연스레 사용하고 있는 것입니다. 그리고 우리가 잘 모르는 사이에 이 throttle 기능은 외부의 과다한 요청을 거부하는 기능을 수행하고 있는 것입니다.


근데 오픈소스 툴의 묘미랄까? 어떠한 기능이든 해당 기능이 돌아가는 로직을 파악하지 못하면 전혀 예상치 못한 위험에 직면하게 됩니다.


예를 들자면 제 경험으로 어느 정도 막을 수 있다고 판단한 부하였는데 서비스 장애가 전이 된 적이 있습니다. 행사 이벤트로 인한 아주 짧은 시간 동안의 엄청난 다수 client 들의 엄청난 접속이긴 한데, 현상은 redis 가 먼저 뻗고, 덩달아 private zone 안에 있는 다른 서비스들의 영향을 주며 전면 장애를 일으키는 현상이었습니다.


근데 문제는 왜 redis 인가라는 점이었어요. 부하가 몰리면 redis 에 부하가 몰리는 것은 맞는데, 원래 그러라고 사용하는 게 redis 아닌가요? 게다가 나중에 가서는 redis 가 connection refused 가 될 정도였으니깐요.


원인을 찾고 찾은 끝에 원인이 이 API throttle이라는 것을 찾아냈습니다.


먼저 ThrottleRequests 미들웨어 소스를 보면 ThrottleRequests class 는 생성자에서 RateLimit instance 를 DI 로 호출해서 로딩합니다.


\vendor\laravel\framework\src\Illuminate\Routing\Middleware\ThrottleRequests.php


namespace Illuminate\Routing\Middleware;

...

class ThrottleRequests
{
use InteractsWithTime;

/**
* The rate limiter instance.
*
* @var \Illuminate\Cache\RateLimiter
*/
protected $limiter;

/**
* Create a new request throttler.
*
* @param \Illuminate\Cache\RateLimiter $limiter
* @return void
*/
public function __construct(RateLimiter $limiter)
{
$this->limiter = $limiter;
}

...
}

아래 소스와 같이 RateLimit 라는 것이 Illuminate\Contracts\Cache\Repository contracts 로 즉 '기본 cache' 를 사용하고 있고, CACHE DRIVER 를 redis 설정하면 redis 를 default cache 로 사용하는 게 됩니다.


\vendor\laravel\framework\src\Illuminate\Cache\RateLimiter.php


class RateLimiter
{
use InteractsWithTime;

/**
* The cache store implementation.
*
* @var \Illuminate\Contracts\Cache\Repository
*/
protected $cache;

/**
* Create a new rate limiter instance.
*
* @param \Illuminate\Contracts\Cache\Repository $cache
* @return void
*/
public function __construct(Cache $cache)
{
$this->cache = $cache;
}

...

/**
* Increment the counter for a given key for a given decay time.
*
* @param string $key
* @param int $decaySeconds
* @return int
*/
public function hit($key, $decaySeconds = 60)
{
$this->cache->add(
$key.':timer', $this->availableAt($decaySeconds), $decaySeconds
);

$added = $this->cache->add($key, 0, $decaySeconds);

$hits = (int) $this->cache->increment($key);

if (! $added && $hits == 1) {
$this->cache->put($key, 1, $decaySeconds);
}

return $hits;
}

hit 함수를 보면 cache 에 increment 를 하는게 보입니다. 다시 ThrottleRequests 클래스를 보면,


\vendor\laravel\framework\src\Illuminate\Routing\Middleware\ThrottleRequests.php


class ThrottleRequests
{
...

/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param int|string $maxAttempts
* @param float|int $decayMinutes
* @param string $prefix
* @return \Symfony\Component\HttpFoundation\Response
*
* @throws \Illuminate\Http\Exceptions\ThrottleRequestsException
*/
public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '')
{
$key = $prefix.$this->resolveRequestSignature($request);

$maxAttempts = $this->resolveMaxAttempts($request, $maxAttempts);

if ($this->limiter->tooManyAttempts($key, $maxAttempts)) {
throw $this->buildException($key, $maxAttempts);
}

$this->limiter->hit($key, $decayMinutes * 60);

$response = $next($request);

return $this->addHeaders(
$response, $maxAttempts,
$this->calculateRemainingAttempts($key, $maxAttempts)
);
}

...

/**
* Resolve request signature.
*
* @param \Illuminate\Http\Request $request
* @return string
*
* @throws \RuntimeException
*/
protected function resolveRequestSignature($request)
{
if ($user = $request->user()) {
return sha1($user->getAuthIdentifier());
}

if ($route = $request->route()) {
return sha1($route->getDomain().'|'.$request->ip());
}

throw new RuntimeException('Unable to generate the request signature. Route unavailable.');
}

...
}

원래 API throttle 이 하나의 서버에서 여러 번 요청을 보내는 기능을 막는 것이지만, 만일 엄청난 다수의 개별 client 들의 접근이라면 어떨까요?


ThrottleRequests class 의 resolveRequestSignature 함수가 cache key 스트링의 구성하는 것으로 보입니다. 함수 내용은 guard에서 user가 나오면 user_id로 sha1 hashing 해서 키를 만들고 없으면 접근 clinet IP로 키를 만드는 것을 알 수 있습니다.


캐시라는 것은 이미 만들어진 캐싱 데이터에 hit 될 때 성능 향상이 됩니다. 근데 위의 로직으로 볼 때 행사 이벤트로 인한 아주 짧은 시간 동안의 다수 client 들의 엄청난 접속(중복되지 않은 IP 혹은 대량의 유저)이라면 사실 cache 의 기능을 사용하고 있는 게 아닌 게 되어버리죠. IP별로, 유저별로 단시간에 엄청난 throttle 에 관련된 redis data 를 만들어버리는 의도치 않는 결과가 되어버리는 것입니다.


요 문제점을 발견한 후 라라벨 API throttle 을 꺼버렸습니다. 그리고 API 앞단에서 다른 장치 예를 들자면 WAF나 API gateway 등이 throttle을 하도록 설정했습니다. 그랬더니 당연히 관련 장애는 일어나지 않았습니다.


라라벨이 편하고 좋은 기능들로 '중무장'하고 있어서 좋은 툴로 자리매김하지만, 대량 트래픽에 대한 서비스 개발에서 API throttle 은 기본적으로 장착된 기능일지라도 사용에 조심해야 합니다.


앞으로 이런 현업에서 업무 개발시 라라벨 사용에서의 팁과 노하우를 "실전 라라벨" 섹션에 연속적으로 연재를 해보도록 하겠습니다.


1일 1식 라라벨 112호

2020년 1월 10일


윤영진

Modern PHP Developer, yupmin@gmail.com