JWT 적용하기

StrayCat·2026년 2월 16일

Spring Boot JWT 가이드

본 포스팅은 Spring Boot에서 JWT(JSON Web Token)를 다루는 방법에 대해 다룹니다. JWT의 생성, 검증, 그리고 쿠키를 통한 관리 방법까지 개발에서 적용할 수 있는 내용을 담았습니다.

📚 목차

  1. 프로젝트 설정
  2. JwtUtil 클래스 이해하기
  3. JWT 토큰 생성
  4. JWT 쿠키 저장
  5. JWT 토큰 추출
  6. JWT 검증
  7. JWT에서 사용자 정보 추출
  8. 테스트 API 작성

프로젝트 설정

1. JWT 의존성 추가

build.gradle 파일에 JWT 관련 라이브러리를 추가합니다.

// JWT
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

⚠️ JWT 라이브러리 버전 관련 주의사항

최신 버전 사용 권장

예시의 JWT 라이브러리 버전(0.11.5)은 2023년 기준입니다. 최신 프로젝트에서는 0.12.x 버전 사용을 권장합니다.

버전별 dependency 설정

0.11.x 버전 (포스팅 예시)

// JWT (0.11.5)
compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

0.12.x 버전 (2024년 이후 권장)

// JWT (0.12.3 - 최신 안정 버전)
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'

주요 변경사항

1. dependency scope 변경

  • compileOnlyimplementation (jjwt-api)
  • 0.12.x부터는 컴파일 타임과 런타임 모두에서 jjwt-api가 필요합니다.

2. Java 버전 요구사항

  • 0.11.x: Java 8 이상
  • 0.12.x: Java 8 이상 (동일하지만 더 나은 Java 17+ 지원)

3. API 변경사항

0.12.x 버전에서는 일부 deprecated 메서드가 제거되었습니다:

// 0.11.x (deprecated 경고 발생)
.signWith(SignatureAlgorithm.HS256, key)

// 0.12.x (권장)
.signWith(key, Jwts.SIG.HS256)

버전 확인 방법

현재 프로젝트에 설치된 JWT 버전 확인:

./gradlew dependencies | grep jjwt

마이그레이션 가이드

0.11.x에서 0.12.x로 업그레이드 시 코드 변경이 필요한 부분:

// ❌ 0.11.x 방식
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

// ✅ 0.12.x 방식
private final SecureDigestAlgorithm signatureAlgorithm = Jwts.SIG.HS256;
// ❌ 0.11.x 방식
Jwts.builder()
    .signWith(key, signatureAlgorithm)
    .compact();

// ✅ 0.12.x 방식
Jwts.builder()
    .signWith(key, Jwts.SIG.HS256)
    .compact();

권장사항

  1. 새 프로젝트: 0.12.3 이상 사용
  2. 기존 프로젝트: 0.11.5에서 잘 작동한다면 급하게 업그레이드할 필요 없음
  3. 보안 업데이트: jjwt GitHub releases를 주기적으로 확인하여 보안 패치 적용

최신 버전 확인

Maven Central Repository에서 최신 버전 확인:


2. Secret Key 설정

application.properties 파일에 JWT 서명에 사용할 Secret Key를 Base64 인코딩하여 추가합니다.

// sample secret key
jwt.secret.key=7Iqk7YyM66W07YOA7L2U65Sp7YG065+9U3ByaW5n6rCV7J2Y7Yqc7YSw7LWc7JuQ67mI7J6F64uI64ukLg==

⚠️ 중요: 실제 프로덕션 환경에서는 환경 변수나 별도의 보안 저장소를 통해 Secret Key를 관리해야 합니다.


JwtUtil 클래스 이해하기

Util 클래스란?

Util 클래스는 특정 매개변수에 대한 작업을 수행하는 메서드들의 집합입니다. 다른 객체에 의존하지 않고 독립적으로 동작하는 것이 특징입니다.

본 포스팅에서는 Jwt를 다루기 위한 메서드들의 집합체인 JwtUtil 클래스를 작성해서 활용합니다.

JwtUtil 클래스는 다음과 같은 JWT 관련 기능을 수행합니다:

  1. JWT 생성
  2. 생성된 JWT를 Cookie에 저장
  3. Cookie에서 JWT 토큰 추출 (Substring)
  4. JWT 검증
  5. JWT에서 사용자 정보 추출

필요한 상수 및 필드 선언

@Component
public class JwtUtil {
    // Header KEY 값
    public static final String AUTHORIZATION_HEADER = "Authorization";
    
    // 사용자 권한 값의 KEY
    public static final String AUTHORIZATION_KEY = "auth";
    
    // Token 식별자 (Bearer 방식)
    public static final String BEARER_PREFIX = "Bearer ";
    
    // 토큰 만료시간 (60분)
    private final long TOKEN_TIME = 60 * 60 * 1000L;
    
    // Base64 인코딩된 Secret Key (application.properties에서 주입)
    @Value("${jwt.secret.key}")
    private String secretKey;
    
    // 암호화에 사용할 Key 객체
    private Key key;
    
    // HMAC SHA256 암호화 알고리즘
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
    
    // 로그 설정
    public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");
}

주요 개념 설명

  • Bearer: JWT 혹은 OAuth를 사용할 때 토큰 타입을 나타내는 접두사입니다.
  • @PostConstruct: Bean이 생성된 직후 한 번만 실행되는 초기화 메서드를 지정합니다.

Secret Key 초기화

@PostConstruct
public void init() {
    // Base64로 인코딩된 Secret Key를 디코딩
    byte[] bytes = Base64.getDecoder().decode(secretKey);
    
    // HMAC-SHA 알고리즘에 사용할 Key 객체 생성
    key = Keys.hmacShaKeyFor(bytes);
}

💡 왜 @PostConstruct를 사용할까?

  • Secret Key는 한 번만 디코딩하면 되는 값입니다.
  • 매번 요청마다 디코딩을 반복하는 것은 비효율적입니다.
  • 생성자 호출 이후 딱 한 번만 실행되어 Key 필드에 값을 주입합니다.

사용자 권한 관리 (UserRoleEnum)

JWT에 저장할 사용자 권한을 Enum으로 관리합니다.

public enum UserRoleEnum {
    USER(Authority.USER),    // 일반 사용자 권한
    ADMIN(Authority.ADMIN);  // 관리자 권한
    
    private final String authority;
    
    UserRoleEnum(String authority) {
        this.authority = authority;
    }
    
    public String getAuthority() {
        return this.authority;
    }
    
    // Spring Security의 권한 규칙에 따라 ROLE_ 접두사 사용
    public static class Authority {
        public static final String USER = "ROLE_USER";
        public static final String ADMIN = "ROLE_ADMIN";
    }
}

📌 Enum을 사용하는 이유

  • 권한 타입을 명확하게 관리할 수 있습니다.
  • 오타나 잘못된 권한 값 입력을 방지합니다.
  • IDE의 자동완성 기능을 활용할 수 있습니다.

JWT 토큰 생성

/**
 * JWT 토큰 생성
 * @param username 사용자 식별자 (ID)
 * @param role 사용자 권한
 * @return "Bearer " 접두사가 붙은 JWT 토큰
 */
public String createToken(String username, UserRoleEnum role) {
    Date date = new Date();
    
    return BEARER_PREFIX +
        Jwts.builder()
            .setSubject(username)                                      // 사용자 식별자 (Payload의 sub)
            .claim(AUTHORIZATION_KEY, role)                            // 사용자 권한 정보
            .setExpiration(new Date(date.getTime() + TOKEN_TIME))      // 만료 시간 (현재 시간 + 60분)
            .setIssuedAt(date)                                         // 발급 시간
            .signWith(key, signatureAlgorithm)                         // Secret Key와 알고리즘으로 서명
            .compact();                                                // JWT 생성
}

JWT 구조 복습

생성된 JWT는 다음과 같은 구조를 가집니다:

Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJSb2JiaWUiLCJhdXRoIjoiUk9MRV9VU0VSIiwiaWF0IjoxNjE2MjM5MDIyfQ.xxx
       ^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^
              Header                               Payload                                         Signature
  • Header: 토큰 타입(JWT)과 암호화 알고리즘 정보
  • Payload: 사용자 정보 (subject, claim 등)
  • Signature: Header + Payload를 Secret Key로 서명한 값

JWT 쿠키 저장

생성된 JWT를 HTTP 응답의 쿠키에 저장합니다.

/**
 * 생성된 JWT를 Cookie에 저장하여 클라이언트에 전달
 * @param token JWT 토큰
 * @param res HTTP 응답 객체
 */
public void addJwtToCookie(String token, HttpServletResponse res) {
    try {
        // Cookie는 공백을 허용하지 않으므로 URL 인코딩
        token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20");
        
        // Cookie 생성 (Name-Value 쌍)
        Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token);
        cookie.setPath("/");  // 모든 경로에서 쿠키 접근 가능
        
        // Response 객체에 Cookie 추가
        res.addCookie(cookie);
    } catch (UnsupportedEncodingException e) {
        logger.error(e.getMessage());
    }
}

💡 왜 URL 인코딩을 할까?

  • Cookie의 Value에는 공백이나 특수문자가 올 수 없습니다.
  • JWT는 .으로 구분된 문자열이므로 안전한 전송을 위해 인코딩합니다.
  • + 문자는 공백으로 해석될 수 있어 %20으로 치환합니다.

JWT 토큰 추출

Cookie에서 받아온 JWT 토큰에서 "Bearer " 접두사를 제거합니다.

/**
 * Cookie에서 가져온 JWT 토큰의 Bearer 접두사 제거
 * @param tokenValue "Bearer "를 포함한 전체 토큰 값
 * @return 순수 JWT 토큰 문자열
 */
public String substringToken(String tokenValue) {
    // 토큰 값이 존재하고, "Bearer "로 시작하는지 확인
    if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
        // "Bearer " 이후의 문자열 반환 (7번째 인덱스부터)
        return tokenValue.substring(7);
    }
    
    logger.error("Not Found Token");
    throw new NullPointerException("Not Found Token");
}

검증 과정

  1. StringUtils.hasText(): 공백, null 체크
  2. startsWith(): "Bearer "로 시작하는지 확인
  3. substring(7): 7번째 인덱스부터 문자열 추출 ("Bearer " 제거)

JWT 검증

받아온 JWT 토큰이 유효한지 검증합니다.

/**
 * JWT 토큰 유효성 검증
 * @param token 순수 JWT 토큰 (Bearer 접두사 제거됨)
 * @return 유효한 토큰이면 true, 그렇지 않으면 false
 */
public boolean validateToken(String token) {
    try {
        // JWT 파싱 및 서명 검증
        Jwts.parserBuilder()
            .setSigningKey(key)    // Secret Key로 서명 확인
            .build()
            .parseClaimsJws(token); // JWT 파싱 및 검증
        return true;
        
    } catch (SecurityException | MalformedJwtException | SignatureException e) {
        logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
    } catch (ExpiredJwtException e) {
        logger.error("Expired JWT token, 만료된 JWT token 입니다.");
    } catch (UnsupportedJwtException e) {
        logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
    } catch (IllegalArgumentException e) {
        logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
    }
    
    return false;
}

검증 예외 처리

예외 타입의미
SecurityException, MalformedJwtException, SignatureExceptionJWT 서명이 유효하지 않음
ExpiredJwtException토큰이 만료됨
UnsupportedJwtException지원하지 않는 JWT 형식
IllegalArgumentExceptionJWT Claims가 비어있음

JWT에서 사용자 정보 추출

검증된 JWT에서 사용자 정보(Claims)를 추출합니다.

/**
 * JWT에서 사용자 정보(Claims) 추출
 * @param token 순수 JWT 토큰
 * @return JWT의 Payload 부분 (Claims)
 */
public Claims getUserInfoFromToken(String token) {
    return Jwts.parserBuilder()
        .setSigningKey(key)    // Secret Key로 서명 검증
        .build()
        .parseClaimsJws(token) // JWT 파싱
        .getBody();            // Payload 부분 추출
}

Claims란?

JWT의 Payload 부분에 담긴 정보를 의미합니다. Claims는 여러 개의 key-value 쌍으로 구성됩니다.

{
  "sub": "Michael",           // subject (사용자 식별자)
  "auth": "ROLE_USER",       // 사용자 권한
  "iat": 1616239022,         // issued at (발급 시간)
  "exp": 1616242622          // expiration (만료 시간)
}

테스트 API 작성

JWT 생성과 검증을 테스트할 수 있는 API를 작성해봅니다.

@RestController
@RequiredArgsConstructor
public class JwtTestController {
    
    private final JwtUtil jwtUtil;
    
    /**
     * JWT 생성 테스트 API
     * - JWT를 생성하고 Cookie에 저장
     */
    @GetMapping("/create-jwt")
    public String createJwt(HttpServletResponse res) {
        // 1. JWT 생성 (사용자: Michael, 권한: USER)
        String token = jwtUtil.createToken("Michael", UserRoleEnum.USER);
        
        // 2. JWT를 쿠키에 저장
        jwtUtil.addJwtToCookie(token, res);
        
        return "createJwt : " + token;
    }
    
    /**
     * JWT 검증 및 정보 추출 테스트 API
     * - Cookie에서 JWT를 받아와 검증하고 사용자 정보 추출
     */
    @GetMapping("/get-jwt")
    public String getJwt(@CookieValue(JwtUtil.AUTHORIZATION_HEADER) String tokenValue) {
        // 1. JWT 토큰 substring (Bearer 제거)
        String token = jwtUtil.substringToken(tokenValue);
        
        // 2. 토큰 검증
        if (!jwtUtil.validateToken(token)) {
            throw new IllegalArgumentException("Token Error");
        }
        
        // 3. 토큰에서 사용자 정보 추출
        Claims info = jwtUtil.getUserInfoFromToken(token);
        
        // 4. 사용자 username 추출 (subject)
        String username = info.getSubject();
        System.out.println("username = " + username);
        
        // 5. 사용자 권한 추출
        String authority = (String) info.get(JwtUtil.AUTHORIZATION_KEY);
        System.out.println("authority = " + authority);
        
        return "getJwt : " + username + ", " + authority;
    }
}

테스트 시나리오

  1. JWT 생성: GET /create-jwt 호출

    • JWT가 생성되고 Cookie에 저장됩니다.
    • 브라우저 개발자 도구에서 Cookie 확인 가능
  2. JWT 검증: GET /get-jwt 호출

    • Cookie에서 JWT를 가져와 검증합니다.
    • 검증에 성공하면 사용자 정보를 추출하여 반환합니다.

전체 JwtUtil 코드

package com.mywork.springauth.jwt;

import com.mywork.springauth.entity.UserRoleEnum;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.Key;
import java.util.Base64;
import java.util.Date;

@Component
public class JwtUtil {
    // Header KEY 값
    public static final String AUTHORIZATION_HEADER = "Authorization";
    // 사용자 권한 값의 KEY
    public static final String AUTHORIZATION_KEY = "auth";
    // Token 식별자
    public static final String BEARER_PREFIX = "Bearer ";
    // 토큰 만료시간
    private final long TOKEN_TIME = 60 * 60 * 1000L; // 60분

    @Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
    private String secretKey;
    private Key key;
    
     // 버전에 따라 표기 다름에 주의
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    // 로그 설정
    public static final Logger logger = LoggerFactory.getLogger("JWT 관련 로그");

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    // 토큰 생성
    public String createToken(String username, UserRoleEnum role) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(username) // 사용자 식별자값(ID)
                        .claim(AUTHORIZATION_KEY, role) // 사용자 권한
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }

    // JWT Cookie 에 저장
    public void addJwtToCookie(String token, HttpServletResponse res) {
        try {
            token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20");
            Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token);
            cookie.setPath("/");
            res.addCookie(cookie);
        } catch (UnsupportedEncodingException e) {
            logger.error(e.getMessage());
        }
    }

    // JWT 토큰 substring
    public String substringToken(String tokenValue) {
        if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        }
        logger.error("Not Found Token");
        throw new NullPointerException("Not Found Token");
    }

    // 토큰 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException | SignatureException e) {
            logger.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
        } catch (ExpiredJwtException e) {
            logger.error("Expired JWT token, 만료된 JWT token 입니다.");
        } catch (UnsupportedJwtException e) {
            logger.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
        } catch (IllegalArgumentException e) {
            logger.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
        }
        return false;
    }

    // 토큰에서 사용자 정보 가져오기
    public Claims getUserInfoFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
    }
}

💡 핵심 정리

JWT 처리 흐름

  1. 생성: createToken() → 사용자 정보와 권한을 담은 JWT 생성
  2. 저장: addJwtToCookie() → 생성된 JWT를 쿠키에 저장하여 클라이언트에 전달
  3. 추출: substringToken() → 쿠키에서 받은 JWT의 "Bearer " 접두사 제거
  4. 검증: validateToken() → JWT가 유효한지 검증 (서명, 만료시간 등)
  5. 정보 추출: getUserInfoFromToken() → 검증된 JWT에서 사용자 정보 추출

보안 고려사항

  • Secret Key 관리: 실제 운영 환경에서는 환경 변수나 별도의 보안 저장소에 보관
  • HTTPS 사용: JWT를 전송할 때는 반드시 HTTPS를 사용하여 중간에 탈취되지 않도록 함
  • 토큰 만료시간: 너무 길면 보안에 취약하고, 너무 짧으면 사용자 경험이 나빠짐 (적절한 시간 설정 필요)
  • Refresh Token: Access Token이 만료되었을 때 재발급받을 수 있는 Refresh Token 도입 고려

기타 팁

  • JWT는 상태를 저장하지 않는(Stateless) 인증 방식입니다.
  • 서버에서 토큰을 별도로 저장하지 않아도 되므로 확장성이 좋습니다.
  • 민감한 정보는 JWT에 담지 않는 것이 좋습니다. (JWT는 디코딩이 가능하므로)
  • 로그아웃 기능 구현 시 Blacklist나 Redis를 활용하여 토큰 무효화를 처리할 수 있습니다.

JWT를 활용하여 안전하고 확장 가능한 인증 시스템을 구현할 수 있도록 합시다.

profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글