Go HTTP server shutdown이 멈춘다면, 문제의 중심은 shutdown 호출 자체보다 handler, goroutine, queue consumer, background loop가 정상 종료 상태까지 가지 못하는 경우가 많습니다.
그래서 shutdown 장애는 헷갈리기 쉽습니다. signal은 들어오고 shutdown 코드도 실행되는데, 프로세스는 기대한 방식으로 끝나지 않기 때문입니다. 실제로는 서버가 아직 살아 있어야 한다고 믿는 무언가를 기다리고 있는 경우가 많습니다.
이 글은 실전 순서에 집중합니다.
- active request draining과 background work shutdown을 어떻게 나눠 볼지
- graceful shutdown이 안 끝날 때 무엇을 먼저 볼지
- signal handling, handler lifecycle, worker cancellation이 어떻게 얽히는지
짧게 말하면 shutdown이 시작된 뒤 무엇이 아직 active인지 먼저 찾고, draining 중인 요청과 요청 밖 background 작업을 분리한 다음, cancellation과 stop ownership이 실제로 끝까지 연결되어 있는지 추적해야 합니다.
더 넓은 Go 분기부터 다시 보고 싶다면 Golang 트러블슈팅 가이드로 가세요.
먼저 draining 경로부터 보기
가장 먼저 나눠야 할 것은 shutdown이 active request draining 때문에 느린지, 아니면 background 작업이 끝나지 않아서 느린지입니다.
이 차이는 다음 단계 자체를 바꿉니다.
- 요청 draining이 문제면 handler, streaming 경로, request cancellation을 봐야 함
- background 작업이 문제면 worker, loop, queue consumer, 내부 stop signal을 봐야 함
이 첫 분기 없이 보면, 실제 문제는 worker loop인데도 handler 코드만 오래 보게 되는 경우가 많습니다.
graceful shutdown은 실제로 무엇에 달려 있나
깨끗한 HTTP server shutdown은 보통 여러 조건이 함께 맞아야 합니다.
- 프로세스가 signal을 제대로 받고 처리함
- 서버가 새 요청을 더 받지 않음
- active request가 끝나거나 cancellation을 존중함
- background goroutine이 새 작업을 더 만들지 않음
- queue consumer와 watcher가 정상적으로 빠져나옴
이 중 하나라도 흐리면 shutdown은 timeout까지 끌릴 수 있습니다.
그래서 graceful shutdown은 HTTP API 문제라기보다 lifecycle coordination 문제인 경우가 많습니다.
자주 나오는 원인
1. 오래 걸리는 handler
하나의 handler가 graceful shutdown 전체를 오래 붙잡을 수 있습니다.
예를 들면:
- long polling이나 streaming handler
- 느린 dependency를 기다리는 handler
- context cancellation을 보지 않는 요청 경로
handler가 아직 active인데 shutdown 시작을 알아차리지 못하면 서버는 timeout이 끝날 때까지 기다릴 수 있습니다.
2. cancellation을 무시하는 background worker
shutdown이 시작돼도 helper goroutine이 stop signal을 받지 못하거나 무시해서 계속 돌 수 있습니다.
이 패턴은 보통 아래에서 자주 나옵니다.
- worker가 parent shutdown context 대신
context.Background()를 사용함 - ticker loop에 exit path가 없음
- queue consumer가 shutdown 뒤에도 계속 일을 기다림
밖에서 보면 서버가 멈춘 것처럼 보이지만, 실제로는 내부 loop 하나가 아직 살아 있어야 한다고 판단하고 있는 것입니다.
3. signal / shutdown 흐름이 불완전함
signal은 제대로 받았는데 애플리케이션이 내부 loop를 전부 닫지 못하는 경우도 많습니다.
자주 보이는 실패 패턴은 아래와 같습니다.
- HTTP server는 shutdown 되는데 내부 goroutine은 계속 돌고 있음
- 한 컴포넌트가 다른 컴포넌트 종료를 기다리지만 ownership이 불명확함
- shutdown 순서가 뒤집혀서 worker가 이미 사라진 자원을 기다리게 됨
그래서 하나의 graceful shutdown timeout이 사실은 더 큰 lifecycle 문제를 숨기고 있을 수 있습니다.
실전 점검 순서
shutdown이 안 끝날 때는 아래 순서가 보통 가장 빠릅니다.
- shutdown 시작 후 무엇이 아직 active인지 확인
- request draining 경로와 background worker 상태 비교
- active loop가 cancellation을 모두 보고 있는지 점검
- signal handling과 컴포넌트 간 stop 순서 검토
- handler 문제인지, worker 문제인지, shutdown ownership 문제인지 판단
이 순서가 잘 먹히는 이유는 shutdown 호출 한 줄만 따로 디버깅하는 실수를 막아주기 때문입니다. 호출은 보통 맞고, 주변 lifecycle이 틀려 있는 경우가 많습니다.
stuck worker가 goroutine retention처럼 보인다면 Goroutine leak 찾는 법과 같이 보세요.
코드는 맞아 보여도 실제 협력이 없으면 멈출 수 있다
go srv.ListenAndServe()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
이 코드는 겉으로는 맞아 보이지만, 시스템 전체가 협조해야만 깔끔하게 동작합니다.
handler가 cancellation을 무시하거나, worker가 계속 돌거나, 내부 loop에 stop 경로가 없으면 shutdown은 여전히 timeout까지 끌 수 있습니다.
핵심은 srv.Shutdown이 shutdown을 조율해 준다는 점입니다. 애플리케이션 안의 goroutine을 마법처럼 알아서 종료해 주지는 않습니다.
장수 loop마다 물어볼 질문
worker, watcher, consumer를 볼 때마다 아래를 물어보면 도움이 됩니다.
- 누가 이것을 시작하는가
- 누가 이것을 멈추는 책임을 지는가
- 어떤 signal이 오면 exit해야 하는가
- 기다리는 중 shutdown이 시작되면 어떻게 되는가
이 답 중 하나라도 흐리면 shutdown 버그 가능성이 크게 올라갑니다.
이 프레이밍이 좋은 이유는 많은 shutdown 장애가 사실 ownership 장애이기 때문입니다.
shutdown hang과 goroutine leak이 같이 보이는 이유
이 둘은 운영에서 자주 같이 나타납니다.
장수 goroutine에 clean stop path가 없으면:
- process exit를 지연시키고
- 리소스를 계속 잡고
- 메모리 reference를 붙잡고
- shutdown timeout을 HTTP 문제처럼 보이게 만들 수 있습니다
빠르게 나누면 이렇게 볼 수 있습니다.
- 핵심 증상이 “서버가 안 끝난다”면 shutdown부터
- 핵심 증상이 “많은 goroutine이 blocked state로 쌓인다”면 goroutine leak도 바로 비교
FAQ
Q. srv.Shutdown이 모든 goroutine을 자동으로 멈춰주나요?
아닙니다. HTTP server shutdown을 조율하는 데 도움을 주지만, worker, loop, background task는 별도의 cancellation과 stop logic이 필요합니다.
Q. shutdown hang 때 무엇부터 보는 게 가장 빠른가요?
shutdown이 시작된 뒤에도 무엇이 active인지 찾고, 그것이 request handler인지 background work인지 먼저 나누는 것이 가장 빠릅니다.
Q. signal 경로는 맞아 보이는데도 왜 graceful shutdown이 timeout 나나요?
signal 경로는 정상이더라도 handler, worker, queue consumer 중 하나에 실제 exit path가 없을 수 있기 때문입니다.
Read Next
- Go 전체 분기부터 다시 보고 싶다면 Golang 트러블슈팅 가이드로 가세요.
- stuck worker가 goroutine retention처럼 보이면 Goroutine leak 찾는 법을 보세요.
- blocked coordination이 더 깊은 원인처럼 보이면 Golang channel deadlock 자주 보는 원인과 해결 방향도 같이 보세요.
Related Posts
Sources:
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.
먼저 읽어볼 가이드
검색 유입이 많은 핵심 글부터 이어서 보세요.
- 미들웨어 트러블슈팅 가이드: Redis vs RabbitMQ vs Kafka 개발자를 위한 미들웨어 트러블슈팅 허브 글입니다. Redis, RabbitMQ, Kafka 중 어떤 증상부터 먼저 봐야 하는지와 어떤 문제 패턴이 각 시스템에 가까운지 정리합니다.
- Kubernetes CrashLoopBackOff: 먼저 볼 것들 startup failure, probe, config, resource limit 관점에서 CrashLoopBackOff를 어떻게 나눠서 봐야 하는지 정리한 가이드입니다.
- Kafka consumer lag가 계속 늘 때: 트러블슈팅 가이드 Kafka consumer lag가 계속 늘어날 때 무엇부터 봐야 하는지 정리합니다. poll 주기, 처리 속도, rebalance, consumer 설정까지 실전 기준으로 다룹니다.
- Kafka Rebalancing Too Often 가이드 Kafka consumer group에서 rebalance가 너무 자주 일어날 때 membership flapping, poll timing, protocol, assignment churn을 어떤 순서로 봐야 하는지 설명하는 실전 가이드입니다.
- Docker container가 계속 재시작될 때: 먼저 확인할 것들 exit code, command failure, environment mistake, health check 관점에서 Docker restart loop를 푸는 실전 가이드입니다.
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.