Spring JWT 인증 과정 뜯어보기 🔑 | Spring Security 없이 구현하는 방법

2025. 2. 25. 17:08·TIL (Today I Learned)

JWT의 이론에 대해서는 이미 학습을 했고, Spring Boot에서 JWT를 활용하는 방법을 학습하겠다 🤓

 

[Spring] 인증/인가와 Session 방식과 JWT 방식의 차이

인증 / 인가인증 Authentication특정 인증 요청(로그인, 이메일 인증 등)사용자 식별을 하기 위해서 필요ex) 뉴스피드 프로젝트: 로그인인가 Authorization이미 인증된 사용자에게 특정 자원에 대한 접

mannakingdom.tistory.com


Spring 심화 개인 과제를 진행하기 위해 클론한 프로젝트가 JWT가 구현되어있어서 이 코드를 분석해보려고 한다🍴

 

GitHub - f-api/spring-advanced

Contribute to f-api/spring-advanced development by creating an account on GitHub.

github.com

⚠️ Spring Security를 사용하지 않는 JWT 활용 방법에 대해 다룬다‼️

 


 

1. Spring Boot에서 JWT 인증 구현 코드 분석

1️⃣ JWT 유틸 클래스 (JwtUtil.java)

JWT를 생성하고 검증하는 역할을 하는 클래스이다.

@Slf4j(topic = "JwtUtil")
@Component
public class JwtUtil { ... }
  • `@Component` ➡️ Bean 자동 등록
  • `Slf4j(topic = "JwtUtil")` ➡️ 로그 남기기

 

📌 JWT 키 생성 및 Init

    @Value("${jwt.secret.key}")
    private String secretKey;
    private Key key;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }
  • `@Value("${jwt.secret.key}")`
        ➡️ `application.yml`(또는 `application.properties`)에서 설정한 JWT 키를 가져온다.
  • `Base64.getDecoder().decode(secretKey)`
        ➡️ 가져온 키 값(Base64 인코딩된 문자열)을 Base64 디코딩
  • `Keys.hmacShaKeyFor(bytes)`
        ➡️ JWT 서명(Signatur)에 사용할 Key 생성

 

✨ 참고 사항

📍 SignatureAlgorithm.HS256
HS256: HMAC-SHA256 (Hash-based Message Authentication Code with SHA-256)
SHA-256의 출력 크기가 256비트(32바이트) 이므로, HMAC-SHA256을 사용할 때는 키 길이가 최소 256비트(32바이트) 이상이어야 함.
https://datatracker.ietf.org/doc/html/rfc7518#section-3.2

📍 Keys.hmacShaKeyFor()
Keys.hmacShaKeyFor(byte[]) 메서드는 HMAC-SHA 계열 알고리즘(HMAC-SHA256, HMAC-SHA384, HMAC-SHA512 등)에서 사용할 키를 안전하게 생성하는 기능을 제공
키 길이가 충분하지 않으면 WeakKeyException을 발생시킴.
https://javadoc.io/doc/io.jsonwebtoken/jjwt-api/latest/io/jsonwebtoken/security/Keys.html#hmacShaKeyFor-byte:A-

➡️ JWT 서명을 위해 HS256을 사용할 경우 반드시 256비트(32바이트) 이상의 키를 Base64로 인코딩하여 사용해야 함.

 

📌 JWT 토큰 생성

    public String createToken(Long userId, String email, UserRole userRole) {
        Date date = new Date();

        return BEARER_PREFIX +
                Jwts.builder()
                        .setSubject(String.valueOf(userId))
                        .claim("email", email)
                        .claim("userRole", userRole)
                        .setExpiration(new Date(date.getTime() + TOKEN_TIME))
                        .setIssuedAt(date) // 발급일
                        .signWith(key, signatureAlgorithm) // 암호화 알고리즘
                        .compact();
    }
  • JWT 생성 시 claim으로 userId, email, userRole을 포함한다.
  • `signWith(key, signatureAlgorithm)`
    • ➡️ init에서 생성한 key로 HMAC SHA256 알고리즘을 이용해 서명(Signature) 추가
    • ➡️ 검증이 필요할 때, 이 값으로 토큰 검증을 한다.

 

📌 JWT 토큰에서 "Bearer" 제거

    public String substringToken(String tokenValue) {
        if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
            return tokenValue.substring(7);
        }
        throw new ServerException("Not Found Token");
    }
  • 헤더의 토큰에서 "Bearer"을 제거하여 실제 JWT 값만 추출한다.
    • 헤더의 토큰 예시
          ➡️ key: Authorization, value: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIiw(생략)

 

📌 JWT 토큰 검증 및 정보 추출

    public Claims extractClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
  • JWT의 서명을 검증한 후 Claims 객체를 반환
    • Claims에는 createToken에서 claim으로 포함한 userId, email, userRole이 있다.
  • 만료된 JWT라면 ExpiredJwtException이 발생한다.

 


 

2️⃣ JWT 필터 (JwtFilter.java)

JWT를 검사하고 사용자 정보를 설정하는 역할이다.
Filter 인터페이스를 implements하는 클래스

@Slf4j
@RequiredArgsConstructor
public class JwtFilter implements Filter {

    private final JwtUtil jwtUtil;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }
    ...    
}

 

📌 doFilter 메서드 분석

        String url = httpRequest.getRequestURI();

        if (url.startsWith("/auth")) {
            chain.doFilter(request, response);
            return;
        }
  • /auth로 시작되는 url(`/auth/signin`, `/auth/signup`)은 JWT 검사 없이 리턴
        String bearerJwt = httpRequest.getHeader("Authorization");

        if (bearerJwt == null) {
            // 토큰이 없는 경우 400을 반환합니다.
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
            return;
        }

        String jwt = jwtUtil.substringToken(bearerJwt);
  • Authorization 헤더의 value를 `bearerJwt`에 저장
  • Authorization 헤더가 없으면 `400 BAD REQUEST` 반환
  • 저장한 토큰(`bearerJwt`)을 `substringToken` 메서드로 JWT 값만 추출해서 저장
            // JWT 유효성 검사와 claims 추출
            Claims claims = jwtUtil.extractClaims(jwt);
            if (claims == null) {
                httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
                return;
            }
  • JWT가 유효하지 않다면 `400 BAD REQUEST` 반환
            httpRequest.setAttribute("userId", Long.parseLong(claims.getSubject()));
            httpRequest.setAttribute("email", claims.get("email"));
            httpRequest.setAttribute("userRole", claims.get("userRole"));
  • HttpServletRequest 객체에 JWT에서 추출한 정보 저장
             UserRole userRole = UserRole.valueOf(claims.get("userRole", String.class));
 
             if (url.startsWith("/admin")) {
                // 관리자 권한이 없는 경우 403을 반환합니다.
                if (!UserRole.ADMIN.equals(userRole)) {
                    httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN, "관리자 권한이 없습니다.");
                    return;
                }
                chain.doFilter(request, response);
                return;
            }
  • /admin 경로 접근 시, `userRole`이 `UserRole.ADMIN`이 아니면 `403 FORBIDDEN` 반환

 


 

3️⃣ JWT 필터 등록 (FilterConfig.java)

@Configuration
@RequiredArgsConstructor
public class FilterConfig {

    private final JwtUtil jwtUtil;

    @Bean
    public FilterRegistrationBean<JwtFilter> jwtFilter() {
        FilterRegistrationBean<JwtFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new JwtFilter(jwtUtil));
        registrationBean.addUrlPatterns("/*"); // 필터를 적용할 URL 패턴을 지정합니다.

        return registrationBean;
    }
}
  • JwtFilter를 Spring Boot에 등록해서 모든 요청(`/*`)에 적용
  • jwtUtil을 주입해서 JWT 검증이 가능하도록 설정

 


 

2. 커스텀 어노테이션으로 사용자 정보 주입 과정 분석

구현한 JWT 필터의 doFilter 메서드를 보면, HttpServletRequest에 사용자 정보를 저장하는 것을 알 수 있다.

이를 활용해서, 컨트롤러에서 `@Auth AuthUser authUser`를 파라미터로 사용자 정보를 가져오는 방식을 분석해보겠다.

 

1️⃣인증된 사용자 정보를 담는 DTO (AuthUset.java)

@Getter
public class AuthUser {

    private final Long id;
    private final String email;
    private final UserRole userRole;

    public AuthUser(Long id, String email, UserRole userRole) {
        this.id = id;
        this.email = email;
        this.userRole = userRole;
    }
}
  •  JWT에서 추출한 사용자 정보를 담는 DTO

 


 

2️⃣ 커스텀 어노테이션 @Auth (Auth.java)

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auth {
}
  • `@Target(ElementType.PARAMETER)` ➡️ 메서드의 매개변수에만 적용할 수 있도록 설정
  • `@Retention(RetentionPolicy.RUNTIME)` ➡️ 런타임에도 어노테이션이 유지되도록 설정
  • @Auth가 붙은 매개변수는 AuthUserArgumentResolver에서 자동으로 주입된다. (설명은 아래에서)

 


 

3️⃣ 사용자 정보 자동 주입 (AuthUserArgumentResolver.java)

AuthUserArgumentResolver는 HandlerMethodArgumentResolver를 implements하여, 컨트롤러의 메서드가 실행될 때 `@Auth AuthUser authUser` 매개변수를 자동으로 채워주는 역할을 한다.

public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { ... }
  • HandlerMethodArgumentResolver는 Spring MVC에서 컨트롤러의 메서드 파라미터를 변환하는 인터페이스이다.
  • 컨트롤러에서 특정 어노테이션이 붙은 매개변수를 자동으로 해석하고 변환하여 주입할 수 있도록 한다.

 

📌 supportsParameter()

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasAuthAnnotation = parameter.getParameterAnnotation(Auth.class) != null;
        boolean isAuthUserType = parameter.getParameterType().equals(AuthUser.class);

        // @Auth 어노테이션과 AuthUser 타입이 함께 사용되지 않은 경우 예외 발생
        if (hasAuthAnnotation != isAuthUserType) {
            throw new AuthException("@Auth와 AuthUser 타입은 함께 사용되어야 합니다.");
        }

        return hasAuthAnnotation;
    }
  • 매개변수가 @Auth와 AuthUser 타입이 함께 사용되는지 확인한다.
    • 컨트롤러의 매개변수에 `@Auth`가 있는지 확인
    • 매개변수 타입이 `AuthUser`인지 확인
    • 둘 다 만족해야 `resolveArgument()` 실행
    • 즉, 파라미터가 `@Auth AuthUser authUser`이 아니면 예외처리

 

📌 resolveArgument()

    @Override
    public Object resolveArgument(
            @Nullable MethodParameter parameter,
            @Nullable ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest,
            @Nullable WebDataBinderFactory binderFactory
    ) {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();

        // JwtFilter 에서 set 한 userId, email, userRole 값을 가져옴
        Long userId = (Long) request.getAttribute("userId");
        String email = (String) request.getAttribute("email");
        UserRole userRole = UserRole.of((String) request.getAttribute("userRole"));

        return new AuthUser(userId, email, userRole);
    }
  • HttpServletRequest에서 JwtFilter가 저장한 userId, email, userRole을 가져옴
  • 가져온 정보로 AuthUser 객체를 생성하여 반환
  • 컨트롤러의 `@Auth AuthUser authUser`에 AuthUser 객체가 자동으로 주입

 


 

4️⃣ Argument Resolver 등록 (WebConfig.java)

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    // ArgumentResolver 등록
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new AuthUserArgumentResolver());
    }
}

 


 

3. 정리

  1. 클라이언트가 로그인하면 JWT를 받음 ➡️ `Authorization: Bearer <JWT>`
  2. 클라이언트가 API 요청 시 `Authorization: Bearer <JWT>` 헤더를 포함하여 보냄
  3. `JwtFilter`가 JWT를 검증하고 사용자 정보(userId, email, userRole)를 `HttpServletRequest`에 저장
  4. 컨트롤러에서 `@Auth AuthUser authUser`를 사용하면 자동으로 `AuthUserArgumentResolver`가 실행
    • HttpServletRequest에서 사용자 정보를 가져와 AuthUser 객체로 생성 후 반환
  5. 별도의 인증 없이 `authUser.getId()` 같은 방식으로 사용자 정보 활용 가능
저작자표시 비영리 변경금지 (새창열림)

'TIL (Today I Learned)' 카테고리의 다른 글

Spring Security를 활용한 JWT 인증 적용해보기 🖥️🔑  (0) 2025.02.27
Interceptor와 AOP 개념 정리 및 API 로깅 예시 ✍️  (0) 2025.02.26
git clone 후 build.gradle 빨간 줄❓ 해결 방법‼️  (0) 2025.02.24
[Spring] 인증/인가와 Session 방식과 JWT 방식의 차이  (0) 2025.02.20
[Spring] JPA 연관관계 매핑 확실하게 알아보기❗  (0) 2025.02.14
'TIL (Today I Learned)' 카테고리의 다른 글
  • Spring Security를 활용한 JWT 인증 적용해보기 🖥️🔑
  • Interceptor와 AOP 개념 정리 및 API 로깅 예시 ✍️
  • git clone 후 build.gradle 빨간 줄❓ 해결 방법‼️
  • [Spring] 인증/인가와 Session 방식과 JWT 방식의 차이
기만나🐸
기만나🐸
공부한 내용을 기록합시다 🔥🔥🔥
  • 기만나🐸
    기만나의 공부 기록 🤓
    기만나🐸
  • 전체
    오늘
    어제
    • ALL (147)
      • TIL (Today I Learned) (56)
      • Dev Projects (15)
      • Algorithm Solving (67)
        • Java (52)
        • SQL (15)
      • Certifications (8)
        • 정보처리기사 실기 (8)
  • 인기 글

  • 태그

    Subquery
    jQuery
    mysql
    BOJ
    완전탐색
    프로그래머스
    programmers
    GROUP BY
    HTML
    sql
    백트래킹
    greedy
    jwt
    Firebase
    javascript
    Google Fonts
    BFS
    java
    DFS
    websocket
    자료구조
    dp
    그리디
    시뮬레이션
    join
    다이나믹프로그래밍
    백준
    CSS
    jpa
    bootstrap
  • 최근 글

  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.3
기만나🐸
Spring JWT 인증 과정 뜯어보기 🔑 | Spring Security 없이 구현하는 방법
상단으로

티스토리툴바