본문 바로가기

Project

Spring Security와 JWT로 로그인 구현하기

1. 왜 JWT와 Spring Security를 선택했는가?

프로젝트에서 인증 기능이 필요했고, 다음 한 가지 조건이 있었다.

  • 프론트엔드 없이 백엔드 단독으로도 테스트 가능한 인증 방식이 필요했다.

그래서 내가 선택한 방법은 Spring Security + JWT를 직접 구현하는 구조였다.

세션을 서버에 저장하지 않아도 되니 구조가 단순해졌다.

 

2. 세션이나 쿠키를 사용하지 않은 이유는?

이유1 : 백엔드 단독 실행 및 테스트의 편의성

  • 프론트 없이 Postman, Swagger 등에서 직접 요청을 보낼 수 있어야 했다.
  • 세션 기반 인증은 보통 쿠키 기반 요청을 전제로 하므로 브라우저 기반에서 테스트가 편하지만, Postman에서 매번 JSESSIONID를 수동 저장하거나 쿠키를 관리해야 함.

이유2 : 서버가 상태를 기억하지 않기 떄문에 확장에 유리

  • 세션 기반 인증은 서버가 유저의 상태를 기억해야 한다 -> 서버 메모리에 저장 or Redis 사용
  • 하지만 JWT는 서버가 상태를 기억하지 않음(Stateless)
    • 클라이언트가 매 요청마다 토큰을 가지고 다님
  • 그래서 나중에 서버가 여러 대로 늘어나도 수평확장에 부담이 없다.

 


2. 회원가입과 로그인 흐름 요약

  • 회원가입 /api/auth/singup -> 유저 정보 저장
  • 로그인 /api/auth/login -> 이메일/비밀번호 확인 -> JWT 발급
  • 이후 모든 요청은 Authorization 헤더에 JWT 포함
  • Spring Security가 JWT를 필터링하고, 인증객체를 만들어준다.

3. 핵심 코드 설명

JwtUtil

public class JwtUtil {

    private static final String PREFIX = "Bearer ";
    private final long TOKEN_TIME = 60 * 60 * 1000L * 24;
    private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

    @Value("${jwt.secret.key}")
    private String secretKey;
    private Key key;

    @PostConstruct
    public void init() {
        byte[] bytes = Base64.getDecoder().decode(secretKey);
        key = Keys.hmacShaKeyFor(bytes);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser()
                .setSigningKey(key) // 비밀 키를 사용하여 서명 검증
                .parseClaimsJws(token)
                .getBody();
    }

    public String generateToken(Long userId) {
        Date date = new Date();

        String token = Jwts.builder()
                .setSubject(userId.toString())
                .setExpiration(new Date(date.getTime() + TOKEN_TIME)) // 만료 시간 설정
                .setIssuedAt(date) // 발급 시간 설정
                .signWith(key, signatureAlgorithm) // 비밀 키와 알고리즘으로 서명
                .compact(); // JWT 토큰 생성

        return PREFIX + token;
    }

    public Long extractUserId(String token) {
        return Long.parseLong(extractAllClaims(token).getSubject());
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(key) // 비밀 키 설정
                    .build() // 파서 빌더 빌드
                    .parseClaimsJws(token); // 토큰 파싱 및 검증
            return true; // 토큰이 유효한 경우
        } catch (SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.", e);
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT token, 만료된 JWT token 입니다.", e);
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.", e);
        } catch (IllegalArgumentException e) {
            log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.", e);
        }
        return false;
    }
}

 

  • generateToken(userId) : 유저 ID를 기반으로 JWT 생성
  • extractUserId(token) : 토큰에서 subject(=userId) 추출
  • validateToekn(token) : 서명 및 만료시간 검증

JwtFilter

@RequiredArgsConstructor
@Configuration
public class JwtFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        String token = resolveToken(request);

        if (token != null && jwtUtil.validateToken(token)) {
            Long userId = jwtUtil.extractUserId(token);

            //인증 객체 생성
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(userId, null, List.of());

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        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;
    }
}

 

  • 모든 요청을 가로채 JWT가 있는지 확인
  • 유효한 경우 UsernamePasswordAuthenticationToken 생성
  • SecurityContextHolder에 등록 -> 이후 컨트롤러에서 인증 정보 사용 가능

➡️ 토큰이 유효하지 않으면 인증 없이 요청 처리


SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private JwtUtil jwtUtil;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable) // BasicAuthenticationFilter 비활성화
                .addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/signup").permitAll()
                        .requestMatchers("/api/auth/login").permitAll()
                        .anyRequest().authenticated()
                )
                .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public JwtFilter jwtFilter() {
        return new JwtFilter(jwtUtil);
    }
}

 

  • addFilterBefore()로 JwtFilter를 Spring Security 필터 앞에 추가
  • /signup, /login은 permitAll() -> 로그인 없이 접근 가능
  • 나머지 요청은 모두 authenticatited() -> 토큰 없으면 401 오류

➡️ JWT 필터는 항상 작동하지만, Security는 설정에 따라 접근을 허용하거나 거부


❓ JwtFilter에서 토큰이 없으면 Security는 어떻게 401을 던지는가?

 

➡️ 기본 원칙 : SpringSecurity는 SecurityContext가 비어있으면 인증되지 않은 요청이라고 판단한다.

 

✳️ 요청 흐름을 다시 보면

  1. 클라이언트가 GET /protected-api 요청을 보낸다.
  2. SecurityFilterChain이 작동
  3. JwtFilter 실행 -> 토큰 없음 -> SecurityContextHolder에 인증 객체를 넣지 않음
  4. 다음 필터로 넘어감(현재 프로젝트에서는 SecurityConfig에 있는 필터)
  5. 이 시점에서SecurityContextHolder.getContext().getAuthentication()null
  6. FilterSecurityInterceptor가 요청을 막고 Spring이 401을 응답한다.

✳️  시점별 인증 상태 

시점 SecurityContext 상태  결과
JwtFilter 들어가기 전 비어 있음 아직 인증 안 됨
JwtFilter에서 유효한 JWT 있으면 인증 객체 등록됨 통과 가능
JwtFilter에서 토큰 없거나 실패 그대로 비어 있음 FilterSecurityInterceptor가 401

4. 전체 요청 흐름 요약

  1. 클라이언트 -> /api/auth/signup(회원가입)
  2. 클라이언트 -> /api/auth/login(이베일/비번 -> JWT 응답)
  3. 클라이언트 -> 보호된 API 호출 시 Authorization 헤더에 JWT 포함
  4. 서버의 JwtFilter가 토큰 검증 후 인증 객체 생성
  5. 인증된 상태로 요청 처리