회사 프로젝트 서버에서 메모리 누수로 인해 서버가 계속 죽다 살아나는 현상이 관찰되었다.
70%을 향해 치솟는 memory usage와 10% 이하에서 평온하게 노는 cpu usage
메모리 누수가 어디서 발생하는지 정확하지 않아서 인터넷 검색을 통해 go 프로젝트에서 주의해야할 부분에 예방 코드를 모조리 집어넣었다. 아래 그 예방 코드들과 이유에 관해 정리해보려고 한다. -> 그리고 현재는 프로젝트에서 발생하는 메모리릭 문제를 완전히 해결했다! (마무리 부분을 참고)
아래 글을 참고하여 작성되었다.
Avoiding Memory Leak in Golang API
Finding Memory Leak in Go Service
50 Shades of Go
0) Lock 확인하기
여러 프로젝트에서 발생하는 메모리 누수의 근본적인 원인이었다!
mongo에서는 mutex 기능을 제공하지 않지만, go에서는 sync.Mutex를 통한 동시성 제어 기능을 제공한다.
이때, 필요가 없는 부분에 mutex를 사용하진 않았는지, 프로세스 상 dead lock, spin lock, write lock starvation 등의 문제가 발생할 수 있는지 검토를 한 이후에 써야한다.
Lock(): only one go routine read/write at a time by acquiring the lock.
RLock(): multiple go routine can read(not write) at a time by acquiring the lock.
If a goroutine holds a RWMutex for reading and another goroutine might call Lock, no goroutine should expect to be able to acquire a read lock until the initial read lock is released. In particular, this prohibits recursive read locking. This is to ensure that the lock eventually becomes available; a blocked Lock call excludes new readers from acquiring the lock.
Go의 sync.RWMutex 설명
Otherwise a series of readers that each acquired the read lock before the previous released it could starve out writes indefinitely.
This means that it is always unsafe to call RLock on a RWMutex that the same goroutine already has read locked. (Which by the way is also true of Lock on regular mutexes as well, as Go's mutexes do not support recursive locking.)
The reason it is unsafe is that if the goroutine ever blocks getting the second read lock (due to a blocked writer) it will never release the first read lock. This will cause every future lock call on the mutex to block forever, deadlocking part or all of the program. Go will only detect a deadlock if all goroutines are blocked.
StackOverFlow 설명
실제로 프로젝트에서 데이터를 프로젝트 내부 변수로 캐싱하여 사용하고 있었는데, lock을 걸어 업데이트 함수가 모두 완료된 후 데이터를 읽어 오는 의도로 코드가 짜여져 있었다. 여기서 문제는 데이터를 업데이트 하는 로직과 읽어오는 로직 모두 go routine으로 작동하고 있었다는 것이다. 데이터를 읽어오는 go routine들에 의해 read lock이 계속 걸리면서 주기적으로 데이터를 업데이트해줘야하는 write 순서가 지연되었고, 이 상황이 반복되면서 그과 관련 데이터들이 차지하는 메모리가 계속 증가해 누수를 발생시키게 됐다.
해당 데이터를 프로젝트 내부 변수로 관리하지 않고 redis로 관리하도록 수정하면서, 자연스럽게 RWLock을 사용하지 않게 로직이 변경 되었다.
그 외의 대안으로는 read, write lock 이 공평하게 순번을 갖도록 Mutex를 custom 하여 사용하거나,
mutex와 다른 전략을 사용하는 Semaphore, 혹은 atomic 연산을 이용하면 된다.
1) http.Client 커스터마이징
참고 문서: Go http package docs
Go는 자유도가 굉장히 높은 언어이다. 다른 언어처럼 디폴트 설정을 신뢰하면서 개발하면 큰코다치기 십상이다.
아래는 Go의 http.Client의 디폴트 설정이다.
Timeout 설명에 집중해보자.
"A Timeout of zero means no timeout."
time.Duration은 int64 값으로 아무것도 설정해주지 않으면 0으로 초기화된다.
timeout이 없기 때문에 디폴트 설정을 그대로 사용하면 요청을 보내고 응답을 무한정으로 기다리게 되는 불상사가 발생한다!!
type Client struct {
// Transport specifies the mechanism by which individual
// HTTP requests are made.
// If nil, DefaultTransport is used.
Transport RoundTripper
// CheckRedirect specifies the policy for handling redirects.
// If CheckRedirect is not nil, the client calls it before
// following an HTTP redirect. The arguments req and via are
// the upcoming request and the requests made already, oldest
// first. If CheckRedirect returns an error, the Client's Get
// method returns both the previous Response (with its Body
// closed) and CheckRedirect's error (wrapped in a url.Error)
// instead of issuing the Request req.
// As a special case, if CheckRedirect returns ErrUseLastResponse,
// then the most recent response is returned with its body
// unclosed, along with a nil error.
//
// If CheckRedirect is nil, the Client uses its default policy,
// which is to stop after 10 consecutive requests.
CheckRedirect func(req *Request, via []*Request) error
// Jar specifies the cookie jar.
//
// The Jar is used to insert relevant cookies into every
// outbound Request and is updated with the cookie values
// of every inbound Response. The Jar is consulted for every
// redirect that the Client follows.
//
// If Jar is nil, cookies are only sent if they are explicitly
// set on the Request.
Jar CookieJar
// Timeout specifies a time limit for requests made by this
// Client. The timeout includes connection time, any
// redirects, and reading the response body. The timer remains
// running after Get, Head, Post, or Do return and will
// interrupt reading of the Response.Body.
//
// A Timeout of zero means no timeout.
//
// The Client cancels requests to the underlying Transport
// as if the Request's Context ended.
//
// For compatibility, the Client will also use the deprecated
// CancelRequest method on Transport if found. New
// RoundTripper implementations should use the Request's Context
// for cancellation instead of implementing CancelRequest.
Timeout time.Duration
}
또한 http.Post, http.Get은 모드 DefaultClient 즉, 디폴트 설정의 client 객체를 사용한다.
// DefaultClient is the default Client and is used by Get, Head, and Post.
var DefaultClient = &Client{}
func Get(url string) (resp *Response, err error) {
return DefaultClient.Get(url)
}
func Post(url, contentType string, body io.Reader) (resp *Response, err error) {
return DefaultClient.Post(url, contentType, body)
}
회사 프로젝트에서는 http 패키지의 Get, Post를 사용하고 있었고, time out 설정, idle 시간 설정 등을 아무것도 하지 않고 http 연결을 하고 있었음을 발견했다!
이에 나는 몇가지 설정을 추가한 custom client를 사용하도록 수정했다. 그리고 client.Do를 사용해 Post, Get 요청을 수행하도록 했다.
keepAliveTimeout:= 600 * time.Second
defaultTransport := &http.Transport{
Dial: (&net.Dialer{
KeepAlive: keepAliveTimeout,
}).Dial,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
}
timeout:= 2 * time.Second
client:= &http.Client{
Transport: defaultTransport,
Timeout: timeout,
}
커넥션 재사용을 위해 전송(Transport) 정보를 설정하고, 재사용 가능한 커넥션에 대해 최대 유휴(idle) 시간을 조절했다.
idle 시간을 조절하지 않고 유휴 상태인 커넥션을 닫는 CloseIdleConnections() 함수를 defer 키워드로 추가해줄 수도 있다.
client := http.Client{
Timeout: 10 * time.Second,
}
defer client.CloseIdleConnections()
이 설정들을 통해 다른 서비스를 호출하는데 소요되는 최대 시간을 줄일 수 있었다.
2) Response Body 닫기
기본적으로 Go의 http client는 커넥션을 재사용한다. (참고)
By default, Transport caches connections for future re-use. This may leave many open connections when accessing many hosts.
그러나 조건이 있는데, response body가 close 되어야만 커넥션이 재사용된다. (참고)
The default HTTP client's Transport may not reuse HTTP/1.x "keep-alive" TCP connections if the Body is not read to completion and closed
Go http 패키지에도 반드시 response body를 닫으라고 설명이 나와있는 부분이다.
The caller must close the response body when finished with it:
resp, err := http.Get("http://example.com/")
if err != nil {
// handle error
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
// ...
당연하지만 단위 시간 당 요청을 많이 보내야하는 경우에 커넥션 재사용은 아주 중요하다. 서버/클라이언트 모두의 부하를 줄여줄 수 있기 때문이다. 더 중요한 건, 짧은 시간 내에 요청을 아주 많이 보내는 경우 커넥션 재사용을 하지 않으면 높은 확률로 connection reset by peer 에러가 발생할 수 있다. 커넥션을 재사용하지 않는다는 건 매번 새로운 커넥션을 연다는 것인데, 서버가 유지할 수 있는 커넥션 개수는 제한적일 수 밖에 없다. 따라서 너무 많은 handshake 요청이 동시에 들어오면 서버 쪽에서 거부하기 시작하면서 해당 에러가 발생한다.
http 패키지를 사용하여 응답을 요청하면 http 응답 변수를 얻는다.
이때, response body를 읽지 않더라도 response body를 닫아야하고, 빈 응답에 대해서도 이 작업을 수행해야한다.
그리고 이 작업을 수행할 때 코드 위치가 중요한데, go 개발자는 다음과 같은 실수를 한다.
package main
import (
"fmt"
"net/http"
"io/ioutil"
)
func main() {
resp, err := http.Get("https://api.ipify.org?format=json")
defer resp.Body.Close()//not ok
if err != nil {
fmt.Println(err)
return
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(body))
}
이 코드는 성공적으로 response를 받았을 때는 작동하지만, http 요청이 실패하면 response 변수가 nil이 될 수 있으며, 이 nil로 인해 런타임 패닉이 발생한다!
따라서 response body는 http response의 오류를 확인 후에 닫아야 한다.
http 요청이 실패하는 대부분의 경우 resp 변수는 nil, err 변수는 non-nil이기 때문에 아래 코드와 같이 작성할 수 있다.
package main
import (
"fmt"
"net/http"
"io/ioutil"
)
func main() {
resp, err := http.Get("https://api.ipify.org?format=json")
if err != nil {
fmt.Println(err)
return
}
defer resp.Body.Close()//ok, most of the time :-)
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(body))
}
그러나 리디렉션(redirection)이 실패하면 두 변수는 모두 non-nil을 가지게 된다! 이는 여전히 response body를 닫지 못해 memory leak이 발생할 수 있음을 의미한다.
response가 non-nil인 경우에 response body를 닫도록 조건문을 추가하면 된다.
package main
import (
"fmt"
"net/http"
"io/ioutil"
)
func main() {
resp, err := http.Get("https://api.ipify.org?format=json")
if resp != nil {
defer resp.Body.Close()
}
if err != nil {
fmt.Println(err)
return
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(body))
}
response.Body.Close()의 원래 구현(original implementation)을 보면, 남은 reponse body 데이터를 모두 읽고 버린다.
이렇게 하면 연결을 유지하고 있는(keepalive) http 연결을 다른 요청에 재사용할 수 있다.
(즉, 세션을 향후 재사용하기 위해 response body를 모두 읽어야 한다! = 닫아야한다)
하지만 이 부분이 안티패턴이라는 의견이 지배적이었다. 예를 들어, GET 메소드로 파일을 요청하고 서버로부터 응답을 받았다다고 가정해보자. 응답의 Content-Length 헤더를 읽었는데, 파일이 기대한 것보다 훨씬 커서 아무런 바이트를 읽지 않은 채 Response body를 닫아버렸다. 그렇지만 우리의 Go는 response body를 소비하기 위해 서버로부터 전체 파일을 다운로드 하게 될 것이다!
그래서 최신 http client의 동작은 다르다. 이제 나머지 응답 데이터를 읽고 삭제하는건 개발자의 책임이다!
그렇게 response body를 비우지 않으면 http 연결이 재사용되지 않고 닫혀버릴 수 있다. 이 문제는 Go 1.15에 문서화되어 있다.
이에 따라 나는 코드 전체를 검토하여 response body를 close하지 않는 부분에 적절하게 코드를 추가해주었다.
회사 프로젝트에서는 모두 ioutil.ReadAll()을 사용하고 있었기 때문에 response가 non-nil인 경우 body.close 함수를 추가했다.
딱 하나의 부분에서 아래와 같이 사용하고 있었는데, 이런 경우가 바로 전체 응답 body를 읽지 않는 코드이다!
json.NewDecoder(resp.Body).Decode(&data)
전체 응답 body를 즉시 읽지 않는 경우는 아래와 같은 코드를 추가하여 응답 body를 읽고 삭제하면 된다.
_, err = io.Copy(ioutil.Discard, resp.Body)
io.Copy 함수는 response body의 모든 바이트를 읽어서 iotuil.Discard에 전부 쓰는 형태로 response body를 모두 소비한다. ioutils.Discard 함수는 쓰는 모든 데이터를 버려 버리는 특별한 io.Writer.
3) mongoDB cursor close
golang에서 mongoDB에 있는 데이터를 읽어올 때 2가지 방법이 존재한다.
- cursor.All()
- cursor.Next()
기본적으로 cursor가 소비할 데이터가 없거나 긴 시간 유휴상태였으면 자동으로 cusor는 close된다고 한다. (참고)
그러나 cursor.Next(), cursor.TryNext()는 tailable cursor로 명시적으로 cursor를 닫아주어야 한다. (참고)
tailable cursor는 클라이언트가 쵝 커서의 결과를 모두 사용한 후에도 열려있으며 계속해서 문서를 검색(retrieve)하기 때문이다.
(unix 명령어인 tail의 -f 옵션에서 유래됐다고 한다)
프로젝트에서 cursor.Next()를 사용하는 경우에는 defer 키워드를 사용해 cursor를 close하도록 추가했다.
if cursor != nil {
defer cursor.Close(context.Background())
}
4) defer 위치 이전
defer 키워드는 함수가 종료되기 직전에 실행되는 코드 블록을 지정할 때 사용된다. 이를 사용하면 함수가 종료되기 전에 정리 작업을 수행할 수 있어 메모리 누수를 방지하는데 도움이 된다.
그러나 defer문이 for 루프 내에서 사용되면 루프가 반복될 때마다 defer가 호출되므로 리소스가 반복으로 할당되고 해제되는 문제가 발생할 수 있다!
따라서 프로젝트 안에서 for문 안에 있는 defer문을 모두 for문 밖으로 이전시켜주었다.
이때 주의해야할 점이 있다. 에러가 발생하고, "미리" 선언해두었던 defer 구문이 마지막으로 실행되고 종료되는 것이다. 만약에 defer 구문을 에러가 발생하는 코드 뒤에 선언하면 호출되지 않고 프로그램이 종료된다.
따라서 에러가 발생하는 코드 전에 defer을 선언해야한다!
또한, 여러개의 defer문이 선언되었을 경우, 함수의 끝에서부터 역순으로 실행된다. 자료구조의 스택(LIFO)과 동일한데 제일 나중에 지연호출한 함수가 제일 먼저 실행되는 것이다!
5) context를 활용한 goroutine 취소(cancellation)
회사 프로젝트와는 관련이 없으나 정리를 위해 번역글을 옮겨본다
// 고치기전 코드
type sampleChannel struct{
Data *Sample
Err error
}
func (u *usecase) GetSample(id int64, someparam string, anotherParam string) ([]*Sample, error) {
chanSample := make(chan sampleChannel, 3)
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
defer wg.Done()
chanSample <- u.getDataFromGoogle(id, anotherParam) // 함수의 예시
}()
wg.Add(1)
go func() {
defer wg.Done()
chanSample <- u.getDataFromFacebook(id, anotherParam)
}()
wg.Add(1)
go func() {
defer wg.Done()
chanSample <- u.getDataFromTwitter(id,anotherParam)
}()
wg.Wait()
close(chanSample)
result := make([]*Sample, 0)
for sampleItem := range chanSample {
if sampleItem.Error != nil {
logrus.Error(sampleItem.Err)
}
if sampleItem.Data == nil {
continue
}
result = append(result, sampleItem.Data)
}
return result
}
위 코드는 WaitGroup이 모든 프로세스가 끝날 때까지 대기하고 있다. 모든 응답을 처리하고 반환하기 위해서는 모든 API의 호출이 끝나야한다는 것이다! 이렇게 되면, 하나의 서비스가 죽었을 때 문제가 발생한다. 죽은 서비스가 복구될 때까지 계속 기다릴 것이기 때문이다.
처음에는 timeout을 추가하여 해결하려고 시도했다고 한다. 유저는 오랫동안 기다리지 않아도 되며 대신 Internal Server Error를 받게 된다.
func (u *usecase) GetSample(id int64, someparam string, anotherParam string) ([]*Sample, error) {
chanSample := make(chan sampleChannel, 3)
defer close(chanSample)
go func() {
chanSample <- u.getDataFromGoogle(id, anotherParam) // 함수의 예시
}()
go func() {
chanSample <- u.getDataFromFacebook(id, anotherParam)
}()
go func() {
chanSample <- u.getDataFromTwitter(id,anotherParam)
}()
result := make([]*feed.Feed, 0)
timeout := time.After(time.Second * 2)
for loop := 0; loop < 3; loop++ {
select {
case sampleItem := <-chanSample:
if sampleItem.Err != nil {
logrus.Error(sampleItem.Err)
continue
}
if feedItem.Data == nil {
continue
}
result = append(result,sampleItem.Data)
case <-timeout:
err := fmt.Errorf("Timeout to get sample id: %d. ", id)
result = make([]*sample, 0)
return result, err
}
}
return result, nil;
}
하지만 이렇게 된다면, 유저에게 error를 반환했음에도 여전히 고루틴이 살아있게 된다.
응답이 반환되면 백그라운드에서 돌아가고 있는 고루틴 및 API 호출을 포함한 모든 리소스가 예외 없이 지워지길 바란다면,
타임 아웃을 위해 time.After를 사용하는 대신 context.Context을 사용하면 된다.
// 컨텍스트를 사용한 최종 수정 코드
func (u *usecase) GetSample(c context.Context, id int64, someparam string, anotherParam string) ([]*Sample, error) {
if c== nil {
c= context.Background()
}
ctx, cancel := context.WithTimeout(c, time.Second * 2)
defer cancel()
chanSample := make(chan sampleChannel, 3)
defer close(chanSample)
go func() {
chanSample <- u.getDataFromGoogle(ctx, id, anotherParam) // 함수의 예시
}()
go func() {
chanSample <- u.getDataFromFacebook(ctx, id, anotherParam)
}()
go func() {
chanSample <- u.getDataFromTwitter(ctx, id,anotherParam)
}()
result := make([]*feed.Feed, 0)
for loop := 0; loop < 3; loop++ {
select {
case sampleItem := <-chanSample:
if sampleItem.Err != nil {
continue
}
if feedItem.Data == nil {
continue
}
result = append(result,sampleItem.Data)
// ============================================================
// 일관성 없는 데이터를 방지하기 위해 컨텍스트가 타임아웃을 초과하는 경우
// 유저에게 빈 배열과 에러 메시지를 보낸다
// ============================================================
case <-ctx.Done(): // 컨텍스트가 타임아웃을 초과했다는 알림 시그널을 받음
err := fmt.Errorf("Timeout to get sample id: %d. ", id)
result = make([]*sample, 0)
return result, err
}
}
return result, nil;
}
우리는 모든 고루틴 호출에 context를 사용한다. 이는 메모리를 해제하고 고루틴 호출을 취소하는데 도움이 된다.
추가적으로, 보다 더 통제 가능하고 신뢰성 있는 서비스를 위해 HTTP 요청에 컨텍스트를 전달한다.
func ( u *usecase) getDataFromFacebook(ctx context.Context, id int64, param string) sampleChanel{
req,err := http.NewRequest("GET", "https://facebook.com", nil)
if err != nil {
return sampleChannel{
Err: err,
}
}
// ============================================================
// 요청에 컨텍스트를 전달한다
// 이 기능은 Go 1.7부터 사용할 수 있다
// ============================================================
if ctx != nil {
req = req.WithContext(ctx) // HTTP 호출 요청에 컨텍스트를 사용하고있다.
}
resp, err:= u.httpClient.Do(req)
if err != nil {
return sampleChannel{
Err: err,
}
}
body,err:= ioutils.ReadAll(resp.Body)
if err!= nil {
return sampleChannel{
Err:err,
}
}
sample:= new(Sample)
err:= json.Unmarshall(body,&sample)
if err != nil {
return sampleChannle{
Err:err,
}
}
return sampleChannel{
Err:nil,
Data:sample,
}
}
고루틴이 제대로 닫히지 않으면 메모리 누수가 발생하니 주의하기 바란다!
6) Global 변수 확인
원래는 스택 메모리에 할당된 뒤 다른 곳에서 변수를 참조하는 일이 없기 때문에 바로 release되어 스택 메모리에서 점유 해제되어야 한다.
하지만 몇몇 경우에 스택이 아닌 힙에 데이터가 저장될 수 있는데, 너무 큰 값을 선언하는 경우다.
스택이 아닌 힙에 데이터가 저장되었고, 힙이 할당받은 메모리를 해제해주지 않아 생기는 문제가 있다. (에러 해결 블로그)
-> Go 1.16 버전부터는 메모리 계산 방식이 바뀌어서 괜찮다고 한다
var globalSlice = make([]int64, 0)
func appendSlice(c *fiber.Ctx) error {
globalSlice = append(globalSlice, time.Now().Unix())
return c.JSON(map[string]int{
"sliceSize": len(globalSlice),
})
}
전역 변수는 선언이 되면 힙(Heap)에 메모리를 할당받는데 이는 매우 조심해야한다!
만약 slice와 같이 크기가 고정되지 않은 타입의 데이터를 전역 변수로 선언했다면, 어쩌면 메모리가 고갈될 때까지 계속해서 변수가 커질 수도 있기 때문이다.
전역 slice, 전역 구조체 내의 slice가 있는지 확인을 해야한다. 그리고 전역 변수가 제한된 메모리 할당(has limited memory allocation)을 가지도록 수정해야한다.
7) CPU, Memory 프로파일링
프로파일링은 코드를 분석하여 성능 병목 현상과 리소스 소비 비효율성을 식별하는 프로세스다.
Go는 개발자가 애플리케이션을 프로파일링하고 성능 특성을 이해할 수 있도록 하는 'pprof' 패키지와 같은 기본 제공 도구를 제공한다.
CPU, Memory 사용 측면에서 Go 서비스의 성능을 측정하고 실행시간을 소비하는 위치를 파악하여 메모리 누수를 테스트해볼 수 있다.
그리고 고루틴의 누수를 검사해주는 패키지도 있다: goleak
아직 프로파일링을 적용해보지는 못했는데 추후에 시도 후 업데이트해보겠다. (참고할 사이트)
Production 환경에서 Fiber의 pprof 사용은 권장하지 않는다고 한다. 이유는 다음과 같다.
- Fiber의 pprof 도구는 엔드 포인트(/debug/pprof)를 노출한다. 이 엔드포인트는 인증(authentication)으로 보호되지 않기 때문에 API가 공개된 경우 전 세계 누구나 메모리 할당을 확인할 수 있다.
- Production 환경의 Heap은 매우 클 것이다. 따라서 이렇게 방대한 양의 텍스트를 분석하는 건 쉽지 않다.
따라서 pprof은 로컬환경 프로파일링에만 권장한다.
8) 그 외
- 가능한 경우 메모리 재사용
새 개체를 할당하는 대신 가능할 때마다 개체 재사용
Go는 가비지 컬렉터를 사용하여 메모리를 관리하므로 개체를 만들고 삭제할 때마다 가비지 컬렉터가 정리해야한다.
이로 인해 처리량이 많은 서비스의 경우 성능 오버헤드가 발생할 수 있다!
메모리를 효과적으로 재사용하려면 sync.Pool 또는 사용자 지정 구현과 같은 개체 풀을 사용하는 것이 좋다. 객체 풀은 응용 프로그램에서 재사용할 수 있는 개체 모음을 저장하고 관리한다. 객체 풀을 통해 메모리를 재사용함으로써 전체 메모리 할당 및 할당 해제량을 줄이고 가비지 컬렉션이 애플리케이션 성능에 미치는 영향을 최소화할 수 있습니다.
- 불필요한 할당 방지
- make([]T, size, capacity)를 사용하여 정해진 크기로 슬라이스 사전 할당
- append 기능을 사용하여 중간 슬라이스가 생성되지 않도록 함
- 큰 구조를 값으로 전달하는 대신 포인터를 사용하여 데이터에 대한 참조 전달
- 클로저는 편리하지만 추가 할당이 발생할 수 있음. 가능하면 클로저를 통해 매개변수를 캡쳐하는 대신 함수 매개변수를 명시적으로 전달
- 클로저를 사용하면 함수는 inline화 되고, 변수는 heap으로 이동하게 된다. (힙으로 가지마!!!)
- 함수 인라인은 함수 호출 오버헤드를 줄여 성능을 향상시키는데 도움이 될 수 있음. 인라인 공격성(?)을 제어하려면 go build -gcflags '-l=4' 사용하면 된다(값이 높을수록 인라인이 증가함)
- 일반함수를 호출하면 해당 함수의 주소로 갔다가 계산하고 돌아오는 과정을 거쳐야하는데 inline 함수는 이미 치환되어있으므로 따로 함수 호출 과정을 통해서 주소를 이동할 필요없이 그 위치에서 처리하므로 속도가 빠르다.
- 치환되는 함수가 큰 경우 프로그램의 목적코드의 크기가 커질 수 있지만 요즘 컴파일러는 알아서 판단해서 최적화를 해주기 때문에 개발자가 inline을 붙이지 않아도 inline이 붙은 함수처럼 작동하기도하고 개발자가 inline을 붙였다 하더라도 inline이 안붙은 함수처럼 작동하기도 함
- 포인터 안전하게 작업하기
- 포인터는 필요할 때만 아껴서 사용하고 한다(?) 과도하게 사용하면 실행 속도가 느려지고 메모리 소비가 증가할 수 있다.
- 범위가 클수록 참조를 추적하고 메모리 누수를 방지하기가 더 어려워지기 때문에 포인터 사용 범위를 최소화 해야한다.
- 꼭 필요한 경우가 아니면 unsafe.Pointer 사용하지 말자. Go의 유형 안전성을 우회하고 디버그하기 어려운 문제로 이어질 수 있기 때문이다.
- 공유 메모리에서 원자적 작업을 위해 sync/atomic 패키지를 사용하자. 일반 포인터 작업은 원자적이지 않으며 잠금 또는 기타 동기화 메커니즘을 사용하여 동기화하지 않으면 데이터 경합이 발생할 수 있다.
마무리
사실 포스팅을 올리고 해결하기까지 기간이 좀 걸렸다. 범위를 좁혀 0번 때문이라는건 알았지만 정확히 어디서 왜 발생하는지 파악을 못해 코드를 고치지 못하고 있는 상태였기 때문이다. 끈질기게 원인을 찾다가 결국 발견하게 된 과정은 다음과 같다.
- 문제의 데이터를 가져올 때 mutex read lock을 걸고, 업데이트할 때마다 write lock을 거는 상황이다.
- 웹 소켓에서 데이터를 5초마다 내려주는데 간혹 6~8초 정도 텀이 발생하는 경우를 발견했다.
- 10초마다 데이터를 업데이트할 때, write lock이 걸리면서 read 시간이 지연되는 걸로 추측했다.
- 또한, 데이터 업데이트가 늦어진 이유는 read lock이 계속 걸리면서 write 순서가 지연되었을 것이다. (starvation 상태?)
아무튼 이 현상이 발생하는 이유가 데이터에 걸려있는 write, read lock이 문제라고 생각하여 해당 부분을 위에서 설명한대로 redis 로 관리하도록 수정했다.
여러 환경에 올리면서 모니터링 기간을 거쳤고, 드디어 문제가 해결됐을 때! 이루 말할 수 없이 기뻤다. 아래는 서버 배포를 담당하는 DevOps 팀과 우리 팀의 반응이다. 너무 귀엽고 재밌고 뿌듯해서 가져와봤다. (흰색이 나!다)
Go에서 메모리릭이 어느 부분에서 발생할 수 있는지 공부할 수 있는 아주 좋은 기회였다.
그 외 참고 사이트
'Error Handling' 카테고리의 다른 글
[Go] 실시간 ohlcv 차트(캔들 차트) 데이터 적재 서버 개발 in 블록체인 플랫폼 - Part 1. 문제편 (0) | 2025.02.26 |
---|---|
Go 서버 악마 퇴치기 1탄 - 포인터와 슬라이스를 쓸 때 주의할 점 (0) | 2024.12.15 |