# Spring Security 6: 완벽한 JWT 인증 가이드 > Spring Security 6로 JWT 인증을 구현하는 실용 가이드. 구성, 토큰 생성, 검증, 보안 모범 사례를 다룹니다. - Published: 2026-02-19 - Updated: 2026-04-28 - Author: SharpSkill - Tags: spring security, jwt, java, spring boot, authentication - Reading time: 15 min --- JWT(JSON Web Token) 기반 인증은 현대 REST API 보안의 표준이 되었습니다. Spring Security 6는 함수형 구성 방식을 도입하여 구현을 단순화하면서 보안을 강화합니다. 이 가이드는 초기 설정부터 엔드포인트 보호까지 전체 과정을 안내합니다. > **전제 조건** > > 이 튜토리얼은 Spring Boot 3.2+와 Spring Security 6.2+를 사용합니다. 개념은 이후 버전에서도 약간의 문법 조정만으로 그대로 적용됩니다. ## Spring Security에서의 JWT 아키텍처 JWT 인증은 stateless 원칙에 기반합니다. 서버는 어떠한 세션도 저장하지 않습니다. 각 요청에는 사용자의 신원을 증명하는 서명된 토큰이 포함됩니다. 이 아키텍처는 인스턴스 간 세션 공유 없이 수평 확장을 가능하게 합니다. JWT 인증 흐름은 여러 단계로 나뉩니다. 사용자가 자격 증명으로 인증하고, 서명된 JWT 토큰을 받은 뒤, 이후 모든 요청에 이 토큰을 첨부합니다. 서버는 서명을 검증하고 토큰에서 사용자 정보를 추출합니다. ```java // JwtAuthenticationFlow.java // Conceptual representation of the authentication flow public class JwtAuthenticationFlow { // 1. Initial authentication: POST /api/auth/login // → Verify credentials against database // → Generate signed JWT token // → Return token to client // 2. Authenticated requests: GET /api/protected // → Header: Authorization: Bearer // → Extract and validate token // → Create SecurityContext // → Access protected resource } ``` 이 방식은 sticky session 문제를 제거하고 분산 환경에서의 배포를 단순화합니다. ## Maven 의존성 구성 프로젝트에는 Spring Security 의존성과 JWT 라이브러리가 필요합니다. JJWT(Java JWT)는 토큰을 다루기 위한 유려하고 잘 관리되는 API를 제공합니다. ```xml org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-web io.jsonwebtoken jjwt-api 0.12.5 io.jsonwebtoken jjwt-impl 0.12.5 runtime io.jsonwebtoken jjwt-jackson 0.12.5 runtime org.springframework.boot spring-boot-starter-data-jpa ``` JJWT를 세 모듈(api, impl, jackson)로 나누는 구성은 캡슐화 원칙을 따릅니다. 컴파일 시점에는 API만 노출되고, 구현은 런타임 세부 사항으로 남습니다. ## JWT 생성 및 검증 서비스 JWT 서비스는 토큰 관련 모든 작업을 한곳에 모읍니다. 생성, 클레임 추출, 검증을 담당합니다. 안전한 비밀 키가 각 토큰에 서명하여 무결성을 보장합니다. ```java // JwtService.java @Service public class JwtService { // Secret key injected from application.yml @Value("${app.jwt.secret}") private String secretKey; // Token validity duration (24 hours by default) @Value("${app.jwt.expiration:86400000}") private long jwtExpiration; // Generates a JWT token for an authenticated user public String generateToken(UserDetails userDetails) { return generateToken(new HashMap<>(), userDetails); } // Generates a token with custom claims public String generateToken(Map extraClaims, UserDetails userDetails) { return Jwts.builder() .claims(extraClaims) // Additional claims (roles, permissions) .subject(userDetails.getUsername()) // Principal identifier .issuedAt(new Date()) // Creation date .expiration(new Date(System.currentTimeMillis() + jwtExpiration)) .signWith(getSigningKey(), Jwts.SIG.HS256) // HMAC-SHA256 signature .compact(); } // Extracts the username (subject) from the token public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } // Extracts a specific claim via an extraction function public T extractClaim(String token, Function claimsResolver) { final Claims claims = extractAllClaims(token); return claimsResolver.apply(claims); } // Validates the token: correct signature and not expired public boolean isTokenValid(String token, UserDetails userDetails) { final String username = extractUsername(token); return username.equals(userDetails.getUsername()) && !isTokenExpired(token); } // Checks if the token has expired private boolean isTokenExpired(String token) { return extractExpiration(token).before(new Date()); } // Extracts the expiration date private Date extractExpiration(String token) { return extractClaim(token, Claims::getExpiration); } // Parses the token and extracts all claims private Claims extractAllClaims(String token) { return Jwts.parser() .verifyWith(getSigningKey()) // Verifies the signature .build() .parseSignedClaims(token) // Parses the signed token .getPayload(); // Returns the claims } // Generates the signing key from the Base64-encoded secret private SecretKey getSigningKey() { byte[] keyBytes = Decoders.BASE64.decode(secretKey); return Keys.hmacShaKeyFor(keyBytes); } } ``` 비밀 키는 충분히 길어야 하며(HS256의 경우 최소 256비트) 안전하게 보관해야 합니다. 절대 소스 코드에 두지 마세요. > **비밀 키 보안** > > JWT 키는 환경 변수 또는 시크릿 매니저로 관리하세요. 키가 유출되면 어떤 사용자에게나 유효한 토큰을 위조할 수 있습니다. ## User 엔티티 구성 사용자 엔티티는 Spring Security의 `UserDetails`를 구현하여 인증 시스템과 직접 통합됩니다. ```java // User.java @Entity @Table(name = "users") public class User implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String email; @Column(nullable = false) private String password; @Column(nullable = false) private String firstName; @Column(nullable = false) private String lastName; // Role stored as enum for type safety @Enumerated(EnumType.STRING) @Column(nullable = false) private Role role; // UserDetails implementation: returns user authorities @Override public Collection getAuthorities() { return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); } // Username corresponds to email in this implementation @Override public String getUsername() { return email; } // Account always active (adapt as needed) @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } // Getters and setters omitted for brevity } ``` ```java // Role.java public enum Role { USER, // Standard user ADMIN // Administrator with extended privileges } ``` `Role` 열거형은 가능한 값을 제한하고, SpEL 표현식에서의 권한 검사도 단순화해 줍니다. ## JWT 인증 필터 JWT 필터는 모든 요청을 가로채 토큰을 추출하고 검증합니다. Spring Security 필터 체인에서 표준 인증 필터보다 앞에 배치됩니다. ```java // JwtAuthenticationFilter.java @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserDetailsService userDetailsService; @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain ) throws ServletException, IOException { // Retrieve the Authorization header final String authHeader = request.getHeader("Authorization"); // Check for Bearer prefix if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; } // Extract the token (without the "Bearer " prefix) final String jwt = authHeader.substring(7); try { // Extract username from token final String userEmail = jwtService.extractUsername(jwt); // Check that user is not already authenticated if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { // Load user details from database UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail); // Validate token (signature + expiration + user match) if (jwtService.isTokenValid(jwt, userDetails)) { // Create authentication object UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); // Add request details authToken.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) ); // Set authentication in security context SecurityContextHolder.getContext().setAuthentication(authToken); } } } catch (ExpiredJwtException e) { // Token expired: user must re-authenticate response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Token expired"); return; } catch (JwtException e) { // Invalid token: incorrect signature or malformed format response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("Invalid token"); return; } // Continue the filter chain filterChain.doFilter(request, response); } } ``` `OncePerRequestFilter`는 forward나 include가 발생하더라도 필터가 요청당 단 한 번만 실행되도록 보장합니다. ## Spring Security 6 구성 Spring Security 6는 람다를 사용하는 함수형 방식으로 보안 체인을 구성합니다. 이 구성은 접근 규칙을 정의하고 JWT 필터를 통합합니다. ```java // SecurityConfig.java @Configuration @EnableWebSecurity @EnableMethodSecurity // Enables @PreAuthorize and @PostAuthorize @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthFilter; private final AuthenticationProvider authenticationProvider; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http // Disable CSRF since API is stateless (no session cookies) .csrf(csrf -> csrf.disable()) // Configure authorization rules .authorizeHttpRequests(auth -> auth // Public endpoints: authentication and registration .requestMatchers("/api/auth/**").permitAll() // API documentation accessible without authentication .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // Admin endpoints reserved for administrators .requestMatchers("/api/admin/**").hasRole("ADMIN") // All other requests require authentication .anyRequest().authenticated() ) // Stateless mode: no server-side HTTP session .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) // Custom authentication provider .authenticationProvider(authenticationProvider) // Insert JWT filter before standard authentication filter .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .build(); } } ``` ```java // ApplicationConfig.java @Configuration @RequiredArgsConstructor public class ApplicationConfig { private final UserRepository userRepository; // User loading service for Spring Security @Bean public UserDetailsService userDetailsService() { return username -> userRepository.findByEmail(username) .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); } // Authentication provider with password encoder @Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); authProvider.setUserDetailsService(userDetailsService()); authProvider.setPasswordEncoder(passwordEncoder()); return authProvider; } // Authentication manager exposed as bean @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } // BCrypt encoder for secure password hashing @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } ``` stateless API에서 CSRF를 비활성화해도 안전합니다. 인증이 브라우저가 자동으로 전송하는 쿠키가 아니라 명시적인 헤더에 의존하기 때문입니다. ## 인증 컨트롤러 컨트롤러는 회원가입과 로그인 엔드포인트를 노출합니다. 이 엔드포인트는 공개되어 있으며, 인증에 성공하면 JWT 토큰을 반환합니다. ```java // AuthenticationController.java @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthenticationController { private final AuthenticationService authService; // POST /api/auth/register - Register a new user @PostMapping("/register") public ResponseEntity register( @Valid @RequestBody RegisterRequest request ) { return ResponseEntity.ok(authService.register(request)); } // POST /api/auth/login - Login existing user @PostMapping("/login") public ResponseEntity login( @Valid @RequestBody AuthenticationRequest request ) { return ResponseEntity.ok(authService.authenticate(request)); } // POST /api/auth/refresh - Token refresh (optional) @PostMapping("/refresh") public ResponseEntity refresh( @RequestHeader("Authorization") String authHeader ) { return ResponseEntity.ok(authService.refreshToken(authHeader)); } } ``` ```java // AuthenticationRequest.java public record AuthenticationRequest( @NotBlank(message = "Email required") @Email(message = "Invalid email format") String email, @NotBlank(message = "Password required") String password ) {} ``` ```java // RegisterRequest.java public record RegisterRequest( @NotBlank(message = "First name required") String firstName, @NotBlank(message = "Last name required") String lastName, @NotBlank(message = "Email required") @Email(message = "Invalid email format") String email, @NotBlank(message = "Password required") @Size(min = 8, message = "Password must contain at least 8 characters") String password ) {} ``` ```java // AuthenticationResponse.java public record AuthenticationResponse( String token, String type, long expiresIn ) { public AuthenticationResponse(String token, long expiresIn) { this(token, "Bearer", expiresIn); } } ``` Java 레코드는 DTO 정의를 단순하게 만들면서 불변성을 보장합니다. ## 인증 서비스 이 서비스는 회원가입과 인증 로직을 조율하며, 토큰 생성은 `JwtService`에 위임합니다. ```java // AuthenticationService.java @Service @RequiredArgsConstructor public class AuthenticationService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; private final AuthenticationManager authenticationManager; @Value("${app.jwt.expiration:86400000}") private long jwtExpiration; // Register a new user @Transactional public AuthenticationResponse register(RegisterRequest request) { // Check that email is not already in use if (userRepository.existsByEmail(request.email())) { throw new EmailAlreadyExistsException("Email already registered"); } // Create user entity with hashed password User user = new User(); user.setFirstName(request.firstName()); user.setLastName(request.lastName()); user.setEmail(request.email()); user.setPassword(passwordEncoder.encode(request.password())); user.setRole(Role.USER); // Persist the user userRepository.save(user); // Generate and return JWT token String jwtToken = jwtService.generateToken(user); return new AuthenticationResponse(jwtToken, jwtExpiration); } // Authenticate an existing user public AuthenticationResponse authenticate(AuthenticationRequest request) { // Delegate verification to AuthenticationManager authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.email(), request.password() ) ); // Load user (authentication succeeded) User user = userRepository.findByEmail(request.email()) .orElseThrow(() -> new UsernameNotFoundException("User not found")); // Generate token with additional claims Map claims = new HashMap<>(); claims.put("role", user.getRole().name()); claims.put("userId", user.getId()); String jwtToken = jwtService.generateToken(claims, user); return new AuthenticationResponse(jwtToken, jwtExpiration); } // Refresh token (extend session) public AuthenticationResponse refreshToken(String authHeader) { if (authHeader == null || !authHeader.startsWith("Bearer ")) { throw new InvalidTokenException("Invalid token"); } String oldToken = authHeader.substring(7); String userEmail = jwtService.extractUsername(oldToken); User user = userRepository.findByEmail(userEmail) .orElseThrow(() -> new UsernameNotFoundException("User not found")); // Generate new token String newToken = jwtService.generateToken(user); return new AuthenticationResponse(newToken, jwtExpiration); } } ``` `AuthenticationManager`는 자격 증명 검증을 한곳에 모으고 인증 전략을 손쉽게 교체할 수 있게 합니다. > **리프레시 토큰** > > 보안을 강화하려면 데이터베이스에 저장하고 폐기 가능한, 더 긴 유효기간을 가진 리프레시 토큰 시스템을 도입하세요. ## 어노테이션을 통한 엔드포인트 보호 메서드 수준의 보안은 비즈니스 코드 안에서 세밀한 권한 제어를 가능하게 합니다. ```java // UserController.java @RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class UserController { private final UserService userService; // Accessible to all authenticated users @GetMapping("/me") public ResponseEntity getCurrentUser( @AuthenticationPrincipal User currentUser ) { return ResponseEntity.ok(UserDTO.from(currentUser)); } // Only the concerned user or an admin can modify the profile @PreAuthorize("hasRole('ADMIN') or #id == authentication.principal.id") @PutMapping("/{id}") public ResponseEntity updateUser( @PathVariable Long id, @Valid @RequestBody UpdateUserRequest request ) { return ResponseEntity.ok(userService.updateUser(id, request)); } // Reserved for administrators @PreAuthorize("hasRole('ADMIN')") @GetMapping public ResponseEntity> getAllUsers() { return ResponseEntity.ok(userService.findAll()); } // Deletion with post-execution verification @PreAuthorize("hasRole('ADMIN')") @DeleteMapping("/{id}") public ResponseEntity deleteUser(@PathVariable Long id) { userService.deleteUser(id); return ResponseEntity.noContent().build(); } } ``` ```java // AdminController.java @RestController @RequestMapping("/api/admin") @PreAuthorize("hasRole('ADMIN')") // All methods require ADMIN public class AdminController { private final AdminService adminService; @GetMapping("/dashboard") public ResponseEntity getDashboard() { return ResponseEntity.ok(adminService.getDashboardStats()); } @PostMapping("/users/{id}/role") public ResponseEntity changeUserRole( @PathVariable Long id, @RequestParam Role newRole ) { return ResponseEntity.ok(adminService.changeUserRole(id, newRole)); } } ``` `@AuthenticationPrincipal` 어노테이션은 인증된 사용자를 직접 주입하여 `SecurityContext`에 수동으로 접근할 필요를 없애 줍니다. ## 인증 오류 처리 오류를 한곳에서 처리하면 인증이 실패했을 때 일관되고 풍부한 정보를 담은 응답을 보낼 수 있습니다. ```java // SecurityExceptionHandler.java @RestControllerAdvice public class SecurityExceptionHandler { private static final Logger log = LoggerFactory.getLogger(SecurityExceptionHandler.class); // Authentication error (incorrect credentials) @ExceptionHandler(BadCredentialsException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleBadCredentials(BadCredentialsException ex) { return new ErrorResponse( "INVALID_CREDENTIALS", "Invalid email or password", null ); } // Access denied (authenticated but not authorized) @ExceptionHandler(AccessDeniedException.class) @ResponseStatus(HttpStatus.FORBIDDEN) public ErrorResponse handleAccessDenied(AccessDeniedException ex) { return new ErrorResponse( "ACCESS_DENIED", "Access to this resource is not authorized", null ); } // Expired JWT token @ExceptionHandler(ExpiredJwtException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleExpiredToken(ExpiredJwtException ex) { return new ErrorResponse( "TOKEN_EXPIRED", "Session expired, please log in again", null ); } // Invalid JWT token @ExceptionHandler(JwtException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleInvalidToken(JwtException ex) { log.warn("Invalid JWT token: {}", ex.getMessage()); return new ErrorResponse( "INVALID_TOKEN", "Invalid authentication token", null ); } // Email already in use during registration @ExceptionHandler(EmailAlreadyExistsException.class) @ResponseStatus(HttpStatus.CONFLICT) public ErrorResponse handleEmailExists(EmailAlreadyExistsException ex) { return new ErrorResponse( "EMAIL_EXISTS", ex.getMessage(), null ); } } ``` ```java // ErrorResponse.java public record ErrorResponse( String code, String message, Map details ) {} ``` 표준화된 오류 코드는 클라이언트에서의 처리와 디버깅을 한층 수월하게 만듭니다. ## application.yml 구성 외부화된 구성 덕분에 환경별로 JWT 매개변수를 조정할 수 있습니다. ```yaml # application.yml app: jwt: # Base64 secret key (256 bits minimum for HS256) # Generate with: openssl rand -base64 32 secret: ${JWT_SECRET:yourSuperSecretKeyOf256BitsMinimum} # Validity duration in milliseconds (24 hours) expiration: 86400000 spring: datasource: url: jdbc:postgresql://localhost:5432/springjwt username: ${DB_USERNAME:postgres} password: ${DB_PASSWORD:postgres} jpa: hibernate: ddl-auto: validate show-sql: false ``` ```yaml # application-dev.yml app: jwt: # Short expiration for development (1 hour) expiration: 3600000 spring: jpa: show-sql: true hibernate: ddl-auto: update logging: level: org.springframework.security: DEBUG ``` 운영 환경에서는 비밀 키를 환경 변수나 Vault 같은 시크릿 관리 서비스에서 가져와야 합니다. ## 통합 테스트 테스트는 회원가입부터 보호된 자원에 접근하기까지 인증 시스템 전반의 동작을 검증합니다. ```java // AuthenticationIntegrationTest.java @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @AutoConfigureTestDatabase(replace = Replace.ANY) class AuthenticationIntegrationTest { @Autowired private TestRestTemplate restTemplate; @Autowired private UserRepository userRepository; @BeforeEach void setUp() { userRepository.deleteAll(); } @Test void shouldRegisterAndAuthenticateUser() { // Registration RegisterRequest registerRequest = new RegisterRequest( "John", "Doe", "john@example.com", "password123" ); ResponseEntity registerResponse = restTemplate .postForEntity("/api/auth/register", registerRequest, AuthenticationResponse.class); assertThat(registerResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(registerResponse.getBody().token()).isNotBlank(); // Login with same credentials AuthenticationRequest loginRequest = new AuthenticationRequest( "john@example.com", "password123" ); ResponseEntity loginResponse = restTemplate .postForEntity("/api/auth/login", loginRequest, AuthenticationResponse.class); assertThat(loginResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(loginResponse.getBody().token()).isNotBlank(); } @Test void shouldRejectInvalidCredentials() { AuthenticationRequest request = new AuthenticationRequest( "unknown@example.com", "wrongpassword" ); ResponseEntity response = restTemplate .postForEntity("/api/auth/login", request, ErrorResponse.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @Test void shouldAccessProtectedResourceWithValidToken() { // Register to get a token RegisterRequest registerRequest = new RegisterRequest( "Jane", "Doe", "jane@example.com", "password123" ); AuthenticationResponse authResponse = restTemplate .postForObject("/api/auth/register", registerRequest, AuthenticationResponse.class); // Access protected resource with token HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(authResponse.token()); ResponseEntity response = restTemplate.exchange( "/api/users/me", HttpMethod.GET, new HttpEntity<>(headers), UserDTO.class ); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody().email()).isEqualTo("jane@example.com"); } @Test void shouldRejectAccessWithoutToken() { ResponseEntity response = restTemplate .getForEntity("/api/users/me", Void.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } } ``` 이 테스트는 회원가입, 로그인, 허용된 접근, 거부된 접근이라는 핵심 시나리오를 다룹니다. ## 결론 Spring Security 6로 JWT 인증을 구현하는 과정은 명확한 패턴을 따릅니다. JWT 서비스가 토큰의 생성과 검증을 맡고, 필터가 요청을 가로채 보안 컨텍스트를 구성하며, 구성 파일이 접근 규칙을 정의합니다. **배포 체크리스트:** - ✅ 환경 변수에 저장된 256비트 이상의 비밀 키 - ✅ 운영 환경에서 토큰을 전송 중에 보호하기 위한 HTTPS 의무 적용 - ✅ 컨텍스트에 맞는 토큰 유효 기간(15분 ~ 24시간) - ✅ 재인증 없이 긴 세션을 지원하는 리프레시 토큰 - ✅ 비즈니스 코드와 함께 표준화된 오류 처리 - ✅ 실패한 인증 시도의 로그 기록 - ✅ 인증 시나리오를 망라하는 통합 테스트 - ✅ 인증 엔드포인트의 레이트 리미팅 이 아키텍처는 REST API를 보호하는 견고한 토대가 되며, 다중 요소 인증이나 OAuth2 통합과 같은 더 복잡한 요구로도 자연스럽게 확장됩니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/spring-boot/spring-security-6-jwt-authentication