Quick Flow
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown failed: %v", err)
}종료 흐름
Shutdown은 새 연결을 막고 기존 요청을 기다린다
http.Server.Shutdown(ctx)는 서버 listener를 닫아 새 연결을 받지 않게 하고, 이미 처리 중인 요청이 끝나기를 기다립니다. timeout context가 끝나면 shutdown도 중단됩니다. 반면 Close()는 열린 연결을 즉시 닫으므로 graceful shutdown이 아닙니다.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown failed: %v", err)
}운영 서버에서는 종료 대기 시간을 무한대로 두지 않습니다. 배포 시스템의 termination grace period보다 짧은 timeout을 정해 프로세스가 예측 가능한 시간 안에 내려가게 합니다.
ListenAndServe의 http.ErrServerClosed는 정상 종료 신호다
Shutdown을 호출하면 ListenAndServe는 http.ErrServerClosed를 반환합니다. 이 값을 일반 오류처럼 log.Fatal로 처리하면 정상 종료가 실패처럼 기록됩니다. 서버 시작 goroutine에서는 이 오류를 별도로 제외해야 합니다.
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}서버 실행 오류와 종료 신호는 다른 경로로 다루는 편이 좋습니다. 포트 바인딩 실패는 시작 실패이고, ErrServerClosed는 shutdown 절차가 시작됐다는 신호입니다.
요청 context와 서버 종료 context는 다르다
r.Context()는 클라이언트 연결과 요청 수명에 묶인 context입니다. Server.Shutdown에 전달한 context는 shutdown이 얼마나 기다릴지만 제어하며, 진행 중인 handler의 r.Context()를 자동으로 취소하지는 않습니다. 요청 단위 DB 쿼리와 외부 API에는 r.Context()를 넘기고, 서버 전체 background worker에는 별도의 애플리케이션 수명 context를 넘깁니다.
func handler(w http.ResponseWriter, r *http.Request) {
rows, err := db.QueryContext(r.Context(), "SELECT * FROM jobs")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
}background worker는 request context가 아니라 애플리케이션 수명 context를 받아야 합니다. 요청이 끝났다는 이유로 전체 worker가 멈추면 안 됩니다. shutdown timeout이 지나도 남은 요청을 강제로 끊어야 한다면 Shutdown 오류를 처리하는 별도 운영 정책으로 Close 여부를 결정합니다.
운영 기준
| 상황 | 먼저 확인할 것 |
|---|---|
| 배포 중 연결 끊김 최소화 | Server.Shutdown |
| 즉시 강제 종료 | Server.Close |
| 종료 신호 수신 | signal.NotifyContext |
| 요청 하위 작업 취소 | r.Context() 전달 |
| shutdown 지연 | handler, DB, 외부 API timeout |
주의할 점
ListenAndServe 한 줄로 서버를 시작하면 종료 신호, timeout, 정상 종료 오류 처리를 놓치기 쉽습니다. 운영 서버는 http.Server를 직접 만들고, Shutdown timeout과 http.ErrServerClosed 처리를 명시해야 합니다.
graceful shutdown은 모든 작업을 끝까지 보장하는 장치가 아닙니다. timeout이 지나면 Shutdown은 context 오류를 반환하지만 진행 중인 요청을 자동으로 끊지는 않습니다. 프로세스를 반드시 내려야 한다면 Close 호출 여부와 background worker 취소를 별도 운영 정책으로 정해야 합니다. 오래 걸리는 작업은 요청 handler 안에서 직접 끝내기보다 job queue나 background worker로 분리하고, 재시도 가능한 상태 저장을 함께 설계해야 합니다.
참고 링크
2 sources