JWT 토큰을 생성해보자!

Drumj·2025년 5월 16일
post-thumbnail

JWT 토큰은 어떻게 만들어질까?

내 프로젝트에 Spring Security를 세팅하기 전에!
JWT 토큰 만드는 걸 Test 한 이후에 Security 적용까지 완료해보자.

이번에는 아주 간단하게 JWT를 어떻게 만들어야 하는지에 대해 정리하려고 한다.

implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

2025년 5월 16일 기준 가장 최신 버전인 0.12.6을 사용해서 알아보자.


JWT Properties

jwt와 관련된 설정들을 application.yml에 두고 자바에서 값을 읽어서 사용할 것이다.

이미 알고 있는 @Value보다 이전에 공부했던 @ConfigurationProperties 를 사용해보자.

#application.yml
jwt:
  secret: ThisIsMyToyProjectJwtSecretKeyForStudySpringSecurityAndJwtAndMore
  expiration: 3600000 # 1시간
  refresh-expiration: 86400000 # 24시간
  issuer: "승호"

사실 secretexpriation만 있어도 충분하긴 하다.
그리고 secret은 충분히 긴 문자로 세팅해주자! (이후에 SecretKey 세팅 때문이다.)

@Getter
@ConfigurationProperties("jwt")
public class JwtProperties {
    private final String secret;
    private final String issuer;
    private final long expiration;
    private final long refreshExpiration;

    public JwtProperties(String secret, String issuer, long expiration, long refreshExpiration) {
        this.secret = secret;
        this.issuer = issuer;
        this.expiration = expiration;
        this.refreshExpiration = refreshExpiration;
    }
}

그리고 @ConfigurationProperties()를 적용한 클래스 생성

이제 application.yml에 작성한 설정값들을 자바 클래스로 사용할 수 있다.


JwtProvider

이제 이 클래스에서 실제 JWT를 생성해보자!

@Component
@EnableConfigurationProperties({JwtProperties.class})
public class JwtProvider {
    private final JwtProperties jwtProperties;
    private final SecretKey secretKey;

    public JwtProvider(JwtProperties jwtProperties) {
        this.jwtProperties = jwtProperties;
        this.secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(UTF_8));
    }

    public List<String> generateToken() {
        Date now = new Date();

        String accessToken = tokenBuilder(now, jwtProperties.getExpiration());//access token 생성
        String refreshToken = tokenBuilder(now, jwtProperties.getRefreshExpiration());//refresh token 생성

        return List.of(accessToken, refreshToken);
    }

    public void validateToken(String token) {
        try {
            Jws<Claims> claims = parseToken(token);

            System.out.println(claims.getPayload());
        } catch (ExpiredJwtException e) {
            throw new InvalidTokenException("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            throw new InvalidTokenException("지원하지 않는 JWT 토큰입니다.");
        } catch (MalformedJwtException e) {
            throw new InvalidTokenException("JWT 구조가 손상되었거나 잘못된 형식입니다.");
        }catch (SecurityException e) {
            throw new InvalidTokenException("JWT 서명 검증에 실패했습니다.", e);
        } catch (IllegalArgumentException e) {
            throw new InvalidTokenException("JWT 토큰이 잘못 되었습니다.", e);
        } catch (Exception e) {
            throw new InvalidTokenException("JWT 예기치 않은 예외 발생", e);
        }
    }


    private String tokenBuilder(Date now, long time) {
        Date expirationTime = new Date(now.getTime() + time);

        return Jwts.builder()
                .claims(getClaims())
                .issuer(jwtProperties.getIssuer())
                .issuedAt(now)
                .expiration(expirationTime)
                .signWith(secretKey)
                .compact();
    }

    private Claims getClaims() {
        // TODO: 실제 Account_id, Account_nickname, Account_role 가져오기
        return Jwts.claims()
                .add("id",1)
                .add("nickname","testUser")
                .add("role", ADMIN.name())
                .build();
    }

    private Jws<Claims> parseToken(String token) {
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token);
    }
}

젤 처음에 말했듯이 현재는 Spring Security를 추가하지 않고 JWT 생성과 검증에 대해서만 확인하고 있기 때문에 Claims 를 하드코딩한 데이터를 사용해서 만들고 있다.

jwt를 검사하는 parseToken()을 수행하는 도중에 JwtException과 관련된 예외들이 터질 수 있는데 예외처리를 단순하게 하려면 JwtException만 처리하고 아니면 이 코드처럼 각 원인에 대해서 상세하게 처리를 하면 좋을 것 같다.

버전이 0.12.x 로 바뀌면서 생성 방식이 군데군데 바뀌었다.

다른 블로그들과 AI를 통해 본 코드를 구현하려고 하니 Deprecated된 것들이 많아서 최대한 찾아보면서 알맞는 메서드로 바꿔주었다. 0.12.x 버전을 사용한다면 해당 코드를 사용하면 쉽게 생성할 수 있을 것이다.

아 그리고 Claim 을 만들때 Map<>을 사용하는 방법도 있는데 개인적으로는 저렇게 생성하는게 보기 편해서 Claims를 사용!


Exception 처리??

JwtException을 구현한 여러 하위 클래스들의 처리를 어떻게 해야 할지 알아보는 중에 (Throwable cause)를 사용하는 메서드가 있는 것을 발견..!!

public class InvalidTokenException extends RuntimeException {
    public InvalidTokenException(String message) {
        super(message);
    }

    public InvalidTokenException(String message, Throwable cause) {
        super(message, cause);
    }
}

이렇게 jwt와 관련된 exception을 처리하기 위해 커스텀 예외를 만들었는데!

이전까지는 항상 위의 (String message)만 사용했었다.

AI가 알려줘서 꼬치꼬치 물어보니 실무에서는 밑에 방식을 더 사용한다고 한다.(AI 너가 실무를 알아??)

이 사진이 단순하게 String message 만 사용한 경우.

이 결과물이 (String message, Throwable cause) 를 사용한 경우이다.

Exception을 타고 계속 올라가보면 최상위에 Throwable이 있는데 여기 안에 cause 가 있다. 이걸 통해서 어떤 원인으로 예외가 발생했는지 알 수 있는 것 같다.

Throwable 클래스 확인 결과

음음 이렇게 cause를 남기면 로깅 툴을 통해서 쉽게 파악을 할 수도 있다고 한다.

로깅 툴에 대한 건 추후에 더 공부를 해봐야겠다...
테스트 단계나 지금처럼 혼자 개발을 하는 경우에는 message만 남겨도 대충 감을 잡을 수 있어서 빠르게 개발할 때는 상관 없다고 한다.

하지만 이런 사소한 습관도 만들면 당연히 더 좋지!!
(이때까지 message만 쓴거 반성하자...)


만드는건 상당히 쉽지?

2022년.. 맨처음 개발을 공부할 때 jwt를 접했을 때는 도통 무슨 말인지 몰랐다.. 사실 그때는 jwt뿐만 아니라 다른 것도 다 몰랐다 ㅋㅋㅋ

클래스도 나눠져있고 여기저기 돌아다니는 코드를 파악하는 능력도 없었고 무슨 말인지 이해도 못하고 ㅠㅠ

JWT의 생성 자체는 상당히 간단하다!! 이걸 이제 Spring Security와 연결해서 Filter를 추가하거나 다른 보안적인 부분을 깔끔하게 처리하는게 좀... 아니다 그것도 쉽나..? ㅋㅎㅎㅎ

이제 더 정리해야할 건 만들어진 토큰을 어떻게 해야할까? 이다.


그래서? 만든 토큰은?

Access TokenRefresh Token을 어떻게 관리할까?

간단하게 보면 token을 만들때 동시에 두 토큰을 만든다.

그리고 두 토큰 모두 클라이언트로 넘겨주고 클라이언트에선 두 개의 토큰을 적절한 위치(local storage)에 저장하고 요청 시 넘겨주면 된다.

그러면 서버는 어떻게 처리하면 될까?

아직 토큰이 유효한 경우라면 토큰에서 사용자 정보를 꺼내 확인 후 사용자가 원하는 데이터를 넘겨주기만 하면 된다. 끝!!

그리고 토큰이 유효하지 않은 경우라면...


토큰이 만료됐어요!

AccessToken이 만료된 경우 401 을 반환해주고.. (아래와 같이 처리 중)

// GlobalExceptionHandler.java
@ExceptionHandler(InvalidTokenException.class)
public ResponseEntity<ApiResponse<String>> invalidToken(InvalidTokenException e) {
	return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
    		.body(ApiResponse.unAuthorized(e.getMessage()));
}

// ApiResponse.java
public static <T> ApiResponse<T> unAuthorized(T data) {
	return new ApiResponse<>(UNAUTHORIZED.value(), UNAUTHORIZED.getReasonPhrase(), now(), data);
}

클라이언트에서 401을 반환받으면 /api/refreshToken 과 같이 refresh 토큰을 활용해서 access 토큰을 발급받는 api를 호출(하거나 이동)한다.

그 이후에 클라이언트가 가지고 있던 refresh Token을 서버로 전송하고 서버는 해당 refreshToken을 검사하고 DB에 저장된 refreshToken과 같다면! 새로 accessToken을 발급해주고 refreshToken도 재발급해서 저장하면 된다~


오케이!!!

이제 생성도 알아봤고, 토큰이 만료되었을 경우 어떻게 처리해줘야 하는지 흐름도 알아봤다.

다음에는 실제 코드를 통해 토큰이 만료되었을 경우 어떻게 처리를 하는지, 또 Spring Security를 추가하고 어떻게 코드가 더 추가되는지 알아보도록 하자!

0개의 댓글