Spring Security와 JWT를 이용한 REST API 보호
1. 개요
REST API는 일반적으로 세션을 사용하지 않습니다. 각 요청은 자체적으로 인증 정보를 포함해야 하며, JWT(JSON Web Token)가 이를 처리하는 가장 일반적인 방법입니다.
이 문서는 Spring Security 6과 Spring Boot 3을 사용하여 주문 관리 API를 보호하는 방법을 안내합니다. 사용자 로그인 시 JWT를 발급하고, 모든 요청에서 토큰을 인증하며, 역할에 따라 권한을 부여합니다. 마지막 부분에서는 주요 동작에 대한 자동화된 테스트를 작성합니다.
JWT 필터를 수동으로 작성하지 않습니다. 대신 Spring Security에 내장된 OAuth2 Resource Server를 사용합니다. 이 방식은 코드가 적고 더 안전합니다.
2. Maven 의존성
pom.xml에 다음 의존성이 필요합니다:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
oauth2-resource-server starter는 Nimbus JOSE + JWT 라이브러리를 포함합니다. 이 라이브러리는 토큰 서명과 서명 검증을 처리합니다. 별도로 jjwt나 다른 JWT 라이브러리를 추가할 필요가 없습니다.
3. 비밀 키 구성
간단하게 하기 위해 HS256 대칭 알고리즘으로 토큰에 서명합니다. 서명과 검증에 동일한 비밀 키가 사용됩니다.
jwt.secret=${JWT_SECRET:doi-chuoi-nay-thanh-mot-khoa-bi-mat-dai-it-nhat-32-byte}
jwt.expiration-minutes=15
표현식 ${JWT_SECRET:...}은 JWT_SECRET 환경 변수에서 키를 읽습니다. 이 변수가 없으면 Spring이 콜론 뒤의 기본값을 사용합니다. 기본값은 개발 환경용입니다.
주의: HS256은 최소 256비트, 즉 32바이트의 키 길이가 필요합니다. 키가 더 짧으면 처음 토큰을 서명할 때 Nimbus가
KeyLengthException을 던집니다.
4. Spring Security 구성
4.1. JwtEncoder, JwtDecoder 및 사용자
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${jwt.secret}")
private String secret;
@Bean
SecretKey jwtSecretKey() {
return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
}
@Bean
JwtEncoder jwtEncoder(SecretKey key) {
return new NimbusJwtEncoder(new ImmutableSecret<>(key));
}
@Bean
JwtDecoder jwtDecoder(SecretKey key) {
return NimbusJwtDecoder.withSecretKey(key)
.macAlgorithm(MacAlgorithm.HS256)
.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
UserDetailsService userDetailsService(PasswordEncoder encoder) {
var customer = User.withUsername("alice")
.password(encoder.encode("alice123")).roles("CUSTOMER").build();
var admin = User.withUsername("bob")
.password(encoder.encode("bob123")).roles("CUSTOMER", "ADMIN").build();
return new InMemoryUserDetailsManager(customer, admin);
}
@Bean
AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder) {
var provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(uds);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
}
각 bean의 역할:
SecretKey는 비밀 문자열을 HMAC-SHA256 키로 감쌉니다.JwtEncoder는 이 키를 사용하여 사용자 로그인 시 토큰에 서명합니다.JwtDecoder는 각 요청에서 토큰의 서명과 만료 시간(exp)을 검증합니다. Resource Server는 자동으로 이 bean을 사용합니다.InMemoryUserDetailsManager는 두 명의 샘플 사용자를 생성합니다. 실제 환경에서는 데이터베이스에서 읽는 구현으로 변경합니다.AuthenticationManager는 로그인 엔드포인트에서 사용자명과 비밀번호를 검증합니다.
4.2. SecurityFilterChain
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
return http.build();
}
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var authorities = new JwtGrantedAuthoritiesConverter();
authorities.setAuthoritiesClaimName("roles");
authorities.setAuthorityPrefix("");
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authorities);
return converter;
}
이 bean은 SecurityConfig 클래스 내에 있습니다. 각 부분의 역할:
csrf.disable(): API는 쿠키로 인증하지 않으므로 CSRF 공격을 받지 않습니다.STATELESS: Spring이HttpSession을 생성하지 않습니다. 각 요청은 토큰에서 다시 인증됩니다.authorizeHttpRequests: 로그인 엔드포인트는 공개합니다./api/admin/**는ADMIN역할만 접근 가능합니다. 나머지 엔드포인트는 로그인이 필요합니다.oauth2ResourceServer(...jwt...):Authorization: Bearer <token>헤더를 읽고JwtDecoder로 토큰을 디코딩하는 필터를 활성화합니다.
기본적으로 Spring은 scope claim에서 권한을 읽고 SCOPE_ 접두사를 추가합니다. hasRole("ADMIN")을 사용하려면 JwtAuthenticationConverter를 다시 구성하여 roles claim을 읽어야 합니다. 접두사는 생략됩니다. 토큰의 값이 이미 ROLE_ADMIN 형태이기 때문입니다.
5. 로그인 시 토큰 발급
5.1. TokenService
@Service
public class TokenService {
private final JwtEncoder encoder;
private final long expirationMinutes;
public TokenService(JwtEncoder encoder,
@Value("${jwt.expiration-minutes}") long expirationMinutes) {
this.encoder = encoder;
this.expirationMinutes = expirationMinutes;
}
public String generateToken(Authentication authentication) {
Instant now = Instant.now();
List<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("nonla-shop")
.issuedAt(now)
.expiresAt(now.plus(expirationMinutes, ChronoUnit.MINUTES))
.subject(authentication.getName())
.claim("roles", roles)
.build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
public long getExpirationSeconds() {
return expirationMinutes * 60;
}
}
토큰에는 다음 claim이 포함됩니다: 발급자(iss), 발급 시간(iat), 만료 시간(exp), 사용자명(sub) 및 역할 목록.
JwsHeader.with(MacAlgorithm.HS256) 줄은 필수입니다. 이를 빠뜨리면 NimbusJwtEncoder가 기본적으로 RS256을 선택합니다. 그러면 적절한 키를 찾지 못하고 Failed to select a JWK signing key 오류를 던집니다.
5.2. AuthController
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final TokenService tokenService;
public AuthController(AuthenticationManager authenticationManager, TokenService tokenService) {
this.authenticationManager = authenticationManager;
this.tokenService = tokenService;
}
public record LoginRequest(String username, String password) {}
public record TokenResponse(String accessToken, String tokenType, long expiresIn) {}
@PostMapping("/login")
public TokenResponse login(@RequestBody LoginRequest request) {
Authentication auth = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(
request.username(), request.password()));
String token = tokenService.generateToken(auth);
return new TokenResponse(token, "Bearer", tokenService.getExpirationSeconds());
}
}
authenticate()는 DaoAuthenticationProvider를 통해 사용자명과 비밀번호를 검증합니다. 잘못되면 메서드가 BadCredentialsException을 던집니다. Spring Security는 이를 HTTP 401로 변환합니다. 맞으면 컨트롤러가 토큰을 생성하고 클라이언트에 반환합니다.
curl로 시도해봅시다:
curl -s -X POST localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"alice123"}'
{"accessToken":"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJub25sYS1zaG9wIiwic3ViIjoiYWxpY2UiLC...","tokenType":"Bearer","expiresIn":900}
6. 보호되는 엔드포인트
@RestController
@RequestMapping("/api")
public class OrderController {
public record Order(long id, String customer, BigDecimal total) {}
private final List<Order> orders = List.of(
new Order(1, "alice", new BigDecimal("250000")),
new Order(2, "bob", new BigDecimal("990000")));
@GetMapping("/orders")
public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
return orders.stream()
.filter(o -> o.customer().equals(jwt.getSubject()))
.toList();
}
@GetMapping("/admin/orders")
public List<Order> allOrders() {
return orders;
}
}
@AuthenticationPrincipal Jwt jwt는 인증된 토큰을 컨트롤러에 주입합니다. jwt.getSubject()로 사용자명을 가져올 수 있습니다. 다른 claim을 읽으려면 jwt.getClaim("roles")을 사용합니다.
세 가지 경우를 시도해봅시다:
# 토큰 없음
curl -i localhost:8080/api/orders
# alice가 유효한 토큰으로 호출
curl -s localhost:8080/api/orders -H "Authorization: Bearer $TOKEN"
# alice가 관리자 엔드포인트 호출
curl -i localhost:8080/api/admin/orders -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401
WWW-Authenticate: Bearer
[{"id":1,"customer":"alice","total":250000}]
HTTP/1.1 403
WWW-Authenticate: Bearer error="insufficient_scope", ...
결과는 두 가지 다른 오류 코드를 보여줍니다. 401은 인증되지 않음: 토큰 누락, 토큰 서명 오류 또는 토큰 만료. 403은 인증되었지만 권한 부족입니다.
7. 테스트 작성
spring-security-test는 jwt() post-processor를 제공합니다. 실제로 토큰을 서명하지 않고 인증된 JWT를 시뮬레이션합니다. 이를 실제 로그인을 포함하는 end-to-end 테스트와 결합합니다.
@SpringBootTest
@AutoConfigureMockMvc
class OrderSecurityTest {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@Test
void whenNoToken_thenUnauthorized() throws Exception {
MvcResult result = mockMvc.perform(get("/api/orders")).andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(401);
}
@Test
void whenLoginAndCallWithToken_thenReturnOwnOrders() throws Exception {
String body = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","password":"alice123"}
"""))
.andReturn().getResponse().getContentAsString();
String token = objectMapper.readTree(body).get("accessToken").asText();
MvcResult result = mockMvc.perform(get("/api/orders")
.header("Authorization", "Bearer " + token))
.andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(200);
assertThat(result.getResponse().getContentAsString())
.contains("\"customer\":\"alice\"")
.doesNotContain("\"customer\":\"bob\"");
}
@Test
void whenWrongPassword_thenUnauthorized() throws Exception {
MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"alice\",\"password\":\"sai\"}"))
.andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(401);
}
@Test
void whenCustomerCallsAdminEndpoint_thenForbidden() throws Exception {
MvcResult result = mockMvc.perform(get("/api/admin/orders")
.with(jwt().jwt(j -> j.subject("alice"))
.authorities(new SimpleGrantedAuthority("ROLE_CUSTOMER"))))
.andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(403);
}
}
필요한 static import는 MockMvcRequestBuilders.*, SecurityMockMvcRequestPostProcessors.jwt 및 AssertJ의 Assertions.assertThat입니다.
두 번째 테스트는 전체 흐름을 확인합니다. 로그인 엔드포인트를 호출하고, 토큰을 가져온 후, 토큰으로 API를 호출합니다. 이 테스트는 encoder 및 decoder 구성 오류, 예를 들어 JwsHeader를 잊거나 키가 너무 짧으면 감지합니다.
마지막 테스트는 jwt()를 사용하여 권한 부여 부분만 확인합니다. .authorities(...)는 converter를 무시하므로 올바른 authority 이름 ROLE_CUSTOMER를 전달해야 합니다.
8. 일반적인 오류 및 사용하지 말아야 할 경우
일반적인 오류:
- 키가 너무 짧음: 32바이트 미만의 키는 HS256 사용 시
KeyLengthException을 유발합니다. - HS256 헤더 누락:
JwtEncoder가Failed to select a JWK signing key를 보고합니다. - 잘못된 authority 접두사: 기본 converter를 사용하면 권한은
SCOPE_...형태입니다. 그러면hasRole("ADMIN")은 항상 403을 반환합니다. claim과 접두사가 일치하는지 확인하세요. - 저장소에 비밀 하드코딩: 항상 환경 변수나 비밀 관리자에서 비밀을 읽으세요.
- 민감한 데이터를 payload에 넣기: JWT payload는 Base64URL로만 인코딩되며 암호화되지 않습니다. 토큰을 가진 사람이라면 누구나 내용을 읽을 수 있습니다. 비밀번호, 카드 번호 또는 개인 정보를 토큰에 넣지 마세요.
주의: JWT는 만료 전에 서버에 추가 메커니즘(예: blacklist)이 없으면 회수할 수 없습니다. access token은 짧은 수명(5–15분)을 유지하세요. 긴 세션이 필요하면 refresh token을 추가로 사용하여 서버에서 회수할 수 있게 하세요.
이 방식을 사용하지 말아야 할 경우:
- Thymeleaf 같은 서버 측 렌더링 웹 애플리케이션: 세션과 쿠키가 더 간단하고 안전합니다.
- 로그아웃이나 계정 잠금이 즉시 적용되어야 할 경우: 세션이나 introspection을 포함한 opaque token이 더 적합합니다.
- 여러 서비스가 있는 시스템: 모든 서비스와 하나의 HMAC 키를 공유하는 것은 매우 위험합니다. 이 경우 Spring Authorization Server나 Keycloak 같은 전용 authorization server를 사용해야 합니다. Authorization server는 RSA나 EC로 토큰을 서명하고, 각 서비스는
spring.security.oauth2.resourceserver.jwt.issuer-uri를 통해 JWKS에서 공개 키를 가져올 수 있습니다.
9. 결론
이 문서는 JWT로 보호되는 stateless REST API를 구축했습니다. 단계는 다음과 같습니다:
- HS256 키와 함께
NimbusJwtEncoder및NimbusJwtDecoder사용. /api/auth/login엔드포인트에서AuthenticationManager를 통해 토큰 발급.- OAuth2 Resource Server가
Bearer헤더를 자동으로 인증하도록 구성. rolesclaim을 authority로 매핑하여hasRole사용.- MockMvc와 AssertJ로 401, 403 및 성공 케이스 검증.
이 기초에서 InMemoryUserDetailsManager를 데이터베이스의 사용자로 바꾸고, refresh token을 추가하거나, 시스템이 여러 서비스로 확장될 때 비대칭 키로 전환할 수 있습니다.