Go 언어로 JWT 완벽 마스터! 안전한 로그인과 권한 부여, A부터 Z까지 파헤치기 (JWT 초보 탈출 가이드)
안녕하세요!
오늘은 웹 애플리케이션의 보안을 튼튼하게 지켜주는 아주 중요한 기술, 바로 JWT에 대해 알아보려고 합니다.
JWT가 도대체 뭐길래 이렇게 중요하다고 하는 걸까요?
지금부터 JWT의 모든 것을 함께 파헤쳐 보겠습니다!
JWT란 무엇일까요?
JWT는 JSON Web Token의 줄임말인데요.
여러 웹사이트나 서비스들을 넘나들면서도 '이 사람이 진짜 그 사람이 맞나?' 하고 안전하게 확인하고 정보를 주고받을 수 있게 해주는 특별한 신분증 같은 것이라고 생각하면 쉽습니다.
웹 애플리케이션에서 사용자를 인증하고 정보를 안전하게 전달하는 데 아주 중요한 역할을 한답니다.
JWT가 해결해 주는 문제점들
전통적인 사용자 인증 방식의 한계

옛날 방식의 사용자 확인은 어땠을까요?
보통 사용자의 컴퓨터(클라이언트)에는 쿠키(cookie)라는 작은 정보 조각을 저장하고, 웹사이트를 운영하는 컴퓨터(서버)에는 세션(session)이라는 사용자 방문 기록을 남겨서 사용자를 확인했는데요.
이건 서버가 한 대일 때는 괜찮았지만, 요즘처럼 여러 대의 서버가 함께 일하는 큰 규모의 서비스에서는 문제가 생기기 시작했습니다.
예를 들어, 엄청나게 큰 놀이공원에 갔는데, 각 놀이기구마다 입장권을 따로 검사하고, 그 정보가 서로 공유되지 않는다고 생각해 보세요.
A 놀이기구를 탈 때 확인받았던 정보가 B 놀이기구에서는 소용없어서 다시 처음부터 확인해야 하는 불편함이 생기는 거죠.
여러 서버가 각자 독립적인 세션(session) 정보를 가지고 있다 보니, 사용자가 이 서버에서 저 서버로 옮겨 다닐 때마다 로그인 상태가 일치하지 않는 문제가 발생할 수 있었습니다.
또, 쿠키(cookie)는 특정 도메인(웹사이트 주소)을 기준으로 만들어지기 때문에, 서로 다른 도메인을 가진 여러 서비스에서 한 번의 로그인으로 모든 서비스를 이용하는 '싱글 사인온(single-sign-on)'을 구현하기도 어려웠습니다.
마치 A 쇼핑몰 로그인 정보로 B 쇼핑몰을 이용할 수 없는 것과 같다고 생각하면 된답니다.
JWT의 장점
JWT는 이런 문제들을 해결하기 위해 등장했습니다!
JWT를 사용하면 애플리케이션이 상태를 저장하지 않는 '무상태(stateless)' 방식으로 작동하게 되는데요.
서버가 일일이 사용자 세션 정보를 기억하고 있을 필요가 없어집니다.
대신, JWT 자체에 사용자 정보가 포함되어 있어서, 서버는 매번 요청이 올 때마다 JWT의 유효성만 검증하면 사용자를 확인할 수 있습니다.
여러 서버가 함께 작동하는 분산 시스템에서도 JWT는 서버를 늘리거나 관리하기가 훨씬 쉬워집니다.
서버의 개수나 위치에 영향을 받지 않기 때문이죠.
JWT의 형식 알아보기

자, 그럼 JWT는 어떻게 생겼을까요?
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImxlYXBjZWxsIiwibmJmIjoxNDQ0NDc4NDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
(예시 이미지: JWT 구조도 - 실제 사용 시에는 JWT 구조를 나타내는 이미지를 삽입하는 것이 좋습니다.)
딱 보면 외계어 같지만, 자세히 보면 규칙이 있습니다.
JWT 문자열은 위 예시처럼 마침표(.)로 연결된 세 부분으로 이루어져 있는데요.
바로 헤더(Header), 페이로드(Payload), 그리고 서명(Signature)입니다.
헤더 (Header)
첫 번째 부분인 헤더(Header)는 토큰의 타입과 어떤 암호화 알고리즘을 사용했는지에 대한 정보를 담고 있는 JSON 객체입니다.
예를 들면 이런 식인데요.
{
"typ": "JWT",
"alg": "HS256"
}
`typ`은 보통 "JWT"라고 적고, `alg`에는 HS256처럼 다양한 암호화 알고리즘 이름이 들어갑니다.
이 JSON 객체를 Base64URL이라는 특별한 방식으로 문자열로 바꾸면 JWT의 헤더(Header) 부분이 완성됩니다.
여기서 잠깐! 그냥 Base64가 아니라 Base64URL을 사용하는 이유가 있는데요.
일반 Base64로 만들어진 문자열에는 URL 주소창에서 특별한 의미를 가지는 문자들(+, /, =)이 포함될 수 있습니다.
우리가 토큰을 URL 주소에 넣어서 전달할 때 (예: `test.com?token=xxx`) 이런 특수 문자들은 문제를 일으킬 수 있거든요.
Base64URL 알고리즘은 Base64로 만들어진 문자열에서 `=`를 빼고, `+`는 `-`로, `/`는 `_`로 바꿔서 URL에서도 안전하게 사용할 수 있는 문자열을 만들어 준답니다.
페이로드 (Payload)
두 번째 부분인 페이로드(Payload)도 헤더(Header)처럼 JSON 객체인데요.
여기에 우리가 실제로 전달하고 싶은 데이터들이 담깁니다.
JWT 표준에서는 7가지 선택적인 필드(정보 항목)를 제공하는데요.
iss(issuer): 토큰을 발급한 사람(또는 시스템)을 나타냅니다. 대소문자를 구분하는 문자열이나 URI 주소 형식으로 씁니다.sub(subject): 토큰의 주제, 보통 사용자를 식별하는 데 사용됩니다.exp(expiration time): 토큰이 만료되는 시간입니다. 이 시간이 지나면 토큰은 더 이상 유효하지 않게 됩니다.aud(audience): 토큰을 사용할 대상(수신자)을 나타냅니다.iat(issued at): 토큰이 발급된 시간입니다.nbf(not before): 이 시간 이전에는 토큰이 유효하지 않다는 것을 의미합니다.jti(JWT ID): JWT의 고유 식별자입니다.
이런 표준 필드들 외에도, 우리 서비스에 필요한 정보들을 자유롭게 추가해서 사용할 수 있습니다.
예를 들면 이런 식으로요.
{
"iss":"admin",
"jti":"test",
"username":"drift",
"gender":"male",
"avatar":"https://avatar.drift.jpg"
}
이 JSON 객체도 헤더(Header)처럼 Base64URL 알고리즘을 사용해서 문자열로 바꾸면 JWT의 페이로드(Payload) 부분이 완성됩니다.
서명 (Signature)
마지막 세 번째 부분은 바로 서명(Signature)입니다.
이 서명은 JWT가 중간에 누군가에 의해 위조되거나 변경되지 않았다는 것을 보장해 주는 아주 중요한 역할을 한데요.
서명을 만드는 방법은 이렇습니다: Base64URL 방식으로 인코딩된 헤더(Header)와 페이로드(Payload)를 마침표(.)로 연결한 뒤, 헤더(Header)에 지정된 암호화 방식과 비밀키(secretKey)를 사용해서 암호화하면 최종적으로 서명(Signature)이 만들어집니다.
서버는 이 서명(Signature)을 확인해서 JWT의 무결성과 진위 여부를 검증할 수 있는 것이죠.
마치 편지 봉투에 찍힌 봉인 도장 같다고 생각하면 이해하기 쉽습니다.
봉인이 온전하면 편지 내용이 중간에 바뀌지 않았다는 것을 알 수 있는 것처럼요!
JWT의 특징들
JWT를 사용할 때 알아두면 좋은 특징들이 몇 가지 있는데요.
- 보안을 위한 약속, HTTPS 사용하기: JWT를 안전하게 사용하려면 HTTPS 프로토콜을 사용하는 것이 가장 좋습니다.
HTTP 프로토콜은 데이터를 암호화하지 않고 그대로 주고받기 때문에, 중간에 누군가 엿보거나 가로채서 JWT를 훔쳐갈 가능성이 있습니다.
HTTPS는 데이터를 암호화해서 전송하기 때문에 JWT를 효과적으로 보호할 수 있답니다. - 무효화 메커니즘의 한계점: 한 번 발급된 JWT는 설정된 만료 시간이 지나기 전까지는 특별히 무효화할 방법이 없습니다 (서버에서 암호화 알고리즘을 바꾸지 않는 한 말이죠).
이게 무슨 말이냐면, 만약 유효기간 내에 JWT가 도둑맞는다면, 나쁜 사람이 그 토큰을 악의적으로 사용할 수 있다는 의미입니다. - 민감한 정보 저장 시 주의사항: JWT가 암호화되지 않은 경우, 그 안에 주민등록번호나 비밀번호 같은 민감한 정보를 직접 저장해서는 안 됩니다.
만약 민감한 정보를 저장해야 한다면, 반드시 추가적으로 암호화하는 것이 좋습니다.
왜냐하면 JWT 자체는 암호를 푸는 것처럼 디코딩해서 내용을 볼 수 있기 때문인데요.
민감한 정보가 암호화되지 않은 채로 들어있다면 보안상 위험할 수 있습니다. - 만료 시간 설정은 짧게!: JWT의 만료 시간은 가급적 짧게 설정하는 것이 좋습니다.
그래야 혹시라도 토큰이 도둑맞더라도 유효한 시간이 짧아서 피해를 줄일 수 있습니다.
만료 시간이 짧으면 도둑맞은 후에도 토큰이 사용될 수 있는 시간이 제한되니까요. - 비즈니스 정보 저장 활용: JWT의 페이로드(Payload) 부분에는 사용자 이름이나 등급 같은 간단한 비즈니스 정보를 저장할 수도 있습니다.
이렇게 하면 매번 데이터베이스를 조회하는 횟수를 줄일 수 있어서 시스템 성능 향상에 도움이 됩니다.
예를 들어, 기본적인 사용자 정보를 페이로드(Payload)에 담아두면, 요청이 올 때마다 서버는 JWT에서 바로 이 정보를 꺼내 쓸 수 있어서 데이터베이스를 다시 조회할 필요가 없어지는 것이죠.
JWT 사용 방법
서버에서 JWT를 발급하면, 이 토큰은 클라이언트(사용자 컴퓨터나 앱)로 보내집니다.
클라이언트가 웹 브라우저라면 쿠키(cookie)나 로컬 스토리지(localStorage)에 저장할 수 있고, 스마트폰 앱이라면 sqlite 같은 데이터베이스에 저장할 수 있습니다.
그리고 나서 API 인터페이스를 요청할 때마다 이 JWT를 함께 보내는데요.
JWT를 서버로 전달하는 방법은 여러 가지가 있습니다.
URL 쿼리(query)로 보내거나, 쿠키(cookie)에 담거나, HTTP 헤더(header) 또는 본문(body)에 넣어서 보낼 수도 있습니다.
간단히 말해, 서버로 데이터를 전달할 수 있는 어떤 방법이든 사용할 수 있는 것이죠.
하지만 좀 더 표준적이고 권장되는 방법은 HTTP 헤더(header)의 Authorization 필드를 통해 업로드하는 것입니다.
형식은 다음과 같은데요.Authorization: Bearer <token>
이렇게 HTTP 요청 헤더(header)에 JWT를 담아 보내는 방식은 일반적인 인증 규격에 부합하고, 서버에서 통일된 방식으로 인증 처리를 하기에 편리합니다.
Go 프로젝트에서 JWT 사용하기
자, 이제 이론은 충분히 배웠으니 실제 Go 언어 프로젝트에서 JWT를 어떻게 사용하는지 알아보겠습니다!
JWT 생성하기
github.com/golang-jwt/jwt 라이브러리를 사용하면 JWT를 생성하거나 분석하는 작업을 쉽게 할 수 있습니다.NewWithClaims() 메소드를 사용해서 토큰(Token) 객체를 만들고, 이 객체의 메소드를 사용해서 JWT 문자열을 생성할 수 있는데요.
예를 한번 볼까요?
package main
import (
"fmt"
"time"
"github.com/golang-jwt/jwt"
)
func main() {
hmacSampleSecret := []byte("123")// Secret key, must not be leaked
// Generate a token object
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"foo": "bar",
"nbf": time.Date(2015, 10, 10, 12, 0, 0, 0, time.UTC).Unix(),
})
// Generate a jwt string
tokenString, err := token.SignedString(hmacSampleSecret)
fmt.Println(tokenString, err)
}
`New()` 메소드를 사용해서 토큰(Token) 객체를 만들고 JWT 문자열을 생성할 수도 있습니다.
예를 들면 이렇습니다
package main
import (
"fmt"
"time"
"github.com/golang-jwt/jwt"
)
func main() {
hmacSampleSecret := []byte("123")
token := jwt.New(jwt.SigningMethodHS256)
// Data cannot be carried when created through the New method, so data can be defined by assigning values to token.Claims
token.Claims = jwt.MapClaims{
"foo": "bar",
"nbf": time.Date(2015, 10, 10, 12, 0, 0, 0, time.UTC).Unix(),
}
tokenString, err := token.SignedString(hmacSampleSecret)
fmt.Println(tokenString, err)
}
위 예제들에서는 `jwt.MapClaims` 자료 구조를 통해 JWT 페이로드(Payload)의 데이터를 정의했는데요.
`jwt.MapClaims` 말고 우리가 직접 만든 구조체를 사용할 수도 있습니다.
다만, 이 구조체는 반드시 다음 인터페이스를 구현해야 한답니다.
type Claims interface {
Valid() error
}
다음은 사용자 정의 데이터 구조체를 구현한 예제입니다.
package main
import (
"fmt"
"github.com/golang-jwt/jwt"
)
type CustomerClaims struct {
Username string json:"username"
Gender string json:"gender"
Avatar string json:"avatar"
Email string json:"email"
}
func (c CustomerClaims) Valid() error {
return nil
}
func main() {
// Secret key
hmacSampleSecret := []byte("123")
token := jwt.New(jwt.SigningMethodHS256)
token.Claims = CustomerClaims{
Username: "Drift",
Gender: "male",
Avatar: "https://avatar.drift.jpg",
Email: "admin@test.org",
}
tokenString, err := token.SignedString(hmacSampleSecret)
fmt.Println(tokenString, err)
}
만약 우리가 만든 구조체 안에서 JWT 표준 필드들을 사용하고 싶다면, 이렇게 할 수 있습니다.
type CustomerClaims struct {
*jwt.StandardClaims// Standard fields
Username string `json:"username"`
Gender string `json:"gender"`
Avatar string `json:"avatar"`
Email string `json:"email"`
}
JWT 분석하기
JWT를 분석(Parsing)하는 것은 생성의 반대 작업이라고 생각하면 됩니다.
토큰을 분석해서 헤더(Header)와 페이로드(Payload) 정보를 얻고, 서명(Signature)을 통해 데이터가 위변조되지 않았는지 확인하는 과정인데요.
구체적인 구현은 다음과 같습니다.
package main
import (
"fmt"
"github.com/golang-jwt/jwt"
)
type CustomerClaims struct {
Username string `json:"username"`
Gender string `json:"gender"`
Avatar string `json:"avatar"`
Email string `json:"email"`
jwt.StandardClaims
}
func main() {
var hmacSampleSecret = []byte("111") // 실제로는 JWT 생성 시 사용한 비밀키와 같아야 합니다.
// The token generated in the previous example (실제 유효한 토큰으로 대체해야 합니다)
tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkxlYXBjZWxsIiwiZ2VuZGVyIjoibWFsZSIsImF2YXRhciI6Imh0dHBzOi8vYXZhdGFyLmxlYXBjZWxsLmpwZyIsImVtYWlsIjoiYWRtaW5AdGVzdC5vcmcifQ.some_signature_here"
token, err := jwt.ParseWithClaims(tokenString, &CustomerClaims{}, func(t *jwt.Token) (interface{}, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing method: %v", t.Header["alg"])
}
return hmacSampleSecret, nil
})
if err!= nil {
fmt.Println(err)
return
}
if claims, ok := token.Claims.(*CustomerClaims); ok && token.Valid {
fmt.Println(claims.Username, claims.Email)
} else {
fmt.Println("Invalid token or claims type assertion failed", err)
}
}
위 코드에서 `tokenString`은 실제 유효한 토큰 문자열로 대체해야 하며, `hmacSampleSecret`은 해당 토큰 생성 시 사용된 정확한 비밀키여야 합니다.
또한, 서명 알고리즘 검증 로직을 추가하여 보안을 강화하는 것이 좋습니다.
Gin 프로젝트에서 JWT 사용하기
Go 언어의 인기 웹 프레임워크인 Gin에서 JWT를 사용하는 방법을 알아볼까요?
Gin 프레임워크에서는 보통 미들웨어(middleware)를 통해 로그인 인증을 구현하는데요.github.com/appleboy/gin-jwt 라이브러리는 github.com/golang-jwt/jwt 라이브러리를 통합하고, 관련된 미들웨어(middleware)와 컨트롤러(controller)를 미리 정의해 두어서 아주 편리하게 사용할 수 있습니다.
구체적인 예제를 한번 살펴보겠습니다.
package main
import (
"log"
"net/http"
"time"
jwt "github.com/appleboy/gin-jwt/v2"
"github.com/gin-gonic/gin"
)
// Used to receive the username and password for login
type login struct {
Username string `form:"username" json:"username" binding:"required"`
Password string `form:"password" json:"password" binding:"required"`
}
var identityKey = "id"
// Data in the payload of jwt
type User struct {
UserName string
FirstName string
LastName string
}
func main() {
// Define a Gin middleware
authMiddleware, err := jwt.New(&jwt.GinJWTMiddleware{
Realm: "test zone", // Identification
SigningAlgorithm: "HS256", // Encryption algorithm
Key: []byte("secret key"), // Secret key
Timeout: time.Hour,
MaxRefresh: time.Hour, // Maximum refresh extension time
IdentityKey: identityKey, // Specify the id of the cookie
PayloadFunc: func(data interface{}) jwt.MapClaims { // Payload, where the data in the payload of the returned jwt can be defined
if v, ok := data.(*User); ok {
return jwt.MapClaims{
identityKey: v.UserName,
}
}
return jwt.MapClaims{}
},
IdentityHandler: func(c *gin.Context) interface{} {
claims := jwt.ExtractClaims(c)
return &User{
UserName: claims[identityKey].(string),
}
},
Authenticator: Authenticator, // Login verification logic can be written here
Authorizator: func(data interface{}, c *gin.Context) bool { // When a user requests a restricted interface through a token, this logic will be executed
if v, ok := data.(*User); ok && v.UserName == "admin" {
return true
}
return false
},
Unauthorized: func(c *gin.Context, code int, message string) { // Response when there is an error
c.JSON(code, gin.H{
"code": code,
"message": message,
})
},
// Specify where to get the token. The format is: "<source>:<name>". If there are multiple, separate them with commas
TokenLookup: "header: Authorization, query: token, cookie: jwt",
TokenHeadName: "Bearer",
TimeFunc: time.Now,
})
if err!= nil {
log.Fatal("JWT Error:" + err.Error())
}
r := gin.Default()
// Login interface
r.POST("/login", authMiddleware.LoginHandler)
auth := r.Group("/auth")
// Logout
auth.POST("/logout", authMiddleware.LogoutHandler)
// Refresh token, extend the token's validity period
auth.POST("/refresh_token", authMiddleware.RefreshHandler)
auth.Use(authMiddleware.MiddlewareFunc()) // Apply the middleware
{
auth.GET("/hello", helloHandler)
}
if err := http.ListenAndServe(":8005", r); err!= nil {
log.Fatal(err)
}
}
func Authenticator(c *gin.Context) (interface{}, error) {
var loginVals login
if err := c.ShouldBind(&loginVals); err!= nil {
return "", jwt.ErrMissingLoginValues
}
userID := loginVals.Username
password := loginVals.Password
if (userID == "admin" && password == "admin") || (userID == "test" && password == "test") {
return &User{
UserName: userID,
LastName: "Drift",
FirstName: "Admin",
}, nil
}
return nil, jwt.ErrFailedAuthentication
}
// Controller for handling the /hello route
func helloHandler(c *gin.Context) {
claims := jwt.ExtractClaims(c)
user, _ := c.Get(identityKey)
c.JSON(200, gin.H{
"userID": claims[identityKey],
"userName": user.(*User).UserName,
"text": "Hello World.",
})
}
서버를 실행한 후, `curl` 명령어를 사용해서 로그인 요청을 보내보면 됩니다.
예를 들어 이렇게요.
`curl http://localhost:8005/login -d "username=admin&password=admin"`
그러면 응답 결과로 토큰이 반환될 텐데요.
예를 들면 이런 모습입니다.
`{"code":200,"expire":"2023-10-27T10:00:00+09:00","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTgzNzgwMDAsImlkIjoiYWRtaW4iLCJvcmlnX2lhdCI6MTY5ODM3NDQwMH0.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}`
(실제 토큰 값과 만료 시간은 실행 시점에 따라 다릅니다.)
'Go' 카테고리의 다른 글
| Go (고) 언어에서 고루틴 풀(Goroutine Pool)을 구현하는 방법은? (0) | 2025.05.17 |
|---|---|
| Go 언어 완벽 마스터: 함수형 프로그래밍이 최고의 선택인 이유 (0) | 2025.05.17 |
| 고(Go) 1.24 정식 출시! 더 빠르고, 똑똑해지고, 강력해진 Go 언어의 모든 것, 지금부터 파헤쳐 볼까요? (0) | 2025.05.07 |
| 고랭(Golang) 채널(Channel) 완벽 마스터: 기초부터 실전까지 (0) | 2025.05.06 |
| 랭(Golang)에서 로컬 SSH 설정 파일 읽어 원격 서버 접속하기 (0) | 2025.05.06 |