[Spring] Spring Security로 로그인 구현하기 (JWT)

icebox127·2026년 1월 15일

TO:DAY 서버

목록 보기
4/8

지난 포스트 - 회원가입하기에서 이어집니다.
https://velog.io/@icebox127/Spring-Spring-Security%EB%A1%9C-%ED%9A%8C%EC%9B%90%EA%B0%80%EC%9E%85-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

회원가입에 이어 로그인을 구현하였다.

세션 기반 인증 vs 토큰 기반 인증

세션 기반 인증

  • 로그인 시 서버가 세션 객체를 생성하고, 클라이언트에 쿠키를 전달한다.
    • 이후 클라이언트의 요청마다 쿠키를 통해 사용자를 식별한다.
  • 사용자의 정보가 서버의 메모리에 저장한다. (Stateful)
  • 클라이언트는 쿠키로 인증을 계속 유지한다.
    • 로그아웃 시에는 세션을 삭제해야 한다.

토큰 기반 인증

  • 로그인 시 서버가 토큰을 발급하고, 클라이언트는 이를 저장한다.
    • 이후 요청에 Authorization 헤더로 전송한다.
  • 사용자의 정보는 토큰 자체에 포함되고, 서버는 이를 저장하지 않는다. (Stateless)
    • 로그아웃 시에는 토큰을 삭제하거나 서버에서 블랙리스트 관리가 필요하다.

가장 큰 차이점은 서버가 사용자 정보를 저장하냐 아니냐(Stateful/Stateless)이다.
jwt 토큰 기반은 무상태성을 가져 서버 부하를 줄이고 확장성을 높여 REST API 기반 서비스에 많이 쓰인다. 따라서 나도 jwt 방식으로 구현하였다.

JWT 토큰이란?

JWT는 토큰의 한 종류로, JSON 형식과 서명을 통해 인증 정보를 안전하게 전달하는 표준화된 방식이다.
Header.Payload.Signature 세 부분으로 나뉜 긴 문자열이며, 각 부분은 .으로 구분된다.

  • Header: 토큰 타입(JWT)과 서명 알고리즘 정보
  • Payload: 사용자 식별자, 권한, 만료 시간 등 (민감한 정보는 넣지 않음)
  • Signature: 서버의 시크릿 키로 서명하여 위조 여부 검증

서버가 클라이언트 상태를 저장하지 않고, 클라이언트가 매 요청마다 헤더에 JWT를 전달하여 검증하기 때문에 수평 확장에 유리하다. 세션 기반 인증처럼 서버 메모리에 상태를 저장하지 않아도 되므로 성능상 이점도 있다.
다만 JWT는 토큰 자체가 인증 수단이므로 탈취 시 곧바로 권한이 넘어가는 위험이 있다. 이를 보완하기 위해 Access Token은 짧은 유효 기간을 두고, Refresh Token을 별도로 관리한다.

구현 과정

1. 설정 추가

  • build.gradle에 JWT 관련 의존성 추가
	// Jwt
    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
    implementation 'org.springframework.boot:spring-boot-configuration-processor'
  • application.yml에 jwt 관련 설정 추가
spring:
... (생략)
jwt:
  token:
    secretKey: ${SECRET_KEY}
    expiration:
      access: 14400000
  • secretKey는 JWT 토큰의 서명을 검증하기 위한 비밀값임
    해당 서버에서 발급한 jwt 토큰이 맞는지 검증하기 위해 사용하는 것이므로, 공개되어선 안 되기에 .env에 저장하는 것이 좋다.
    이 키 값은 길고 랜덤한 문자열로 생성한다.

2. Controller 생성

 	@PostMapping("/auth/login/email")
    public ApiResponse<MemberResDto.LoginDto> emailLogin(@Valid @RequestBody MemberReqDto.LoginDto dto){
        return ApiResponse.success(memberService.emailLogin(dto));
    }

3. JwtUtil 생성

@Component
public class JwtUtil {
    private final SecretKey secretKey;
    private final Duration accessExpiration;

    public JwtUtil(
            @Value("${jwt.token.secretKey}") String secret,
            @Value("${jwt.token.expiration.access}") Long accessExpiration
    ) {
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.accessExpiration = Duration.ofMillis(accessExpiration);
    }

    // AccessToken 생성
    public String createAccessToken(Member user) {
        return createToken(user, accessExpiration);
    }

    // 토큰에서 이메일 가져오기
    public String getEmail(String token) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    // 토큰 유효성 확인
    public boolean isValid(String token) {
        try {
            getClaims(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }

    // 토큰 생성
    private String createToken(Member user, Duration expiration) {
        Instant now = Instant.now();

        return Jwts.builder()
                .subject(user.getEmail())
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plus(expiration)))
                .signWith(secretKey)
                .compact();
    }

    // 토큰 정보 가져오기
    private Jws<Claims> getClaims(String token) throws JwtException {
        return Jwts.parser()
                .verifyWith(secretKey)
                .clockSkewSeconds(60)
                .build()
                .parseSignedClaims(token);
    }
}

JwtFilter에서 사용할 Jwt와 관련 메서드들을 정의한다.
(토큰 생성, 유효성 확인, 토큰에서 정보 확인 등)

4. JwtFilter 생성

@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;
    private final MemberRepository memberRepository;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
            // 토큰 가져오기
            String token = request.getHeader("Authorization");
            // token이 없거나 Bearer가 아니면 넘기기
            if (token == null || !token.startsWith("Bearer ")) {
                filterChain.doFilter(request, response);
                return;
            }
            // Bearer이면 추출
            token = token.replace("Bearer ", "");
            // AccessToken 검증하기: 올바른 토큰이면
            if (jwtUtil.isValid(token)) {
                // 토큰에서 이메일 추출
                String email = jwtUtil.getEmail(token);
                // 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성
                Member user = memberRepository.findByEmail(email)
                        .orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND));
                Authentication auth = new UsernamePasswordAuthenticationToken(
                        user,
                        null,
                        null
                );
                // 인증 완료 후 SecurityContextHolder에 넣기
                SecurityContextHolder.getContext().setAuthentication(auth);
            }
            filterChain.doFilter(request, response);
    }
}

작동 흐름
1. 먼저 필터들을 쭉 통과하면서 JwtAuthFilter의 doFilterInternal 메서드를 실행
2. 요청 헤더 중 Authorization 헤더를 읽어서 Bearer 접두사를 제거
3. JWT 토큰의 정보를 읽고 만료되었는지 확인 (JwtUtil - isValid 사용)
4. JWT 토큰에서 사용자 정보를 추출 (JwtUtil - getEmail 사용)
5. 정보를 바탕으로 인증 객체 생성 및 컨테이너 삽입

SecurityContextHolder
현재 보안 컨텍스트에 대한 세부 정보를 보관한다.

5. SecurityConfig 추가

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtUtil jwtUtil;
    private final MemberRepository memberRepository;

    private final String[] allowUris = {
            "/swagger-ui/**",
            "/swagger-resources/**",
            "/v3/api-docs/**",
            "/auth/**"
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(requests -> requests
                        .requestMatchers(allowUris).permitAll()
                        .anyRequest().authenticated()
                )
                // 폼로그인 비활성화
                .formLogin(AbstractHttpConfigurer::disable)
                .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
                .csrf(AbstractHttpConfigurer::disable)
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/login?logout")
                        .permitAll()
                );

        return http.build();
    }

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

    @Bean
    public JwtAuthFilter jwtAuthFilter() {
        return new JwtAuthFilter(jwtUtil, memberRepository);
    }

}
  • addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
    jwtAuthFilter를 등록한다.

6. Service 생성

public class MemberResDto {
    @Data
    @Builder
    public static class LoginDto{
        Long memberId;
        String accessToken;
    }
}
	private final JwtUtil jwtUtil;
    private final PasswordEncoder passwordEncoder;
    
	@Override
    public MemberResDto.LoginDto emailLogin(MemberReqDto.LoginDto dto) {
        Member member = memberRepository.findByEmail(dto.getEmail())
                .orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND));

        if (!passwordEncoder.matches(dto.getPassword(), member.getPassword())){
            throw new MemberException(MemberErrorCode.INVALID_PW);
        }

        String accessToken = jwtUtil.createAccessToken(member);
        return MemberConverter.toLoginResDto(member, accessToken);
    }

실행 로직

  • 해당 이메일의 멤버 객체를 repository에서 찾아온다.
    • 없을 시, 사용자를 찾을 수 없다는 에러 발생
  • 해당 멤버의 비밀번호가 입력한 비밀번호가 맞는지 검증한다.
    • 아닐 시, 비밀번호가 틀리다는 에러 발생
  • 토큰을 생성 후 멤버 id와 토큰을 반환한다.

실행 결과

profile
감자의 공부기록🥔

0개의 댓글