안정성 패턴이란?
분산 애플리케이션이 자신의 안정성이나 자신이 속한 시스템의 안정성을 높이는 데 사용되는 패턴이다.
서킷 브레이커(Circuit Breaker)
개요
직역하면 회로 차단기라고 할 수 있다. 퓨즈와 같이 과부하, 누전 등으로 전기 회로를 보호하는 안전장치다. 분산 컴퓨팅 시스템에서의 서킷 브레이커도 동일한 기능을 의미하는 패턴이다.
앞으로도 계속 언급되겠지만 분산된 클라우드 네이티브 시스템은 에러와 실패를 피해갈 수 없다.
서비스는 잘못 설정될 수 있고, 데이터베이스는 망가질 수 있으며 네트워크는 끊어질 수 있다.
내가 만든 애플리케이션에서 사용하는 의존성이 언제든 망가질 수 있다는 가정하에서 서비스를 디자인해야 한다.
그래서 의존성이 망가지는 상황에서 실패를 감지하고 '서킷'을 임시로 개방하여 수행을 일시 중지하고 적절한 에러 메시지를 제때 반환하는 작업이 필요하다.
예를 들어 사용자로부터 요청을 받아 DB쿼리를 수행하고 결과를 반환하는 이상적인 서비스를 생각해보자.
DB에 문제가 생긴다면 1.서비스는 의미 없는 쿼리 요청을 지속하고, 2.에러메시지가 가득한 로그가 쌓일 것이고, 3.타임아웃 발생이 늘어나거나 쓸모없는 에러만 반환한다. DB에게 요청을 보내봤자 에러 때문에 결과가 정해져있기 때문에 잠시라도 빨리 요청을 버리고 사용자에게 의미있는 에러 응답을 해주는게 중요하다.
구현
서킷 브레이커는 추가적인 에러 로직을 넣기 위해 서킷을 브레이커로 감싼 특화된 어댑터 패턴이다.
전기 스위치와 마찬가지로 브레이커는 두 가지 상태값 closed
와 open
을 갖는다.
closed
상태일 때는 모든 기능이 평상시처럼 동작한다. 브레이커는 사용자로부터 수신된 모든 요청을 변경없이 서킷으로 전달하고, 서킷은 또 다른 서킷(서비스)를 호출할 수 있으며, 서킷을 통해 만들어진 모든 응답은 사용자에게 전달된다.
open
상태일 때는 브레이커가 요청을 서킷으로 전달하지 않는다. 그 대신 상황에 대한 정보를 담은 에러 메시지를 응답함으로써 '빠른 실패'를 수행한다.
위 그림은 서킷 브레이커 어댑터가 동작하는 로직을 나타낸 것이다.
상태를 open 으로 전환하는 원리
브레이커는 내부적으로 서킷이 반환한 에러를 추적한다.
서킷이 사전에 정의된 에러의 기준을 넘어서는 에러를 반환하면 브레이커를 요청을 차단하고 상태를 open 으로 전환한다.
대부분의 서킷 브레이커 구현은 자동으로 서킷을 닫고 일정시간 유지하는 로직을 포함한다.
이때 주의할 점은 서킷의 열림/닫힘 간격이 너무 짧다면 고장난 서비스에 대해 재시도 요청을 많이 하게 된다는 것이다.
이런 상황은 그 자체로 여러 문제를 일으킬 수 있는데, 이를 해결하기 위해서 시간의 흐름에 따라 재시도 비율을 감소시키는 백오프 로직을 가지고 있어야 한다.
다중 노드 서비스에서 서킷 브레이커의 구현은 Redis 같은 공유 메모리를 사용해서 서킷의 상태를 추적하도록 확장된다.
// Circuit
// DB 또는 다른 서비스와 상호 작용하는 함수의 시그니처
// Context 를 받아서 (string, error) 를 반환하는 Circuit 이라는 람다 함수 형식 정의
type Circuit func(context.Context) (string, error)
// Breaker
// 클로저 개념이 적용되었다 - 필요한 기능을 제공하기 위해 circuit 을 감싼 또 다른 함수를 만들었다.
// circuit : Circuit 타입 정의를 따르는 어떤 함수라도 사용 가능하다.
// failureThreshold : 서킷을 자동으로 open 상태로 바꾸기 전까지 허용하는 실패 횟수
func Breaker(circuit Circuit, failureThreshold uint) Circuit {
consecutiveFailures := 0
lastAttempt := time.Now()
var mutex sync.RWMutex
return func(ctx context.Context) (string, error) {
mutex.RLock() // Read Lock
d := consecutiveFailures - int(failureThreshold)
if d >= 0 {
// failureThreshold 만큼 연속 실패 후 재시도 시간 간격을 2배씩 늘린다.
allowRetryTime := lastAttempt.Add(time.Second * 2 << d)
if !time.Now().After(allowRetryTime) {
mutex.RUnlock()
return "", errors.New("service unreachable")
}
}
mutex.RUnlock() // read unlock
response, err := circuit(ctx)
mutex.Lock() // lock
defer mutex.Unlock()
lastAttempt = time.Now() // 요청 시간 기록
if err != nil { // circuit이 에러를 반환하는 경우
consecutiveFailures++ // 연속 실패 횟수를 증가시키고
return response, err // 해당 응답과 에러를 반환
}
consecutiveFailures = 0 // 실패 횟수 초기화
return response, nil
}
}
Breaker
는 몇 초 후 circuit을 다시 호출 가능하게 하는 자동 초기화 구조를 가지고 있다.
그리고 서비스 접근 불가 에러를 반환한 후 circuit
에 대한 재시도 간격을 두 배씩 늘리는 지수 백오프 로직도 가지고 있다.
디바운스(Debounce)
개요
함수 호출 빈도를 제한하여 여러 번의 호출이 빠르게 발생했을 때 가장 처음이나 마지막 호출만 실제로 동작하게 하는 패턴이다.
이 기술은 프론트엔드 측에서 매크로, 하드웨어 오작동으로 인한 수많은 호출 횟수를 제한하기 위해서 사용되고 있다.
유저의 첫 번째 이벤트만 사용하거나 사용자가 이벤트를 멈출 때까지 작업을 지연시키는 방식인데,
예를 들어 1.검색 창에서 자동 완성 팝업이 즉시 노출되지 않고 입력을 멈춘 뒤 잠시 기다려야 하는 경우, 2.버튼을 반복해서 클릭했을 때 첫 번째 클릭이 무시되고 이후의 클릭만 인식되는 경우 등이 있다.
이 패턴은 함수가 얼마나 자주 호출될 수 있는지를 제한한다는 점에서 스로틀과 비슷하다고 볼 수 있다.
간단히 차이점을 언급하자면 디바운스는 일련의 연속적인 호출을 제한하고, 스로틀은 단순히 일정 시간 범위를 기준으로 제한한다는 점이다. 아래의 스로틀 파트에서 디바운스와의 차이를 상세히 설명했다.
구현
디바운스 구현은 서킷 브레이커가 Circuit을 빈도 제한 로직(2의 지수 백오프)으로 감싼 것과 유사하다.
- DebounceFirst : 서비스 함수를 한 번만 호출하고 이후의 요청을 무시한다. 이는 최초 응답을 캐싱하여 이후 응답에 사용하게 된다.
- DebounceLast : 일련의 요청에 대해 바로 내부 서비스 함수를 호출하지 않고 마지막 요청까지 기다린다.
이는 프론트에서 자주 사용되며, 검색 창에서 입력 값을 넣는 동안 매번 함수를 호출하지 않고 입력이 잠시 멈췄을 때 입력된 값을 사용하여 함수를 호출하는 것이 대표적이다. 백엔드에서는 많이 사용되지 않는데 이유는 함수 입력 값에 따라 즉시 응답하는 경우가 드물기 때문이다.
// DebounceFirst
// time.Duration 은 1 nanosecond 를 의미한다.
// Thread-Safety 를 보장하기 위해 함수 전체를 뮤텍스로 감싼다.
// 정해진 시간 d 이내에 함수가 다시 호출될 경우 마지막으로 호출된 시간을 추적해 캐시된 결과를 반환한다.
func DebounceFirst(circuit Circuit, d time.Duration) Circuit {
var threshold time.Time
var result string
var err error
var m sync.Mutex
return func(ctx context.Context) (string, error) {
m.Lock()
defer func() {
// 함수 호출될 때마다 초기화되도록 한다.
threshold = time.Now().Add(d)
m.Unlock()
}()
// 마지막으로 호출된 시간 확인
if time.Now().Before(threshold) {
return result, err
}
result, err = circuit(ctx)
return result, err
}
}
DebounceFirst
는 매우 직관적으로 흐름이 보인다. 정해진 시간 d 이내에 마지막으로 호출된 시간을 확인하여 캐시된 결과를 반환하면 되기 때문이다.
DebounceFirst
를 사용할 때 주의할 점은 duration 기간을 길게 둔다면, 지속적인 요청이 온다면 응답 캐싱으로 인해 갱신된 정보를 제대로 받아오지 못할 수 있다. 따라서 적절한 duration 값의 설정이 매우 중요하다.
// DebounceLast
// 함수가 호출된 이후 충분한 시간이 흘렀는지를 확인하기 위해 time.Ticker 를 사용한다.
func DebounceLast(circuit Circuit, d time.Duration) Circuit {
threshold := time.Now()
var ticker *time.Ticker
var result string
var err error
var once sync.Once
var m sync.Mutex
return func(ctx context.Context) (string, error) {
m.Lock()
defer m.Unlock()
// 호출될 때마다 시간 갱신
threshold = time.Now().Add(d)
// circuit 호출 로직은 단 한번만 발생한다.
once.Do(func() {
ticker = time.NewTicker(time.Millisecond * 100)
go func() {
defer func() {
m.Lock()
ticker.Stop()
once = sync.Once{}
m.Unlock()
}()
for {
select {
case <-ticker.C:
m.Lock()
// tick 만큼 시간이 흐르는 동안 debounce 호출이 없었다면 circuit 서비스를 호출한다.
if time.Now().After(threshold) {
result, err = circuit(ctx)
m.Unlock()
return
}
case <-ctx.Done():
m.Lock()
result, err = "", ctx.Err()
m.Unlock()
return
}
}
}()
})
return result, err
}
}
DebounceLast
코드는 함수가 마지막으로 호출된 이후 충분한 시간이 흘렀는지를 판단하기 위해 time.Ticker
를 사용했다.
Ticker를 사용했기 때문에 클로저 부분이 꽤나 복잡해 보일 수 있다.
함수의 대부분이 once.Do()
메서드 안에서 실행된다. Once
는 함수가 정확히 한 번만 실행하도록 도와주는 구조체이다.
재시도(Retry)
개요
실패한 작업을 투명하게 다시 시도함으로써 분산 시스템에서 발생할 수 있는 일시적인 오류를 처리하는 패턴이다.
분산 시스템이 복잡할 때 일시적인 오류는 피할 수 없다. 예를 들어 부하가 높을 때 일시적으로 요청을 거부하는 스로틀링을 건다거나 하는 상황에서 발생할 수 있다. 이처럼 일정 시간이 지나면 자연스럽게 해소되고 그때 재요청 하는 것으로 충분한 해결이 가능할 때 재시도 전략을 구현할 수 있다.
구현
이 패턴은 함수의 시그니처를 정의하는 Effector 타입을 통해 서킷 브레이커나 디바운스와 유사하게 동작한다.
type Effector func(context.Context) (string, error)
// Retry
// Effector 시그니처를 가진 함수를 반환하지만 함수가 외부 상태를 갖지 않는다.
// 따라서 동시성 문제를 해결하기 위해 뮤텍스 같은 기능을 사용할 필요가 없다.
func Retry(effector Effector, retries int, delay time.Duration) Effector {
return func(ctx context.Context) (string, error) {
for r := 0; ; r++ {
response, err := effector(ctx)
// 서비스 호출이 성공했거나 재시도 제한 횟수에 도달했으면 응답을 반환한다.
if err == nil || r >= retries {
return response, err
}
log.Printf("Attempt %d failed; retrying in %v", r+1, delay)
// case에 걸릴 때까지 blocking 되는 특징이 있다.
select {
// delay 시간이 흐른 후 for문을 다시 순회한다.
case <-time.After(delay):
case <-ctx.Done():
return "", ctx.Err()
}
}
}
}
재시도 함수는 이펙터 함수를 받고, 재시도 로직을 제공하는 이펙터를 실행시키고 이펙터와 동일한 시그니처의 클로저를 반환한다.
스로틀(Throttle)
개요
함수 호출을 단위 시간마다 n 번으로 제한하는 패턴이다.
예를 들어 1.사용자는 초당 10회의 서비스 요청만 할 수 있다 2.고객은 특정 함수를 0.5초에 한 번만 호출할 수 있다 3.특정 계정에 대해 24시간 동안 3번까지만 로그인 실패를 허용한다.
스로틀을 적용하는 가장 흔한 이유는 요청 처리에 많은 비용이 들거나 시스템 가용량이 포화되어 서비스 품질이 저하되는 상황을 막기 위해서 이다. 사용자 요청을 처리하기 위해서 시스템을 스케일아웃 하는 등의 오토 스케일링 기법을 사용할 수 있겠지만 이는 서버측 비용 문제와도 직결되기 작업이라 결정이 쉽지 않다.
스로틀과 디바운스의 차이는?
개념적으로 이 둘은 비슷해 보인다. 특히 둘 다 단위 시간 동안 유입되는 호출의 수를 줄인다는 특징을 가지고 있다.
하지만 아래 그림과 같이 각 기능이 적용되는 타이밍에는 다소 차이가 있다.
- 스로틀은 자동차가 엔진으로 흘러들어가는 연료 양의 최대 비율을 제한하는 것과 비슷하다.
위 그림에 내용이 잘 나타나 있다. 입력 함수가 얼마나 호출되었든 스로틀은 단위 시간당 고정된 개수의 호출만 허용한다.
그림에서는 요청을 4초에 1번만 허용하는 스로틀이 걸려있다.
- 디바운스는 일련의 요청 집합인 요청 클러스터를 중점으로 보며 클러스터의 시작이나 끝 지점에서 함수가 단 한 번만 호출되도록 한다.
DebounceFirst 구현은 위 그림에 나와있다. 입력 함수에 대한 요청은 두 개의 클러스터로 구분되며 디바운스는 각 클러스터의 시작 지점에서 한 번의 호출만 처리하도록 되어 있다. 위 그림에서 디바운스의 시간 간격은 1초를 넘어서는 값일 것이다.
구현
스로틀의 빈도 제한을 구현하는 가장 일반적인 알고리즘은 토큰 버킷을 사용하는 알고리즘이며, 이건 최대 토큰 수를 저장할 수 있는 버킷과 같은 특성을 활용한다. 함수가 호출되면 버킷에서 토큰을 가져오며 일정한 비율로 다시 채워진다.
버킷에 충분한 토큰이 없을 때 스로틀이 요청을 다루는 방법은 요구사항에 따라 달라질 수 있지만 일반적으로 다음과 같은 전략을 사용한다.
- 에러 반환
납득이 어렵거나 잠재적으로 악용될 수 있는 사용자 요청을 제한한다. 이 전략을 채택하면 '너무 많은 요청'을 나타내는 429 응답 코드를 반환할 수 있다.
- 마지막으로 성공한 함수 호출의 재현
서비스나 비용이 많이 소요되는 함수가 반환하는 결과가 똑같은 경우에 유용하다. 프론트엔드에서 종종 사용하는 방식이다.
- 토큰이 충분해 졌을 때 큐에 넣기
모든 요청을 반드시 처리해야 할 때 유용하지만 복잡하며 특히 메모리가 부족해지지 않도록 주의해야 한다.
에러 반환 전략을 사용해서 스로틀을 구현한 코드이다.
// Throttle
// 에러 반환 전략을 사용하는 기본적인 토큰 버킷 알고리즘을 사용했다.
func Throttle(effector Effector, max uint, refill uint, d time.Duration) Effector {
var tokens = max
var once sync.Once
return func(ctx context.Context) (string, error) {
if ctx.Err() != nil {
return "", ctx.Err()
}
// 일정 시간마다 토큰을 리필하는 로직은 스로틀 별로 한 번만 실행한다.
once.Do(func() {
ticker := time.NewTicker(d)
go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C: // 주기 d 마다 토큰을 리필한다.
t := tokens + refill
if t > max {
t = max
}
tokens = t
}
}
}()
})
if tokens <= 0 {
return "", fmt.Errorf("too many calls")
}
tokens--
return effector(ctx)
}
}
Throttle 구현은 빈도를 제한하는 로직을 가진 클로저 함수로 이펙터 함수, 서킷 함수를 감쌌던 다른 구현 코드들과 비슷하다.
버킷은 초기에 max 개 만큼 할당된다. 클로저가 호출될 때마다 남은 토큰이 있는지 확인하고, 남아있다면 하나를 줄이고 이펙터 호출, 없다면 에러를 반환한다. 토큰의 리필은 주기 d 마다 최대치로 채워진다.
타임아웃(Timeout)
개요
응답이 오지 않을 것이라는 것이 명확해졌을 때 프로세스가 응답을 기다리는 것을 멈추는 패턴이다.
네트워크는 신뢰할 수 없음을 가정해야 한다. 스위치는 고장날 수 있고 라우터와 방화벽은 잘못 설정될 수 있으며 이 때문에 패킷은 갈 곳을 잃고 버려질 수 있다. 네트워크에 수많은 장애가 생겨도 시의적절하게 응답을 보장할 만큼 네트워크가 똑똑하진 않다.
타임아웃은 이런 딜레마에 대한 해결책이고 패턴으로 넣어도 될지 고민할 만큼 단순하다. 서비스를 요청하고 예상된 시간보다 오래 응답이 오지 않을 때 기다리는 것을 멈추면 된다.
단순하다 != 별 것 아니다
타임아웃은 아주 많이 사용되는 패턴이고 그만큼 유용하다는 뜻이다. 타임아웃을 적절히 사용하면 장애를 격리시키고 연쇄적인 장애를 방지할 수 있다.
구현
// SlowFunction
// 일정 시간 내에 동작을 마칠 수도 있고 아닐 수 도 있는 가상의 함수 시그니처
type SlowFunction func(string) (string, error)
type WithContext func(context.Context, string) (string, error)
// Timeout
// SlowFunction 을 직접 호출하는 대신 SlowFunction 을 클로저로 감싸고 WithContext 함수를 반환한다.
func Timeout(function SlowFunction) WithContext {
return func(ctx context.Context, arg string) (string, error) {
chres := make(chan string)
cherr := make(chan error)
// 오래 걸리는 함수의 실행을 고루틴에서 실행시키고, 고채널을 통해서 결과를 받아온다.
go func() {
res, err := function(arg)
chres <- res
cherr <- err
}()
select {
case res := <-chres: // 채널에 응답이 적재되면 값을 반환한다.
return res, <-cherr
case <-ctx.Done():
return "", ctx.Err()
}
}
}
SlowFunction
은 Timeout
이 만든 함수 안에서 고루틴을 통해 실행된다.
func main() {
ctx := context.Background()
ctxt, cancel := context.WithTimeout(ctx, time.Second*1)
defer cancel()
timeout := Core.Timeout(Slow)
res, err := timeout(ctxt, "hello, world")
fmt.Println(res, err)
}
func Slow(str string) (string, error) {
time.Sleep(2 * time.Second)
return str, nil
}
Timeout
은 위 코드처럼 사용할 수 있다.
Slow
함수가 2초가 걸리는데 context의 timeout이 1초가 걸려있기 때문에 context deadline exceeded
라는 에러가 발생하게 된다.
'DevOps > CN' 카테고리의 다른 글
[클라우드 네이티브 패턴] 1. Context 패키지 (5) | 2024.01.10 |
---|