고 실전 #1 첫 HTTP 서버

4 분 소요

Go 실전 시리즈 시작. 지금까지의 기초/중급/고급에서 본 도구들이 — 실제 백엔드를 짤 때 어떻게 조립되는지를 6 편에 걸쳐 정리합니다.

  1. 첫 HTTP 서버 (이 글) — net/http, ListenAndServe, graceful shutdown
  2. 라우팅 — Go 1.22+ ServeMux 패턴 매칭
  3. JSON 입출력과 입력 검증
  4. DB 연동database/sql, 트랜잭션
  5. 미들웨어 패턴
  6. 테스트와 배포httptest, Docker

Go가 백엔드에서 강한 가장 큰 이유 — 표준 net/http만으로 프로덕션 서버가 가능하다는 점. 이 글은 그 시작입니다.

가장 작은 서버 #

hello.go
package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello, world!")
	})

	http.ListenAndServe(":8080", nil)
}
실행
go run hello.go
curl http://localhost:8080
# Hello, world!

19 줄(공백 제외 7 줄). 외부 의존 0. Express, FastAPI 같은 프레임워크의 도움 없이 — 표준 라이브러리만으로.

세 가지 핵심 타입 #

net/http의 핵심
type Handler interface {
	ServeHTTP(w ResponseWriter, r *Request)
}

type ResponseWriter interface {
	Header() Header
	Write([]byte) (int, error)
	WriteHeader(statusCode int)
}

type Request struct {
	Method, URL, Header, Body, ...
}
  • Handler — 모든 요청 처리기의 공통 인터페이스
  • ResponseWriter — 응답 작성
  • Request — 요청 정보

이 셋이 — Go HTTP 코드의 기본 구성 요소입니다.

HandleFunc vs Handler #

두 가지 등록
// 함수로 등록
http.HandleFunc("/a", func(w, r) { ... })

// Handler 인터페이스로 등록
type myHandler struct{}
func (h *myHandler) ServeHTTP(w, r) { ... }

http.Handle("/b", &myHandler{})

대부분은 — HandleFunc가 간결해서 자주 쓰입니다. 상태(필드)가 필요한 핸들러는 — 구조체 + ServeHTTP.

HandleFunc는 — 내부적으로 http.HandlerFunc 어댑터로 함수를 Handler로 만듭니다(어댑터 패턴의 정석 사례).

응답 쓰기 #

응답 작성 순서
func handler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")  // 1) 헤더 먼저
	w.WriteHeader(http.StatusOK)                         // 2) 상태 코드
	fmt.Fprintln(w, `{"ok":true}`)                       // 3) 바디
}

순서가 중요합니다:

  • Header().SetWriteHeader 또는 Write가 불리기 전에
  • 그 후 헤더를 바꾸면 효과 없음

WriteHeader를 안 부르면 — 첫 Write 시점에 자동으로 200.

URL/Query 파라미터 #

쿼리스트링
func handler(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	name := q.Get("name")          // ?name=...
	page := q.Get("page")
	fmt.Fprintf(w, "Hello %s on page %s", name, page)
}
curl 'http://localhost:8080/?name=lee&page=2'
# Hello lee on page 2

Query()가 — url.Values (map[string][]string)를 돌려줍니다. 같은 키가 여러 번 등장할 수 있는 점에 주의.

메서드 분기 #

메서드별 처리
func handler(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		// 조회
	case http.MethodPost:
		// 생성
	default:
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
	}
}

Go 1.22+ 부터는 — ServeMux 자체가 메서드 매칭을 지원합니다. 다음 글에서 자세히.

요청 바디 읽기 #

POST 바디
func handler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "read failed", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	fmt.Fprintf(w, "got %d bytes", len(body))
}

r.Body는 — io.ReadCloser. JSON 같은 구조화된 입력은 #3 JSON에서 자세히.

바디 크기 제한 #

MaxBytesReader
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)    // 1 MiB
body, err := io.ReadAll(r.Body)

악의적 큰 요청을 막기 위해 — 항상 한도를 두는 게 좋습니다.

상태 코드 — 표준 상수 #

http.StatusOK                    // 200
http.StatusCreated               // 201
http.StatusBadRequest            // 400
http.StatusUnauthorized          // 401
http.StatusNotFound              // 404
http.StatusInternalServerError   // 500

매직 넘버 대신 상수로. IDE 자동완성과 가독성 모두 좋아집니다.

http.Error 헬퍼 #

http.Error(w, "not found", http.StatusNotFound)

내부적으로 — 헤더 설정 + 상태 코드 + 메시지를 한 번에 정리합니다. 단순한 에러 응답에 자주 씁니다.

Server 구조체로 설정 명시 #

명시적 Server 구성
srv := &http.Server{
	Addr:         ":8080",
	Handler:      mux,
	ReadTimeout:  5 * time.Second,
	WriteTimeout: 10 * time.Second,
	IdleTimeout:  120 * time.Second,
}
log.Fatal(srv.ListenAndServe())

http.ListenAndServe(":8080", nil)는 편하지만 — 타임아웃 기본값이 무한. 프로덕션에서는 항상 http.Server를 직접 만들어 타임아웃 설정하는 게 안전합니다(slowloris 공격 방어 등).

Graceful shutdown — 정상 종료 #

서버를 끄려고 SIGINT를 보낼 때 — 진행 중인 요청을 마치고 종료하고 싶을 때 사용합니다.

graceful shutdown
package main

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

func main() {
	srv := &http.Server{Addr: ":8080", Handler: handler()}

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

	// SIGINT/SIGTERM 기다림
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()
	<-ctx.Done()

	log.Println("shutting down...")
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Fatal("shutdown:", err)
	}
	log.Println("bye")
}

흐름:

  1. 별도 고루틴에서 ListenAndServe — 메인은 시그널 대기
  2. signal.NotifyContext — Go 1.16+ 의 표준 패턴. 컨텍스트로 시그널 처리
  3. srv.Shutdown(ctx) — 진행 중 요청이 끝날 때까지 (또는 ctx 타임아웃)

쿠버네티스나 ECS 같은 환경에서 안전한 롤아웃을 위해 필수.

정적 파일 — http.FileServer #

정적 파일 서빙
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public"))))
  • http.Dir("public") — 디스크의 디렉터리
  • http.FileServer — 그 디렉터리를 서빙하는 핸들러
  • http.StripPrefix — URL prefix 제거 (이거 빼면 public/static/...로 찾으려 함)

작은 정적 사이트는 — 한 줄로 끝.

http.HandlerFunc 어댑터 #

함수를 Handler로
func myFunc(w http.ResponseWriter, r *http.Request) { ... }

var h http.Handler = http.HandlerFunc(myFunc)

함수가 — 인터페이스 메서드 한 개를 만족하도록 변환. 미들웨어 (#5)의 핵심 기법입니다.

함정 — 핸들러는 동시 실행 #

같은 핸들러가 — 여러 요청에서 동시에 호출됩니다. 핸들러가 공유 상태를 건드린다면 — 고급 #2 sync의 도구가 필요.

자주 만나는 함정
var counter int    // 공유 변수

func handler(w http.ResponseWriter, r *http.Request) {
	counter++       // ✗ race
}

해결: atomic.Int64 또는 Mutex.

함정 — 응답 작성 후 return 잊기 #

자주 만나는 함정
if err != nil {
	http.Error(w, "...", 400)
	// return 빠뜨림 → 아래 코드도 실행되어 헤더 충돌
}
fmt.Fprintln(w, "...")

에러 응답 후에는 항상 **return**으로 빠져나오기.

마무리 #

이번 글에서 정리한 내용:

  • net/http 만으로 프로덕션 서버 가능
  • 핵심 세 타입 — Handler, ResponseWriter, Request
  • HandleFunc가 흔함, 상태 필요하면 구조체 + ServeHTTP
  • 응답 작성 순서 — Header → WriteHeader → Write
  • http.Server 직접 만들고 타임아웃 항상 설정
  • graceful shutdownsignal.NotifyContext + srv.Shutdown(ctx)
  • 핸들러는 동시 실행 — 공유 상태는 동기화

다음 글(#2 라우팅)에서는 — Go 1.22+ 의 ServeMux가 어떻게 패턴/메서드 매칭을 지원하는지, 그리고 별도 라우터(chi, gorilla/mux 등)가 필요한 경우를 정리합니다.

X