Golang WaitGroup Stuck: 무엇부터 확인할까
마지막 업데이트

Golang WaitGroup Stuck: 무엇부터 확인할까


Go 서비스가 WaitGroup 에서 멈춘 것처럼 보일 때 문제는 대개 WaitGroup 자체가 아닙니다. 더 흔한 경우는 Done 에 도달하지 못하는 worker, 영원히 return 하지 않는 blocking path, 혹은 error / panic / cancellation 경로에서 깨지는 Add / Done 균형입니다.

짧게 말하면 핵심은 이것입니다. 어떤 goroutine 이 끝나지 않고 있는지, 그리고 그 Done 경로가 실제로 아직 reachable 한지 먼저 찾아야 합니다. stuck WaitGroup 은 거의 항상 counter 가 0이 되지 않았다는 뜻입니다.

Java 전체 대신 Go 쪽 더 넓은 흐름부터 보고 싶다면 Golang Troubleshooting Guide 를 먼저 봐도 좋습니다.


Add 와 Done 균형부터 본다

WaitGroup 버그는 대부분 accounting bug 나 lifecycle bug 입니다.

그래서 아래 질문이 먼저입니다.

  • Add 는 어디서 호출되는가
  • Done 은 어디서 호출되는가
  • 어떤 경로가 Done 을 건너뛸 수 있는가
  • 어떤 worker 가 return 하지 못하고 있는가

이걸 답하기 전까지 WaitGroup 자체는 대개 흥미로운 부분이 아닙니다.


stuck WaitGroup 은 실전에서 어떻게 보이나

운영 환경에서는 보통 이렇게 보입니다.

  • shutdown 이 끝나지 않는다
  • 한 request path 가 끝나지 않는 worker 를 계속 기다린다
  • 거의 끝난 것처럼 보이는데 hidden goroutine 하나가 남아 있다
  • 운영자는 deadlock 을 의심하지만 실제론 missing Done 이다

겉으로 보이는 증상은 “Wait 이 안 끝난다” 이지만, 실제 원인은 훨씬 구체적인 경우가 많습니다.


흔한 원인

1. Done 이 호출되지 않는다

error branch, timeout branch, panic path 로 worker 가 빠져나가면서 counter 감소가 빠질 수 있습니다.

가장 흔한 원인입니다.

2. Add 가 잘못된 위치에서 호출된다

너무 늦게 Add 하거나, 안전하지 않은 concurrent path 에서 호출하면 wait 동작이 매우 헷갈려질 수 있습니다.

Go 에서는 worker launch 대비 Add 위치가 중요합니다.

3. worker 하나가 영원히 block 된다

channel wait, network call, DB call, dependency stall 때문에 goroutine 하나가 계속 살아 있으면서 전체 wait 를 막을 수 있습니다.

4. shutdown 과 cancellation 이 불완전하다

context cancellation 때 멈춰야 하는 worker 가 계속 기다리면, 실제 문제는 lifecycle coordination 인데 WaitGroup 이 고장 난 것처럼 보일 수 있습니다.

5. panic 또는 early-return 경로가 예상 경로를 우회한다

happy path 는 맞아도 unusual exit branch 에서 Done 보장이 깨질 수 있습니다.


실전 점검 순서

1. 어떤 wait path 가 막혔고 어떤 worker 가 아직 살아 있는지 확인한다

첫 단계는 counter 를 0으로 못 내리는 goroutine 을 찾는 것입니다.

2. stuck path 주변의 모든 Add / Done 쌍을 점검한다

기억에 의존하지 말고 실제 control flow 를 따라가야 합니다.

3. blocked channel operation 또는 downstream call 이 있는지 본다

worker 가 return 하지 않으면 defer wg.Done() 이 있어도 실제로는 도움이 안 됩니다.

4. panic, timeout, cancellation branch 에서도 Done 이 보장되는지 확인한다

운영에서 많이 터지는 WaitGroup 버그가 여기 숨어 있습니다.

5. 최근 concurrency 변경과 같이 본다

새 fan-out, 새 shutdown logic, 바뀐 cancellation rule 이 WaitGroup 이 갑자기 sticky 해진 이유일 수 있습니다.


예시: defer 는 맞지만 lifecycle 이 틀린 경우

var wg sync.WaitGroup

wg.Add(1)
go func() {
	defer wg.Done()
	if err := doWork(ctx); err != nil {
		return
	}
}()

wg.Wait()

겉보기엔 안전합니다. 실제로도 자주 맞습니다. 하지만 doWork(ctx) 가 영원히 return 하지 않으면 wg.Done() 은 실전에서는 도달 불가능합니다.

그래서 defer wg.Done() 은 가장 안전한 패턴이지만, 그 자체로 충분한 해답은 아닙니다.


stuck path 를 찾은 뒤 무엇을 바꾸면 좋나

Done 이 빠질 수 있다면

모든 exit path 에서 Done 이 보장되도록 worker 구조를 다시 짜야 합니다.

Add 가 unsafe 하다면

worker launch 전에 accounting 이 끝나도록 위치를 옮겨야 합니다.

worker 가 영원히 block 된다면

blocking condition, timeout path, dependency behavior 를 먼저 고쳐야 합니다.

cancellation 이 불완전하다면

worker shutdown 을 명시적으로 만들어서 Wait 가 실제 lifecycle completion 을 반영하게 해야 합니다.

실제 문제가 leaked work 라면

WaitGroup bug 보다 goroutine lifecycle 문제로 다뤄야 합니다.


장애 중에 던져볼 질문

이 질문이 거의 항상 유용합니다.

WaitGroup counter 를 0보다 크게 유지하는 정확한 goroutine 은 무엇이고, 그 goroutine 이 exit 하려면 어떤 조건이 만족돼야 하는가?

이 질문이 real bug 로 거의 바로 이어집니다.


FAQ

Q. defer wg.Done() 이면 항상 충분한가

대개 가장 안전한 패턴이지만, worker 가 return 하지 않으면 여전히 안 끝납니다.

Q. 가장 빠른 첫 단계는 무엇인가

어떤 goroutine 이 아직 살아 있고, 그 Done 경로가 reachable 한지 찾는 것입니다.

Q. stuck WaitGroup 은 항상 deadlock 인가

아닙니다. missing accounting 이나, 다른 무언가를 영원히 기다리는 worker 인 경우가 많습니다.

Q. context cancellation 이 도움이 되나

그렇지만 worker 가 그 signal 을 실제로 존중하고 clean 하게 exit 해야만 합니다.


Sources:

먼저 읽어볼 가이드

검색 유입이 많은 핵심 글부터 이어서 보세요.