Spring 숙련주차 -2

dev_joo·2026년 2월 6일

JWT 다루기

Dependency

// 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'

JWTUtil 작성

Util 클래스: 다른 객체에 의존하지 않고 하나의 모듈로서 동작하는 특정 매개 변수(파라미터)에 대한 작업을 수행하는 메서드들이 존재하는 클래스

JWT Util :
<JWT 관련 기능>
1. JWT 생성
2. 생성된 JWT를 Cookie에 저장
3. Cookie에 들어있던 JWT 토큰을 Substring
4. JWT 검증
5. JWT에서 사용자 정보 가져오기

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를 조회하고, 우선순위가 높은 설정을 먼저 적용한다.
  1. Command Line Arguments (Spring Boot 실행 인자)
    --jwt.secret.key=cmd

  2. JVM System Properties (JVM 옵션)
    -Djwt.secret.key=jvm

  3. OS Environment Variables
    JWT_SECRET_KEY=env
    (Spring이 자동으로 jwt.secret.key 로 매핑)

  4. application-{profile}.properties / yml
    application-prod.properties = prod
    (활성화된 profile 기준)

  5. application.properties / application.yml
    jwt.secret.key=local

@PostConstruct 스프링이 빈을 만들 때 생성자 호출 후,

@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

JWT 생성

필요에 따라 값을 생략할 수 있다.

// 토큰 생성
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 토큰을 전달하는 방법에는 다음 두 가지가 있으며 이는 프론트 개발자와 협의하에 정하면 된다.

1. Response Header에 포함시키기

// 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 토큰 검증

// 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;
}

JWT 토큰 사용하기

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

JWT 토큰 쿠키로 전달 / 쿠키 받아오기 테스트

@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;
}

CRUD 프로젝트에 Enum 적용하기

엔티티 Status 값 Enum 적용하기

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;

실무에서는 이 방법에 비용이 클 것이라 생각돼서 앞으로 엔티티 설계에 더 신중해야겠다는 생각을 하게 되었다.

profile
풀스택 연습생. 끈기있는 삽질로 무대에서 화려하게 데뷔할 예정 ❤️🔥

0개의 댓글