Menu

Golang HTTP 서버: net/http 라우팅, JSON, 상태 코드

Go 표준 net/http 패키지로 웹 서버를 만드는 방법: 핸들러, 메서드와 경로 와일드카드를 쓰는 ServeMux 라우팅(Go 1.22), JSON 응답, 상태 코드, 미들웨어, 타임아웃, 정상 종료를 다룹니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

몇 줄로 만드는 서버

핸들러는 요청을 받아 응답을 쓰는 함수입니다. ServeMux는 요청을 핸들러로 라우팅합니다.

에디터는 브라우저의 연결을 받을 수 없으므로, 이 페이지의 예제는 httptest.NewServer로 서버를 시작하고 같은 프로그램에서 호출합니다. 실제 프로그램에서는 마지막 부분이 블록되어 영원히 요청을 처리하는 한 줄로 바뀝니다:

log.Fatal(http.ListenAndServe(":8080", mux))

그러면 curl localhost:8080/hello/gopherHello, gopher!를 출력합니다. ListenAndServe는 (예를 들어 포트가 이미 쓰이는 등) 오류가 났을 때만 반환하므로 log.Fatal로 감쌉니다.

각 요청은 자기 고루틴에서 실행됩니다. 맵이나 카운터처럼 핸들러들이 공유하는 것에는 뮤텍스가 필요합니다.

핸들러

ServeHTTP(http.ResponseWriter, *http.Request) 메서드가 있는 것은 모두 http.Handler입니다. http.HandlerFunc는 평범한 함수를 그 인터페이스에 맞춰 주고, mux.HandleFunc는 그 변환을 대신 해 줍니다. 핸들러에 의존성이 필요하다면 구조체 핸들러가 편리합니다:

type API struct {
	db *sql.DB
}

func (a *API) listItems(w http.ResponseWriter, r *http.Request) { /* uses a.db */ }

mux.HandleFunc("GET /items", api.listItems)

*http.Request는 다음을 제공합니다:

필드나 메서드담긴 것
r.MethodGET, POST, ...
r.URL.Path경로, /items/42
r.PathValue("id")라우트 패턴의 와일드카드(Go 1.22)
r.URL.Query().Get("q")쿼리 문자열 매개변수
r.Header.Get("Authorization")요청 헤더
r.Body요청 본문, io.ReadCloser(서버가 닫음)
r.FormValue("name")폼 필드나 쿼리 매개변수
r.Context()클라이언트 연결이 끊기면 취소되는 컨텍스트

라우팅 패턴 (Go 1.22)

Go 1.22부터 ServeMux 패턴은 [METHOD ][HOST]/[PATH] 형태이며, 경로에 와일드카드를 넣을 수 있습니다.

패턴일치하는 것
"/items/"/items/와 그 아래 모든 것(끝의 슬래시 = 접두사)
"/items"/items
"GET /items/{id}"/items/42에 대한 GET(과 HEAD), r.PathValue("id") == "42"
"POST /items"/items에 대한 POST
"/files/{path...}"/files/a/b/c, path"a/b/c"
"/{$}"모든 경로가 아니라 /
"/"다른 패턴과 일치하지 않는 모든 경로

두 패턴이 일치하면 더 구체적인 쪽이 이기므로 /items/new/items/{id}를 이깁니다. 어느 쪽도 더 구체적이지 않다면, 예를 들어 /items/{id}/{kind}/new(둘 다 /items/new와 일치)처럼, 두 번째를 등록할 때 두 패턴을 모두 적은 메시지와 함께 패닉이 납니다. 경로는 일치하지만 메서드가 일치하지 않으면, 직접 코드를 쓰지 않아도 mux가 Allow 헤더와 함께 405 Method Not Allowed로 응답합니다.

"/" 패턴은 모든 것을 받는 패턴입니다. 홈페이지를 "/"로 등록했다가 모르는 URL마다 200으로 응답하는 것을 보고 놀라는 사람이 많습니다. 홈페이지에는 "GET /{$}"를 쓰세요.

이 패턴에는 go.modgo 1.22 이상이 필요합니다. 버전 줄이 더 오래되었다면 mux는 예전 동작으로 돌아갑니다. "GET /items"를 호스트 이름 다음에 경로가 오는 것으로 읽기 때문에, 라우트가 절대 일치하지 않고 /items에 대한 모든 요청이 404를 받습니다.

작은 JSON API

마지막 요청은 어떤 라우트도 받지 않는 DELETE를 쓰며, mux가 스스로 Allow: GET, HEAD와 함께 405로 응답합니다. 그 경로에 등록된 메서드가 그것들이기 때문입니다(GET 라우트는 HEAD도 받습니다).

상태 코드와 쓰기 순서

응답은 세 부분으로 되어 있고, 헤더, 상태, 본문 순서로 써야 합니다.

  1. w.Header().Set(...)은 헤더를 바꿉니다. 상태가 전송되기 전까지만 효과가 있습니다.
  2. w.WriteHeader(code)는 상태 줄과 헤더를 보냅니다.
  3. w.Write(...)(또는 fmt.Fprint(w, ...), 인코더)는 본문을 보냅니다. WriteHeader를 호출하지 않았다면 첫 번째 Write가 먼저 200 OK를 보냅니다.

결과적으로:

  • 본문이 시작된 뒤에 헤더를 설정해도 아무 효과가 없습니다.
  • WriteHeader를 두 번 호출하면 http: superfluous response.WriteHeader call이 로그에 남고 첫 번째 상태가 유지됩니다.
  • 오류 응답을 보낸 뒤에는 return하세요. http.Error는 핸들러를 멈추지 않으며, 그 뒤의 코드는 같은 응답에 계속 씁니다.

숫자 그대로보다는 이름 있는 상수(http.StatusOK, http.StatusCreated, http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError)를 쓰세요. http.StatusText(404)"Not Found"를 반환합니다.

미들웨어

미들웨어는 핸들러를 받아 핸들러를 반환하는 함수로, 다음 핸들러를 호출하기 전이나 후에 무언가를 합니다:

같은 요청에 대해 log: 줄은 항상 client got:보다 먼저 나옵니다. 핸들러 고루틴이 반환하기 전에 그것을 출력하고, 서버는 핸들러가 반환할 때까지 응답을 끝내지 않기 때문입니다.

statusRecorder는 실제 ResponseWriter를 임베딩하고 WriteHeader만 덮어씁니다. 미들웨어에서 상태 코드를 관찰하는 표준 방법입니다.

운영 환경 설정

http.ListenAndServe는 타임아웃이 없는 서버를 쓰므로 느린 클라이언트가 연결을 무한정 잡고 있을 수 있습니다. 인터넷에 노출되는 서버라면 http.Server를 명시적으로 설정하고 정상적으로 종료하세요:

srv := &http.Server{
	Addr:              ":8080",
	Handler:           mux,
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       10 * time.Second,
	WriteTimeout:      30 * time.Second,
	IdleTimeout:       120 * time.Second,
}

go func() {
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}()

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done() // wait for Ctrl+C or a SIGTERM from the orchestrator

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil { // finish in-flight requests
	log.Println("shutdown:", err)
}

Shutdown은 새 연결을 받지 않고, 컨텍스트의 마감 시간까지 진행 중인 요청이 끝나기를 기다립니다. ListenAndServeShutdown이 시작되는 즉시 http.ErrServerClosed를 반환하므로, 그 오류는 실패로 취급하지 않습니다. 오래 걸리는 핸들러라면 클라이언트가 떠났을 때 멈추도록 r.Context()를 아래로 넘기세요.

정적 파일 제공은 한 줄입니다: mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public")))).

흔한 실수

  • http.Error 뒤에 반환하지 않음. 핸들러가 계속 실행되면서 응답에 더 씁니다.
  • 본문을 쓴 뒤에 헤더를 설정함. 조용히 버려집니다.
  • 홈페이지를 "/"에 등록함. 모르는 모든 경로를 받는 패턴이 됩니다. "/{$}"를 쓰세요.
  • 잠금 없이 핸들러 사이에서 상태를 공유함. 요청은 동시에 실행됩니다.
  • 운영 환경에서 기본 서버를 씀. 타임아웃이 없습니다. http.Server에 설정하세요.
  • 라우팅 패턴이 무시됨. "GET /x/{id}"가 절대 일치하지 않는다면 go.modgo 1.22 이상이 적혀 있는지 확인하세요.

자주 묻는 질문

Go로 간단한 웹 서버는 어떻게 만드나요?

핸들러를 등록하고 수신을 시작합니다: http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "hello") }) 다음에 log.Fatal(http.ListenAndServe(":8080", nil)). 표준 라이브러리 서버는 운영 환경에 쓸 수 있는 수준이며, HTTP/1.1, TLS 위의 HTTP/2, keep-alive, 연결당 고루틴 하나를 처리합니다.

Go net/http에서 경로 매개변수는 어떻게 얻나요?

Go 1.22부터 ServeMux 패턴에 와일드카드를 쓸 수 있습니다. mux.HandleFunc("GET /items/{id}", h)로 등록하고 핸들러 안에서 r.PathValue("id")로 값을 읽으세요. 끝에 붙인 {path...}는 경로의 나머지와 일치합니다. 1.22 이전에는 r.URL.Path를 직접 나누거나 chi 같은 라우터를 써야 했습니다.

Go로 REST API를 만들려면 Gin 같은 프레임워크가 필요한가요?

아니요. Go 1.22부터 net/http가 메서드와 경로 매개변수로 라우팅하므로, 사람들이 라우터를 쓰던 주된 이유가 해결되었습니다. 본문에는 encoding/json을, 로깅과 인증에는 작은 미들웨어 함수를 쓰면 대부분의 API는 표준 라이브러리로 충분합니다. 프레임워크는 요청 바인딩과 검증 같은 편의 기능을 더해 줍니다.

Go HTTP 핸들러에서 상태 코드는 어떻게 설정하나요?

본문을 쓰기 전에 w.WriteHeader(http.StatusCreated)를 호출합니다. 본문을 먼저 쓰면 Go가 자동으로 200 OK를 보내고, 이후의 WriteHeadersuperfluous response.WriteHeader call 로그 메시지와 함께 무시됩니다. 헤더도 WriteHeader 전에 w.Header().Set으로 설정하세요. 오류라면 http.Error(w, msg, code)가 이 모두를 해 줍니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기