Spring Boot JWT 인증과 인가 구현하기
- 세션 기반 인증은 사용자 상태를 서버에 저장한다
- 수평 확장하거나 마이크로서비스를 구축할 때 문제가 된다
- JWT는 사용자 정보를 서명된 토큰에 인코딩하여 이 문제를 해결한다
JWT를 사용하는 이유
JWT는 세션 기반 인증에 비해 여러 장점을 제공한다:
- 무상태(Stateless): 서버 측 세션 저장소가 필요 없음
- 확장성: 공유 상태 없이 어떤 서버든 토큰을 검증할 수 있음
- 크로스 도메인: 여러 서비스 간에 원활하게 동작
- 모바일 친화적: 모바일 앱에서 저장하고 전송하기 쉬움
JWT는 헤더, 페이로드, 서명 세 부분으로 구성된다
서버가 비밀 키로 토큰에 서명하므로 데이터베이스 조회 없이 진위를 검증할 수 있다
프로젝트 설정
Spring Security 6.x는 Spring Boot 3.x가 필요하다
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.1'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
// Database
runtimeOnly 'com.h2database:h2'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}jjwt 라이브러리(버전 0.12.x)는 api, impl, jackson 세 모듈이 모두 필요하다
JWT 설정
application.yml에 JWT 비밀 키와 만료 시간을 설정한다
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
h2:
console:
enabled: true
jwt:
secret: your-256-bit-secret-key-here-must-be-at-least-256-bits-long
expiration: 3600000 # 1시간 (밀리초)
refresh-expiration: 604800000 # 7일 (밀리초)HS256 알고리즘을 위해 비밀 키는 최소 256비트(32자)가 필요하다
프로덕션에서는 환경 변수를 사용한다
User 엔티티
User 엔티티는 Spring Security 통합을 위해 UserDetails를 직접 구현한다
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
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;
private String name;
@Enumerated(EnumType.STRING)
private Role role;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return true; }
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return true; }
}public enum Role {
USER, ADMIN
}JWT 유틸리티 클래스
JwtUtil 클래스는 모든 JWT 작업을 처리한다
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
@Value("${jwt.refresh-expiration}")
private Long refreshExpiration;
private SecretKey getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
claims.put("role", userDetails.getAuthorities().iterator().next().getAuthority());
return createToken(claims, userDetails.getUsername(), expiration);
}
public String generateRefreshToken(UserDetails userDetails) {
return createToken(new HashMap<>(), userDetails.getUsername(), refreshExpiration);
}
private String createToken(Map<String, Object> claims, String subject, Long expirationTime) {
return Jwts.builder()
.claims(claims)
.subject(subject)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expirationTime))
.signWith(getSigningKey())
.compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}주요 포인트:
Keys.hmacShaKeyFor()로 비밀 문자열에서 보안 키 생성- 액세스 토큰에 사용자 역할을 커스텀 클레임으로 포함
- 리프레시 토큰은 더 긴 만료 시간과 최소한의 클레임
UserDetailsService 구현
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
}public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Boolean existsByEmail(String email);
}JWT 인증 필터
필터는 요청을 가로채서 JWT를 추출하고 보안 컨텍스트를 설정한다
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
final String username;
try {
username = jwtUtil.extractUsername(jwt);
} catch (Exception e) {
filterChain.doFilter(request, response);
return;
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}필터는 요청당 한 번 실행을 보장하기 위해 OncePerRequestFilter를 확장한다
보안 설정
Spring Security 6.x는 SecurityFilterChain을 사용한 컴포넌트 기반 설정을 사용한다
더 이상 WebSecurityConfigurerAdapter를 사용하지 않는다
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/h2-console/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.headers(headers -> headers
.frameOptions(frameOptions -> frameOptions.sameOrigin())
);
return http.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}주요 설정:
- CSRF 비활성화 (무상태 API에는 불필요)
- 세션 관리를 STATELESS로 설정
UsernamePasswordAuthenticationFilter앞에 JWT 필터 추가@EnableMethodSecurity로@PreAuthorize어노테이션 활성화
DTO
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RegisterRequest {
private String email;
private String password;
private String name;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
private String email;
private String password;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse {
private String accessToken;
private String refreshToken;
private String tokenType;
private Long expiresIn;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RefreshTokenRequest {
private String refreshToken;
}인증 서비스
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final AuthenticationManager authenticationManager;
public AuthResponse register(RegisterRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new RuntimeException("Email already exists");
}
User user = User.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.name(request.getName())
.role(Role.USER)
.build();
userRepository.save(user);
String accessToken = jwtUtil.generateToken(user);
String refreshToken = jwtUtil.generateRefreshToken(user);
return AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(3600L)
.build();
}
public AuthResponse login(LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
String accessToken = jwtUtil.generateToken(user);
String refreshToken = jwtUtil.generateRefreshToken(user);
return AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(3600L)
.build();
}
public AuthResponse refresh(RefreshTokenRequest request) {
String refreshToken = request.getRefreshToken();
String username = jwtUtil.extractUsername(refreshToken);
User user = userRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
if (!jwtUtil.validateToken(refreshToken, user)) {
throw new RuntimeException("Invalid refresh token");
}
String newAccessToken = jwtUtil.generateToken(user);
return AuthResponse.builder()
.accessToken(newAccessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(3600L)
.build();
}
}인증 컨트롤러
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@RequestBody RegisterRequest request) {
return ResponseEntity.ok(authService.register(request));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request) {
return ResponseEntity.ok(authService.login(request));
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshTokenRequest request) {
return ResponseEntity.ok(authService.refresh(request));
}
}보호된 엔드포인트 예시
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserController {
@GetMapping("/me")
public ResponseEntity<Map<String, Object>> getCurrentUser(@AuthenticationPrincipal User user) {
return ResponseEntity.ok(Map.of(
"id", user.getId(),
"email", user.getEmail(),
"name", user.getName(),
"role", user.getRole()
));
}
@GetMapping("/admin/users")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<String> adminOnly() {
return ResponseEntity.ok("Admin access granted");
}
}@AuthenticationPrincipal은 인증된 User를 주입한다
@PreAuthorize는 메서드 레벨 보안을 제공한다
API 테스트
회원가입:
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123","name":"Test User"}'로그인:
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123"}'보호된 엔드포인트 접근:
curl -X GET http://localhost:8080/api/me \
-H "Authorization: Bearer <your-access-token>"토큰 갱신:
curl -X POST http://localhost:8080/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"<your-refresh-token>"}'프로젝트 구조
src/main/java/com/example/jwt/
├── JwtApplication.java
├── config/
│ └── SecurityConfig.java
├── controller/
│ ├── AuthController.java
│ └── UserController.java
├── domain/
│ ├── Role.java
│ └── User.java
├── dto/
│ ├── AuthResponse.java
│ ├── LoginRequest.java
│ ├── RefreshTokenRequest.java
│ └── RegisterRequest.java
├── repository/
│ └── UserRepository.java
├── security/
│ ├── CustomUserDetailsService.java
│ ├── JwtAuthenticationFilter.java
│ └── JwtUtil.java
└── service/
└── AuthService.java
결론
JWT 인증은 REST API 보안을 위한 무상태의 확장 가능한 접근 방식을 제공한다
핵심 컴포넌트:
- JwtUtil: 토큰 생성/검증
- JwtAuthenticationFilter: 요청 인터셉션
- SecurityFilterChain: 보안 설정
프로덕션 고려사항:
- HTTPS 사용
- 주기적으로 비밀 키 교체
- 폐기 기능을 위해 리프레시 토큰을 데이터베이스에 저장
- 갱신 시 토큰 로테이션 고려