golang 비동기. 클로저 선언 시 메모리 할당 동작 방식과 circuit breaker 패턴 구현
이번에는 두 가지 주제를 같이 다뤄보려고 합니다. 첫 번째는 클로저 구현 시 메모리 할당 동작 방식이고 두 번째는 circuit breaker 패턴 구현입니다. 클로저가 golang 내부에서 어떻게 외부 컨텍스트의 메모리를 참조하는지 알아보고 그 뒤에 circuit breaker를 어떻게 구현하는지 살펴봅니다. circuit breaker 구현에서 왜 클로저 동작 방식이 등장하는지 궁금할 수 있습니다. 이 이유는 golang에서 클로저를 이용하여 circuit breaker를 구현할 수 있기 때문입니다. 굉장히 신기한 방식인데 이번 아티클에서는 클로저를 이용한 구현 방식을 클로저 원리와 같이 살펴보도록 하겠습니다.
golang의 클로저
클로저는 다음 두 가지를 만족하는 함수를 의미합니다.
- 익명 함수
- 익명 함수 밖의 변수를 Read/Write할 것(흔히 클로저에서 캡처라고 부릅니다)
정의는 간단합니다. 아래 예시를 살펴보겠습니다.
package main
import "fmt"
func adder() func(int) int {
sum := 0
return func(x int) int {
sum += x
return sum
}
}
func main() {
pos := adder()
fmt.Println(pos(1)) // 1
fmt.Println(pos(2)) // 3
fmt.Println(pos(3)) // 6
neg := adder()
fmt.Println(neg(-1)) // -1
fmt.Println(neg(-2)) // -3
fmt.Println(neg(-3)) // -6
}
여기서 adder라는 함수가 있고 이 함수는 익명 함수를 리턴합니다. 이 익명 함수는 함수 밖의 sum이라는 변수를 캡처합니다. 따라서 리턴되는 익명 함수를 클로저라 부를 수 있습니다. 특이할 것이 없는 코드지만 여기서 궁금한 점이 생겼습니다. 이 코드에서 sum은 어디에 할당될까요? 이것 또한 golang/go 내부 소스 코드에서 확인할 수 있습니다.
https://github.com/golang/go/blob/a10e42f219abb9c5b/src/cmd/compile/internal/walk/closure.go#L15
cmd/compile/internal/walk 내부에는 클로저가 컴파일 시 어떻게 변환되는지 보여주고 있습니다.
// directClosureCall rewrites a direct call of a function literal into
// a normal function call with closure variables passed as arguments.
// This avoids allocation of a closure object.
//
// For illustration, the following call:
//
// func(a int) {
// println(byval)
// byref++
// }(42)
//
// becomes:
//
// func(byval int, &byref *int, a int) {
// println(byval)
// (*&byref)++
// }(byval, &byref, 42)
func directClosureCall(n *ir.CallExpr) {
여기에 closure.go 내부 directClosureCall이라는 함수는 클로저 시 이를 일반 함수 호출로 변환함으로써 클로저 객체의 할당을 피하도록 설계되어 있습니다. 이때 예시로 다음 코드의 변환을 설명합니다.
// 변환 전
func(a int) {
println(byval)
byref++
}(42)
// 변환 후
func(byval int, &byref *int, a int) {
println(byval)
(*&byref)++
}(byval, &byref, 42)
해당 코드를 살펴보면 캡처된 변수는 인자와 참조 형태로 새롭게 변환된 시그니처에 인자로 포함되어 있음을 알 수 있습니다. 여기서 캡처 형식이 두 종류임을 알 수 있는데, 하나는 값으로 캡처되는 경우와 다른 하나는 참조로 캡처된 경우입니다. 위의 예시에서 byval과 &byref 두 종류로 구분되는데, 값으로 캡처된 byval은 값으로 캡처되어 println(byval)로 read-only인 경우에 사용되고 &byref는 참조로 캡처되어 (*&byref)++로 write를 포함하는 것을 확인할 수 있습니다. 즉, 값으로 캡처되는 경우는 클로저 내부에서 read-only인 경우, 참조로 캡처되는 경우는 write가 포함된 경우임을 알 수 있습니다.
그럼 이번에는 첫 번째 예시 코드가 어떻게 바뀔지 생각해봅시다. 첫 번째 예시 코드에서 클로저는 다음과 비슷한 형태로 변환됨을 알 수 있습니다.
// 변환 전
return func(x int) int {
sum += x
return sum
}
// 변환 후
return func(&sum *int, x int) int {
*sum += x
return *sum
}
위 호출에서 알 수 있는 것은 sum은 이전 컨텍스트에서 포인터로 가져와야 한다는 것입니다. 따라서 sum은 힙에 할당되어야 합니다. 그러나 값으로만 캡처되는 경우 클로저의 생명 주기가 끝나면 메모리에서 할당 해제되므로 힙에 할당될 필요가 없어 스택에 할당됩니다. 즉, 참조로 캡처되는 경우 힙, 값으로 캡처되는 경우 스택에 할당됩니다.
이렇게 간단하게 golang의 클로저 선언 시 메모리 할당 동작 방식을 알아봤습니다. 이번에는 클로저를 활용하여 circuit breaker 패턴을 구현해봅시다.
circuit breaker 패턴 구현
클로저를 알아봤으니 이를 활용하여 circuit breaker 패턴을 구현해봅시다.
circuit breaker 패턴은 분산 시스템이나 MSA에서 서비스간 의존성을 관리하기 위한 패턴입니다. 외부 서비스 호출이 네트워크나 내부 로직 등의 장애로 인해 연속적으로 발생할 때 외부 서비스를 호출한 기존 서비스에 장애로 인한 리소스가 낭비되지 않고 시스템이 회복할 수 있도록 합니다. circuit breaker 패턴은 이를 서비스 호출을 지속적으로 감시하고 실패가 일정 횟수 이상 발생한 경우 호출을 차단하는 역할을 합니다. 이때 circuit breaker는 세 가지 상태를 가지고 시스템의 안전성을 관리합니다.
- closed: 서비스가 정상적으로 동작하는 경우입니다. 이때는 외부 서비스 호출이 허용됩니다.
- open: 실패가 일정 횟수를 초과한 경우입니다. 이때 circuit breaker의 상태가 closed에서 open으로 변경되고 외부 서비스 요청에 대하여 즉시 오류를 반환합니다.
- half-open: open이 된 상태에서 일정 시간이 지난 경우, 다시 외부 서비스를 호출할 수 있도록 합니다. 만약 이때 다시 에러가 리턴된다면 open상태로 변경합니다. 만약 정상적으로 동작했다면 closed상태로 변경합니다.
먼저 코드를 살펴보겠습니다. 해당 코드는 cloud native go의 4장 안전성 패턴에서 가져와 제너릭을 추가했습니다.
type Circuit[T any] func(context.Context) (T, error)
func Breaker[T any](circuit Circuit[T], failureThreshold uint) Circuit[T] {
var consecutiveFailures int = 0 // 실패 횟수 캡처
var lastAttempt = time.Now() // 마지막 호출 캡처
var m sync.RWMutex
// 서킷 클로저 생성
return func(context context.Context) (t T, err error) {
// 읽기 잠금 부분은 서킷오픈 시 사용
m.RLock()
d := consecutiveFailures - int(failureThreshold)
if d >= 0 { // failure threshold보다 더 실패한 경우(서킷 open)
// 실패 횟수에 따라 지수적 백오프: 2배씩 증가
shouldRetryAt := lastAttempt.Add(time.Second * 2 << d)
// should retry at보다 이른 호출인 경우
if !time.Now().After(shouldRetryAt) {
m.RUnlock()
return t, errors.New("service unreachable")
}
}
m.RUnlock()
// 서킷 half-open or close 시 아래 로직으로
t, err = circuit(context)
// 쓰기 잠금 부분은 서킷 close 시 사용
m.Lock()
defer m.Unlock()
lastAttempt = time.Now() // 실행 시간 기록
if err != nil { // 에러가 반환되면
consecutiveFailures++ // 실패 카운트 증가 후 반환
return t, err
}
consecutiveFailures = 0 // 실패 카운트 초기화
return t, nil
}
}
해당 코드는 크게 세 부분으로 나눠집니다.
- 클로저에 캡처될 변수 선언: 실패 횟수를 저장할 consecutiveFailures, 마지막 실행 시간을 저장할 lastAttempt, 여러 고루틴에서 동시 접근을 방어할 m을 선언합니다.
- circuit 함수 리턴에서 circuit open인 경우: 이때는 여러 고루틴이 동시에 읽기 락을 획득하고 접근할 수 있습니다. 왜냐하면 외부 캡처된 변수에 쓰기를 시도하지 않고 바로 리턴할 수 있기 때문입니다. 실패 횟수와 failureThreshold값을 비교하여 실패 횟수가 더 큰 경우 open으로 간주하고 half-open 상태인지 확인합니다. 만약 아니라면 바로 읽기 락을 해제하고 에러를 리턴합니다.
- circuit half-open, open인 경우: 이때 실행을 쓰기 잠금을 시도합니다. 이 이유는 여러 고루틴 호출이 발생한 상황에서 외부 캡처된 변수에 대하여 쓰기가 발생할 수 있기 때문입니다. 실행 시간을 저장하고 에러인지 판단합니다. 만약 에러가 맞다면 실패 카운트를 기록하고 에러를 리턴하고 정상인 경우 정상 응답을 리턴합니다.
circuit breaker 패턴처럼 golang에서 대부분의 안전성 패턴은 위처럼 클로저를 이용하여 여러 고루틴이 동시에 접근하는 경우를 락으로 방어하여 구현할 수 있습니다. debounce, retry, throttle등의 패턴도 존재하지만 아티클에서 다 다루기보다 나머지 패턴을 살펴볼 수 있는 링크를 남깁니다.
cloud-native-go/ch04 at main · dybooksIT/cloud-native-go
이번에는 클로저와 클로저를 활용한 안전성 패턴 구현으로 circuit breaker 패턴을 알아봤습니다. 간단한 요약을 해보자면 다음과 같습니다.
- 클로저의 변수 캡처는 컴파일 시 함수형을 변경하는 것으로 구현됩니다.
- 클로저에서 변수를 어떻게 다루냐에 따라 값에 의한 캡처가 일어나거나 참조에 의한 캡처가 일어납니다.
- golang의 여러 안전성 패턴은 클로저를 이용하여 활용할 수 있습니다.
참고자료
- cloud native go
- https://hwan-shell.tistory.com/339
- http://golang.site/go/article/11-Go-클로저
Leave a comment