Go 언어 - 속도 제한 (Rate Limiting)

검프·2021년 10월 23일
5

Concurrency in Go

목록 보기
6/6
post-thumbnail

Go 동시성 프로그래밍의 내용을 참고하여 작성했습니다.

속도 제한이란?

서비스를 개발하다 보면 다양한 목적으로 속도 제한을 구현하게 됩니다. 속도 제한Rate Limiting^{Rate\ Limiting}이란, 리소스에 대한 접근을 단위 시간당 특정 횟수로 제한하는 것을 이야기합니다. 여기서 말한 리소스에는 API 연결, 디스크 I/O, 네트워크 트래픽 등이 있습니다.

그렇다면 서비스에 속도 제한을 하는 이유는 무엇일까요?

속도 제한이 왜 필요한가?

다양한 목적으로 사용할 수 있지만, 가장 중요한 이유는 서비스에 대한 악의적인 공격으로부터 시스템을 안전하게 보호하는 것입니다. 또한, 요청 트래픽을 제한해서 시스템의 부하를 줄이고 다수의 사용자에게 공정한Fairness^{Fairness} 시스템이 될 수 있도록 조정하는 것도 중요한 이유라 할 수 있습니다.

  • 무차별 암호 대입 공격 (Brute force attacks)
  • DoS/DDoS 공격
  • 과도한 리소스 요청에 따른 시스템 장애
  • 특정 사용자의 리소스 과다 사용이 다른 사용자의 성능을 저하 시킴

이런 이유들로 일반적인 사용자가 어느 정도의 성능을 기대할 수 있는지에 대한 기준을 만들고 이를 보장하기 위한 목적으로 속도 제한을 구현합니다. 또한 속도 제한을 통해 시스템의 성능과 안정성을 보장합니다.

Go 언어에서 속도 제한

토큰 버킷

Go 언어에서 속도 제한은 토큰 버킷Token bucket^{Token\ bucket}이라는 알고리즘으로 구현합니다. 토큰 버킷 알고리즘을 간단히 설명하면 아래아 같습니다.

  • 리소스에 접근할 때 리소스에 대한 접근 토큰이 있어야 한다고 가정하며, 토큰이 없으면 요청은 거부됨
  • 토큰은 깊이가 b인 버킷에서 관리되며, 버킷은 b 만큼의 토큰을 보유함
  • 토큰은 일정 속도r로 버킷에 충전되며, 버킷이 꽉 차 있으면 토큰은 충전되지 않고 버려짐
  • 리소스에 접근할 때마다 버킷에서 토큰을 하나씩 제거함
  • 버킷에 토큰이 남아 있는 동안 요청이 가능함
  • 버킷에 토큰이 다 소진되면 요청을 거부하거나 대기열에 요청을 저장함

즉, 요청이 들어온 시점에 버킷이 비어있으면 새 토큰이 충전될 때까지 기다려야 하기 때문에 재충전 속도 r에 의해 속도 제한이 이루어지게 됩니다.

아래 표는 b = 1이고 r = 1인 토큰 버킷의 요청 처리 결과입니다. 최초 요청이 즉시 처리되고, 이후 2초에 한 번씩 요청이 처리되고 있습니다.

시간버킷요청
01
00요청 성공
10
21
20요청 성공
30
41
40요청 성공

속도 제한 설정이 없는 예제

우선 속도 제한 설정이 없는 예제를 살펴보겠습니다. APIConnectionReadFile, ResolveAddress 두 개의 API를 가지고 있습니다. 예제 단순화를 위해 인수들은 제거한 상태이지만, 속도 제한을 설명하기 위해서 context.Context를 인수로 가지고 있습니다. main에서 ReadFile, ResolveAddress API를 각각 10번씩 동시에 호출합니다.

func Open() *APIConnection {
	return &APIConnection{}
}

type APIConnection struct{}

func (a *APIConnection) ReadFile(ctx context.Context) error {
	// Pretend we do work here
	return nil
}

func (a *APIConnection) ResolveAddress(ctx context.Context) error {
	// Pretend we do work here
	return nil
}

func main() {
	defer log.Printf("Done.")
	log.SetOutput(os.Stdout)
	log.SetFlags(log.Ltime)

	apiConnection := Open()
	var wg sync.WaitGroup
	wg.Add(20)

	for i := 0; i < 10; i++ {
		go func() {
			defer wg.Done()
			err := apiConnection.ReadFile(context.Background())
			if err != nil {
				log.Printf("cannot ReadFile: %v", err)
			}
			log.Printf("ReadFile")
		}()
	}

	for i := 0; i < 10; i++ {
		go func() {
			defer wg.Done()
			err := apiConnection.ResolveAddress(context.Background())
			if err != nil {
				log.Printf("cannot ResolveAddress: %v", err)
			}
			log.Printf("ResolveAddress")
		}()
	}

	wg.Wait()
}

<출력 결과>
15:09:44 ResolveAddress
15:09:44 ReadFile
15:09:44 ReadFile
15:09:44 ReadFile
15:09:44 ReadFile
15:09:44 ReadFile
15:09:44 ReadFile
15:09:44 ResolveAddress
15:09:44 ResolveAddress
15:09:44 ReadFile
15:09:44 ResolveAddress
15:09:44 ReadFile
15:09:44 ReadFile
15:09:44 ResolveAddress
15:09:44 ResolveAddress
15:09:44 ResolveAddress
15:09:44 ResolveAddress
15:09:44 ResolveAddress
15:09:44 ResolveAddress
15:09:44 ReadFile
15:09:44 Done.

출력 결과를 확인해 보면 모든 API 요청이 거의 동시에 처리되는 것이 확인됩니다. 속도 제한 설정이 없으므로 요청자가 원하는 만큼 자주 API를 호출할 수 있게 됩니다.

Go 언어의 속도 제한 패키지

golang.org/x/time/rate 패키지는 토큰 버킷 속도 제한기를 구현하고 있습니다. golang.org/x Prefix를 갖는 패키지들은 Go 프로젝트이지만, 하위 저장소에 저장된 패키지들로 사실상 Go 언어 표준 라이브러리입니다.

// rate.go

// Limit은 이벤트의 최대 빈도를 정의
// 초당 이벤트 수, `0`이면 아무런 이벤트도 허용하지 않음
type Limit float64

// NewLimiter는 r의 속도를 가지며 최대 b개의 토큰을 갖는 새로운 Limiter를 반환
func NewLimiter(r Limit, b int) *Limiter

// Limit을 만들기 위한 도우미 함수인 Every를 제공
// Every는 Limit에 대한 이벤트 사이의 최소 시간 간격을 변환
func Every(interval time.Duration) Limit

// WaitN은 Limiter가 n개의 이벤트 발생을 허용할 때까지 대기
// n이 Limiter의 b 크기를 초과하면 error를 리턴, Context는 취소
// Context는 Deadline이 지날 때까지 대기
func (lim *Limiter) WaitN(ctx context.Context, n int) (err error)

// Wait는 WaitN(ctx, 1)의 축약형
func (lim *Limiter) Wait(ctx context.Context) (err error)

속도 제한을 설정한 예제

golang.org/x/time/rate 패키지를 이용해 속도 제한을 구현해 보겠습니다.

func Open() *APIConnection {
	return &APIConnection{
		// 1초당 1개의 이벤트라는 속도 제한을 설정
		rateLimiter: rate.NewLimiter(rate.Limit(1), 1),
	}
}

type APIConnection struct {
	rateLimiter *rate.Limiter
}

func (a *APIConnection) ReadFile(ctx context.Context) error {
	// 속도 제한에 의해 요청을 완료하기에 충분한 토큰을 가질 때까지 대기
	if err := a.rateLimiter.Wait(ctx); err != nil {
		return err
	}
	// Pretend we do work here
	return nil
}

func (a *APIConnection) ResolveAddress(ctx context.Context) error {
	if err := a.rateLimiter.Wait(ctx); err != nil {
		return err
	}
	// Pretend we do work here
	return nil
}

func main() {
	... 앞선 예제와 동일하여 생략
}

<결과 출력>
15:40:39 ReadFile
15:40:40 ResolveAddress
15:40:41 ReadFile
15:40:42 ReadFile
15:40:43 ReadFile
15:40:44 ReadFile
15:40:45 ResolveAddress
15:40:46 ResolveAddress
15:40:47 ReadFile
15:40:48 ResolveAddress
15:40:49 ReadFile
15:40:50 ResolveAddress
15:40:51 ReadFile
15:40:52 ResolveAddress
15:40:53 ResolveAddress
15:40:54 ResolveAddress
15:40:55 ReadFile
15:40:56 ResolveAddress
15:40:57 ReadFile
15:40:58 ResolveAddress
15:40:58 Done.

속도 제한을 사용하지 않을 때는 모든 API 요청을 거의 동시에 처리했던 반면, 속도 제한을 사용하면 일정 시간 동안 정해진 API 요청만을 처리합니다. 이번엔 더 복잡한 속도 제한기 예제를 살펴보도록 하겠습니다.

// RateLimiter 인터페이스를 정의
type RateLimiter interface {
	Wait(context.Context) error
	Limit() rate.Limit
}

func MultiLimiter(limiters ...RateLimiter) *multiLimiter {
	byLimit := func(i, j int) bool {
		return limiters[i].Limit() < limiters[j].Limit()
	}
	// 최적화를 위하여 Limit() 값으로 정렬 (오름차순)
	sort.Slice(limiters, byLimit)
	return &multiLimiter{limiters: limiters}
}

type multiLimiter struct {
	limiters []RateLimiter
}

func (l *multiLimiter) Wait(ctx context.Context) error {
	// 모든 Limiter로부터 토큰을 획득해야 실행 가능
	for _, l := range l.limiters {
		if err := l.Wait(ctx); err != nil {
			return err
		}
	}
	return nil
}

func (l *multiLimiter) Limit() rate.Limit {
	// 가장 한정적인 Limit() 값을 반환 (분당 속도 제한기를 반환)
	// 이 예제에서 실제 호출되지 않음
	return l.limiters[0].Limit()
}

// 단위 시간당 요청수를 좀 더 직관적으로 표현하기 위해서 도우미 함수를 정의
func Per(eventCount int, duration time.Duration) rate.Limit {
	return rate.Every(duration / time.Duration(eventCount))
}

func Open() *APIConnection {
	// 초당 최대 2번의 요청을 허용하는 속도 제한 (Limit() = 2)
	secondLimit := rate.NewLimiter(Per(2, time.Second), 1)
	// 6초에 1번 재충전되고, 최대 10번의 요청을 동시에 허용하는 속도 제한 (Limit() = 0.16666666666666666)
	minuteLimit := rate.NewLimiter(Per(10, time.Minute), 10)
	return &APIConnection{
		rateLimiter: MultiLimiter(secondLimit, minuteLimit),
	}
}

type APIConnection struct {
	rateLimiter RateLimiter
}

func (a *APIConnection) ReadFile(ctx context.Context) error {
	if err := a.rateLimiter.Wait(ctx); err != nil {
		return err
	}
	// Pretend we do work here
	return nil
}

func (a *APIConnection) ResolveAddress(ctx context.Context) error {
	if err := a.rateLimiter.Wait(ctx); err != nil {
		return err
	}
	// Pretend we do work here
	return nil
}

func main() {
	... 앞선 예제와 동일하여 생략
}

<결과 출력>
08:37:17 ReadFile
08:37:17 ReadFile
08:37:18 ResolveAddress
08:37:18 ReadFile
08:37:19 ReadFile
08:37:19 ReadFile
08:37:20 ReadFile
08:37:20 ResolveAddress
08:37:21 ReadFile
08:37:21 ResolveAddress
08:37:23 ResolveAddress
08:37:29 ReadFile
08:37:35 ResolveAddress
08:37:41 ResolveAddress
08:37:47 ReadFile
08:37:53 ResolveAddress
08:37:59 ResolveAddress
08:38:05 ResolveAddress
08:38:11 ResolveAddress
08:38:17 ReadFile
08:38:17 Done.
시간secondLimitminuteLimit요청
0110
009요청 성공
019
008요청 성공
117
106요청 성공
116
105요청 성공
215
204요청 성공
214
203요청 성공
313
302요청 성공
312
301요청 성공
411
400요청 성공
410
410
510
510
510
510
611
600요청 성공
610
610
............
1211
1200요청 성공
1210
1210

echo 프레임워크에서 속도 제한

백엔드 API를 개발할 때는 웹 프레임워크를 사용하기 마련인데요. echo 프레임워크에서도 미들웨어를 이용해 속도 제한을 지원하고 있습니다. 아래 예제는 요청 IP 별로 초당 요청 수(requests/sec)를 제한하는 예제입니다.

func main() {
	e := echo.New()
	e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(2)))

	e.GET("/", func(c echo.Context) error {
		return c.String(http.StatusOK, "Hello, World!")
	})

	e.Logger.Fatal(e.Start(":1234"))
}

IP 단위로 초당 2건의 요청을 처리하도록 제한하였고, 이보다 큰 요청이 들어오면 HTTP/1.1 429 Too Many Requests 상태 코드를 반환합니다.


디바운싱과 쓰로틀링

프론트엔드 분야에서도 성능상의 이유로 속도 제한을 이용합니다.

  • Debouncing : 연속적인 요청 중 마지막 요청만 실행되도록 하는 것
  • Throttling : 연속적인 요청을 일정 주기로 하나씩만 실행되도록 하는 것

어떤 분이 Debouncing과 Throttling의 차이를 잘 보여주는 예제를 만들어 주셔서 링크합니다.

profile
권구혁

0개의 댓글