Skip to content

오류 처리

CyberGo JWT 는 센티널 오류 (sentinel errors) 패턴을 사용하며, 모든 오류는 errors.Is()로 판별합니다.

기본 패턴

go
claims, valid, err := processor.Validate(tokenString)
if err != nil {
    switch {
    case errors.Is(err, jwt.ErrTokenExpired):
        // 토큰 만료
    case errors.Is(err, jwt.ErrTokenRevoked):
        // 토큰이 폐기됨
    case errors.Is(err, jwt.ErrTokenInvalidIssuer):
        // 발급자 불일치
    case errors.Is(err, jwt.ErrTokenInvalidAudience):
        // 수신자 불일치
    case errors.Is(err, jwt.ErrInvalidToken):
        // 서명 무효 또는 형식 오류
    case errors.Is(err, jwt.ErrProcessorClosed):
        // Processor 가 종료됨
    default:
        // 기타 오류
    }
}

errors.Is() 사용

err == jwt.ErrTokenExpired 또는 문자열 매칭을 사용하지 마세요. errors.Is()는 래핑된 오류를 올바르게 처리합니다.

오류 분류

설정 단계

jwt.New()는 다음 오류를 반환할 수 있습니다:

오류원인해결 방법
ErrInvalidConfig여러 설정 항목이 올바르지 않음Config 각 필드 확인
ErrInvalidSecretKeyHMAC 키가 32 바이트 미만이거나 약한 키더 강한 키 사용
ErrInvalidSigningMethod지원하지 않는 서명 알고리즘내장 12 개 알고리즘 중 하나 사용

토큰 작업

오류메서드처리 제안
ErrEmptyToken모든 토큰 작업 메서드요청 헤더 확인
ErrInvalidTokenValidate, Refresh, ValidateInto, RefreshInto, Revoke, IsRevoked서명 불일치, 접근 거부
ErrAlgorithmMismatchValidate, Refresh, ValidateInto, RefreshInto토큰 알고리즘이 설정과 불일치, 접근 거부
ErrExpirationRequiredValidate, Refresh, ValidateInto, RefreshIntoRequireExpiration 활성화되었으나 토큰에 exp 클레임 없음
ErrTokenTypeMismatchRefresh, RefreshInto액세스 토큰 (token_type=access) 으로 갱신 시도, 접근 거부
ErrTokenExpiredValidate, Refresh, ValidateInto, RefreshInto사용자에게 토큰 갱신 안내
ErrTokenNotValidYetValidate, Refresh, ValidateInto, RefreshInto시계 동기화 확인
ErrTokenInvalidIssuerValidate, Refresh, ValidateInto, RefreshInto, Revoke, IsRevoked발급자 불일치
ErrTokenInvalidAudienceValidate, Refresh, ValidateInto, RefreshInto, Revoke, IsRevoked수신자 불일치
ErrTokenRevokedValidate, Refresh, ValidateInto, RefreshInto토큰이 폐기됨, 접근 거부
ErrInvalidClaimsCreate, CreateRefresh, Validate, Refresh, ValidateInto, RefreshInto비즈니스 검증 실패
ErrTokenMissingIDRevoke, IsRevoked토큰에 jti 가 없음

속도 제한 및 블랙리스트

오류메서드처리 제안
ErrRateLimitExceededCreate, CreateRefresh, Refresh, RefreshInto429 반환
ErrBlacklistNotConfiguredRevoke블랙리스트 설정

라이프사이클

오류메서드처리 제안
ErrProcessorClosed모든 메서드Processor 재생성
ErrStoreClosedRevoke 등저장소가 종료됨

오류 타입

ValidationError

필드 수준 검증 실패 시 반환되며, 구체적인 필드와 오류 정보를 포함합니다:

go
type ValidationError struct {
    Field   string  // 오류가 발생한 필드명
    Message string  // 오류 설명
    Err     error   // 내부 오류
}

오류 래핑 체인

CyberGo JWT의 오류는 센티널 오류(errors.Is로 매칭 가능)와 래핑 오류(errors.As로 구조화 정보 추출 필요)로 나뉩니다. 래핑 체인을 이해하면 실패 원인을 정확히 파악할 수 있습니다.

ValidationError 와 errors.As

필드 수준 검증 실패(길이 초과, 인젝션 탐지 등)는 구체적인 필드명과 오류 정보를 포함한 *ValidationError를 반환합니다. 몇 겹으로 래핑되더라도 errors.As가 관통합니다:

go
token, err := processor.Create(claims)
if err != nil {
    var ve *jwt.ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("필드: %s, 이유: %s\n", ve.Field, ve.Message)
        // 필드: user_id, 이유: suspicious pattern detected
        return
    }
    // 필드 수준 오류가 아님, errors.Is 분기로 이동
}

ErrInvalidClaims 가 Claims.Validate()를 래핑

Claims.Validate()(또는 커스텀 Claims의 Validate())가 반환하는 것은 설명적 오류(예: errors.New("user_id is required"))이며, 센티널 오류가 아닙니다. Processor는 이를 ErrInvalidClaims로 래핑합니다:

invalid claims: user_id is required
└── ErrInvalidClaims (센티널, 외층)
    └── user_id is required (설명적, 내층)

따라서 매칭 방식은 이중층입니다:

go
if errors.Is(err, jwt.ErrInvalidClaims) {
    // Claims 검증 실패라는 카테고리
    fmt.Println("상세:", err) // invalid claims: user_id is required
}

ParseUnverified 의 파싱 오류

ParseUnverified는 토큰 형식 오류(base64 디코딩 실패, JSON 파싱 실패 등) 시 반환하는 파싱 오류는 래핑 오류이며, 센티널 오류가 아닙니다:

go
err := processor.ParseUnverified(malformedToken, &claims)
if err != nil {
    // ❌ errors.Is 로 구체적 원인 매칭 불가
    // ✅ "파싱 실패"라는 사실만 판단 가능
    fmt.Println("파싱 실패:", err) // failed to parse token: ...
}

ParseUnverified의 유일한 두 센티널 오류는 ErrProcessorClosed(Processor 종료됨)와 ErrEmptyToken(빈 문자열 전달)이며, 나머지 형식 오류는 errors.Is로 정확히 매칭할 수 없습니다.

errors.Is vs errors.As 사용 시기

  • errors.Is: 센티널 오류(ErrTokenExpired, ErrInvalidClaims 등) 매칭, "어떤 카테고리의 실패인지" 판단에 사용.
  • errors.As: 구조화 오류(*ValidationError) 추출, "구체적으로 어느 필드에서 무슨 문제가 발생했는지" 파악에 사용.
  • 둘은 조합 가능: 먼저 errors.Is로 카테고리를 파악한 후 errors.As로 세부 정보 추출.

HTTP 상태 코드 매핑

RESTful API에서 JWT 오류를 적절한 HTTP 상태 코드로 매핑하는 것은 모범 사례입니다 — 클라이언트는 이를 통해 "자격 증명 문제"(401), "요청 형식 문제"(400), "서버 문제"(500)를 구분할 수 있습니다.

매핑 표

JWT 오류HTTP 상태 코드클라이언트 동작
ErrEmptyToken401 Unauthorized인증 토큰 제공
ErrInvalidToken401 Unauthorized재로그인
ErrAlgorithmMismatch401 Unauthorized토큰 출처가 신뢰할 수 없음, 재로그인
ErrTokenExpired401 Unauthorized리프레시 토큰으로 새 토큰 획득
ErrTokenRevoked401 Unauthorized토큰이 폐기됨, 재로그인
ErrTokenInvalidIssuer401 Unauthorized토큰 발급자 불일치
ErrTokenInvalidAudience401 Unauthorized토큰 수신자 불일치
ErrTokenNotValidYet401 Unauthorized클라이언트 클록 동기화 확인
ErrTokenTypeMismatch401 Unauthorized올바른 리프레시 토큰 사용
ErrExpirationRequired401 Unauthorized토큰에 만료 클레임 없음
ErrInvalidClaims400 Bad RequestClaims 내용 수정 (생성 시나리오)
ErrRateLimitExceeded429 Too Many Requests요청 빈도 낮추고 잠시 후 재시도
ErrProcessorClosed500 Internal Server Error서버에서 Processor 재시작 필요

RESTful 모범 사례

  • 401 Unauthorized: 모든 토큰 유효성 문제(만료, 폐기, 서명 오류, 발급자/수신자 불일치). 클라이언트는 재인증 또는 토큰 갱신을 안내받아야 합니다.
  • 400 Bad Request: 토큰 생성 시 Claims 검증 실패 — 이는 인증 실패가 아닌 호출자의 프로그래밍 오류입니다.
  • 429 Too Many Requests: 속도 제한 트리거 시 이 코드를 반환하고 Retry-After 헤더로 대기 시간을 알립니다.
  • 500 Internal Server Error: ErrProcessorClosed는 서버 상태 이상이며, 클라이언트에 노출해서는 안 됩니다.

웹 서비스에서의 오류 처리

아래 프로세서는 Validate가 반환할 수 있는 모든 일반 오류를 다루며, HTTP 상태 코드 매핑에 따라 적절한 응답을 반환합니다:

go
package main

import (
    "encoding/json"
    "errors"
    "net/http"

    "github.com/cybergodev/jwt"
)

// authError 는 JWT 오류를 HTTP 상태 코드와 메시지로 매핑
func authError(w http.ResponseWriter, err error) {
    w.Header().Set("Content-Type", "application/json")

    switch {
    // 토큰 만료 — 클라이언트에게 갱신 안내
    case errors.Is(err, jwt.ErrTokenExpired):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "token_expired",
            "message": "토큰이 만료되었습니다, 갱신하세요",
        })

    // 토큰이 폐기됨
    case errors.Is(err, jwt.ErrTokenRevoked):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "token_revoked",
            "message": "토큰이 폐기되었습니다",
        })

    // 발급자 불일치
    case errors.Is(err, jwt.ErrTokenInvalidIssuer):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "invalid_issuer",
            "message": "발급자가 불일치합니다",
        })

    // 수신자 불일치
    case errors.Is(err, jwt.ErrTokenInvalidAudience):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "invalid_audience",
            "message": "수신자가 불일치합니다",
        })

    // 아직 유효하지 않음 — 클록 비동기
    case errors.Is(err, jwt.ErrTokenNotValidYet):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "token_not_valid_yet",
            "message": "토큰이 아직 유효하지 않습니다",
        })

    // 알고리즘 불일치
    case errors.Is(err, jwt.ErrAlgorithmMismatch):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "algorithm_mismatch",
            "message": "서명 알고리즘이 불일치합니다",
        })

    // 토큰 무효 (서명 오류, 형식 오류, 빈 토큰)
    case errors.Is(err, jwt.ErrInvalidToken),
        errors.Is(err, jwt.ErrEmptyToken),
        errors.Is(err, jwt.ErrExpirationRequired):
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "invalid_token",
            "message": "토큰이 무효합니다",
        })

    // Claims 검증 실패 — 필드 수준 세부 정보 추출 시도
    case errors.Is(err, jwt.ErrInvalidClaims):
        var ve *jwt.ValidationError
        if errors.As(err, &ve) {
            w.WriteHeader(http.StatusBadRequest)
            json.NewEncoder(w).Encode(map[string]string{
                "error":   "validation_failed",
                "field":   ve.Field,
                "message": ve.Message,
            })
        } else {
            w.WriteHeader(http.StatusBadRequest)
            json.NewEncoder(w).Encode(map[string]string{
                "error":   "validation_failed",
                "message": "클레임 검증 실패",
            })
        }

    // 속도 제한
    case errors.Is(err, jwt.ErrRateLimitExceeded):
        w.Header().Set("Retry-After", "60")
        w.WriteHeader(http.StatusTooManyRequests)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "rate_limited",
            "message": "요청이 너무 빈번합니다, 잠시 후 재시도하세요",
        })

    // 시스템 오류 — Processor 종료됨
    case errors.Is(err, jwt.ErrProcessorClosed):
        w.WriteHeader(http.StatusInternalServerError)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "internal_error",
            "message": "서비스를 일시적으로 사용할 수 없습니다",
        })

    // 폴백
    default:
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(map[string]string{
            "error":   "auth_failed",
            "message": "인증 실패",
        })
    }
}

func handleProtected(w http.ResponseWriter, r *http.Request) {
    tokenString := extractToken(r)
    claims, valid, err := processor.Validate(tokenString)
    if err != nil {
        authError(w, err)
        return
    }
    if !valid {
        authError(w, jwt.ErrInvalidToken)
        return
    }
    // 인증 통과, 요청 처리
    _ = claims
}

authError 재사용

authError는 특정 라우트와 무관한 오류 매핑 함수로, 인증이 필요한 모든 프로세서에서 재사용할 수 있습니다. 갱신 엔드포인트에서 ErrTokenTypeMismatch를 처리할 때도 호출할 수 있습니다.

다음 단계