Develop

[Spring Boot] 게시판 - 15. JWT 인증 구현하기 (Token 기반으로 전환) 본문

백엔드/게시판 만들기

[Spring Boot] 게시판 - 15. JWT 인증 구현하기 (Token 기반으로 전환)

230801 2025. 12. 16. 04:43

안녕하세요 ~ .ᐟ

 

어제는 Spring Security 의 Basic 인증을 구현했습니다.

Basic 인증은 매 요청마다 사용자명과 비밀번호를 Base64로 인코딩해서 보내야 하고, 서버에서 세션을 관리해야 하는 단점이 있었습니다.

 

그래서 오늘은 JWT를 이용한 무상태(Stateless) 인증 방식으로 전환을 해보겠습니다.

 

1. JWT (JSON Web Token) 란?

  • 정의
    • JSON 형태의 정보를 안전하게 전송하기 위한 토큰
  • 구성
    • 헤더 (Header) : 토큰 타입과 알고리즘 정보
    • 페이로드 (Payload) : 실제 데이터 (사용자 이메일 등)
    • 서명 (Signature) : 토큰이 변조되지 않았음을 증명
  • 장점
    • Stateless : 서버에 세션 저장 불필요
    • 확장성 좋음 (MSA 환경에 적합)
    • 모바일 앱에서 사용 편리
    • 토큰 자체에 사용자 정보 포함 가능
  • 주의할 점
    • JWT Base64 로 인코딩되어있어서 누구나 디코딩이 가능하고, 내용을 볼 수 있습니다.
      • 그래서 JWT 에는 비밀번호나 민감한 개인정보를 넣으면 안됩니다.
    • 하지만 서명이 있어서 내용이 변조되었는지 검증이 가능합니다.

 

 

2. AccessToken 과 RefreshToken

  • 왜 Token 을 2개나 사용할까?
    • 보안과 사용성의 균형을 위해서 입니다.
    • AccessToken만 사용하면 유효기간을 길게하면 탈취됐을때 해킹범이 사용할 수 있는기간이 늘어나 위험하고, 짧게 설정하면 사용자가 자주 로그인을 해야돼서 사용자 경험이 저하되고 유저이탈로 이어질 수 있습니다.
    • 그래서 RefreshToken 을 추가해서 AccessToken을 짧게 가져가고, RefreshToken을 자동으로 갱신하게 해 사용자 경험을 좋게 만들 수 있습니다.
  • AccessToken
    • 짧은 유효기간
    • 실제 API 요청에 사용
    • 보통 1시간 정도로 짧게 설정
    • 탈취되어도 피해 최소화
  • RefreshToken
    • 긴 유효기간
    • AccessToken 재발급에만 사용
    • 보통 7~30일로 설정
    • 더 안전한 곳에 보관 (HttpOnly 쿠키 등)

 

Filter vs Interceptor 의 차이

  • 실행시점
    • 요청 -> Filter -> DispatcherServlet -> Interceptor -> Controller
  • Filter (서블릿 레벨)
    • Spring 과 무관하게 동작
    • 요청/응답 객체 변경 가능
    • 인코딩, 보안, 로깅등에 사용
    • JWT 인증은 Spring Security와 통합해야 하므로 Filter 사용
  • Interceptor (Spring MVC 레벨)
    • Spring Bean 접근 가능
    • 컨트롤러 실행 전/후 처리
    • 인증/인가, 로깅, API 호출 추적 등에 사용

 

3. 설정

// build.grade
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'  // Inteface
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'    // 실제 구현체
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' // Json 파싱용

 

 

application.properties

  • 환경변수로 처리된 부분은 .env 파일에 추가해줍니다.
# jwt
jwt.secret= ${JWT_SECRET}                              // 최소 32자 이상 작성
jwt.access-token-validity= ${JWT_ACCESS_EXPIRATION}    // 1시간 : 3600000
jwt.refresh-token-validity= ${JWT_REFRESH_EXPIRATION}  // 7일 : 604800000

 

 

JwtProperties

  • application.properties 의 설정값을 Java 객체로 바인딩 합니다.
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
    private String secret;
    private Long accessTokenValidity;
    private Long refreshTokenValidity;
}

 

 

 


 

4. JwtUtil

  • 토큰 생성/ 검증 로직
더보기
@Slf4j
@Component
public class JwtUtil {

    private final JwtProperties jwtProperties;
    private final SecretKey signingKey;

    public JwtUtil(JwtProperties jwtProperties) {
        this.jwtProperties = jwtProperties;
        this.signingKey = Keys.hmacShaKeyFor(
            jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)
        );
    }

    // Access Token 생성
    public String generateAccessToken(String email) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + jwtProperties.getAccessTokenValidity());

        return Jwts.builder()
            .subject(email)
            .issuedAt(now)
            .expiration(expiryDate)
            .signWith(signingKey)
            .compact();
    }

    // Refresh Token 생성
    public String generateRefreshToken(String email) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + jwtProperties.getRefreshTokenValidity());

        return Jwts.builder()
            .subject(email)
            .issuedAt(now)
            .expiration(expiryDate)
            .signWith(signingKey)
            .compact();
    }

    // Token 에서 이메일 추출
    public String getEmailFromToken(String token) {
        return Jwts.parser()
            .verifyWith(signingKey)
            .build()
            .parseSignedClaims(token)
            .getPayload()
            .getSubject();
    }

    // Token 유효성 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parser()
                .verifyWith(signingKey)
                .build()
                .parseSignedClaims(token);
            return true;
        } catch (ExpiredJwtException e) {
            log.warn("만료된 토큰: {}", e.getMessage());
            throw new CustomJwtException("토큰이 만료되었습니다");
        } catch (MalformedJwtException e) {
            log.warn("잘못된 형식의 토큰: {}", e.getMessage());
            throw new CustomJwtException("유효하지 않은 토큰입니다");
        } catch (SecurityException e) {
            log.warn("서명 검증 실패: {}", e.getMessage());
            throw new CustomJwtException("토큰 검증에 실패했습니다");
        }
    }
}

 

 

JwtAuthenticationFilter

  • JWT 인증 필터 (모든 요청에서 토큰을 검사하는 필터)
  • OncePerRequestFilter 상속
    • 한 요청당 한번만 실행을 보장하기 위해 사용
더보기
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserDetailsService userDetailsService;

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

        // 1. 헤더에서 JWT 토큰 추출
        String token = extractToken(request);

        // 2. 토큰이 있고 유효한 경우
        if (token != null && jwtUtil.validateToken(token)) {

            // 3. 토큰에서 이메일 추출
            String email = jwtUtil.getEmailFromToken(token);

            // 4. UserDetails 조회
            UserDetails userDetails = userDetailsService.loadUserByUsername(email);

            // 5. 인증 객체 생성
            UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(
                    userDetails,
                    null,
                    userDetails.getAuthorities()
                );

            authentication.setDetails(
                new WebAuthenticationDetailsSource().buildDetails(request)
            );

            // 6. SecurityContext 에 인증 정보 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        // 7. 다음 필터로 요청 전달
        filterChain.doFilter(request, response);
    }

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

 

 


 

5. SecurityConfig 수정

  • addFilterBefore() : JWT 필터를 기본 인증 필터 앞에 배치
  • `/auth/**`: 로그인/회원가입/토큰갱신 경로는 인증 불필요
더보기
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) // 테스트용 : CSRF 비활성화
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            // JWT 쓰므로 세션 안씀
            .sessionManagement(
                session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                // 인증 없이 접근 가능한 엔드포인트
                .requestMatchers(
                    "/", "/error",
                    "v3/api-docs/**",
                    "/swagger-ui/**",
                    "/swagger-ui.html",
                    "/actuator/health",
                    "/users",
                    "/auth/signup",
                    "/auth/login",
                    "/auth/refresh"
                ).permitAll()
                // 나머지는 인증 필요
                .anyRequest().authenticated()
            )
            .addFilterBefore(
                jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class
            )
            .httpBasic(basic -> basic.disable()); // Basic Auth 안씀
        return http.build();
    }

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

    @Bean
    public AuthenticationManager authenticationManager(
        AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.setAllowedOrigins(List.of("http://localhost:3000"));
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        configuration.setAllowedOrigins(List.of("*"));
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);

        return source;
    }
}

 

RefreshTokenRequest

  • AccessToken 이 만료되면 RefreshToken 으로 새로운 AccessToken 을 발급받습니다.
더보기
@Getter
@NoArgsConstructor
public class RefreshTokenRequest {
    @NotBlank(message = "Refresh Token은 필수입니다")
    private String refreshToken;
}

 

AuthController

  • refreshToken 발급 요청 Api 생성 (원래는 재사용됨)
더보기
@PostMapping("/refresh")
public ResponseEntity<ApiResponse<TokenResponse>> refresh(
    @Valid @RequestBody RefreshTokenRequest request) {
    return ResponseEntity.ok(
        ApiResponse.success(authService.refreshToken(request.getRefreshToken()))
    );
}

 

AuthService

  • 로그인 시 토큰 발급하는 로직으로 수정
더보기
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class AuthService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;

    public void signup(SignupRequest request) {
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new DuplicateResourceException(ErrorCode.EMAIL_ALREADY_EXISTS, "이미 존재하는 이메일입니다.");
        }

        User user = User.builder()
            .email(request.getEmail())
            .password(passwordEncoder.encode(request.getPassword()))
            .name(request.getName())
            .build();

        userRepository.save(user);
    }

    public TokenResponse login(LoginRequest request) {
        // 1. AuthenticationManager 에게 인증 요청
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.getEmail(),
                request.getPassword()
            )
        );

        // 2. 인증 성공 시 토큰 생성
        String email = authentication.getName();
        String accessToken = jwtUtil.generateAccessToken(email);
        String refreshToken = jwtUtil.generateRefreshToken(email);

        // 3. 응답 생성
        return new TokenResponse(accessToken, refreshToken);
    }

    public TokenResponse refreshToken(String refreshToken) {

        // 1. RefreshToken 검증
        if (!jwtUtil.validateToken(refreshToken)) {
            throw new CustomJwtException("유효하지 않은 Refresh Token 입니다.");
        }

        // 2. RefreshToken 에서 이메일 추출
        String email = jwtUtil.getEmailFromToken(refreshToken);

        // 3. 새로운 AccessToken 발급 (RefreshToken 은 재사용)
        String newAccessToken = jwtUtil.generateAccessToken(email);

        return new TokenResponse(newAccessToken, refreshToken);
    }
}

 

 

 


6. 예외처리

JWT 관련 예외를 처리하기 위한 커스텀 예외를 만들어줍니다.

더보기
// CustomJwtException
public class CustomJwtException extends RuntimeException {
    public CustomJwtException(String message) {
        super(message);
    }
}



// GlobalExceptionHandler
@ExceptionHandler(CustomJwtException.class)
public ResponseEntity<ApiResponse<Void>> handleCustomJwtException(CustomJwtException e) {
    log.warn("JWT 예외 발생: {}", e.getMessage());
    return ResponseEntity
        .status(HttpStatus.UNAUTHORIZED)
        .body(ApiResponse.error(e.getMessage()));
}

 

7. API 테스트

더보기
### 1. 로그인
POST http://localhost:8080/auth/login
Content-Type: application/json

{
  "email": "test@example.com",
  "password": "Test1234!@"
}


### 2. 인증이 필요한 API 호출
GET http://localhost:8080/posts
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...


### 3. 토큰 갱신 (AccessToken 만료 시)
POST http://localhost:8080/auth/refresh
Content-Type: application/json

{
  "refreshToken": "eyJhbGciOiJIUzI1NiJ9..."
}

 

 


 

느낀 점

  • 팀플당시에는 accessToken / refreshToken 개념만 인지하고 있었는데, 직접 구현해보니까 동작흐름을 알게되었다.
    • 인증(Authentication)과 인가(Authorization)의 분리
    • FilterChain 을 통한 요청 전처리로 인증된 사용자만 접근 가능하도록 설계
    • 토큰 검증 -> 사용자 정보 추출 -> SecurityContext 등록의 과정
    • 서비스 로직에서 @PreAuthorize or hasRole() 로 역할기반 인가 체크 (내일 쯤 적용할듯..)
  • 토큰에 담는 정보
    • JWT 는 Base64 인코딩이지 암호화가 아니라서 디코딩이되기때문에 절대 중요한 정보는 담으면 안된다.
    • 비밀번호는 로그인 시 DB에 저장된 hash 값만 비교 검증하고, 이후에는 토큰으로만 인증하므로 JWT에 담을 필요가 없습니다.
  • Stateless 아키텍처의 장점
    • Basic 인증 -> JWT 로 전환
      • 서버를 재시작해도 토큰만 있으면 인증이 유지된다.
      • 여러 서버로 확장해도 세션 동기화가 불필요하다 (MSA 환경 준비)
      • 모바일 앱에서도 동일한 방식으로 인증이 가능하다.

 

RefreshToken DB 저장, 로그아웃, 강제 로그아웃 등..

내일 마저 하겠습니다 하하