어제 오늘 내일

[Spring Security] 7편 - JWT 로그인을 위한 준비 & TokenProvider 만들기 본문

IT/SpringBoot

[Spring Security] 7편 - JWT 로그인을 위한 준비 & TokenProvider 만들기

hi.anna 2026. 2. 19. 07:19

지난 6편(이론편)에서 JWT는 "서버가 발급해 주는 위조 불가능한 출입증"이라고 배웠습니다.
이제부터 실전입니다!

기존 세션 방식을 버리고 JWT 방식을 적용하기 위해 가장 먼저 해야 할 일은

'출입증을 찍어내는 기계(Provider)'를 만드는 것입니다.

이 클래스 하나만 잘 만들어두면, 로그인할 때 토큰을 만들고, 요청이 올 때 토큰을 검사하는 모든 곳에서 핵심 부품으로 사용됩니다.


Step 1. 의존성 추가 (build.gradle)

JWT 기능을 구현하려면 외부 라이브러리가 필요합니다. Java 진영에서 가장 널리 쓰이는 jjwt 라이브러리를 추가합니다.

  • 파일: build.gradle
dependencies {
    // ... 기존 의존성 ...

    // JWT 라이브러리 (0.11.5 버전 사용)
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

(추가 후 우측 상단 코끼리 아이콘(Load Gradle Changes) 클릭 필수!)


Step 2. 설정 파일에 비밀키 등록

토큰을 만들 때 가장 중요한 건 '서버만 아는 도장(Secret Key)'입니다.

이 키가 털리면 누구나 관리자 토큰을 위조할 수 있으므로 아주 중요합니다.

사용 중인 파일 형식(properties vs yml)에 맞춰 하나만 등록하세요.

🅰️ application.properties 사용하는 경우

# 기존 설정들...

# JWT Secret Key (임의의 긴 문자열을 입력하세요)
# 주의: HS256 알고리즘을 사용하려면 256비트(32바이트) 이상이어야 안전합니다.
jwt.secret=VlwEyVlPKZ6yV9yH3Yf0wz5w9z4x8y7a6b5c4d3e2f1g0h9i8j7k6l5m4n3o2p1

🅱️ application.yml 사용하는 경우

# 기존 설정들...

jwt:
  secret: VlwEyVlPKZ6yV9yH3Yf0wz5w9z4x8y7a6b5c4d3e2f1g0h9i8j7k6l5m4n3o2p1

Step 3. 토큰 정보를 담을 DTO 만들기 (JwtToken)

TokenProvider를 만들기 전에, 생성된 토큰 정보를 담아서 반환할 포장지(DTO)를 먼저 만듭니다.

  • 위치: src/main/java/com/example/board/jwt/JwtToken.java
    (패키지가 없으면 jwt 패키지를 새로 만들어주세요)
package com.example.board.jwt;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;

@Builder
@Data
@AllArgsConstructor
public class JwtToken {
    private String grantType;   // JWT 권한 인증 타입 (Bearer 등)
    private String accessToken; // 우리가 만든 진짜 토큰
    private String refreshToken; // (추후 구현 예정)
}

Step 4. ★핵심★ JwtTokenProvider 만들기

이제 '토큰 발급기'를 만듭니다. 이 클래스는 다음 3가지 핵심 기능을 수행합니다.

  1. 토큰 생성: 사용자의 정보를 받아서 JWT를 만들어준다.
  2. 토큰 복호화: 토큰을 받아서 "이거 누구 거야?"(사용자 정보)를 꺼낸다.
  3. 토큰 검증: "이 토큰 유효기간 안 지났어? 위조 안 됐어?"를 검사한다.
  • 위치: src/main/java/com/example/board/jwt/JwtTokenProvider.java
package com.example.board.jwt;

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

@Slf4j
@Component
public class JwtTokenProvider {

    private final Key key;

    // application.properties에서 secret 값 가져와서 key에 저장
    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    // 1. 토큰 생성 메서드
    public JwtToken createToken(Authentication authentication) {
        // 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        // 1일: 24 * 60 * 60 * 1000 = 86400000 밀리초
        Date accessTokenExpiresIn = new Date(now + 86400000);

        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return JwtToken.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .build();
    }

    // 2. 토큰에서 사용자 정보 추출
    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get("auth") == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    // 3. 토큰 유효성 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }

    // 토큰 파싱 (내부용)
    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(accessToken)
                    .getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

Step 5. 💡 코드 상세 분석 (Line by Line)

이 클래스는 JWT 인증의 심장과도 같은 곳입니다. 코드가 조금 길지만, 각 부분이 어떤 역할을 하는지 뜯어보면 명확해집니다.

0. 클래스 및 필드 선언

@Slf4j
@Component
public class JwtTokenProvider {
    private final Key key;
  • @Slf4j: 로그를 출력하기 위한 기능(Lombok)을 활성화합니다.
  • @Component: 이 클래스를 스프링 빈(Bean)으로 등록합니다. 이제 다른 곳에서 @Autowired나 생성자 주입을 통해 가져다 쓸 수 있습니다.
  • private final Key key: 토큰을 암호화하고 복호화할 때 사용할 비밀키를 담을 변수입니다.

1. 생성자 (초기화)

public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
    byte[] keyBytes = Decoders.BASE64.decode(secretKey);
    this.key = Keys.hmacShaKeyFor(keyBytes);
}
  • @Value("${jwt.secret}"): 설정 파일(application.properties 등)에 적어둔 암호문을 가져옵니다.
  • Decoders.BASE64.decode: 가져온 비밀키가 Base64로 인코딩되어 있으므로, 이를 원래의 바이트 배열로 복원(디코딩)합니다.
  • Keys.hmacShaKeyFor: 복원된 바이트 배열을 기반으로 HMAC-SHA 알고리즘에 사용할 수 있는 진짜 키 객체를 만듭니다.

2. createToken (토큰 생성)

로그인에 성공한 사용자 정보(authentication)를 받아서 JWT를 생성합니다.

String authorities = authentication.getAuthorities().stream()
        .map(GrantedAuthority::getAuthority)
        .collect(Collectors.joining(","));
  • 사용자가 가진 권한 목록(List)을 꺼내서, 콤마(,)로 구분된 하나의 문자열로 합칩니다.
  • 예: [ROLE_USER, ROLE_ADMIN] (List) ➡️ "ROLE_USER,ROLE_ADMIN" (String)
long now = (new Date()).getTime();
Date accessTokenExpiresIn = new Date(now + 86400000);
  • now: 현재 시간을 밀리초 단위로 가져옵니다.
  • accessTokenExpiresIn: 현재 시간(now)에 86,400,000 밀리초(24시간)를 더해 토큰의 유효기간을 설정합니다. 이 시간이 지나면 토큰은 무효가 됩니다.
String accessToken = Jwts.builder()
        .setSubject(authentication.getName())
        .claim("auth", authorities)
        .setExpiration(accessTokenExpiresIn)
        .signWith(key, SignatureAlgorithm.HS256)
        .compact();
  • .setSubject: 토큰의 주인(Subject)으로 사용자 ID(이메일 등)를 설정합니다.
  • .claim("auth", ...): "auth"라는 이름으로 위에서 만든 권한 문자열을 토큰에 몰래 넣어둡니다. (나중에 이것만 보고 권한을 알 수 있습니다.)
  • .setExpiration: 방금 계산한 만료 시간을 토큰에 기록합니다.
  • .signWith(...): [핵심] 우리 서버만 아는 비밀키(key)로 서명을 합니다. 이 서명 덕분에 토큰이 위조되지 않았음을 증명할 수 있습니다.

3. getAuthentication (토큰에서 인증 정보 추출)

복호화된 토큰을 받아 스프링 시큐리티가 사용할 수 있는 인증 객체를 만듭니다.

Claims claims = parseClaims(accessToken);
  • 암호화된 토큰을 복호화하여 그 안에 담긴 정보(Claims)를 꺼냅니다.
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
  • User: DB를 조회하지 않고, 토큰에 있던 정보만으로 사용자 객체를 만듭니다. (비밀번호는 빈 문자열로 둡니다.)
  • UsernamePasswordAuthenticationToken: 최종적으로 시큐리티가 사용할 수 있는 인증 객체(Authentication)를 반환합니다.

4. validateToken (토큰 유효성 검사)

토큰이 이상한지 아닌지 검사하는 문지기입니다.

Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
  • parseClaimsJws(token): [핵심] 토큰을 파싱해봅니다. 만약 위조되었거나 만료되었다면 여기서 예외(Exception)가 발생합니다.
  • 예외 없이 이 라인을 통과했다면 "정상적인 토큰"이므로 true를 반환합니다.
  • 예외가 발생하면 catch 블록에서 로그를 남기고 false를 반환합니다.

5. parseClaims (내부 유틸 메서드)

private Claims parseClaims(String accessToken) {
    try {
        return Jwts.parserBuilder()...parseClaimsJws(accessToken).getBody();
    } catch (ExpiredJwtException e) {
        return e.getClaims();
    }
}
  • try-catch: 만료된 토큰이어도(ExpiredJwtException), 그 안에 들어있던 정보(Claims)는 필요할 때가 있습니다. (예: 토큰 재발급 시 사용자 ID 확인 등) 그래서 예외가 발생해도 정보를 꺼내서 반환하도록 처리했습니다.

마무리

자, 이제 '출입증(JwtToken)''출입증 발급기(Provider)'까지 모두 완성했습니다.

하지만 아직 이 기계를 사용하는 사람이 아무도 없습니다.
다음 8편에서는 이 발급기를 Spring Security의 필터(Filter)에 장착해서,
"요청이 들어올 때마다 토큰을 검사하고, 없으면 막아버리는" 보안 문지기를 만들어보겠습니다.

고생하셨습니다! 다음 편에서 만나요. 🚀

 

 

반응형
Comments