Go로 서버를 개발하다 보면, 포인터와 슬라이스를 사용했을 때 '말도 안되는' 버그를 마주치게 될 때가 있다.
그 사건들을 차례대로 정리해보려고 한다.
문제 상황
아래와 같이 주기적으로 호출되어 데이터를 업데이트하는 코드가 있다.
type Token struct {
Symbol string
...
IsListed bool
PairIdentifier string
}
func (o *TokenManager) refreshTokenData(tokens []*Token) error {
o.tokenMutex.Lock()
defer o.tokenMutex.Unlock()
updatedTokens := make(map[string]*Token)
for _, token := range tokens {
updatedTokens[token.Symbol] = token
}
type PairResponse []struct {
Identifier string
Symbol string
}
var pairs PairResponse
if err := o.rpcClient.Call(&pairs, "fetch_all_pairs"); err != nil {
return err
}
for _, pair := range pairs {
if updatedTokens[pair.Symbol] == nil {
continue
}
updatedTokens[pair.Symbol].IsListed = true
updatedTokens[pair.Symbol].PairIdentifier = pair.Identifier
}
o.tokenData = updatedTokens
return nil
}
겉보기에는 문제가 없어 보이지만, 실제로는 'IsListed' 필드 값이 뒤죽박죽으로 들어가서 후 프로세스에서 데이터 처리에 영향을 끼친다는 사실을 발견했다. (true가 되어야 할 값이 false가 되어 있거나, 혹은 그 반대가 되는 일이 발생한다!)
팀원들과 함께 코드를 아무리 다시 살펴봐도 딱히 이상한 부분이 없어 보였고 도저히 납득이 가지 않는 상황이었다!!
디버깅과 뜻밖의 결과
그러다가 내가 알고 있는 모든 지식을 의심해보자고 생각했다. 내 상식 선에서 이해가 안되는 일이니, 내가 알고 있던 '상식'에 뭔가 오류가 있던게 아닐까.
updatedTokens := make(map[string]*Token)
for _, token := range tokens {
updatedTokens[token.Symbol] = token
}
위 코드를 보면 기본적으로 'IsListed' 필드가 false로 들어가도록 의도한 것을 알 수 있다.
그래서 테스트 삼아 아래와 같이 명시적으로 false를 넣어주도록 코드를 수정해 보았다.
updatedTokens := make(map[string]*Token)
for _, token := range tokens {
token.IsListed = false
updatedTokens[token.Symbol] = token
}
그랬더니 놀랍게도 제대로 데이터가 들어가는걸 알 수 있었다. 이렇게 단순한 수정으로 갑자기 에러가 해결되다니! 더 황당했다.
그래서 제대로 원인을 파악하기 위해 고민을 해보았다.
원인 분석
이 함수는 백그라운드에서 주기적으로 호출된다. 따라서 토큰 데이터를 매번 새로 불러오게 되는데, 아래와 같은 상황이 벌어질 수 있습니다.
- tokens 슬라이스에 들어 있는 각 *Token의 포인터가 그대로 updatedTokens 맵에 복사된다.
- 다음 주기에는 다른 토큰이 슬라이스의 특정 인덱스 자리를 차지하고 있을 수 있는데, 이전 주기에 세팅된 필드 값이 남아 있을 가능성이 존재한다.
- 만약 새로 들어온 토큰의 IsListed 값을 명시적으로 초기화하지 않는다면, 이전에 true였던 값이 그대로 재활용되어 버그가 발생할 수 있다!
즉, 순서가 뒤섞이는 슬라이스를 매번 그대로 복사하는 로직에서, 과거 값(쓰레기 값)이 그대로 남는 것이 문제의 핵심이었다.
해결책 정리
1. 명시적 초기화
필요한 필드는 매번 명시적으로 설정해서 과거에 설정된 쓰레기 값이 남아 있을 가능성을 없앤다.
for _, token := range tokens {
token.IsListed = false
updatedTokens[token.Symbol] = token
}
2. 구조체 초기화, Deep Copy 사용
슬라이스 내 포인터를 그대로 사용하기보다는, 새로운 구조체를 생성해서 안전하게 복제를 하는게 깔끔하다.
Go에서 구조체 생성 시, &Token{} 처럼 빈 객체를 만들 경우, 필드가 기본값으로 깔끔하게 초기화되니 아래와 같이 값을 초기화해서 넣어주자.
updatedTokens := make(map[string]*Token)
for _, token := range tokens {
updatedTokens[token.Symbol] = &token{
// 필드 설정
}
}
결론
Go에서 슬라이스와 맵에 구조체 포인터를 함께 사용할 때, 한쪽에서 수정된 필드가 다른 쪽에도 영향을 끼치는 “공유 상태”가 생길 수 있다.
- 구조체를 deep copy하거나,
- 필요한 필드를 항상 명시적으로 초기화하거나,
- 동시성 제어를 확실히 해 주면
예상치 못한 값이 남아 있는 현상을 막을 수 있다.
또한,
- 공유 상태가 숨어 있지는 않은지,
- 데이터 레이스가 발생하진 않는지,
- 이전 루프에서 남은 쓰레기 값이 잔존하지 않는지
등을 꼭 점검해 보자!
이 글이 나처럼 '도저히 이해가 안되는 에러'를 마주한 사람에게 도움이 되었으면 좋겠다 :) !
'Error Handling' 카테고리의 다른 글
[Go] 실시간 ohlcv 차트(캔들 차트) 데이터 적재 서버 개발 in 블록체인 플랫폼 - Part 1. 문제편 (0) | 2025.02.26 |
---|---|
Go 프로젝트에서 메모리 누수(Memory Leak) 예방하기 (0) | 2023.09.30 |