Skip to content
isdnetworks
Go back

뒤에서 도는 것이 한도를 다 써 버렸다

외부 시세를 받아 오는 호출이 가끔 거절당했는데 요청 수 한도를 넘었다는 응답이었다.

Table of contents

Open Table of contents

원인 — 같은 한도를 쓰던 셋

API 를 부르는 자리가 셋이었다.

실시간 시세 갱신   1분에 60회
과거 데이터 채우기  제한 없이 계속
통계 집계          1시간에 1회

두 번째가 문제였는데 빠진 과거 데이터를 채우는 배치가 상한 없이 뒤에서 계속 돌고 있었다.

한도가 분당 100회여서 실시간이 60회를 쓰고 남은 40회를 배치가 쓰는데 배치가 그 40을 넘겨 쓰면 실시간이 거절당했다.

backfill 처럼 급하지 않은 것이 realtime 을 막고 있는 모양이었다. 실시간 쪽 사용량만 보고 있었으니 원인이 그 안에서는 안 보였다.

조치 — 한도를 나눠 줬다

전체 한도를 용도별 몫으로 미리 갈라 두었다.

class RateLimiter {
    private const QUOTA = [
        'realtime' => 70,
        'backfill' => 20,
        'stats'    => 10,
    ];

    public function acquire(string $group): bool {
        $key = "rate:{$group}:" . (int)(time() / 60);
        $n = $this->redis->incr($key);
        if ($n === 1) { $this->redis->expire($key, 120); }
        return $n <= self::QUOTA[$group];
    }
}

QUOTA 에 적힌 몫을 넘으면 acquire 가 거짓을 내고 부르는 쪽이 멈춘다.

그래서 배치는 자기 몫을 다 쓰면 그 자리에서 기다리게 된다.

while (!$limiter->acquire('backfill')) {
    sleep(1);
}
$this->fetch($symbol, $date);

backfill 이 기다리는 동안에도 realtime 은 자기 몫이 남아 있어 안 막힌다.

설정 — 남는 몫을 쓰게 했다

그런데 실시간이 언제나 70을 다 쓰는 것은 아니었다.

public function acquireSpare(string $group): bool {
    $used = 0;
    foreach (array_keys(self::QUOTA) as $g) {
        $used += (int)$this->redis->get("rate:{$g}:" . (int)(time() / 60));
    }
    if ($used >= self::TOTAL - self::RESERVE) { return false; }
    return $this->acquire($group, true);
}

TOTAL 에서 RESERVE 를 뺀 범위 안에서만 다른 용도의 남는 몫을 쓴다.

RESERVE 를 남긴 것은 실시간 요청이 갑자기 늘 수 있어서였다. 배치는 남는 것을 쓰게 되므로 밤처럼 한가한 시간에 더 빨리 돈다.

대응 — 거절당했을 때

몫을 나눠 두어도 상대 쪽 사정으로 거절당할 수 있다.

if ($res->status === 429) {
    $wait = (int)($res->header('Retry-After') ?? 60);
    if ($group === 'realtime') {
        log_error("실시간 시세 거절됨. {$wait}초 대기");
        $this->useLastKnown($symbol);
    } else {
        sleep($wait);
    }
}

realtime 은 기다리지 않고 useLastKnown 으로 마지막 값을 쓴다.

화면에 나가는 시세는 몇 초 낡아도 아예 없는 것보다는 낫기 때문이다. 배치는 급하지 않으니 Retry-After 만큼 그냥 기다리게 두었다.

같은 429 응답이라도 용도에 따라 다르게 다룬 것이다. 한쪽에 맞춰 한 가지로 처리하면 반드시 다른 쪽이 손해를 본다.

검증 — 용도별 사용량 기록

그래서 용도별 사용량을 분마다 로그로 남겨 두기로 했다.

14:02  realtime 58/70  backfill 20/20  stats 0/10   total 78/100
14:03  realtime 61/70  backfill 20/20  stats 0/10   total 81/100

realtimebackfill 중 어느 쪽이 몫을 다 쓰고 있는지가 그대로 보인다.

backfill 이 계속 20을 다 쓰고 있으면 몫을 늘릴지 판단할 재료가 된다. 나누기 전에 얼마나 쓰는지 아는 것이 몫을 정하는 첫 단계였다.

주의 — 부르는 자리를 한 곳으로

몫을 나누는 것이 뜻대로 되려면 부르는 자리를 다 알아야 했다.

$ grep -rn "quote-api" --include=*.php src/ | grep -v RateLimiter

RateLimiter 를 안 거치는 자리가 하나 더 나왔는데 관리 화면의 확인 버튼이었다.

그 버튼도 RateLimiter 를 거치도록 같은 방식으로 고쳤다. 한 자리라도 안 거치면 셋의 몫이 맞아도 전체가 넘친다.

class QuoteClient {
    public function get(string $symbol, string $group): array {
        if (!$this->limiter->acquire($group)) { throw new RateLimited($group); }
        return $this->http->get(...);
    }
}

QuoteClient 를 거치지 않고 직접 부르는 것을 막았다.

마지막으로 상대에게 그 한도를 올릴 수 있는지도 물어봤다. 요금제를 바꾸면 분당 300회까지 되지만 비용을 계산해 보니 배치 하나를 위해 올릴 만큼은 아니었고 배치는 밤에 남는 몫으로 돌면 며칠이면 끝났다.

정리


Share this post on:

Previous Post
컨테이너에서 뭘 백업하나
Next Post
카나리 전에 다른 채널을 센다