Tiny Star

프로젝트/뉴스 요약 (AI)

[뉴스 요약] 로그인 구현 (Spring Security ,JWT Token)

하얀지 2025. 6. 30. 22:09

전체 흐름

로그인 흐름

프론트에서 로그인 요청(`login`)을 보내면 백엔드는 이를 처리해 `accessToken`, `refreshToken`을 발급하고, 클라이언트는 이를 저장한 뒤 홈 화면으로 이동합니다. 이후 홈 페이지에서 사용자 정보 요청(`me`)을 다시 보내 사용자 정보를 렌더링합니다.

 

 

AuthController

로그인 요청 처리
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
     LoginResponse loginResponse = authService.login(request);
     return ResponseEntity.ok().body(loginResponse);
}
  • 프론트에서 사용자 ID, 비밀번호를 전달하면 `@RequestBody` 로 받아 처리합니다.
  • 로그인 검증 및 토큰 발급 처리는 `AuthService` 로 위임합니다.
  • 성공 시 `accessToken`, `refreshToken` 을 담은 응답을 반환합니다.

 

 

AuthService

사용자 인증 및 토큰 발급
public LoginResponse login(LoginRequest request) {
    User user = userRepository.findByUserId(request.getUserId())
        .orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 올바르지 않습니다."));

    if (!user.isEmailVerified()) {
        throw new IllegalStateException("이메일 인증이 완료되지 않았습니다.");
    }

    if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
        throw new IllegalArgumentException("아이디 또는 비밀번호가 올바르지 않습니다.");
    }

    String accessToken = jwtUtil.generateAccessToken(user);
    String refreshToken = jwtUtil.generateRefreshToken(user);

    redisTemplate.opsForValue().set("refresh:" + user.getId(), refreshToken, 7, TimeUnit.DAYS);

    return new LoginResponse(
                accessToken,
                refreshToken,
                user.getUserId(),
                user.getRole().name()
        );
}
  • 사용자 정보를 DB에서 조회하고 비밀번호를 검증합니다.
  • 이메일 인증 여부를 체크합니다.
  • JWT 토큰을 발급하고, `refreshToken` 은 Redis에 저장합니다.

 

 

JwtUtil

JWT 토큰 발급 및 검증
@Component
public class JwtUtil {

    private SecretKey secretKey;

    private final long ACCESS_TOKEN_VALIDITY = 1000L * 60 * 30;
    private final long REFRESH_TOKEN_VALIDITY = 1000L * 60 * 60 * 24 * 7;

    @PostConstruct
    public void init() {
        Dotenv dotenv = Dotenv.load();
        String secret = dotenv.get("JWT_SECRET");
        if (secret == null || secret.isBlank()) {
            throw new IllegalStateException("JWT_SECRET is missing in .env");
        }
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.secretKey = Keys.hmacShaKeyFor(keyBytes);
    }

    public String generateAccessToken(User user) {
        return generateToken(user, ACCESS_TOKEN_VALIDITY);
    }
    
    private String generateToken(User user, long expirationMillis) {
        Date now = new Date();
        Date expiry = new Date(now.getTime() + expirationMillis);

        return Jwts.builder()
                .subject(String.valueOf(user.getId()))
                .issuedAt(now)
                .expiration(expiry)
                .claim("userId", user.getUserId())
                .claim("role", user.getRole().name())
                .signWith(secretKey, Jwts.SIG.HS256)
                .compact();
    }

    public TokenStatus validateToken(String token) {
        try {
            Jwts.parser()
                    .verifyWith(secretKey)
                    .build()
                    .parseSignedClaims(token);
            return TokenStatus.VALID;
        } catch (ExpiredJwtException e) {
            return TokenStatus.EXPIRED;
        } catch (JwtException | IllegalArgumentException e) {
            return TokenStatus.INVALID;
        }
    }

    public Claims parseClaims(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(secretKey)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}
  • `@PostConstruct` 메서드를 통해 `.env` 파일에서 `JWT_SECRET`을 불러와 `secretKey`를 초기화합니다.
    cmd `openssl rand -hex 32` 명령어로 32바이트 비밀 키를 생성할 수 있습니다.
  • `generateToken()`을 통해 HS256 알고리즘으로 서명된 JWT를 발급합니다.
  • `validateToken()`은 서명을 검증하고 만료 여부를 확인합니다.
  • `parseClaims()`로 사용자 ID 등의 정보를 추출합니다.

JWT는 stateless한 인증 방식을 가능하게 하는 핵심 기술입니다. 사용자 상태를 서버가 저장하지 않고도 인증을 유지할 수 있게 해줍니다.

Statless: 클라이언트-서버 관계에서 서버가 클라이언트의 상태를 보존하지 않음
Stateful: 클라이언트-서버 관계에서 서버가 클라이언트의 상태를 보존함

 

 

JwtAuthenticationFilter

모든 요청에 대해 토큰 인증
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserRepository userRepository;
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = resolveToken(request);

        if (token != null && jwtUtil.validateToken(token) == TokenStatus.VALID) {
            Claims claims = jwtUtil.parseClaims(token);
            Long userId = Long.parseLong(claims.getSubject());
            User user = userRepository.findById(userId)
                    .orElseThrow(() -> new UsernameNotFoundException("사용자 없음"));

            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    user, null, user.getAuthorities()
            );
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

로그인 API 에서는 아직 Authorization 헤더에 토큰이 없기 때문에 if 문을 타지 않습니다. 토큰이 있는 경우에만 if 문을 타서 사용자 정보를 설정합니다. 

`SecurityContextHolder.getContext().setAuthentication(authentication);` 이 부분이 인증된 사용자 정보를 SecurityContext에 저장하여, 이후 요청 처리에서 `@AuthenticationPrincipal` 이나 인증 체크가 동작할 수 있도록 합니다.

  • `doFilterInternal()`: 모든 HTTP 요청에 대해 실행됩니다. 요청의 헤더에서 JWT를 추출하고 유효한 경우 인증 객체를 설정합니다.
  • `resolveToken()`: `Authorization` 헤더에서 Bearer 토큰 형식의 JWT를 꺼내 반환합니다.

extends OncePerRequestFilter

  • Spring Security에서 사용되는 필터는 요청마다 단 한 번만 실행돼야 하며, 이 역할을 `OncePerRequestFilter`가 보장하기 때문에 상속받아야함

 

 

User

UserDetails 인터페이스 구현
@Entity
public class User implements UserDetails {
    ...

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
    }

    @Override
    public String getUsername() {
        return userId;
    }

    @Override
    public boolean isEnabled() {
        return emailVerified;
    }
    ...
}

왜 UserDetails를 구현해야 할까?

  • `UsernamePasswordAuthenticationToken``UserDetails` 객체를 요구하기 때문 (`JwtAuthenticationFilter.doFilterInternal`)
  • 이 객체를 통해 사용자 권한, 계정 상태 등을 Spring Security가 판단합니다.

즉, Spring Security와의 연동을 위해 사용자 엔티티는 최소한 `UserDetails`를 구현해야 하며, 이를 통해 우리가 설정한 인증/인가 정책이 제대로 작동합니다.

 

 

 

전체 상세 흐름

JwtAuthenticationFilter의 상세 로직을 타지않는 `/auth/login`과 JwtToken을 헤더에 담아서 요청하는 `/user/me`에 대한 상세 흐름입니다. 헤더 토큰에 따라 SecurityContextHolder에 사용자 정보 저장 유무를 결정합니다.

 

예를 들어 `/auth/login` 요청은 로그인 전 단계이므로 Authorization 헤더에 토큰이 없고, 따라서 `JwtAuthenticationFilter`의 if 블록을 통과하지 않습니다. 반면 `/user/me` 요청은 클라이언트가 accessToken을 Authorization 헤더에 담아 전송하기 때문에, 필터가 이를 파싱해` SecurityContextHolder.getContext().setAuthentication(authentication)`을 호출합니다.

이때 설정된 인증 객체는 이후 컨트롤러 메서드에서` @AuthenticationPrincipal`을 통해 주입되며, 이 어노테이션은 JWT를 직접 파싱하지 않고 `SecurityContext`에 저장된 인증 객체에서 사용자 정보를 꺼냅니다. 즉, 인증 흐름이 정상적으로 설정되어 있어야만 해당 어노테이션이 정상 동작합니다.

이러한 구조는 Spring Security가 모든 요청 전 단계에서 필터 체인을 먼저 적용하고, 컨트롤러는 인증이 완료된 이후에 호출된다는 특징에 기반합니다.

 

 

  1. 프론트는 `accessToken`을 포함해 `/api/user/me` 요청을 보냅니다.
  2. `JwtAuthenticationFilter`가 먼저 실행되어 토큰 검증 및 사용자 조회 후, `SecurityContextHolder`에 인증 정보를 설정합니다.
  3. 설정된 인증 정보는 이후 `UserController`에서 `@AuthenticationPrincipal`을 통해 주입되어 사용됩니다.
  4. 컨트롤러는 사용자 정보를 응답으로 반환하고, 프론트에서는 이를 화면에 표시합니다.

 

로그인과 같이써서 복잡한거지 이정도 흐름으로만 이해하면 됩니다.

 

 

마무리

처음 Spring Security를 적용했을 때는 API 구현에 집중하느라, 토큰이 어떻게 UserDetails로 매핑되는지 정확히 이해하지 못했습니다. 나중에 흐름을 따라가보니, 실제로는 컨트롤러가 실행되기 전에 JWT 토큰을 파싱해 SecurityContext에 인증 객체를 저장하고, 이후 컨트롤러에서는 @AuthenticationPrincipal을 통해 이 정보를 가져오는 구조라는 것을 알게 되었습니다.

 

이처럼 컨트롤러에서 편하게 쓰는 @AuthenticationPrincipal 외에도, 서비스 레이어나 커스텀 로직에서는 SecurityContextHolder.getContext().getAuthentication()을 통해 직접 인증 객체를 참조할 수 있고, @PreAuthorize("hasRole(...)") 같은 애노테이션에서도 SecurityContext의 인증 정보를 기준으로 인가 처리가 이루어집니다.

결국 SecurityContext는 인증된 사용자 정보를 중앙에서 관리하는 핵심 컨텍스트로, Spring Security의 인증·인가 흐름 전반을 지탱하는 기반이라고 할 수 있습니다.

 

처음 구현했을 때의 저처럼 막연하게 구현만 하던 사람들에게 도움이 되었으면 좋겠습니다.

top