Go HTTP 서버의 Graceful Shutdown

1. 개요

새 버전을 배포하거나 스케일 다운할 때, 프로세스는 SIGTERM을 받습니다. 서버가 즉시 종료되면, 처리 중인 요청이 중간에 끊깁니다. 클라이언트는 connection reset 오류를 받고, 주문 데이터가 절반만 저장될 수 있습니다.

이 글은 Go HTTP 서버를 올바르게 종료하는 방법을 설명합니다. 서버는 새로운 연결을 받지 않고, 실행 중인 요청이 제한된 시간 내에 완료될 때까지 기다린 후, 리소스를 올바른 순서로 닫습니다. 모든 예제는 표준 라이브러리만 사용하며 Go 1.22 이상을 필요로 합니다.

2. 갑작스러운 서버 종료 시 발생하는 문제

가장 일반적인 방식부터 시작해봅시다:

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

func main() {
	http.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second) // 느린 DB 쿼리 시뮬레이션
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

서버를 실행한 후 curl localhost:8080/orders/42를 호출합니다. 요청이 처리 중인 동안 서버 터미널에서 Ctrl+C를 누릅니다. 클라이언트 측의 결과:

curl: (52) Empty reply from server

Go는 SIGINT나 SIGTERM에 대한 핸들러를 자동으로 설치하지 않습니다. 신호를 받으면, 런타임은 프로세스를 즉시 종료합니다. 어떤 defer도 실행되지 않고, 요청을 기다리지 않습니다.

3. http.Server.Shutdown을 이용한 Graceful Shutdown

http.Server에는 Shutdown(ctx) 메서드가 있습니다. 이 메서드는 다음 세 가지를 순서대로 수행합니다:

  1. 모든 리스너를 닫아서 서버가 새로운 연결을 받지 않도록 합니다.
  2. 유휴 상태의 연결을 닫습니다.
  3. 활성 연결이 유휴 상태가 될 때까지 기다린 후 닫습니다. ctx의 시간이 먼저 만료되면, 메서드는 컨텍스트 오류를 반환합니다.

이 메서드를 사용하려면, 운영 체제 신호를 직접 처리해야 합니다.

3.1. signal.NotifyContext로 신호 처리하기

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(3 * time.Second)
		fmt.Fprintf(w, "order %s\n", r.PathValue("id"))
	})

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	serverErr := make(chan error, 1)
	go func() {
		log.Println("listening on", srv.Addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			serverErr <- err
		}
	}()

	select {
	case err := <-serverErr:
		log.Fatalf("server error: %v", err)
	case <-ctx.Done():
		log.Println("shutdown signal received")
	}
	stop() // 두 번째 Ctrl+C는 프로세스를 즉시 종료합니다

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("graceful shutdown failed: %v, forcing close", err)
		_ = srv.Close()
	}
	log.Println("server stopped")
}

코드의 주요 포인트:

  • signal.NotifyContext는 컨텍스트를 반환합니다. 이 컨텍스트는 프로세스가 SIGINT (Ctrl+C) 또는 SIGTERM (Docker와 Kubernetes가 보내는 신호)을 받을 때 취소됩니다.
  • ListenAndServe는 별도의 고루틴에서 실행됩니다. Shutdown을 호출한 후에는 http.ErrServerClosed를 즉시 반환합니다. 이것은 정상적인 오류이므로 필터링합니다.
  • 채널 serverErr는 버퍼 크기가 1입니다. 포트가 이미 사용 중인 경우 등 시작 오류를 포착하는 데 도움이 됩니다. 이 덕분에 main이 절대 올 수 없는 신호를 기다리며 중단되지 않습니다.
  • stop()은 신호를 받은 직후에 호출됩니다. 이 함수는 기본 동작을 복원하므로, Ctrl+C를 다시 누르면 프로세스가 즉시 종료됩니다. 종료가 행(hang)될 때 매우 유용합니다.
  • shutdownCtx는 ctx가 아닌 context.Background()를 기반으로 합니다. 이유는 ctx가 이미 취소되었기 때문이고, 이를 사용하면 Shutdown이 즉시 오류를 반환하기 때문입니다.
  • 10초가 지난 후에도 요청이 남아 있으면, srv.Close()가 모든 연결을 강제로 닫습니다.

섹션 2의 실험을 반복합니다: curl을 호출한 후, 요청이 처리 중인 동안 Ctrl+C를 누릅니다. 서버 로그:

2026/10/24 10:00:00 listening on :8080
^C2026/10/24 10:00:01 shutdown signal received
2026/10/24 10:00:03 server stopped

클라이언트는 전체 응답을 받습니다:

order 42

서버는 요청의 나머지 약 2초를 기다린 후 종료됩니다.

주의: 흔한 실수는 두 고루틴의 역할을 바꾸는 것입니다. 일부는 main에서 ListenAndServe를 호출하고 다른 고루틴에서 Shutdown을 호출합니다. ListenAndServe가 Shutdown이 시작되면 즉시 반환하므로, main은 Shutdown이 여전히 요청을 기다리는 동안 종료됩니다. 프로세스가 종료되고 요청이 중단됩니다. 규칙은 main이 Shutdown의 반환을 기다려야 한다는 것입니다.

4. Shutdown이 자동으로 처리하지 않는 것들

4.1. 하이재킹된 연결: WebSocket, HTTP/1.1 업그레이드

Shutdown은 하이재킹된 연결(예: WebSocket)을 추적하지 않습니다. 서버도 이들을 기다리지 않습니다. 이러한 연결을 닫으려면, RegisterOnShutdown을 사용하여 콜백을 등록합니다:

type Hub struct {
	mu    sync.Mutex
	conns map[*websocketConn]struct{}
}

func (h *Hub) CloseAll() {
	h.mu.Lock()
	defer h.mu.Unlock()
	for c := range h.conns {
		c.SendCloseFrame("server shutting down")
		c.Close()
	}
}

// main에서:
hub := &Hub{conns: make(map[*websocketConn]struct{})}
srv.RegisterOnShutdown(hub.CloseAll)

여기서 websocketConn은 사용 중인 WebSocket 라이브러리의 연결 타입을 나타냅니다. 콜백은 Shutdown이 시작될 때 별도의 고루틴에서 실행됩니다. Shutdown은 이 콜백이 완료될 때까지 기다리지 않으므로, 기다려야 하면 sync.WaitGroup을 사용하여 직접 동기화하세요.

4.2. 오래 실행되는 핸들러: 스트리밍, 롱 폴링

데이터를 무한정 스트리밍하는 핸들러(예: Server-Sent Events)는 절대 유휴 상태로 돌아오지 않습니다. 이 경우 Shutdown은 항상 타임아웃이 될 때까지 기다립니다. BaseContext를 사용하여 서버가 종료되기 시작할 때 모든 요청 컨텍스트를 취소할 수 있습니다:

appCtx, cancelApp := context.WithCancel(context.Background())

srv := &http.Server{
	Addr:    ":8080",
	Handler: mux,
	BaseContext: func(net.Listener) context.Context {
		return appCtx
	},
}
srv.RegisterOnShutdown(cancelApp)

mux.HandleFunc("GET /orders/stream", func(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/event-stream")
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-r.Context().Done():
			return // 서버가 종료 중이거나 클라이언트가 연결을 끊음
		case t := <-ticker.C:
			fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
			w.(http.Flusher).Flush()
		}
	}
})

각 요청 컨텍스트는 appCtx에서 생성됩니다. Shutdown이 실행되면 cancelApp이 호출되고 모든 r.Context()가 취소됩니다. 스트리밍 핸들러가 Done()을 보고 반환합니다. 그러면 Shutdown이 빠르게 완료됩니다.

주의: 모든 핸들러에 이 방식을 적용하면 안 됩니다. 주문을 DB에 쓰는 요청의 컨텍스트를 취소하면, 쓰기 명령 자체가 취소됩니다. “요청 완료 대기”라는 목표를 달성할 수 없습니다. 모든 핸들러가 안전하게 중간에 멈출 수 있을 때만 이 방식으로 BaseContext를 사용하세요.

4.3. 백그라운드 고루틴과 부수 리소스

Shutdown은 HTTP 연결만 관리합니다. 이메일 전송 워커, Kafka 컨슈머, DB 연결 풀은 당신의 책임입니다. 닫는 순서는 초기화 순서의 역순이어야 합니다:

if err := srv.Shutdown(shutdownCtx); err != nil {
	log.Printf("http shutdown: %v", err)
}

cancelWorkers() // 백그라운드 고루틴이 중지되도록 알립니다
workersDone := make(chan struct{})
go func() { wg.Wait(); close(workersDone) }()

select {
case <-workersDone:
case <-shutdownCtx.Done():
	log.Println("workers did not stop in time")
}

if err := db.Close(); err != nil {
	log.Printf("db close: %v", err)
}

HTTP 서버가 먼저 종료됩니다. 핸들러가 여전히 DB가 필요할 수 있기 때문입니다. DB는 마지막에 닫힙니다. 전체 프로세스는 공유 shutdownCtx를 사용하므로, 총 시간이 10초 예산을 초과하지 않습니다.

5. Kubernetes에서 실행하기

Pod이 삭제될 때, Kubernetes는 동시에 두 가지를 수행합니다: 컨테이너에 SIGTERM을 보내고, Service의 엔드포인트 목록에서 Pod을 제거합니다. 엔드포인트 제거가 kube-proxy 및 ingress에 전파되려면 몇 초가 걸립니다. SIGTERM을 받을 때 서버가 리스너를 즉시 닫으면, 여전히 Pod으로 라우팅되고 있는 요청이 거부됩니다.

해결책은 readiness 프로브를 먼저 실패로 표시하고, 잠시 기다린 후 Shutdown을 호출하는 것입니다:

var shuttingDown atomic.Bool

mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
	if shuttingDown.Load() {
		http.Error(w, "shutting down", http.StatusServiceUnavailable)
		return
	}
	w.WriteHeader(http.StatusOK)
})

// 신호를 받은 후:
<-ctx.Done()
shuttingDown.Store(true)
time.Sleep(5 * time.Second) // 로드 밸런서가 Pod을 제거할 때까지 대기
srv.Shutdown(shutdownCtx)

또한 terminationGracePeriodSeconds를 대기 시간과 종료 타임아웃의 합보다 크게 설정해야 합니다. 이 예제에서는 5 + 10 = 15초입니다:

spec:
  terminationGracePeriodSeconds: 30
  containers:
    - name: order-service
      image: order-service:1.4.0
      readinessProbe:
        httpGet:
          path: /readyz
          port: 8080
        periodSeconds: 2

grace period가 만료되면, Kubernetes는 SIGKILL을 보냅니다. 이 신호는 포착할 수 없으며, 모든 종료 로직이 무시됩니다.

주의: 바이너리를 셸 스크립트를 통해 실행하는 경우(예: CMD ./start.sh), 셸이 PID 1이 되고 SIGTERM을 Go 프로세스로 전달하지 않을 수 있습니다. exec 형식 CMD ["/app/server"]를 사용하거나 스크립트 끝에 exec /app/server를 호출하세요.

6. Shutdown 동작 테스트하기

Shutdown이 실행 중인 요청이 완료될 때까지 기다리는지 확인하는 테스트를 작성할 수 있습니다. 이 테스트는 testing 패키지만 필요합니다:

package main

import (
	"context"
	"io"
	"net"
	"net/http"
	"testing"
	"time"
)

func TestShutdownWaitsForInFlightRequest(t *testing.T) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}

	started := make(chan struct{})
	srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		close(started)
		time.Sleep(500 * time.Millisecond)
		io.WriteString(w, "order 42")
	})}
	go srv.Serve(ln)

	type result struct {
		body string
		err  error
	}
	resCh := make(chan result, 1)
	go func() {
		resp, err := http.Get("http://" + ln.Addr().String() + "/orders/42")
		if err != nil {
			resCh <- result{err: err}
			return
		}
		defer resp.Body.Close()
		b, err := io.ReadAll(resp.Body)
		resCh <- result{body: string(b), err: err}
	}()

	<-started // 요청이 실제로 핸들러에 들어갔는지 확인
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		t.Fatalf("Shutdown returned error: %v", err)
	}

	res := <-resCh
	if res.err != nil {
		t.Fatalf("request failed: %v", res.err)
	}
	if res.body != "order 42" {
		t.Fatalf("got body %q, want %q", res.body, "order 42")
	}

	if _, err := http.Get("http://" + ln.Addr().String()); err == nil {
		t.Fatal("expected new connections to be refused after shutdown")
	}
}

테스트의 주요 포인트:

  • net.Listen을 포트 0에서 사용하면 OS가 사용 가능한 포트를 선택합니다. 이는 테스트가 병렬로 충돌 없이 실행되도록 합니다.
  • 채널 started는 요청이 실제로 핸들러에 있을 때만 Shutdown이 호출되도록 보장합니다.
  • 테스트는 세 가지를 확인합니다: Shutdown이 nil을 반환하고, 클라이언트가 전체 본문을 받으며, 새 연결이 거부됩니다.
go test -v -run TestShutdown
=== RUN   TestShutdownWaitsForInFlightRequest
--- PASS: TestShutdownWaitsForInFlightRequest (0.50s)
PASS

실행 시간은 약 0.5초이며, 핸들러 대기 시간과 정확히 같습니다. 이는 Shutdown이 요청을 기다렸음을 보여줍니다.

7. 흔한 오류들

  • Shutdown에 이미 취소된 컨텍스트를 사용합니다. signal.NotifyContext의 ctx를 직접 전달하면 Shutdown이 즉시 context canceled를 반환합니다.
  • http.ErrServerClosed를 오류로 취급합니다. 이 오류로 log.Fatal을 호출하면 종료가 완료되기 전에 프로세스가 종료됩니다.
  • 타임아웃을 설정하지 않습니다. 기한이 없는 context.Background()를 사용하면 행(hang)된 핸들러가 Kubernetes가 SIGKILL을 보낼 때까지 프로세스를 계속 보유합니다.
  • ReadHeaderTimeout을 빠뜨립니다. 느린 클라이언트는 연결을 활성 상태로 유지할 수 있으며, 종료는 항상 타임아웃까지 실행됩니다.
  • 종료가 시작된 후 os.Exit 또는 log.Fatal을 사용합니다. 둘 다 모든 defer을 무시하므로 DB와 파일이 닫히지 않습니다.

8. 결론

Go의 graceful shutdown에는 세 가지 핵심 요소가 필요합니다: SIGTERM을 포착하기 위한 signal.NotifyContext, 타임아웃이 있는 컨텍스트를 사용하는 srv.Shutdown, 그리고 main이 Shutdown의 반환을 기다려야 한다는 것입니다. 이 기초 위에서 Shutdown이 자동으로 처리하지 않는 부분을 추가합니다. 즉, WebSocket용 RegisterOnShutdown, 스트리밍 핸들러용 BaseContext, 초기화 순서의 역순으로 워커와 DB를 닫는 것입니다. Kubernetes에서 실행할 때는 readiness를 실패로 표시하고 리스너를 닫기 전에 몇 초를 기다리면서, terminationGracePeriodSeconds를 충분히 크게 설정하세요. 마지막으로, 섹션 6과 같은 테스트를 유지하여 누군가 실수로 종료 흐름을 망칠 때 조기에 발견하세요.

golanghttpgraceful-shutdownkubernetesconcurrency