우당탕탕

Spring Boot JWT 로그인 구현하다 막힌 부분과 해결한 경험담 본문

Tech/Spring

Spring Boot JWT 로그인 구현하다 막힌 부분과 해결한 경험담

모찌모찝 2026. 5. 18. 14:20

Spring Boot로 JWT 로그인 기능을 구현하다가 생각보다 삽질이 많아서 기록해 봤어요. 토큰 발급부터 인증 과정까지 제대로 작동하게 하려니까 작은 설정 하나, 코드 한 줄 차이로도 오류가 확 나더라고요.

이 글에서는 제가 직접 겪은 문제들과 그 해결 과정, 그리고 JWT 로그인 구현 시 꼭 알아야 할 핵심 팁을 정리했어요. 혹시 같은 부분에서 막히시는 분들께는 좋은 참고가 될 거예요.

개발 환경 / 버전 정보

저는 Java 17, Spring Boot 3.2 환경에서 JWT 로그인 구현했어요. 추가로 jjwt 0.11.5 라이브러리를 이용해서 토큰 생성과 검증을 처리했습니다.

JWT 토큰 발급 이렇게 하면 됩니다

사실 토큰 생성 자체는 라이브러리 문서 보고 순서대로 따라 하면 크게 어렵진 않아요. 다만, 토큰의 만료 시간이나 서명 키 설정 부분에서 한참 헤맸어요.

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;

import java.security.Key;
import java.util.Date;

public class JwtProvider {
    private final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256); // 비밀키 생성
    private final long expirationMs = 3600000; // 1시간

    public String createToken(String username) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expirationMs);

        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(key)
                .compact();
    }

    public Key getKey() {
        return key;
    }
}

이렇게 하면 사용자 이름 기반의 토큰을 1시간 유효기간으로 만들 수 있어요. 여기서 중요한 건 비밀키는 어플리케이션이 재기동되도 계속 같아야 한다는 점인데, 저는 처음에 매번 Keys.secretKeyFor()를 호출해서 키가 바뀌는 바람에 토큰 검증이 안 됐던 적이 있어요.

토큰 검증, 여기서 많이 틀립니다

토큰 검증 로직은 의외로 간단한데, 서명키가 다르거나 토큰이 만료되면 예외가 발생해서 처리해줘야 해요. 하지만 저는 처음에 예외를 제대로 잡지 않고 그대로 던져서 500 에러가 났었거든요.

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.SignatureException;

public class JwtProvider {
    // key 생성 및 createToken 메서드는 위와 동일

    public String getUsernameFromToken(String token) {
        Jws<Claims> claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token);
        return claims.getBody().getSubject();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (SignatureException ex) {
            System.out.println("Invalid JWT signature");
        } catch (Exception e) {
            System.out.println("JWT validation error: " + e.getMessage());
        }
        return false;
    }
}

위처럼 예외를 잡아서 로그를 찍고 false를 반환해야 인증 실패 시 적절히 처리할 수 있어요. 여기서 키가 바뀌거나, 토큰이 아예 잘못된 경우 SignatureException, 만료된 토큰은 ExpiredJwtException 예외가 나서 구분해서 처리해도 좋아요.

여기서 삽질했던 부분들

막상 JWT를 넣어서 로그인 요청을 보내도 401 Unauthorized가 계속 떴는데, 막상 코드는 다 맞는 듯해서 한참 헤맸어요. 그래서 문제를 하나씩 체크해보니...

  • HTTP 헤더에 "Authorization" 키를 잘못 쓰거나 "Bearer" 뒤에 토큰을 안 붙였더군요. "Authorization:Bearertoken값"처럼 붙여서 실제로는 올바른 스페이스가 없던 경우였어요.
  • SecurityConfig에서 JWT 필터 순서 설정이 잘못돼서 인증 전에 필터가 작동하지 않았어요.
  • 토큰 생성 시 username을 subject에 안 넣고 다른 값만 넣어서 getUsernameFromToken 메서드가 null을 반환했어요.
// JWT 인증 필터 등록 예시
http
  .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);

이걸 빼먹으면 아무리 토큰이 맞아도 필터가 인증을 못 처리해서 로그인 실패가 나더라고요. 여기서 한참 헤맸던 기억이 납니다.

심화: JWT 필터에서 Authentication 객체 만들기

JWT 토큰에서 username을 뽑아서 Spring Security 인증 컨텍스트에 넣어야 하는데, 이 과정을 놓치면 보안 컨텍스트가 적용 안 돼서 로그인 상태가 유지 안 돼요.

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtProvider jwtProvider, UserDetailsService userDetailsService) {
        this.jwtProvider = jwtProvider;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String token = resolveToken(request);

        if (token != null && jwtProvider.validateToken(token)) {
            String username = jwtProvider.getUsernameFromToken(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearer = request.getHeader("Authorization");
        if (bearer != null && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return null;
    }
}

위처럼 토큰을 꺼내서, 유저 정보를 UserDetailsService로 불러와 인증 객체를 만들어 SecurityContext에 넣어줘야 스프링 시큐리티가 로그인 상태인 걸 인지합니다.

자주 물어보시는 것들

Q. JWT를 쿠키에 저장해도 괜찮나요?

A. 쿠키에 저장할 때 HttpOnly, Secure 옵션을 꼭 넣어야 해요. 특히 XSS 공격에 대비하려면 HttpOnly가 중요합니다. 로컬 스토리지보다 보안 설정에 신경 써야 해서, 저는 보통 Authorization 헤더에 넣는 방식을 선호해요.

Q. 토큰 만료 후 재발급은 어떻게 하죠?

A. 보통 Refresh Token을 따로 발급해서 저장하고, 액세스 토큰 만료 시 Refresh Token으로 새 토큰을 발급해 줍니다. 이 부분은 별도 서비스로 구현하는 게 안전해요.

마무리하자면, JWT 로그인 구현할 때는 토큰 생성과 검증, 스프링 시큐리티 필터 적용 순서, 그리고 키 관리가 가장 중요한 포인트입니다. 저도 처음에 이 부분에서 많이 헤맸는데, 정리해놓으니 다음에는 훨씬 수월할 것 같아요. 혹시 더 궁금한 부분 있으면 댓글로 알려 주세요!

Comments