// 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'
Util 클래스: 다른 객체에 의존하지 않고 하나의 모듈로서 동작하는 특정 매개 변수(파라미터)에 대한 작업을 수행하는 메서드들이 존재하는 클래스
JWT Util :
<JWT 관련 기능>
1. JWT 생성
2. 생성된 JWT를 Cookie에 저장
3. Cookie에 들어있던 JWT 토큰을 Substring
4. JWT 검증
5. JWT에서 사용자 정보 가져오기
# application.properties
jwt.secret.key=${JWT_SECRET_KEY}
@Component
public class JwtUtil {
// Header KEY 값 (Cookie의 A)
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);
}
}
@value
Spring에서 @Value는
Spring Environment에 등록된 모든 PropertySource를 조회하고,
우선순위가 높은 설정을 먼저 적용한다.
Command Line Arguments (Spring Boot 실행 인자)
--jwt.secret.key=cmd
JVM System Properties (JVM 옵션)
-Djwt.secret.key=jvm
OS Environment Variables
JWT_SECRET_KEY=env
(Spring이 자동으로 jwt.secret.key 로 매핑)
application-{profile}.properties / yml
application-prod.properties = prod
(활성화된 profile 기준)
application.properties / application.yml
jwt.secret.key=local
@Value, @Autowired 같은 의존성 주입이 끝난 직후 딱 한 번 실행
secretKey JWT 라이브러리(jjwt)는 내부에서 최소 키 길이 규칙을 강제한다. HS256 → 256bit(32Bytes) 이상
1. 왜 HS256은 32바이트(256bit) 이상을 요구하나?
- HS256 = HMAC + SHA-256
- SHA-256 해시 출력 길이: 256bit
- HMAC의 보안 강도 = min(해시 출력 길이, 키 길이)
→ 해시 강도만큼의 키 길이가 있어야 의미 있는 보안이 된다.
2. “길면 길수록 무조건 더 안전한가?”
- 32바이트 → ✅ (HS256 최소 안전선)
- 그 이상 (64B, 128B): 이론적으로는 더 안전 하지만 체감 보안 이득은 거의 없음 (성능/관리 비용만 증가)
→ 중요한 건 ‘충분히 긴 최소선’을 넘는 것
- Minimum Security Strength
Base64를 쓰면
1️⃣ 바이트 길이 보장
byte[] bytes = Base64.getDecoder().decode(secretKey);
2️⃣ 환경변수 / yml에 안전
줄바꿈,특수문자, 깨짐 ❌
3️⃣ “이건 문자열이 아니라 키다”라는 의미로 키 관리가 명확해짐
시크릿 키 생성하기:
openssl rand -base64 32
필요에 따라 값을 생략할 수 있다.
// 토큰 생성
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 토큰을 전달하는 방법에는 다음 두 가지가 있으며 이는 프론트 개발자와 협의하에 정하면 된다.
// JWT Cookie 에 저장
public void addJwtToCookie(String token, HttpServletResponse res) {
try {
token = URLEncoder.encode(token, "utf-8").replaceAll("\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행
Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
cookie.setPath("/");
// Response 객체에 Cookie 추가
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); // Bearer""
}
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();
}
@GetMapping("/create-jwt")
public String createJwt(HttpServletResponse res) {
// Jwt 생성
String token = jwtUtil.createToken("Robbie", UserRoleEnum.USER);
// Jwt 쿠키 저장
jwtUtil.addJwtToCookie(token, res);
return "createJwt : " + token;
}
@GetMapping("/get-jwt")
public String getJwt(@CookieValue(JwtUtil.AUTHORIZATION_HEADER) String tokenValue) {
// JWT 토큰 substring
String token = jwtUtil.substringToken(tokenValue);
// 토큰 검증
if(!jwtUtil.validateToken(token)){
throw new IllegalArgumentException("Token Error");
}
// 토큰에서 사용자 정보 가져오기
Claims info = jwtUtil.getUserInfoFromToken(token);
// 사용자 username
String username = info.getSubject();
System.out.println("username = " + username);
// 사용자 권한
String authority = (String) info.get(JwtUtil.AUTHORIZATION_KEY);
System.out.println("authority = " + authority);
return "getJwt : " + username + ", " + authority;
}
public enum ProductStatus {
ON_SALE {
@Override
public ProductStatus soldOut() {
return SOLD_OUT;
}
@Override
public ProductStatus delete() {
return DELETED;
}
},
SOLD_OUT {
@Override
public ProductStatus restore() {
return ON_SALE;
}
@Override
public ProductStatus delete() {
return DELETED;
}
},
DELETED {
// 아무 전이도 허용 안 함
};
public ProductStatus soldOut() {
throw new InvalidProductStatusTransitionException(this, SOLD_OUT);
}
public ProductStatus delete() {
throw new InvalidProductStatusTransitionException(this, DELETED);
}
public ProductStatus restore() {
throw new InvalidProductStatusTransitionException(this, ON_SALE);
}
}
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "product_id")
private Long id;
private String name;
private int price;
private int stock;
// 1. EnumType.STRING : enum 이름을 DB에 저장
// 2. EnumType.ORDINAL : enum 순서 값을 DB에 저장
@Enumerated(EnumType.STRING)
private ProductStatus status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "product")
private List<Order> orders;

엔티티 타입 하나만 바꾸는데만 7개 파일을 수정해야 했다.
DB 데이터 마이그레이션
엔티티의 타입이 바뀌니, 기존에 다른 String 값("NOTINSTOCK", "ACTIVE")으로 저장되어 있던 값이 Enum으로 해석이 안되면서 500에러가 발생했다.
java.lang.IllegalArgumentException: No enum constant com.sparta.crud.model.ProductStatus.NOTINSTOCK

데이터베이스에 있는 값을 수정해서 해결했다.
START TRANSACTION;
UPDATE products
SET status = 'ON_SALE'
WHERE status IS NULL
OR status NOT IN ('ON_SALE', 'SOLD_OUT', 'DELETED');
UPDATE products
SET status = 'SOLD_OUT',
updated_at = NOW()
WHERE stock = 0
AND status = 'ON_SALE';
COMMIT;
실무에서는 이 방법에 비용이 클 것이라 생각돼서 앞으로 엔티티 설계에 더 신중해야겠다는 생각을 하게 되었다.