Go Web Development Guide | REST APIs, Middleware, and Production

Go Web Development Guide | REST APIs, Middleware, and Production

이 글의 핵심

Go's standard library is powerful enough to build production web APIs without a framework. This guide covers routing, middleware, database integration, and deployment — from first endpoint to production.

What This Guide Covers

Go’s standard library ships a production-capable HTTP server. This guide builds a complete REST API from scratch — routing, middleware, JSON, database, auth — then covers deployment.

Real-world insight: Rewriting a Node.js service in Go cut memory from 512MB to 28MB and latency p99 from 180ms to 12ms — with fewer lines of code.


Setup

go mod init github.com/yourname/myapi

No external dependencies required for a basic server.


1. Basic HTTP Server

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
)

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
    })

    mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
        name := r.PathValue("name")  // Go 1.22+
        fmt.Fprintf(w, "Hello, %s!\n", name)
    })

    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

Go 1.22 added method and path parameter support to ServeMux — no router library needed for most APIs.


2. JSON Request and Response

type CreateUserRequest struct {
    Name  string `json:"name"  validate:"required"`
    Email string `json:"email" validate:"required,email"`
}

type UserResponse struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func writeError(w http.ResponseWriter, status int, msg string) {
    writeJSON(w, status, map[string]string{"error": msg})
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON")
        return
    }
    if req.Name == "" || req.Email == "" {
        writeError(w, http.StatusBadRequest, "name and email are required")
        return
    }
    user := UserResponse{ID: 1, Name: req.Name, Email: req.Email}
    writeJSON(w, http.StatusCreated, user)
}

3. Middleware

Middleware wraps http.Handler — chain them for logging, auth, CORS, etc.

// Logging middleware
func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

// Auth middleware
func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" {
            writeError(w, http.StatusUnauthorized, "missing token")
            return
        }
        // validate token...
        next.ServeHTTP(w, r)
    })
}

// CORS middleware
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")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        next.ServeHTTP(w, r)
    })
}

// Chain middleware
func chain(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
    for i := len(middleware) - 1; i >= 0; i-- {
        h = middleware[i](h)
    }
    return h
}

// Usage
mux.Handle("/api/", chain(apiHandler, loggingMiddleware, corsMiddleware, authMiddleware))

4. Context

Use context.Context to pass request-scoped values and handle cancellation:

type contextKey string
const userKey contextKey = "user"

// Set in middleware
func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        user := validateToken(r.Header.Get("Authorization"))
        ctx := context.WithValue(r.Context(), userKey, user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Read in handler
func profileHandler(w http.ResponseWriter, r *http.Request) {
    user, ok := r.Context().Value(userKey).(*User)
    if !ok {
        writeError(w, http.StatusUnauthorized, "not authenticated")
        return
    }
    writeJSON(w, http.StatusOK, user)
}

5. Database with GORM

go get gorm.io/gorm gorm.io/driver/postgres
import (
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

type User struct {
    gorm.Model
    Name  string `gorm:"not null"`
    Email string `gorm:"unique;not null"`
}

func initDB() (*gorm.DB, error) {
    dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s sslmode=disable",
        os.Getenv("DB_HOST"),
        os.Getenv("DB_USER"),
        os.Getenv("DB_PASS"),
        os.Getenv("DB_NAME"),
    )
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        return nil, err
    }
    db.AutoMigrate(&User{})
    return db, nil
}

// CRUD
db.Create(&user)
db.First(&user, id)
db.Where("email = ?", email).First(&user)
db.Save(&user)
db.Delete(&user, id)

Dependency injection via struct

type UserHandler struct {
    db *gorm.DB
}

func (h *UserHandler) GetAll(w http.ResponseWriter, r *http.Request) {
    var users []User
    h.db.Find(&users)
    writeJSON(w, http.StatusOK, users)
}

func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
    var user User
    json.NewDecoder(r.Body).Decode(&user)
    h.db.Create(&user)
    writeJSON(w, http.StatusCreated, user)
}

// Wire up
h := &UserHandler{db: db}
mux.HandleFunc("GET /users", h.GetAll)
mux.HandleFunc("POST /users", h.Create)

6. JWT Authentication

go get github.com/golang-jwt/jwt/v5
import "github.com/golang-jwt/jwt/v5"

var jwtSecret = []byte(os.Getenv("JWT_SECRET"))

func generateToken(userID uint) (string, error) {
    claims := jwt.MapClaims{
        "sub": userID,
        "exp": time.Now().Add(24 * time.Hour).Unix(),
    }
    return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret)
}

func validateToken(tokenStr string) (*jwt.MapClaims, error) {
    token, err := jwt.ParseWithClaims(tokenStr, &jwt.MapClaims{},
        func(t *jwt.Token) (any, error) {
            if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("unexpected signing method")
            }
            return jwtSecret, nil
        },
    )
    if err != nil || !token.Valid {
        return nil, err
    }
    claims, _ := token.Claims.(*jwt.MapClaims)
    return claims, nil
}

7. Error Handling Pattern

type AppError struct {
    Code    int
    Message string
    Err     error
}

func (e *AppError) Error() string { return e.Message }

type HandlerFunc func(w http.ResponseWriter, r *http.Request) error

func handle(fn HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if err := fn(w, r); err != nil {
            var appErr *AppError
            if errors.As(err, &appErr) {
                writeError(w, appErr.Code, appErr.Message)
            } else {
                log.Printf("internal error: %v", err)
                writeError(w, http.StatusInternalServerError, "internal server error")
            }
        }
    }
}

// Usage — return errors instead of writing responses inline
mux.HandleFunc("GET /users/{id}", handle(func(w http.ResponseWriter, r *http.Request) error {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        return &AppError{Code: 400, Message: "invalid id"}
    }
    var user User
    if result := db.First(&user, id); result.Error != nil {
        return &AppError{Code: 404, Message: "user not found"}
    }
    return writeJSON(w, 200, user)
}))

8. Graceful Shutdown

func main() {
    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    go func() {
        log.Println("Starting on :8080")
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("Shutting down...")
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    server.Shutdown(ctx)
}

9. Docker Deployment

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server ./cmd/server

FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Final image is ~10MB with no runtime dependencies.


Key Takeaways

TopicKey point
RoutingGo 1.22 ServeMux handles method + path params natively
MiddlewareWrap http.Handler — compose with a chain helper
JSONencoding/json — decode into structs, encode responses
ContextPass auth user and request values via r.Context()
DatabaseGORM for productivity; database/sql for full control
Error handlingReturn errors from handlers; central error middleware
DeploymentMulti-stage Docker build → ~10MB scratch image

Go’s web development story is unusually clean: the standard library handles most needs, goroutines give you concurrency for free, and the compiled binary deploys without a runtime. Start with net/http, add a router when you need it, and reach for frameworks only when the project complexity justifies it.