(최초 작성일: 2024.12 ...ㅎ...)
프로젝트를 진행하면서 전체 코드 마무리를 내가 맡았는데, 데이터를 꼼꼼히 다시 재검토하면서 데이터가 기존과 상이한 것을 발견했다.
대충 코드를 훑어보면 아무 문제가 없어 보였기 때문에 대체 왜!!! 왜!!! 데이터가 제대로 쌓이지 않는지 내 소중하 머리털을 쥐어 뜯으며 삽질에 삽질을 더했다.
다른 이슈들도 병행했기 때문에 원인 발견에만 이틀 정도가 소요되었다!! 해결하지 못하면 어떡하지 싶은 아찔한 기분도 느꼈었다.
원인을 알아내니 다행히 해결은 차근차근 빠르게 진행할 수 있었다.
나는 이 에러들을 'Go 서버의 악마들'이라고 감히 말하고 싶다!
평소에는 Go 언어의 장점으로 개발자들에게 편의성을 제공하는걸로 알려져 있지만 잘못 사용하게 된다면 그야말로 악마로 탈변하게 된다.
바로, goroutine과 channel 이다!
goroutine과 channel을 사용할 때 하기 쉬운 실수들을 되짚어보고, 나는 어떻게 해결했는지 소개해보겠다.
[1] Channel 타입과 Capacity 설계의 오류
기존 코드에서 가장 먼저 발견한 문제는 channel 선언부였다:
dataChan: make(chan []*types.SomeStruct, 1000)
뭔가 이상하지 않는가? capacity가 '슬라이스' 1000 개이다.
이 선언은 슬라이스 자체를 1000개까지 버퍼링할 수 있는 채널을 의미한다.
정말로 슬라이스를 1000개 보내고 싶었을 수 있지만, 해당 로직은 데이터를 DB에 업데이트하기 위해 사용하는 채널이다.
데이터를 1000개씩 잘라서 DB에 적재하기 위한 용도였으나 아주! 잘못 선언되어 있었다. 덕분에 메모리 사용량과 처리 로직에서 모두 비효율이 발생했을 것이다.
이는 아래와 같이 변경이 가능하다.
// 개별 항목을 버퍼링 하는 방식
dataChan: make(chan *types.SomeStruct, 1000)
// 또는 슬라이스 단위로 처리하되 버퍼 크기를 최소화
dataChan: make(chan []*types.SomeStruct, 1) // 이것도 가능하지만 배치 사이즈 1000개를 보장하지는 않는다.
[2] Channel 생명주기 관리의 문제
더 심각한 문제는 channel의 생명주기 관리였다:
func (c *Example) stackData() {
// ... goroutine 작업들 ... //
c.wg.Wait()
close(c.workerPool)
close(c.dataChan)
}
코드를 보면 wg.Wait()로 모든 데이터 생성 goroutine이 완료되기를 기다린 후, 즉시 채널을 닫는 구조로 되어 있다.
이는 채널을 통해 전송된 마지막 배치 데이터가 완전히 처리되기 전에 채널이 닫힐 위험이 있다!
(이 문제는 3번 문제와 함께 수정할 예정이다)
[3] Race Condition 과 데이터 무결성 문제
가장 치명적인 문제는 아래 함수에 있었다(허허.. 파면 팔수록 괴담이..):
func bulkWriter() {
var dataSlice []*types.SomeDataType
for data := range dataChan {
go func(data []*types.SomeDataType) {
dataSlice = append(dataSlice, data...)
if len(dataSlice) >= BATCH_SIZE {
if err := c.bulkWrite(dataSlice); err != nil {
...
} else {
dataSlice = dataSlice[:0]
...
}
}
}(data)
}
}
총체적 난국이자 악마의 집합소 같던 코드다. 기존 코드다 보니 dataChan은 내가 수정한 코드가 아닌 기존 코드 즉, 데이터 슬라이스를 1000개(...) 넘겨주는 코드로 생각하고 봐야한다.
이 코드 블록에서 발견한 문제점은 아래와 같다.
1. 데이터 손실
현재는 반드시 batch size 갯수 이상의 데이터만 업데이트 하도록 되어 있다.
고로, 배치 크기에 도달하지 못하면 아예 데이터가 DB에 업데이트가 안된 채로 시간만 무한정 밀리게 된다는 문제가 있다. (사실상 영구 손실 될 수도 있다.)
또한, 채널이 닫히면 for range 루프가 종료가 되는데 이때, dataSlice에 아직 batch_size에 도달하지 못해 데이터들이 남아 있다면 어떻게 될까? 이 데이터들은 DB에 저장되지 않고 소멸된다! (위의 2번 문제와 연관)
2. Race Condition
여러 goroutine이 같은 dataSlice에 접근하는 현재 코드는 race condition이 발생할 수 있는 조건인데, lock 등의 동기화 매커니즘도 없는 상태다!
특히 else문의 dataSlice가 변경되면서 심각한 문제가 발생할 수 있는데,
예를 들어 고루틴 A가 데이터를 추가하는 동안 고루틴 B가 슬라이스를 [:0]으로 초기화하는 경우 등으로 인해 예상치 못한 데이터 손실이나 중복처리가 발생할 수 있다.
3. Resource Leak
채널에서 받은 데이터마다 새로운 goroutine을 생성하여 리소스가 낭비될 수 있다.
내가 수정한 코드는 아래와 같다.
func bulkWriter() error {
flushTicker := time.NewTicker(5 * time.Second)
var dataSlice []*types.SomeDataType
for {
select {
case supply, ok := <-c.dataChan:
if !ok {
// 채널이 닫혔을 때 남은 데이터 처리
if len(dataSlice) > 0 {
c.flush(dataSlice)
}
flushTicker.Stop()
return
}
dataSlice = append(dataSlice, data)
// 배치 크기 도달 시 즉시 처리
if len(dataSlice) >= MAX_BATCH_SIZE {
c.flush(append([]*types.SomeDataType, dataSlice...));
dataSlice = dataSlice[:0]
}
case <-flushTicker.C:
// 주기적으로 남은 데이터 처리
if len(dataSlice) > 0 {
c.flush(append([]*types.SomeDataType, dataSlice...))
dataSlice = dataSlice[:0]
}
}
}
}
func flush(data []*types.SomeDataType) {
if err := bulkWrite(buffer); err != nil {
// ... log error, retry ... //
}
}
1. 단일 goroutine 패턴
하나의 goroutine으로도 충분히 모든 고루틴을 처리할 수 있고, 데이터 양도 많지 않는 데다가, 실시간으로 쌓여야하는 데이터는 아니다.
현재 상황에서는 오히려 멀티 goroutoine으로 인해 야기되는 문제가 더 많은 상황이었기 때문에 과감하게 빼는 선택을 했다.
만약 요구사항이 달라져 속도를 중요시 하는 프로젝트가 되었다면,
worker pool 패턴을 사용하거나, 샤딩을 통한 병렬 처리를 하거나, 비동기로 배치를 처리하도록 하는 방법 등을 고려할 것 같다.
2. 주기적 flush 매커니즘
배치 단위대로 데이터를 업데이트하는게 기존 로직이었는데, 배치 크기에 도달하지 못한 데이터도 5초마다 정기적으로 데이터를 DB에 업데이트 하는 flush 로직을 추가했다.
3. 안전한 채널 종료 처리
마지막으로 채널이 닫힌 후에도 남은 데이터를 안전하게 처리하도록 했다.
채널이 닫혔다면 데이터들은 모두 flush 시키고, ticker도 stop 하여 리소스를 정리한다.
채널이 열려있는 동안은, 안전하게 dataSlice에 처리할 데이터를 업데이트할 수 있다.
아무 생각없이 사용하다가는 탈이 나는 goroutine과 channel 핸들링을 실제 코드 사례를 통해서 살펴 봤다.
이번에 코드를 수정하면서 나는 go의 장점이라고만 생각했던 기능들에 대해 인식을 바꾸는 계기가 됐다. 이후로는 좀 더 코드를 짤 때 조심스러워진 것 같다.
항상 데이터의 흐름과 생명 주기를 명확히 파악하고, 가능한 한 단순하고 예측 가능한 패턴을 사용하는 것이 중요하다고 생각했다.
'Error Handling' 카테고리의 다른 글
[AI] Claude Code를 이용해 메모리 사용량을 줄인 소소한 후기 (0) | 2025.06.24 |
---|---|
[Go] 실시간 ohlcv 차트(캔들 차트) 데이터 적재 서버 개발 in 블록체인 플랫폼 - Part 2. 해결편 (0) | 2025.06.05 |
[Go] 실시간 ohlcv 차트(캔들 차트) 데이터 적재 서버 개발 in 블록체인 플랫폼 - Part 1. 문제편 (0) | 2025.02.26 |
Go 서버 악마 퇴치기 1탄 - 포인터와 슬라이스를 쓸 때 주의할 점 (0) | 2024.12.15 |
[Go] pprof로 메모리 누수 찾아내기 (0) | 2024.09.09 |