2025-12-30
Go 언어 문법을 익히고 나면 실제로 동작하는 무언가를 만들고 싶어집니다. 그 첫 번째 관문이자 가장 강력한 무기가 바로 HTTP 서버 구축입니다. Go는 표준 라이브러리 net/http 패키지만으로도 프로덕션 수준의 웹 서버를 만들 수 있습니다.
이 글에서는 net/http 패키지의 내부 구조부터 HTTP 서버 구현, HTTP 클라이언트 요청, 미들웨어 패턴, REST API, 파일 업로드, 그리고 Graceful Shutdown까지 — 실무에서 즉시 활용할 수 있는 패턴을 체계적으로 다룹니다.
🛠️ 필자의 실무 경험
필자는 사내 메신저 서버, Telegram 봇 웹훅, 작업 알림 API 등을 Go의
net/http로 구축해 왔습니다. 처음에는fmt.Fprint로 응답을 보내는 수준에서 시작했지만, 점차 JSON 인코딩, 미들웨어 체인, context 연동, Graceful Shutdown까지 확장해 나갔습니다. 이 모든 것을 외부 프레임워크 없이 표준 라이브러리만으로 구현할 수 있다는 점이 Go의 가장 큰 매력 중 하나입니다.
net/http의 핵심은 단 하나의 인터페이스입니다.
// http.Handler 인터페이스 — Go HTTP의 근간
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
모든 핸들러, 미들웨어, 라우터는 이 인터페이스를 구현합니다. http.HandlerFunc는 함수를 Handler로 변환하는 어댑터 타입입니다.
// HandlerFunc는 func(ResponseWriter, *Request)를 Handler로 변환
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
요청 흐름을 시각화하면 다음과 같습니다.
클라이언트 → ListenAndServe → ServeMux(라우터) → Handler → ResponseWriter
package main
import (
"fmt"
"log"
"net/http"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "안녕하세요, Go HTTP 서버입니다!")
}
func main() {
http.HandleFunc("/", helloHandler)
fmt.Println("서버 실행 중: http://localhost:8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("서버 시작 실패: %v", err)
}
}
http.HandleFunc("/", helloHandler) — 기본 DefaultServeMux에 경로를 등록합니다.http.ListenAndServe(":8080", nil) — nil은 DefaultServeMux를 사용한다는 의미입니다.🛠️ 프로덕션에서는 DefaultServeMux를 피하라
DefaultServeMux는 전역 변수이므로, 외부 패키지에서 경로를 등록할 수 있어 보안 위험이 있습니다. 실무에서는http.NewServeMux()로 독립적인 ServeMux를 생성하세요.
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", homeHandler)
mux.HandleFunc("/api/users", usersHandler)
mux.HandleFunc("/api/users/", userByIDHandler) // 트레일링 슬래시는 prefix 매칭
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
log.Fatal(server.ListenAndServe())
}
Go 1.22부터 ServeMux가 메서드와 경로 파라미터를 지원합니다.
mux := http.NewServeMux()
// 메서드 + 경로 패턴 (Go 1.22+)
mux.HandleFunc("GET /api/users", listUsers)
mux.HandleFunc("POST /api/users", createUser)
mux.HandleFunc("GET /api/users/{id}", getUser)
mux.HandleFunc("PUT /api/users/{id}", updateUser)
mux.HandleFunc("DELETE /api/users/{id}", deleteUser)
// 핸들러에서 경로 파라미터 추출
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // Go 1.22+
// id 사용...
}
실무에서 JSON API를 구현할 때 사용하는 패턴입니다.
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type ErrorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}
// JSON 응답 헬퍼 함수
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("JSON 인코딩 실패: %v", err)
}
}
// JSON 요청 파싱 헬퍼 함수
func readJSON(r *http.Request, v any) error {
r.Body = http.MaxBytesReader(nil, r.Body, 1<<20) // 1MB 제한
return json.NewDecoder(r.Body).Decode(v)
}
func getUserHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
user, err := findUserByID(id)
if err != nil {
writeJSON(w, http.StatusNotFound, ErrorResponse{
Code: 404,
Message: "사용자를 찾을 수 없습니다",
})
return
}
writeJSON(w, http.StatusOK, user)
}
🛠️ json.NewEncoder vs json.Marshal
json.NewEncoder(w).Encode(v)—io.Writer에 직접 스트리밍 인코딩. 메모리 효율적.json.Marshal(v)— 바이트 슬라이스로 변환 후w.Write()호출. 로깅/디버깅 시 유용.응답 크기가 크다면
json.NewEncoder를 사용하세요.
// GET 요청 — 간편 함수
resp, err := http.Get("https://api.example.com/users")
if err != nil {
return fmt.Errorf("GET 요청 실패: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("응답 본문 읽기 실패: %w", err)
}
// POST 요청 — JSON body
payload := map[string]any{"name": "Alice", "age": 30}
data, _ := json.Marshal(payload)
resp, err := http.Post(
"https://api.example.com/users",
"application/json",
bytes.NewBuffer(data),
)
기본 http.Get()은 타임아웃이 없어 프로덕션에서 위험합니다. 항상 커스텀 클라이언트를 사용하세요.
// 패키지 수준 싱글턴 클라이언트 (재사용)
var httpClient = &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
},
}
func fetchUser(ctx context.Context, id string) (*User, error) {
url := "https://api.example.com/users/" + id
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("요청 생성 실패: %w", err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("API_TOKEN"))
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("API 호출 실패: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API 오류: status %d", resp.StatusCode)
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("응답 파싱 실패: %w", err)
}
return &user, nil
}
http.NewRequestWithContext(ctx, ...)를 사용하면 context의 취소/타임아웃이 HTTP 요청에도 전파됩니다. 이전 글에서 다룬 context 패키지와의 연동 포인트입니다.
미들웨어는 Handler를 받아 Handler를 반환하는 함수입니다.
type Middleware func(http.Handler) http.Handler
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 상태 코드를 캡처하기 위한 ResponseWriter 래퍼
lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(lrw, r)
log.Printf("[%s] %s %s %d %s",
r.Method,
r.RequestURI,
r.RemoteAddr,
lrw.statusCode,
time.Since(start),
)
})
}
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.statusCode = code
lrw.ResponseWriter.WriteHeader(code)
}
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "인증이 필요합니다", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
userID, err := validateToken(token)
if err != nil {
http.Error(w, "유효하지 않은 토큰입니다", http.StatusUnauthorized)
return
}
// context에 사용자 ID 주입
ctx := context.WithValue(r.Context(), contextKeyUserID, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
type contextKey string
const contextKeyUserID contextKey = "userID"
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func chain(h http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/users/{id}", getUserHandler)
// 미들웨어 체인: 요청은 logging → cors → auth → handler 순으로 통과
handler := chain(mux,
loggingMiddleware,
corsMiddleware,
authMiddleware,
)
server := &http.Server{
Addr: ":8080",
Handler: handler,
}
log.Fatal(server.ListenAndServe())
}
의존성을 주입받는 구조체 기반 핸들러 패턴을 사용하면 테스트하기 쉬운 코드를 작성할 수 있습니다.
type UserRepository interface {
FindAll() ([]User, error)
FindByID(id int) (*User, error)
Create(user *User) error
Update(user *User) error
Delete(id int) error
}
type UserHandler struct {
repo UserRepository
}
func NewUserHandler(repo UserRepository) *UserHandler {
return &UserHandler{repo: repo}
}
func (h *UserHandler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /api/users", h.List)
mux.HandleFunc("POST /api/users", h.Create)
mux.HandleFunc("GET /api/users/{id}", h.Get)
mux.HandleFunc("PUT /api/users/{id}", h.Update)
mux.HandleFunc("DELETE /api/users/{id}", h.Delete)
}
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
users, err := h.repo.FindAll()
if err != nil {
writeJSON(w, http.StatusInternalServerError, ErrorResponse{
Code: 500, Message: "내부 서버 오류",
})
return
}
writeJSON(w, http.StatusOK, users)
}
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := readJSON(r, &req); err != nil {
writeJSON(w, http.StatusBadRequest, ErrorResponse{
Code: 400, Message: "잘못된 요청 형식입니다",
})
return
}
// 입력 검증
if req.Name == "" || req.Email == "" {
writeJSON(w, http.StatusBadRequest, ErrorResponse{
Code: 400, Message: "name과 email은 필수 항목입니다",
})
return
}
user := &User{Name: req.Name, Email: req.Email}
if err := h.repo.Create(user); err != nil {
writeJSON(w, http.StatusInternalServerError, ErrorResponse{
Code: 500, Message: "사용자 생성 실패",
})
return
}
writeJSON(w, http.StatusCreated, user)
}
func uploadHandler(w http.ResponseWriter, r *http.Request) {
// 최대 10MB 제한 (메모리 + 디스크)
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "파일 크기 초과 (최대 10MB)", http.StatusRequestEntityTooLarge)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "파일 필드를 찾을 수 없습니다", http.StatusBadRequest)
return
}
defer file.Close()
// 파일 타입 검증 (Content-Type 헤더는 조작 가능하므로 실제 내용 검사)
buf := make([]byte, 512)
n, err := file.Read(buf)
if err != nil && err != io.EOF {
http.Error(w, "파일 읽기 실패", http.StatusInternalServerError)
return
}
contentType := http.DetectContentType(buf[:n])
if !strings.HasPrefix(contentType, "image/") {
http.Error(w, "이미지 파일만 업로드 가능합니다", http.StatusBadRequest)
return
}
// 파일 포인터를 처음으로 되돌리기
file.Seek(0, io.SeekStart)
// 안전한 파일명 생성 (사용자 입력 파일명은 신뢰하지 않음)
ext := filepath.Ext(header.Filename)
safeFilename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
savePath := filepath.Join("./uploads", safeFilename)
if err := os.MkdirAll("./uploads", 0755); err != nil {
http.Error(w, "디렉터리 생성 실패", http.StatusInternalServerError)
return
}
dst, err := os.Create(savePath)
if err != nil {
http.Error(w, "파일 저장 실패", http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, "파일 복사 실패", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]string{
"filename": safeFilename,
"url": "/uploads/" + safeFilename,
})
}
🛠️ 파일 업로드 보안 체크리스트
- 파일 크기 제한:
r.ParseMultipartForm(maxSize)및http.MaxBytesReader- 파일 타입 검증: Content-Type 헤더 대신
http.DetectContentType()으로 실제 내용 검사- 파일명 검증: 사용자 제공 파일명을 사용하지 말고 새로 생성
- 저장 경로 검증:
filepath.Clean()후 허용 경로 내에 있는지 확인 (Path Traversal 방지)
서버를 갑자기 종료하면 진행 중인 요청이 손상될 수 있습니다. http.Server.Shutdown()으로 진행 중인 요청을 완료하고 종료합니다.
func main() {
mux := http.NewServeMux()
// 핸들러 등록...
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// 별도 고루틴에서 서버 시작
go func() {
log.Println("서버 시작: :8080")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("서버 오류: %v", err)
}
}()
// OS 시그널 대기 (Ctrl+C, kill 등)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("서버 종료 중...")
// 최대 30초 동안 진행 중인 요청 완료 대기
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("강제 종료: %v", err)
}
log.Println("서버 정상 종료 완료")
}
ReadTimeout, WriteTimeout, IdleTimeout 설정도 필수입니다. 설정하지 않으면 슬로우 클라이언트 공격(Slowloris)에 취약합니다.
net/http/httptest 패키지로 서버를 실제로 실행하지 않고 핸들러를 테스트할 수 있습니다.
func TestGetUserHandler(t *testing.T) {
// 테스트용 Mock Repository
mockRepo := &mockUserRepository{
users: map[int]*User{
1: {ID: 1, Name: "Alice", Email: "alice@example.com"},
},
}
handler := NewUserHandler(mockRepo)
// 테스트 요청/응답 생성
req := httptest.NewRequest("GET", "/api/users/1", nil)
req.SetPathValue("id", "1") // Go 1.22+
w := httptest.NewRecorder()
handler.Get(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("예상 상태코드 200, 실제: %d", resp.StatusCode)
}
var user User
json.NewDecoder(resp.Body).Decode(&user)
if user.Name != "Alice" {
t.Errorf("예상 이름 Alice, 실제: %s", user.Name)
}
}
// 전체 서버를 테스트하는 통합 테스트
func TestServerIntegration(t *testing.T) {
ts := httptest.NewServer(setupRouter())
defer ts.Close()
resp, err := http.Get(ts.URL + "/api/users")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("예상 200, 실제: %d", resp.StatusCode)
}
}
mux := http.NewServeMux()
// /static/ 경로로 public 디렉터리 파일 서빙
fs := http.FileServer(http.Dir("./public"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))
// API 핸들러
mux.HandleFunc("GET /api/users", listUsers)
Go 서버를 외부에 배포할 때 Caddy를 리버스 프록시로 사용하면 HTTPS 설정이 자동화됩니다.
# Caddyfile
example.com {
reverse_proxy localhost:8080
}
Caddy는 Let’s Encrypt를 통한 인증서 발급·갱신을 자동으로 처리합니다. 개인 프로젝트나 소규모 서비스 배포에 매우 효과적입니다.
Go의 net/http는 외부 프레임워크 없이도 프로덕션 수준의 HTTP 서버와 클라이언트를 구현할 수 있는 강력한 표준 라이브러리입니다.
오늘 배운 핵심을 정리하면:
http.NewRequestWithContext()로 context를 연동한다.func(http.Handler) http.Handler 패턴으로 구성한다.다음 심화편 글에서는 Go 언어의 HTML 템플릿(html/template) 패키지를 다룹니다. 동적 HTML 페이지 생성, XSS 자동 방어, 레이아웃 상속 패턴, 그리고 이번에 배운 HTTP 서버와 결합하여 완전한 웹 애플리케이션을 구축하는 방법을 살펴보겠습니다.