본 포스팅은 Spring Boot에서 JWT(JSON Web Token)를 다루는 방법에 대해 다룹니다. 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 라이브러리 버전(0.11.5)은 2023년 기준입니다. 최신 프로젝트에서는 0.12.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'
// 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'
compileOnly → implementation (jjwt-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();
Maven Central Repository에서 최신 버전 확인:
application.properties 파일에 JWT 서명에 사용할 Secret Key를 Base64 인코딩하여 추가합니다.
// sample secret key
jwt.secret.key=7Iqk7YyM66W07YOA7L2U65Sp7YG065+9U3ByaW5n6rCV7J2Y7Yqc7YSw7LWc7JuQ67mI7J6F64uI64ukLg==
⚠️ 중요: 실제 프로덕션 환경에서는 환경 변수나 별도의 보안 저장소를 통해 Secret Key를 관리해야 합니다.
Util 클래스는 특정 매개변수에 대한 작업을 수행하는 메서드들의 집합입니다. 다른 객체에 의존하지 않고 독립적으로 동작하는 것이 특징입니다.
본 포스팅에서는 Jwt를 다루기 위한 메서드들의 집합체인 JwtUtil 클래스를 작성해서 활용합니다.
JwtUtil 클래스는 다음과 같은 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 관련 로그");
}
@PostConstruct
public void init() {
// Base64로 인코딩된 Secret Key를 디코딩
byte[] bytes = Base64.getDecoder().decode(secretKey);
// HMAC-SHA 알고리즘에 사용할 Key 객체 생성
key = Keys.hmacShaKeyFor(bytes);
}
💡 왜 @PostConstruct를 사용할까?
- Secret Key는 한 번만 디코딩하면 되는 값입니다.
- 매번 요청마다 디코딩을 반복하는 것은 비효율적입니다.
- 생성자 호출 이후 딱 한 번만 실행되어 Key 필드에 값을 주입합니다.
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 토큰 생성
* @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는 다음과 같은 구조를 가집니다:
Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJSb2JiaWUiLCJhdXRoIjoiUk9MRV9VU0VSIiwiaWF0IjoxNjE2MjM5MDIyfQ.xxx
^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^
Header Payload Signature
생성된 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으로 치환합니다.
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");
}
StringUtils.hasText(): 공백, null 체크startsWith(): "Bearer "로 시작하는지 확인substring(7): 7번째 인덱스부터 문자열 추출 ("Bearer " 제거)받아온 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, SignatureException | JWT 서명이 유효하지 않음 |
ExpiredJwtException | 토큰이 만료됨 |
UnsupportedJwtException | 지원하지 않는 JWT 형식 |
IllegalArgumentException | JWT Claims가 비어있음 |
검증된 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 부분 추출
}
JWT의 Payload 부분에 담긴 정보를 의미합니다. Claims는 여러 개의 key-value 쌍으로 구성됩니다.
{
"sub": "Michael", // subject (사용자 식별자)
"auth": "ROLE_USER", // 사용자 권한
"iat": 1616239022, // issued at (발급 시간)
"exp": 1616242622 // expiration (만료 시간)
}
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;
}
}
JWT 생성: GET /create-jwt 호출
JWT 검증: GET /get-jwt 호출
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();
}
}
createToken() → 사용자 정보와 권한을 담은 JWT 생성addJwtToCookie() → 생성된 JWT를 쿠키에 저장하여 클라이언트에 전달substringToken() → 쿠키에서 받은 JWT의 "Bearer " 접두사 제거validateToken() → JWT가 유효한지 검증 (서명, 만료시간 등)getUserInfoFromToken() → 검증된 JWT에서 사용자 정보 추출JWT를 활용하여 안전하고 확장 가능한 인증 시스템을 구현할 수 있도록 합시다.