Golang 안전성 패턴
*클라우드 네이티브 go 교재를 바탕으로 한다.
참고 깃허브 : https://github.com/cloud-native-go/examples
Cloud Native Go (O'Reilly Media)
Companion code repository to Cloud Native Go by Matthew Titmus (O'Reilly Media) - Cloud Native Go (O'Reilly Media)
github.com
백엔드 단에서 예기치 않은 오류 발생 시, 어떻게 이를 처리하고 클라이언트에게 전달해 줄 것인지에 대한 처리 기술을 설명한다.
1. Circuit Breaker
예기치 않은 오류가 지속적으로 발생하는 상황에서 오류를 멈추고 합리적인 오류응답을 제공하여 연쇄적인 오류 발생을 방지한다. 이 오류를 멈춰주는 것이 서킷 브레이커(Circuit Breaker)다.
type Circuit func(context.Context) (string, error)
func Breaker(circuit Circuit, failureThreshold uint) Circuit {
var consecutiveFailures int = 0
var lastAttempt = time.Now()
var m sync.RWMutex
return func(ctx context.Context) (string, error) {
m.RLock()
d := consecutiveFailures - int(failureThreshold)
if d >= 0 {
shouldRetryAt := lastAttempt.Add(time.Millisecond * 2 << d)
if !time.Now().After(shouldRetryAt) {
m.RUnlock()
return "", errors.New("service unreachable")
}
}
m.RUnlock()
response, err := circuit(ctx)
m.Lock()
defer m.Unlock()
lastAttempt = time.Now()
if err != nil {
consecutiveFailures++
return response, err
}
consecutiveFailures = 0
return response, nil
}
}
여기서 Breaker 함수의 특징은 클로저(Closure) 함수라는 것이다. 원래 함수라면, 함수 호출이 끝난 후 변수 값은 초기화가 된다. 하지만 클로저는 함수 바깥에 있는 변수를 읽거나 쓸 수 있고, 값을 보존할 수 있어 Breaker가 반환하는 익명함수를 여러 번 호출하여 이전 consecutiveFailures 값을 로드하고 업데이트할 수 있다.
Breaker 내부를 보면 circuit 함수 호출시 실패한 경우 consecutiveFailures를 더하여 실패 횟수를 누적한다. 누적된 실패 횟수가 failureThreshold 이상인 경우, circuit 함수를 마지막으로 요청한 시점부터 일정 시간이 지나야 재호출 할 수 있다.
결과
func WaitAndContinue(context.Context) (string, error) {
rand.Seed(time.Now().UnixNano())
if rand.Int() % 2 == 0 {
return "success", nil
}
return "Failed", fmt.Errorf("forced failure")
}
Circuit 타입에 맞추어 무작위로 fail 를 반환해줄 함수를 위와 같이 작성하고 main에서 수행해 본다.
func main() {
fmt.Println("=========== circuite breaker ===========")
count = 0
breaker := Breaker(WaitAndContinue, 3) // load closure function
for i := 1; i <= 10; i++ {
result, err := breaker(context.Background())
fmt.Printf("attempt %d: result=%s, err=%v \n", i, result, err)
}
}
Breaker를 호출하여 클로저 함수를 로드하는데, 이때 클로저 함수가 아닌 경우라면 consecutiveFailures 를 누적하지 못한다. 반복문 안에서 로드하는 경우에도 마찬가지다.
인자로 넘겨준 failureThreshold 값인 3번 이상 실패한 경우 "service unreachable" 오류가 발생한다. 서킷 브레이커를 사용함으로써 오류가 나는 반복적인 요청을 하지 않고 적절한 에러를 반환해 줄 수 있다.
안전성 패턴에서 중요한 것은 시간의 흐름에 따라 재시도 비율을 감소시키는 일종의 백오프(backoff) 로직을 가지는 것이다. 이미 제대로 동작하고 있지 않은 서비스에 대한 재시도 요청을 많이 하는 것은 그 자체로도 또 다른 문제를 이르 킬 수 있다.
위의 서킷 브레이커 코드의 경우에도 백오프 로직을 포함하고 있다.
shouldRetryAt := lastAttempt.Add(time.Millisecond * 2 << d)
재호출이 가능한 시점을 대략 두 배씩 늘리는 지수적인 백오프(Exponential Backoff) 방식을 사용한다.
2. Debounce
디바운스(Debounce)는 함수 호출 빈도를 제한하여 여러 번의 호출이 지속적으로 발생했을 때 가장 처음 혹은 가장 마지막 호출만 작동하도록 한다. 아래의 경우가 디바운스를 이용한 예시다.
- 여러번의 클릭에서 첫 번째 클릭이 무시되고 이후의 클릭부터 인식되는 경우
- 검색창에서 자동완성 팝업이 즉시 노출되지 않고 입력이 멈춘 뒤 잠시 기다려야 하는 경우
Function First - 초기 한번 호출 후 이후 요청을 무시
type Circuit func(context.Context) (string, error)
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
}
}
현재시간이 threshold 전이라면 result 변수에 캐시 된 결과를 거저 오고, threshold 가 지난 후라면 circuit 함수를 호출하여 새 값을 가져온다. defer은 함수가 종료되기 직전 마지막에 수행되는데, threshold 가 지나기 전에 연속적으로 호출하면 threshold가 계속 업데이트되어 캐시 결과를 가져올 것이라는 것을 추측할 수 있다.
마찬가지로 DebounceFirst는 클로저 함수이므로 익명함수 바깥에 저장된 변수에 접근하여 읽고 쓸 수 있다.
결과
func EmulateTransientErrorAfter(ctx context.Context) (string, error) {
fmt.Println("call function")
count++
if count >= 4 {
return "intentional fail", errors.New("error")
} else {
return "success", nil
}
}
Circuit 타입에 맞추어 4번 이상 호출하는 경우 에러를 반환해 줄 함수를 위와 같이 작성하고 main에서 수행해 본다.
func main() {
ffmt.Println("=========== debounce first ===========")
debounceFirst := DebounceFirst(EmulateTransientErrorAfter, time.Millisecond*2)
fmt.Println("[ Continuous Calls ]")
for i := 1; i <= 10; i++ {
result, err := debounceFirst(context.Background())
fmt.Printf("attempt %d: result=%s, err=%v \n", i, result, err)
}
fmt.Println("[ Intermittent Calls ]")
for i := 1; i <= 5; i++ {
result, err := debounceFirst(context.Background())
fmt.Printf("attempt %d: result=%s, err=%v \n", i, result, err)
time.Sleep(time.Millisecond * 2)
}
}
연속적으로 호출하는 경우와 시간을 두고 호출하는 경우 두 가지에 경우를 비교해 보자.
추측했던 결과대로 threshold 값은 계속 업데이트되고, 재호출하는 간격이 threshold 이전이므로 Circuit 함수 호출은 첫 번째 요청에서만 이루어진다.
시간 간격을 두고 호출하는 경우 매번 함수를 호출하며, 4번째 이상 호출부터 에러를 반환해 주는 것을 볼 수 있다.
Function Last - 일련의 요청에 대해 바로 함수를 호출하지 않고 마지막 요청까지 기다림
func DebounceLast(circuit Circuit, d time.Duration) Circuit {
var threshold time.Time = 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)
fmt.Printf("%d'th function call \n", i)
fmt.Println("threshold:", threshold)
once.Do(func() {
ticker = time.NewTicker(time.Millisecond)
go func() {
defer func() {
m.Lock()
ticker.Stop()
once = sync.Once{}
fmt.Println("close go routine")
m.Lock()
}()
for {
select {
case <-ticker.C:
fmt.Println("Ticker out at:", t)
m.Lock()
if time.Now().After(threshold) {
result, err = circuit(ctx)
m.Unlock()
return
}
m.Unlock()
case <-ctx.Done():
m.Lock()
result, err = "", ctx.Err()
m.Unlock()
return
}
}
}()
})
return result, err
}
}
Ticker는 d(time.Duration) 간격으로 채널로 시간을 보내 주기적으로 무언가를 실행할 수 있다. 여기서는 마지막으로 실행된 후 (호출 시마다 threshold 업데이트) threshold 만큼 지난 경우 circuit 함수를 실행한다. 이 함수는 고 루틴과 Ticker가 함께 있어 좀 더 복잡하다. 먼저 결과부터 보고 자세히 알아보자.
결과
func EmulateTransientErrorAfter(ctx context.Context) (string, error) {
fmt.Println("call function")
count++
if count >= 4 {
return "intentional fail", errors.New("error")
} else {
return "success", nil
}
}
Circuit 타입에 맞추어 위와 같이 작성했다. 함수가 실행 된 경우 call function 가 출력된다
func main() {
fmt.Println("=========== debounce last ===========")
debounceLast := DebounceLast(EmulateTransientErrorAfter, time.Microsecond*2)
for i := 1; i <= 10; i++ {
result, err := debounceLast(context.Background(), i)
fmt.Printf("attempt %d: result=%s, err=%v \n\n", i, result, err)
}
fmt.Println("[ Delay ]")
time.Sleep(time.Millisecond * 1600)
}
자세한 결과를 위해 위의 함수에서 출력들을 몇 가지 추가했다.
먼저 10번의 반복문을 돌 때 10 개 모두 익명 함수 안에 들어간다. 총 10번의 function call 출력으로 알 수 있다.
그러나 루틴 안으로 들어와 고 루틴이 실행되는 것은("1'th go routine") 첫 번째뿐이다. 이는 고 루틴이 once.Do(func() {}으로 묶여 있기 때문이다. once.Do는 한 번만 실행하겠다는 의미로, 코드상 once.Do 안에 고 루틴과 Ticker 생성하는 부분이 있기 때문에 고루틴과 Ticker 모두 첫번째 요청에 대해서만 수행 된다.
1. 첫 번째 요청, Circuit 함수 반환 값 익명함수 실행하여 1'th function call, threshold 출력
2. 고루틴 수행 후 함수 나옴
3. 두 번째 요청, Circuit 함수 반환 값 익명함수 실행하여 1'th function call, threshold 출력
4. 세 번째 요청, Circuit 함수 반환 값 익명함수 실행하여 1'th function call, threshold 출력
...
5. 모든 요청이 끝난 뒤 Ticker가 실행될 주기에 도달한 경우 출력
위의 main 코드의 경우 모든 요청이 끝난 뒤 함수가 종료된다. Ticker는 마지막 요청 이후 threshold 시간이 지나야 실행이 되기 때문에 time.Sleep() 을 걸어 주기에 도달할때까지 기다려준다. 이후 "Ticker out at" 이 출력되고 함수가 호출 된 것("call fucntion")을 볼 수 있다.
Ticker 주기를 줄이면 위와 같이 deplay 전 8 번째 호출에서 함수를 실행한 것을 볼 수 있다.
여기서 한가지 의문이 들 수 있다. Ticker 는 특정 주기별로 채널로 시간을 보내 주기적으로 무언가를 실행할 수 있다고 했는데 왜 한번밖에 출력이 되지 않았을까? 이는 if 절 아래에 있는 return 때문이다.
if time.Now().After(threshold) {
result, err = circuit(ctx)
m.Unlock()
return
}
주기에 도달 후 threshold 가 넘은 경우 return을 하여 고루틴으로 실행한 익명함수를 종료시킨다.
return을 없앤 경우 Ticker에 설정된 주기대로 계속 호출하는 것을 볼 수 있다. 루틴은 첫번째 요청에서만 생성되었기 때문에, 첫번째 요청에 대해 circuit 함수를 계속 호출하고, 4번째 호출부터 실패를 반환한다.
정리하자면, 디바운스는 함수 호출 빈도를 제한하는 것으로, 호출이 연속적으로 발생할때 불필요하게 함수를 호출하는 것을 막는다. 디바운스 퍼스트는 최초 요청의 응답을 캐시하여 일정 시간 이내의 같은 요청에 대해서 로직을 실행하지 않고 캐시된 응답을 보낸다. 디바운스 라스트는 같은 요청이 연속적으로 올때 마지막 요청으로부터 일정 시간이 지난 후 응답을 해준다. 백엔드 서비스의 경우 입력값에 따라 즉시 응답하는 경우가 드물기 때문에 디바운드 퍼스트를 사용하는 경우는 많지 않다.
3. Retry
재시도(Retry)는 말그대로 실패한 요청을 다시 요청하는 것이다. 일정시간이 지나면 자연스럽게 해소되는 일시적인 오류의 경우 재시도 전략 구현으로 서비스의 안정성을 높일 수 있다.
type Effector func(context.Context) (string, error)
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 faild; retrying in %v", r+1, delay)
select {
case <-time.After(delay):
case <-ctx.Done():
return "", ctx.Err()
}
}
}
}
함수 호출시 에러(err)가 반환 되는 경우 delay 후 재호출 시도한다. 이때 클로저 호출시 넣어준 retries 보다 더 많이 시도한 경우 에러를 그대로 반환한다.
결과
func EmulateTransientErrorBefore(ctx context.Context) (string, error) {
fmt.Println("call function")
count++
if count <= 3 {
return "intentional fail", errors.New("error")
} else {
return "success", nil
}
}
Effector 타입에 맞는 메인 로직 함수를 만들어준다. 이때 재시도를 해야하므로 초기 몇번은 에러를 반환하도록 한다.
func main() {
fmt.Println("=========== retry ===========")
retry := Retry(EmulateTransientErrorBefore, 2, time.Microsecond*2)
for i := 1; i <= 10; i++ {
result, err := retry(context.Background())
fmt.Printf("attempt %d: result=%s, err=%v \n\n", i, result, err)
}
}
재시도 횟수 제한을 2로 넣어주어 2번의 재시도 후 에러를 그대로 반환해주는 것을 볼 수 있다. circuit 함수 구현에 따라 4번째 호출부터는 에러를 반환하지 않는다.
예제 코드에는 포함되지 않았지만, 보통 재시도 로직은 백오프 알고리즘을 포함한다.
4. Throttle
스로틀(Throttle)은 함수 호출에 대한 빈도를 단위시간 동안 최대 호출 횟수로 제한한다. 사용 예시는 다음과 같다.
- 사용자는 초당 7회의 서비스만 요청 가능함
- 한 계정당 24 시간 동안 4회 로그인 실패를 허용함
type Effector func(context.Context) (string, error)
func Throttle(e 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:
fmt.Println("refill")
t := tokens + refill
if t > max {
t = max
}
tokens = t
}
}
}()
})
if tokens <= 0 {
return "", fmt.Errorf("too many calls")
}
tokens--
return e(ctx)
}
}
빈도제한을 구현하는 가장 일반적인 알고리즘은 토큰 버킷(Token Bucket) 이다. 위의 예제는 아주 기본적인 토큰 버킷 알고리즘 구현체로, 최대 토큰에서 요청시 마다 토큰을 차감한다. 최대 토큰 개수만큼 요청할 수 있으며, Ticker에 설정한 주기별로 토큰을 리필해준다.
코드 구현은 디바운스 라스트와 비슷하지만, 스로틀의 경우에는 여러 요청 발생시 허용하는 요청 개수를 제한하고, 디바운스 라스트는(혹은 퍼스트) 여러 요청 발생시 처음과 끝만 허용한다.
결과
func WaitAndContinue(context.Context) (string, error) {
rand.Seed(time.Now().UnixNano())
if rand.Int() % 2 == 0 {
return "success", nil
}
return "Failed", fmt.Errorf("forced failure")
}
Effector 타입에 맞추어 무작위로 결과를 반환해주는 함수를 선언한다.
func main() {
fmt.Println("=========== throttle ===========")
throttle := Throttle(WaitAndContinue, 3, 2, time.Microsecond*2)
for i := 1; i <= 10; i++ {
result, err := throttle(context.Background())
fmt.Printf("attempt %d: result=%s, err=%v \n\n", i, result, err)
}
}
출력 순서는 무시해도 된다. 리필이 이루어진 후 정상 출력되는 것을 볼 수 있다.
스로틀의 토큰 버킷 구현 시 버킷에 충분한 토큰이 없을때의 처리는 개발자의 요구사항에 따라 달라질 수 있다.
- 에러 반환
- 마지막으로 성공한 함수 호출 재현
- 토큰 리필
위의 예제는 에러반환 전략을 사용한다.
4. TimeOut
타임아웃(TimeOut) 은 응답이 오지 않을 것이 명확해졌을 때 응답을 더이상 기다리지 않도록 멈춘다.
타임 아웃은 구현하는 간단하게 구현할 수 있다.
ctxt, cancel := context.WithTimeout(context.Background(), time.Second*2)
defer cancel()
타임 아웃 처리를 추가할 함수 인자로 context.Context를 받게하고, context에 타임아웃을 설정하면 처리할 수 있다. 하지만 서드파티 라이브러리 사용시에는 함수 인자가 context.Context를 받지 않을 수 있기 때문에 타임아웃을 가진 context를 받도록 래핑하여 활용한다.
이때 타임아웃 시간까지 기다렸다가 처리하는 대신 고루틴으로 처리하여 불필요한 대기시간을 줄인다.
type WithContext func(context.Context, string) (string, error)
func Timeout(f SlowFunction) WithContext {
return func(ctx context.Context, arg string) (string, error) {
chres := make(chan string)
cherr := make(chan error)
go func() {
res, err := f(arg)
chres <- res
cherr <- err
}()
select {
case res := <-chres:
return res, <-cherr
case <-ctx.Done():
return "", ctx.Err()
}
}
}
실행하고자 하는 느린함수 f SlowFunction 를 고루틴으로 실행하고 각 응답을 채널로 보낸다. 이때 변수가 아닌 채널로 응답 값을 받는 이유는, context 가 먼저 타임아웃 신호를 보낸 경우 기다리지 않고 에러를 반환하기 위함이다.
결과
func Slow(s string) (string, error) {
time.Sleep(time.Second)
return "Got input: " + s, nil
}
1초 만큼의 딜레이를 부여했다.
func main() {
fmt.Println("=========== timeout ===========")
ctxt, cancel := context.WithTimeout(context.Background(), time.Second/2)
defer cancel()
timeout := Timeout(Slow)
result, err := timeout(ctxt, "hello world")
fmt.Println(result, err)
}
context의 타임아웃이 0.5초 인 경우 아래와 타임 아웃에러가 발생한다. 이는 응답 채널보다 context.Done() 채널로부터 값이 더빨리 도착했음을 알 수있다.
시간을 1초 이상으로 늘린 경우에는 정상 출력이 가능하다.