IT 개발,관리,연동,자동화

스프링 부트 환경에서 JWT(JSON Web Token)를 사용한 인증 및 인가 시스템을 구축

_Blue_Sky_ 2025. 12. 27. 16:39
728x90

 


1. 스프링 보안 생태계에서의 JWT 위치

스프링 프레임워크에서 보안을 담당하는 Spring Security는 기본적으로 세션 기반 인증을 지원합니다. 하지만 MSA(Microservices Architecture)나 모바일 앱과의 통신에서는 상태를 저장하지 않는 stateless 방식이 선호됩니다. 이때 JWT는 클라이언트의 인증 정보를 암호화된 토큰에 담아 전달함으로써 서버가 세션을 유지해야 하는 부담을 덜어줍니다.


2. JWT 구현의 핵심 컴포넌트

Token Provider (토큰 생성 및 검증)

토큰 프로바이더는 비밀키(Secret Key)를 사용하여 토큰을 생성하고, 전달받은 토큰의 유효 기간이나 서명 위조 여부를 확인합니다. 자바에서는 주로 jjwt 라이브러리를 사용하여 구현합니다. 페이로드에는 사용자의 ID나 권한 정보를 담는 Claims를 설정합니다.

JwtAuthenticationFilter (커스텀 필터)

클라이언트의 요청이 들어올 때 HTTP Header의 Authorization 항목에서 Bearer 토큰을 추출하는 역할을 합니다. 추출된 토큰이 유효하다면 SecurityContext에 인증 객체를 저장하여 이후 시스템이 해당 사용자를 인증된 상태로 인식하게 합니다.

Security Configuration (보안 설정)

Spring Security 설정 클래스에서 CSRF 보호를 비활성화하고, 세션 정책을 STATELESS로 설정해야 합니다. 또한 앞서 만든 커스텀 필터를 UsernamePasswordAuthenticationFilter 이전에 실행되도록 등록하는 과정이 필요합니다.


3. 운영 시 고려해야 할 실무 포인트

토큰의 유효 기간과 Refresh Token

Access Token의 유효 기간을 길게 설정하면 탈취 시 보안에 취약해집니다. 이를 보완하기 위해 짧은 수명의 Access Token과 긴 수명의 Refresh Token을 함께 운용합니다. Refresh Token은 Redis와 같은 인메모리 DB에 저장하여 관리하는 것이 일반적입니다.

예외 처리 (Exception Handling)

토큰이 만료되었거나 형식이 잘못된 경우, 클라이언트가 이해할 수 있는 명확한 에러 코드와 메시지를 반환해야 합니다. AuthenticationEntryPoint 인터페이스를 구현하여 인증 실패 시의 응답을 커스텀할 수 있습니다.

보안 주의사항

JWT의 페이로드는 암호화되지 않고 Base64로 인코딩될 뿐입니다. 따라서 비밀번호나 주민등록번호와 같은 민감한 개인정보는 절대로 페이로드에 포함해서는 안 됩니다.

728x90

1. SecurityConfig 설정 (Spring Boot 3.x 기준)

최신 스프링 부트에서는 WebSecurityConfigurerAdapter가 권장되지 않으므로 Bean 주입 방식을 사용합니다. 세션을 사용하지 않기 때문에 세션 생성 정책을 반드시 STATELESS로 설정해야 합니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) // REST API이므로 CSRF 비활성화
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll() // 로그인 등은 허용
                .anyRequest().authenticated() // 나머지는 인증 필요
            )
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), 
                            UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

2. JwtAuthenticationFilter 구현

요청 헤더에서 토큰을 꺼내 유효성을 검사하는 필터입니다. OncePerRequestFilter를 상속받아 구현하면 요청당 한 번만 실행됨을 보장받을 수 있습니다.

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                    HttpServletResponse response, 
                                    FilterChain filterChain) throws ServletException, IOException {
        
        // 1. 헤더에서 토큰 추출
        String token = resolveToken(request);

        // 2. 유효성 검사 후 SecurityContext에 저장
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication auth = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        filterChain.doFilter(request, response);
    }

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

3. 운영 환경에서의 핵심 로직

인증 과정에서 발생할 수 있는 만료된 토큰(ExpiredJwtException)이나 잘못된 서명(SignatureException) 등은 JwtTokenProvider 내에서 예외 처리를 세밀하게 설계해야 운영 시 모니터링이 용이합니다.

또한, 토큰 탈취에 대비해 Access Token은 30분 내외로 짧게 잡고, 별도의 저장소(Redis)를 활용한 Refresh Token 메커니즘을 도입하여 보안성을 높이는 것이 실무의 표준입니다.

728x90

JwtTokenProvider 구현 예시입니다. 이 클래스는 토큰의 생성, 암호화, 복호화, 유효성 검증을 모두 담당하는 엔진 역할을 합니다.

 


1. JwtTokenProvider 클래스 구현

이 코드는 jjwt 라이브러리를 기반으로 작성되었습니다. 최근 버전에서는 키 생성 시 명시적인 암호화 알고리즘을 지정하는 것이 권장됩니다.

@Component
public class JwtTokenProvider {

    private final Key key;
    private final long tokenValidityInMilliseconds = 1000L * 60 * 30; // 30분

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    // 토큰 생성
    public String createToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        Date validity = new Date(now + this.tokenValidityInMilliseconds);

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", authorities)
                .signWith(key, SignatureAlgorithm.HS256)
                .setExpiration(validity)
                .compact();
    }

    // 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        User principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    // 토큰 유효성 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
}

2. 운영 환경에서의 팁

위 코드에서 @Value로 가져오는 secretKey는 반드시 환경 변수나 보안 설정 파일(Vault 등)에 따로 관리해야 합니다. 코드에 직접 노출되는 것은 매우 위험합니다.

또한, 실무에서는 validateToken 메소드에서 단순히 로그만 남기는 것이 아니라, 각 예외 상황에 맞는 커스텀 응답을 만들어서 프론트엔드에 전달해야 합니다. 예를 들어 만료된 토큰의 경우 401 Unauthorized와 함께 특정 에러 코드를 보내 리프레시 토큰 로직을 트리거하게 유도합니다.


사용자가 로그인을 시도했을 때 자격 증명을 확인하고 JWT를 발급해주는 서비스와 컨트롤러 로직입니다.

 


1. 로그인 요청 처리 컨트롤러

클라이언트로부터 아이디와 비밀번호를 받아 인증을 진행하고 토큰을 반환합니다.

 
@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest loginRequest) {
        String token = authService.authenticate(loginRequest);
        return ResponseEntity.ok(new TokenResponse(token));
    }
}

2. 비즈니스 로직 (AuthService)

Spring Security의 AuthenticationManager를 사용하여 실제 인증을 수행합니다. 이때 DB에 저장된 비밀번호와 비교가 이루어집니다.

@Service
public class AuthService {

    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final JwtTokenProvider jwtTokenProvider;

    public AuthService(AuthenticationManagerBuilder authenticationManagerBuilder, 
                       JwtTokenProvider jwtTokenProvider) {
        this.authenticationManagerBuilder = authenticationManagerBuilder;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    public String authenticate(LoginRequest loginRequest) {
        // 1. ID/PW를 기반으로 Authentication 객체 생성
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), loginRequest.getPassword());

        // 2. 실제 검증 (UserDetailsService의 loadUserByUsername 메서드가 실행됨)
        Authentication authentication = authenticationManagerBuilder.getObject()
                .authenticate(authenticationToken);

        // 3. 인증 정보를 기반으로 JWT 토큰 생성 및 반환
        return jwtTokenProvider.createToken(authentication);
    }
}

3. 운영을 위한 마무리 체크포인트

사용자 정보 조회 로직(UserDetailsService)에서는 반드시 PasswordEncoder(BCrypt 등)를 사용하여 비밀번호를 안전하게 비교해야 합니다.

또한 실제 운영 환경에서는 로그인 성공 시 Access Token뿐만 아니라 Refresh Token도 함께 발급하여 클라이언트에게 전달하는 구조로 확장하는 것이 좋습니다. Refresh Token은 데이터베이스나 Redis에 저장하여 관리함으로써, 사용자의 세션 상태를 강제로 만료시켜야 하는 상황(기기 로그아웃, 보안 위협 등)에 유연하게 대처할 수 있습니다.

728x90