Spring Security + JWT 로그인 구현

유민우·2026년 1월 22일

스프링 시큐리티란?

  • 스프링 시큐리티는 인증, 인가를 지원하고 주요 공격으로부터 어플리케이션을 보호해주는 프레임워크다. 명령형과 리액티브 어플리케이션 모두에서 가장 잘 동작하는, 사실상 스프링 기반 어플리케이션의 표준 보안 프레임워크다.

Spring Security Authentication Architecture(구조)


출처 : https://velog.io/@hope0206/Spring-Security-%EA%B5%AC%EC%A1%B0-%ED%9D%90%EB%A6%84-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%97%AD%ED%95%A0-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0

JWT 인증 흐름

1. 로그인 요청: 클라이언트가 ID/PW를 보냅니다.
2. 검증 및 토큰 발급: 서버는 DB 확인 후 유효하면 Access Token을 생성하여 반환합니다.
3. 토큰 저장: 클라이언트는 토큰을 LocalStorage나 Cookie에 저장합니다.
4. 인가 요청: 이후 요청 시 HTTP 헤더(Authorization: Bearer <Token>)에 토큰을 담아 보냅니다.
5. 필터 검증: 서버의 JWT 필터가 토큰 유효성을 검증하고 SecurityContext에 인증 정보를 저장합니다.

Refresh Token 인증 흐름

1. 로그인 성공: 서버는 Access Token(짧은 수명)과 Refresh Token(긴 수명)을 모두 발급합니다.
2. 저장: 클라이언트는 두 토큰을 저장하고, 서버는 Refresh Token을 DB(Redis)에 저장합니다.
3. Access Token 만료: 클라이언트가 만료된 토큰으로 요청하면 서버는 401 Unauthorized를 응답합니다.
4. 토큰 재발급 요청: 클라이언트는 저장해둔 Refresh Token을 서버로 보냅니다.
5. 검증 및 재발급: 서버는 DB의 토큰과 대조하여 유효하면 새로운 Access Token을 발급합니다.

의존성 추가

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

SecurityConfig

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    private final AuthenticationEntryPoint authenticationEntryPoint;
    private final AccessDeniedHandler accessDeniedHandler;

    // 비밀번호 암호화 (소셜로그인 + JWT 혼합 환경에서도 필요)
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        http
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/user/signup", "/user/login", "/user/reissue").permitAll()
                        .requestMatchers(
                                "/v3/api-docs/**",         // API 명세 JSON 데이터
                                "/swagger-ui/**",          // Swagger UI HTML 페이지
                                "/swagger-ui.html"         // 구버전 호환용
                        ).permitAll()
                        .anyRequest().authenticated()
                )
                .exceptionHandling(exception -> exception
                        .accessDeniedHandler(accessDeniedHandler)
                        .authenticationEntryPoint(authenticationEntryPoint)
                )
                // CSRF: REST API는 보통 비활성화
                .csrf(AbstractHttpConfigurer::disable)

                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)

                // 세션 사용하지 않음 (JWT 기반)
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))


                // 필터 체인에 JWT 필터 추가
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);


        return http.build();
    }

    // CORS 정책 정의
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("*")); // 허용할 도메인
        configuration.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
        configuration.setAllowedHeaders(List.of("*"));
        configuration.setAllowCredentials(true); // 인증 정보 허용 (쿠키 등)
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

위를 통해, UsernamePasswordAuthenticationFilter가 동작하기 전에 OncePerRequestFilter을 구현한 JwtAuthenticationFilter가 동작한다.


JwtAuthenticationFilter

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        String path = request.getRequestURI();

        return path.startsWith("/swagger") ||
                (path.startsWith("/user") && !path.equals("/user/logout"))
                ;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try{
            String token = resolveToken(request);
            if (token == null) {
                filterChain.doFilter(request, response);
                return;
            }
            if (jwtTokenProvider.validateToken(token)) {
                String userId = jwtTokenProvider.getUserId(token).toString();
                UserDetails userDetails = userDetailsService.loadUserByUsername(userId);

                // SecurityContext에 인증 정보 설정
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (JwtException e) {
            // 예외 발생 시 request에 메시지 저장
            request.setAttribute("exception", e.getMessage());
        }
        filterChain.doFilter(request, response);

    }

    /**
     * 요청 헤더에서 JWT 토큰 추출
     * Authorization: Bearer <JWT>
     */
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

JwtTokenProvider

  • jwt 토큰 발급, 검증 등 역할을 한다
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    private final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
    private final long accessTokenValidityMs = 1000L * 60 * 60; // 1시간
    private final long refreshTokenExpiration = 1000 * 60 * 60 * 24; // 24시간
    private final RedisService redisService;

    public JwtTokenDto generateToken(User user) {
        long now = System.currentTimeMillis();

        // 액세스 토큰 생성
        String accessToken = Jwts.builder()
                .claim("category", "access")
                .claim("id", user.getId())
                .claim("userRole", user.getUserRole())
                .setIssuedAt(new Date(now))
                .setExpiration(new Date(now + accessTokenValidityMs))
                .signWith(key)
                .compact();

        // 리프레시 토큰 생성
        String refreshToken = Jwts.builder()
                .claim("category", "refresh")
                .claim("id", user.getId())
                .claim("userRole", user.getUserRole())
                .setIssuedAt(new Date(now))
                .setExpiration(new Date(now + refreshTokenExpiration))
                .signWith(key)
                .compact();

        return new JwtTokenDto(accessToken, refreshToken);
    }

    public void isExpired(String token){
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        } catch (ExpiredJwtException e) {
            // 여기서 만료 에러 던짐
            throw new JwtException("EXPIRED_TOKEN");
        }
    }

    public boolean isValidToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            throw new JwtException("INVALID_TOKEN");
        } catch (ExpiredJwtException e) {
            throw new JwtException("EXPIRED_TOKEN");
        } catch (UnsupportedJwtException e) {
            throw new JwtException("UNSUPPORTED_TOKEN");
        } catch (IllegalArgumentException e) {
            throw new JwtException("EMPTY_TOKEN");
        }
    }

    public Long getUserId(String accessToken){
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(accessToken)
                .getBody()
                .get("id", Long.class);
    }
}

Controller

@RequiredArgsConstructor
@RestController
@Tag(name = "User(유저)")
@RequestMapping("/user")
public class UserController {

    private final CreateUserUseCase createUserUsecase;
    private final LoginUserUseCase loginUserUseCase;
    private final LogoutUserUseCase logoutUserUseCase;
    private final ReissueUseCase reissueUseCase;

    @Operation(summary = "회원가입")
    @PostMapping("/signup")
    public ResponseEntity<Void> createUser(@RequestBody SignInRequest request){
        CreateUserCommand createUserCommand = new CreateUserCommand(request.name(), request.username(), request.password());
        createUserUsecase.createUser(createUserCommand);
        return ResponseEntity.noContent().build();
    }

    @Operation(summary = "로그인")
    @PostMapping("/login")
    public ResponseEntity<TokenResponse> login(@RequestBody LoginRequest request){
        LoginUserCommand loginUserCommand = new LoginUserCommand(request.username(), request.password());
        TokenResponse tokenResponse = loginUserUseCase.login(loginUserCommand);
        return ResponseEntity.ok(tokenResponse);
    }

    @Operation(summary = "로그아웃")
    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@AuthenticationPrincipal CustomUserDetails userDetails){
        logoutUserUseCase.logout(userDetails.getUser());
        return ResponseEntity.noContent().build();
    }

    @Operation(summary = "토큰 재발급")
    @PostMapping("/reissue")
    public ResponseEntity<TokenResponse> reissue(@RequestHeader("refreshToken") String refreshToken){
        TokenResponse response = reissueUseCase.reissue(refreshToken);
        return ResponseEntity.ok(response);
    }
}

회원가입, 로그인, 로그아웃, 토큰 재발급 등을 구현했다.

Service

@RequiredArgsConstructor
@Service
public class UserService implements CreateUserUseCase, LoginUserUseCase, LogoutUserUseCase, ReissueUseCase {

    private final LoadUserPort loadUserPort;
    private final SaveUserPort saveUserPort;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;
    private final RedisService redisService;

    @Override
    public void createUser(CreateUserCommand command) {
        // 1. 중복 검사 (비즈니스 규칙)
        if (loadUserPort.existsByUsername(command.username())) {
            throw new RuntimeException("이미 존재하는 아이디입니다.");
        }

        User user = User.create(
                command.name(),
                command.username(),
                passwordEncoder.encode(command.password()),
                UserRole.USER);
        saveUserPort.save(user);
    }

    @Override
    public TokenResponse login(LoginUserCommand command) {
        User user = loadUserPort.findByUsername(command.username());
        if(!passwordEncoder.matches(command.password(), user.getPassword())){
            throw new RuntimeException("비밀번호가 일치하지 않습니다.");
        }

        JwtTokenDto jwtTokenDto = jwtTokenProvider.generateToken(user);
        String redisKey = "refreshToken:" + user.getId();
        redisService.setData(redisKey, jwtTokenDto.getRefreshToken(), 1000L * 60 * 60 * 24);
        return TokenResponse.from(jwtTokenDto);
    }

    @Override
    public void logout(User user) {
        redisService.deleteData("refreshToken:" + user.getId());
    }

    @Override
    public TokenResponse reissue(String refreshToken) {
        try{
            jwtTokenProvider.validateToken(refreshToken);
            Long userId = jwtTokenProvider.getUserId(refreshToken);

            // Redis에 저장된 토큰과 일치하는지 확인
            String savedToken = (String) redisService.getData("refreshToken:" + userId);
            if (savedToken == null || !savedToken.equals(refreshToken)) {
                throw new RuntimeException("유효하지 않은 재발급 요청입니다.");
            }

            // 4. 새로운 토큰 세트 생성
            User user = loadUserPort.findById(userId);
            JwtTokenDto newTokens = jwtTokenProvider.generateToken(user);

            // 5. Redis 값 업데이트 (RTR 전략: 기존 토큰 덮어쓰기)
            redisService.setData("refreshToken:" + userId, newTokens.getRefreshToken(), 1000L * 60 * 60 * 24);

            return TokenResponse.from(newTokens);
        }catch (JwtException e){
            throw new RuntimeException(e.getMessage());
        }
    }
}
  • 회원가입 시에 아이디 중복확인을 진행한다.
  • 로그인 이후 액세스 토큰 및 리프레시 토큰을 발급한다.
  • 로그아웃 시에 액세스 토큰이 유효하고, 현재 가지고 있는 리프레시 토큰 값과 Redis에 존재하는 리프레시 토큰 값이 일치하면 Redis에서 삭제 처리한다.
  • 토큰 재발급 시에 액세스 토큰, 리프레시 토큰 모두 유효하고 일치할 경우 모두 재발급을 받는다.(Redis에 존재하는 리프레시 토큰의 경우 덮어쓰기 됨)

회원가입 테스트


로그인 테스트


토큰 재발급 테스트


로그아웃 테스트


에러 전역 처리 미적용 등, 미흡한 부분이 많지만 시큐리티를 다뤄봤다. 내부 동작 구조 등 자세하게 한 번 개념을 다시 잡아봐야 할 것 같다.

profile
유민우요

1개의 댓글

comment-user-thumbnail
2026년 1월 23일

공부할 때 참고하겠습니다!

답글 달기