Skip to content

웹 서버 통합

이 페이지는 CyberGo JWT를 프로덕션급 HTTP 서버에 통합하는 방법을 보여주며, 완전한 인증 수명 주기를 다룹니다:

  • 인증 흐름: 로그인 시 액세스 토큰 + 리프레시 토큰 발급
  • 인증 미들웨어: Authorization: Bearer 헤더에서 토큰 추출 및 검증
  • RBAC 역할 제어: claims.Role 기반 세분화된 접근 제어
  • 토큰 리프레시: 리프레시 토큰으로 새 액세스 토큰 교환
  • 로그아웃 폐기: 토큰을 블랙리스트에 추가하여 세션 무효화
  • 우아한 종료: 인터럽트 신호를 감지하고 안전하게 리소스 해제

전체 소스

이 페이지는 번들 예제 examples/6_web_server.go를 기반으로 하며, 직접 컴파일 및 실행할 수 있습니다.

1. Processor 초기화

프로덕션에서는 비밀 키를 환경 변수에서 가져와야 하며, 하드코딩을 금지합니다. 동시에 토큰 TTL, 속도 제한, 블랙리스트를 구성합니다:

go
package main

import (
    "log"
    "os"
    "time"

    "github.com/cybergodev/jwt"
)

// processor는 패키지 수준 JWT processor로 main에서 초기화됩니다.
// expiresInSeconds는 액세스 토큰 TTL에서 파생되어 클라이언트에
// "expires_in"으로 반환되어 실제 TTL과 어긋나지 않습니다.
var (
    processor        *jwt.Processor
    expiresInSeconds int
)

func main() {
    var err error
    processor, expiresInSeconds, err = newJWTProcessor()
    if err != nil {
        log.Fatalf("JWT processor 초기화 실패: %v", err)
    }
    defer processor.Close()
    log.Println("JWT processor 초기화 완료")
}

// newJWTProcessor는 환경 변수 기반 설정으로 processor를 구성합니다.
func newJWTProcessor() (*jwt.Processor, int, error) {
    // 환경 변수에서 비밀 키 가져오기 (프로덕션 안전, 하드코딩 금지)
    secretKey := os.Getenv("JWT_SECRET_KEY")
    if secretKey == "" {
        log.Println("경고: JWT_SECRET_KEY가 설정되지 않아 데모 키 사용")
        secretKey = "Kx9#mP2$vL8@nQ5!wR7&tY3^uI6*oE4%aS1+dF0-gH9~jK2#bN5$cM8@xZ7&vB4!"
    }

    cfg := jwt.DefaultConfig()
    cfg.SecretKey = secretKey
    cfg.AccessTokenTTL = 15 * time.Minute     // 액세스 토큰 수명: 15분
    cfg.RefreshTokenTTL = 7 * 24 * time.Hour  // 리프레시 토큰 수명: 7일
    cfg.Issuer = "web-server-example"

    // 무차별 발급 방지를 위해 속도 제한 활성화
    cfg.EnableRateLimit = true
    cfg.RateLimitRate = 100 // 분당 최대 100회
    cfg.RateLimitWindow = time.Minute

    // 블랙리스트 구성 (로그아웃 폐기용)
    cfg.Blacklist = jwt.BlacklistConfig{
        MaxSize:           10000,
        CleanupInterval:   5 * time.Minute,
        EnableAutoCleanup: true,
    }

    p, err := jwt.New(cfg)
    if err != nil {
        return nil, 0, err
    }
    // expires_in은 실제 TTL에서 파생되어 드리프트 방지
    return p, int(cfg.AccessTokenTTL.Seconds()), nil
}

프로덕션 안전

비밀 키는 항상 JWT_SECRET_KEY 환경 변수를 통해 주입하세요. 소스 코드에 작성하거나 버전 관리에 커밋하지 마세요.

2. 로그인 엔드포인트 (/login)

사용자 자격 증명을 검증한 후, 단기 액세스 토큰과 장기 리프레시 토큰을 동시에 발급하여 access_token + refresh_token + expires_in을 클라이언트에 반환합니다:

go
type User struct {
    ID          string   `json:"id"`
    Username    string   `json:"username"`
    Email       string   `json:"email"`
    Role        string   `json:"role"`
    Permissions []string `json:"permissions"`
}

type LoginRequest struct {
    Username string `json:"username"`
    Password string `json:"password"`
}

type LoginResponse struct {
    AccessToken  string `json:"access_token"`
    RefreshToken string `json:"refresh_token"`
    TokenType    string `json:"token_type"`
    ExpiresIn    int    `json:"expires_in"`
    User         User   `json:"user"`
}

type ErrorResponse struct {
    Error   string `json:"error"`
    Message string `json:"message"`
}

// sendError는 JSON 오류 응답을 전송합니다.
func sendError(w http.ResponseWriter, status int, errorCode, message string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(ErrorResponse{Error: errorCode, Message: message})
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        sendError(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST 메서드만 지원됩니다")
        return
    }

    var req LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        sendError(w, http.StatusBadRequest, "invalid_request", "잘못된 JSON 형식")
        return
    }

    // 사용자 인증 (mock 구현 — 실제 인증으로 교체)
    user, ok := authenticateUser(req.Username, req.Password)
    if !ok {
        sendError(w, http.StatusUnauthorized, "invalid_credentials", "사용자 이름 또는 비밀번호가 잘못되었습니다")
        return
    }

    // JWT Claims 생성
    claims := jwt.Claims{
        UserID:      user.ID,
        Username:    user.Username,
        Role:        user.Role,
        Permissions: user.Permissions,
        SessionID:   fmt.Sprintf("session_%d", time.Now().Unix()),
        Extra: map[string]any{
            "email": user.Email,
        },
    }

    // 액세스 토큰 발급 (단기)
    accessToken, err := processor.Create(&claims)
    if err != nil {
        sendError(w, http.StatusInternalServerError, "token_generation_failed", "토큰 생성 실패")
        return
    }

    // 리프레시 토큰 발급 (장기)
    refreshToken, err := processor.CreateRefresh(&claims)
    if err != nil {
        sendError(w, http.StatusInternalServerError, "token_generation_failed", "리프레시 토큰 생성 실패")
        return
    }

    // 토큰 쌍을 클라이언트에 반환
    response := LoginResponse{
        AccessToken:  accessToken,
        RefreshToken: refreshToken,
        TokenType:    "Bearer",
        ExpiresIn:    expiresInSeconds,
        User:         user,
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

// authenticateUser는 자격 증명을 검증합니다 (mock 구현 — 실제 인증으로 교체)
func authenticateUser(username, password string) (User, bool) {
    users := map[string]User{
        "admin": {
            ID: "1", Username: "admin", Email: "[email protected]", Role: "admin",
            Permissions: []string{"read", "write", "delete", "admin"},
        },
        "user": {
            ID: "2", Username: "user", Email: "[email protected]", Role: "user",
            Permissions: []string{"read"},
        },
    }
    user, exists := users[username]
    if !exists || password != "password" {
        return User{}, false
    }
    return user, true
}

3. 인증 미들웨어

Authorization: Bearer <token> 헤더에서 토큰을 추출하고, Validate로 검증한 후, Claims를 context.Context에 주입하여 다음 핸들러가 읽을 수 있게 합니다:

go
// contextKey는 context 키 충돌을 피하기 위한 커스텀 타입입니다.
type contextKey string

const claimsKey contextKey = "claims"

// extractToken은 Authorization 헤더에서 JWT를 추출합니다.
func extractToken(r *http.Request) string {
    authHeader := r.Header.Get("Authorization")
    if authHeader == "" {
        return ""
    }
    parts := strings.SplitN(authHeader, " ", 2)
    if len(parts) == 2 && parts[0] == "Bearer" {
        return parts[1]
    }
    return ""
}

// authMiddleware는 JWT 토큰을 검증하고 Claims를 context에 주입합니다.
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        token := extractToken(r)
        if token == "" {
            sendError(w, http.StatusUnauthorized, "missing_token", "인증 토큰이 필요합니다")
            return
        }

        claims, valid, err := processor.Validate(token)
        if err != nil || !valid {
            sendError(w, http.StatusUnauthorized, "invalid_token", "토큰이 유효하지 않거나 만료되었습니다")
            return
        }

        // Claims를 context에 주입하여 다음 핸들러에서 사용
        ctx := context.WithValue(r.Context(), claimsKey, &claims)
        next(w, r.WithContext(ctx))
    }
}

// profileHandler는 인증된 사용자의 프로필을 반환합니다.
func profileHandler(w http.ResponseWriter, r *http.Request) {
    // context에서 Claims 읽기
    claims := r.Context().Value(claimsKey).(*jwt.Claims)

    user := User{
        ID:          claims.UserID,
        Username:    claims.Username,
        Role:        claims.Role,
        Permissions: claims.Permissions,
    }
    if email, ok := claims.Extra["email"].(string); ok {
        user.Email = email
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

커스텀 contextKey

type contextKey string 커스텀 타입을 context 키로 사용하면 다른 패키지와의 충돌을 방지할 수 있습니다 — 이것은 Go 팀이 권장하는 관용구입니다.

4. RBAC 역할 제어

claims.Role을 기반으로 역할 기반 접근 제어를 구현하며, 인증 미들웨어 위에 겹쳐 사용할 수 있습니다:

go
// requireRole은 사용자가 필요한 역할을 가지고 있는지 확인합니다.
func requireRole(role string, next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        claims := r.Context().Value(claimsKey).(*jwt.Claims)

        if claims.Role != role {
            sendError(w, http.StatusForbidden, "insufficient_permissions",
                fmt.Sprintf("%s 역할이 필요합니다", role))
            return
        }

        next(w, r)
    }
}

// adminHandler는 관리자 전용 요청을 처리합니다.
func adminHandler(w http.ResponseWriter, r *http.Request) {
    claims := r.Context().Value(claimsKey).(*jwt.Claims)

    response := map[string]any{
        "message":     "관리자 대시보드에 오신 것을 환영합니다",
        "admin":       claims.Username,
        "permissions": claims.Permissions,
        "timestamp":   time.Now().Format(time.RFC3339),
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(response)
}

5. 리프레시 엔드포인트 (/refresh)

액세스 토큰이 만료되면 클라이언트는 리프레시 토큰으로 새 액세스 토큰을 교환합니다. 이 엔드포인트는 인증 수단이 액세스 토큰이 아닌 리프레시 토큰이므로 authMiddleware를 거치지 않습니다:

go
func refreshHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        sendError(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST 메서드만 지원됩니다")
        return
    }

    // 요청 본문에서 리프레시 토큰 가져오기
    var req struct {
        RefreshToken string `json:"refresh_token"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        sendError(w, http.StatusBadRequest, "invalid_request", "잘못된 JSON 형식")
        return
    }

    if req.RefreshToken == "" {
        sendError(w, http.StatusBadRequest, "missing_token", "리프레시 토큰이 필요합니다")
        return
    }

    // 리프레시 토큰으로 새 액세스 토큰 교환
    newAccessToken, err := processor.Refresh(req.RefreshToken)
    if err != nil {
        sendError(w, http.StatusUnauthorized, "invalid_token", "리프레시 토큰이 유효하지 않거나 만료되었습니다")
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]any{
        "access_token": newAccessToken,
        "token_type":   "Bearer",
        "expires_in":   expiresInSeconds,
    })
}

6. 로그아웃 엔드포인트 (/logout)

현재 액세스 토큰을 블랙리스트에 추가하여 즉시 무효화합니다. 폐기된 토큰에 대한 후속 Validate 호출은 오류를 반환합니다:

go
func logoutHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        sendError(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST 메서드만 지원됩니다")
        return
    }

    token := extractToken(r)
    if token == "" {
        sendError(w, http.StatusBadRequest, "missing_token", "토큰을 찾을 수 없습니다")
        return
    }

    // 토큰 폐기 (블랙리스트에 추가)
    if err := processor.Revoke(token); err != nil {
        sendError(w, http.StatusInternalServerError, "logout_failed", "로그아웃 실패")
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "message": "성공적으로 로그아웃되었습니다",
    })
}

블랙리스트 메커니즘

processor.Revoke는 토큰의 jti를 내장 블랙리스트에 추가합니다. 후속 Validate 호출이 자동으로 확인합니다. 블랙리스트 구성(용량, 자동 정리 간격)은 섹션 1의 초기화 시 설정됩니다. 토큰 블랙리스트를 참조하세요.

7. 라우트 등록 및 우아한 종료

모든 라우트를 등록하고, signal.NotifySIGINT/SIGTERM을 감지한 후, server.Shutdown으로 진행 중인 요청이 완료되기를 기다린 다음 processor를 닫아 블랙리스트 등의 리소스를 해제합니다:

go
func main() {
    // processor 초기화 (섹션 1 참조)
    var err error
    processor, expiresInSeconds, err = newJWTProcessor()
    if err != nil {
        log.Fatalf("JWT processor 초기화 실패: %v", err)
    }

    // JWT_PORT로 수신 주소 구성 가능, 기본값 :8080
    addr := ":8080"

    // 라우트 등록
    mux := http.NewServeMux()
    mux.HandleFunc("/login", loginHandler)
    mux.HandleFunc("/profile", authMiddleware(profileHandler))
    mux.HandleFunc("/admin", authMiddleware(requireRole("admin", adminHandler)))
    mux.HandleFunc("/logout", authMiddleware(logoutHandler))
    // /refresh는 리프레시 토큰으로 인증하며, 액세스 토큰 미들웨어를 거치지 않음
    mux.HandleFunc("/refresh", refreshHandler)

    server := &http.Server{
        Addr:         addr,
        Handler:      mux,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // goroutine에서 서버 시작
    go func() {
        log.Printf("서버 수신 중 http://localhost%s", addr)
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("서버 시작 실패: %v", err)
        }
    }()

    // 우아한 종료를 위해 인터럽트 신호 대기
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("서버를 종료하는 중...")

    // 타임아웃이 있는 우아한 종료
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Printf("서버 종료 오류: %v", err)
    }

    // JWT processor 닫기 (블랙리스트 등 리소스 해제)
    if err := processor.Close(); err != nil {
        log.Printf("Processor 종료 오류: %v", err)
    }

    log.Println("서버가 우아하게 중지되었습니다")
}

라우트 개요

메서드경로미들웨어설명
POST/login없음토큰 쌍 발급
GET/profileauthMiddleware사용자 정보 반환
GET/adminauthMiddleware + requireRole("admin")관리자 페이지
POST/logoutauthMiddleware토큰 폐기
POST/refresh없음 (리프레시 토큰 사용)새 액세스 토큰 교환

관련 예제