Spring Boot 3.2+ ๋ฐ Spring Security 6.x ์ต์ ์์ ๊ฐ์ด๋: Lambda DSL ๊ธฐ๋ฐ SecurityFilterChain, JWT ๋ฌด์ํ(Stateless) ์ธ์ฆ ํํฐ ์ ์ฒด ๊ตฌํ, UserDetailsService & DB ์ฐ๋, Method Security(@PreAuthorize), CORS/CSRF ์ ๋ฐ ์ ์ด, 401/403 ์ปค์คํ
JSON ์๋ต ๋ฐ 5.x โ 6.x ๋ง์ด๊ทธ๋ ์ด์
์ฒดํฌ๋ฆฌ์คํธ
## 1. ์ฌ์ ์๊ฑด & ํ๋ก์ ํธ ๋น๋ ๊ตฌ์ฑ (Prerequisites & Setup)
### ๐ป ์์คํ
์ค์น ๋ฐ ์คํ ์๊ตฌ์ฌ์ (System Requirements)
| ํญ๋ชฉ | ์ต์ ์ฌ์ (Minimum) | ๊ถ์ฅ ์ฌ์ (Recommended) | ์ฐธ๊ณ ๋ฐ ๋น๊ณ |
| :--- | :--- | :--- | :--- |
| **์ด์์ฒด์ (OS)** | Windows 10/11, macOS (Apple Silicon / Intel), Linux (Ubuntu, Debian, RHEL) | Ubuntu 22.04 LTS+, macOS Sonoma, Windows 11 (WSL2) | Java ๊ฐ์ ๋จธ์ (JVM) ์ง์ 64-bit OS |
| **CPU** | 2 ์ฝ์ด ์ด์ | 4 ์ฝ์ด ~ 8 ์ฝ์ด ์ด์ | BCrypt ํด์ฑ ์ฐ์ฐ ๋ฐ ๋์ ์ธ์ฆ ์ํธํ ์ฒ๋ฆฌ ์ฑ๋ฅ |
| **๋ฉ๋ชจ๋ฆฌ (RAM)** | 2 GB ์ด์ (๊ฐ๋ฐ/ํ
์คํธ) | 4 GB ~ 8 GB ์ด์ | Spring Boot ๋ฐํ์ ๋ฐ JVM ํ(Heap) ๋ฉ๋ชจ๋ฆฌ |
| **๋์คํฌ (Storage)** | 1 GB ์ด์์ ์ฌ์ ๊ณต๊ฐ | 10 GB ์ด์์ ๊ณ ์ NVMe SSD | ๋น๋ ์ํฐํฉํธ, ์์กด์ฑ JAR ์บ์ ๋ฐ DB ์คํ ๋ฆฌ์ง |
| **๋คํธ์ํฌ / ๋ฐํ์** | Java 17 LTS, Spring Boot 3.2+ / Spring Security 6.2+ | Java 21 LTS / Java 25, Spring Boot 3.3+ | ํฌํธ 8080 (๊ธฐ๋ณธ HTTP), ํฌํธ 443 (HTTPS ํ์) |
---
### ๐ฆ ๋น๋ ๋๊ตฌ๋ณ ์์กด์ฑ ์ค์ (Dependencies)
์ต์ Spring Boot 3.x ํ๊ฒฝ์์๋ `spring-boot-starter-security`์ ํจ๊ป REST API ํ ํฐ ์ธ์ฆ์ ์ํ JJWT ์ต์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ(0.12.x)๋ฅผ ํจ๊ป ์ถ๊ฐํฉ๋๋ค.
#### โ Gradle (`build.gradle`)
```groovy
dependencies {
// Spring Boot ํต์ฌ ์น & ๋ณด์ ์คํํฐ
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
// JJWT 0.12.x ์ต์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ (JWT ํ ํฐ ์์ฑ ๋ฐ ํ์ฑ)
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
// ํ
์คํธ ํ๊ฒฝ
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
```
#### โก Maven (`pom.xml`)
```xml
<dependencies>
<!-- Spring Boot Web & Security -->
<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>
<!-- JJWT 0.12.x -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
</dependencies>
```
---
## 2. Spring Security ํต์ฌ ๋ฉํ ๋ชจ๋ธ & ์ํคํ
์ฒ (Architecture)
Spring Security๋ ์๋ธ๋ฆฟ ์ปจํ
์ด๋(Tomcat)์ **์๋ธ๋ฆฟ ํํฐ ์ฒด์ธ(Servlet Filter Chain)** ์๋จ์ ์์นํ์ฌ, ๋ชจ๋ HTTP ์์ฒญ์ด ์ปจํธ๋กค๋ฌ์ ๋๋ฌํ๊ธฐ ์ ์ **์ธ์ฆ(Authentication: ๋๊ตฌ์ธ๊ฐ?)**๊ณผ **์ธ๊ฐ(Authorization: ์ ๊ทผ ๊ถํ์ด ์๋๊ฐ?)**๋ฅผ ์ผ๊ด ๊ฒ์ฆํฉ๋๋ค.
### ๐ ํํฐ ์ฒด์ธ ๋ฐ ์ธ์ฆ ์ฒ๋ฆฌ ํ๋ฆ (ASCII Diagram)
```text
[ Client HTTP Request ] (ํด๋ผ์ด์ธํธ ์์ฒญ)
โ
โผ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Servlet Container (Tomcat) โ
โ โ
โ [ DelegatingFilterProxy ] (์๋ธ๋ฆฟ ํํฐ์ ์คํ๋ง ๋น์ ์ฐ๊ฒฐํ๋ ์ค๊ณ์) โ
โ โ โ
โ โผ โ
โ [ FilterChainProxy ] (์คํ๋ง ์ํ๋ฆฌํฐ ํํฐ ๋ชฉ๋ก์ ์ด๊ด ๊ด๋ฆฌ) โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ SecurityFilterChain (์์ฐจ์ ์ผ๋ก ์คํ๋๋ ๋ณด์ ํํฐ ์ฒด์ธ ๋ชฉ๋ก) โ โ
โ โ โ โ
โ โ 1. CorsFilter : Cross-Origin ๋ฆฌ์์ค ๊ณต์ ํ์ฉ ์ฌ๋ถ ํ๋ณ โ โ
โ โ 2. CsrfFilter : CSRF ํ ํฐ ์ผ์น ์ฌ๋ถ ํ์ธ (REST API ์ ์ธ)โ โ
โ โ 3. JwtAuthenticationFilter : Bearer ํ ํฐ ์ถ์ถ ๋ฐ SecurityContext ์ ์ฅ โ โ
โ โ 4. UsernamePasswordAuth... : ํผ ๋ก๊ทธ์ธ(์์ด๋/๋น๋ฐ๋ฒํธ) ๊ฒ์ฆ ํํฐ โ โ
โ โ 5. ExceptionTranslation... : ์ธ์ฆ ์์ธ(401) ๋ฐ ์ธ๊ฐ ์์ธ(403) ๋ฒ์ญ โ โ
โ โ 6. AuthorizationFilter : ์ต์ข
URL ๊ฒฝ๋ก๋ณ Role / Authority ๊ฒ์ฌ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ ์ธ์ฆ ์ฑ๊ณต โ
โ โผ โ
โ [ DispatcherServlet ] โ
โ โ โ
โ โผ โ
โ [ @RestController ] โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
```
### ๐งฉ ์ธ์ฆ(Authentication) ํต์ฌ ๊ฐ์ฒด ๊ตฌ์กฐ
```text
[ SecurityContextHolder ] โโโบ [ SecurityContext ] โโโบ [ Authentication ]
โโโ Principal (์ฌ์ฉ์ ์๋ณ ๊ฐ์ฒด / UserDetails)
โโโ Credentials (์๊ฒฉ ์ฆ๋ช
/ ๋น๋ฐ๋ฒํธ - ์ธ์ฆ ํ ์ญ์ )
โโโ Authorities (๊ถํ ๋ชฉ๋ก / Collection<GrantedAuthority>)
[ AuthenticationManager ] โโโบ [ ProviderManager ] โโโบ [ DaoAuthenticationProvider ]
โโโ UserDetailsService (DB์์ ์ ์ ์กฐํ)
โโโ PasswordEncoder (๋น๋ฐ๋ฒํธ ํด์ ์ผ์น ๊ฒ์ฆ)
```
---
## 3. [์ด๋ณด์ 1๋จ๊ณ] 3๋ถ ํฌ๋ก์๋: ์ต์ ๋์ ์์ (Hello World)
Spring Security 6.x์ ์ต์ **Lambda DSL** ๋ฐฉ์์ ์ ์ฉํ์ฌ, ์ธ๋ฉ๋ชจ๋ฆฌ ์ ์ 2๋ช
(์ผ๋ฐ ์ ์ , ๊ด๋ฆฌ์)์ ๋ฑ๋กํ๊ณ URL ์ ๊ทผ ๊ถํ์ ์ ์ดํ๋ ์ต์ ๋์ ์์ ์
๋๋ค.
### โ ๋ณด์ ์ค์ ํด๋์ค (`SecurityConfig.java`)
```java
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// [1] SecurityFilterChain ๋น ๋ฑ๋ก (์ต์ Lambda DSL ํ์ค)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// URL ๊ฒฝ๋ก๋ณ ์ธ๊ฐ(Authorization) ๊ท์น ์ค์
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll() // ๋๊ตฌ๋ ์ ๊ทผ ๊ฐ๋ฅ
.requestMatchers("/api/user/**").hasRole("USER") // USER ๊ถํ ํ์
.requestMatchers("/api/admin/**").hasRole("ADMIN") // ADMIN ๊ถํ ํ์
.anyRequest().authenticated() // ๊ทธ ์ธ ๋ชจ๋ ์์ฒญ์ ๋ก๊ทธ์ธ ํ์
)
// ๋ธ๋ผ์ฐ์ ํผ ๋ก๊ทธ์ธ ๋ฐ ๊ธฐ๋ณธ HTTP Basic ์ธ์ฆ ํ์ฑํ
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults());
return http.build();
}
// [2] ๋น๋ฐ๋ฒํธ ์ํธํ ๋น (BCrypt ๋จ๋ฐฉํฅ ํด์)
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// [3] ์ธ๋ฉ๋ชจ๋ฆฌ ํ
์คํธ ์ ์ ์์ฑ (์ค๋ฌด์์๋ DB ์ฐ๋์ผ๋ก ๋์ฒด)
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
UserDetails user = User.builder()
.username("user1")
.password(passwordEncoder.encode("user1234"))
.roles("USER") // ์๋์ผ๋ก "ROLE_USER" ๊ถํ ๋ถ์ฌ
.build();
UserDetails admin = User.builder()
.username("admin1")
.password(passwordEncoder.encode("admin1234"))
.roles("ADMIN", "USER")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
}
```
### โก ํ
์คํธ ์ปจํธ๋กค๋ฌ (`HelloController.java`)
```java
package com.example.demo.controller;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class HelloController {
@GetMapping("/public/info")
public String publicEndpoint() {
return "๋๊ตฌ๋ ์ด๋ ๊ฐ๋ฅํ ๊ณต๊ฐ ์ ๋ณด์
๋๋ค.";
}
@GetMapping("/user/profile")
public String userEndpoint(@AuthenticationPrincipal UserDetails userDetails) {
return "๋ก๊ทธ์ธํ ์ ์ ํ์ํฉ๋๋ค: " + userDetails.getUsername();
}
@GetMapping("/admin/dashboard")
public String adminEndpoint() {
return "์ต๊ณ ๊ด๋ฆฌ์ ์ ์ฉ ๋์๋ณด๋ ๋ฐ์ดํฐ์
๋๋ค.";
}
}
```
### โข curl ํ
์คํธ ๋ฐ ๊ฒ์ฆ ๊ฒฐ๊ณผ
```bash
# 1. ๊ณต๊ฐ ๊ฒฝ๋ก ์์ฒญ (์ธ์ฆ ์์ด 200 OK ์ฑ๊ณต)
curl -i http://localhost:8080/api/public/info
# HTTP/1.1 200 OK
# ๋๊ตฌ๋ ์ด๋ ๊ฐ๋ฅํ ๊ณต๊ฐ ์ ๋ณด์
๋๋ค.
# 2. ์ ์ ๊ฒฝ๋ก์ ๋น์ธ๊ฐ ์ ๊ทผ ์๋ (401 Unauthorized ๋ฐํ)
curl -i http://localhost:8080/api/user/profile
# HTTP/1.1 401 Unauthorized
# 3. HTTP Basic ์ธ์ฆ์ผ๋ก ์ผ๋ฐ ์ ์ ๋ก๊ทธ์ธ ํ ์ ๊ทผ (์ฑ๊ณต)
curl -i -u user1:user1234 http://localhost:8080/api/user/profile
# HTTP/1.1 200 OK
# ๋ก๊ทธ์ธํ ์ ์ ํ์ํฉ๋๋ค: user1
# 4. ์ผ๋ฐ ์ ์ ๊ฐ ๊ด๋ฆฌ์ ๊ฒฝ๋ก(/api/admin/**) ์ ๊ทผ ์๋ (403 Forbidden ๊ฑฐ๋ถ)
curl -i -u user1:user1234 http://localhost:8080/api/admin/dashboard
# HTTP/1.1 403 Forbidden
```
---
## 4. [2๋จ๊ณ] ํต์ฌ ๊ธฐ๋ฅ: REST API ๋ฌด์ํ(Stateless) JWT ์์ ๊ตฌํ
์ธ์
์ ์๋ฒ ๋ฉ๋ชจ๋ฆฌ์ ์ ์ฅํ์ง ์๊ณ , JWT(JSON Web Token)๋ฅผ HTTP ์์ฒญ ํค๋(`Authorization: Bearer <token>`)๋ก ์ ๋ฌ๋ฐ์ ๊ฒ์ฆํ๋ ํ๋์ ์ธ ๋ชจ๋ ๋ฐฑ์๋ ํ์ค ์ํคํ
์ฒ์
๋๋ค.
### โ JJWT 0.12.x ๊ธฐ๋ฐ ํ ํฐ ๊ณต๊ธ์ (`JwtTokenProvider.java`)
```java
package com.example.demo.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@Component
public class JwtTokenProvider {
// ์ค๋ฌด์์๋ application.yml์์ ์ฃผ์
๋ฐ์ผ๋ฉฐ, ๋ฐ๋์ 256๋นํธ(32์) ์ด์์ด์ด์ผ ํฉ๋๋ค.
private static final String SECRET = "LuckyTechWorldSuperSecretJwtSigningKeyForSpringBootSecurity2026!";
private static final long ACCESS_TOKEN_VALIDITY = 1000L * 60 * 60; // 1์๊ฐ (๋ฐ๋ฆฌ์ด)
private final SecretKey secretKey = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));
// [1] ํ ํฐ ๋ฐ๊ธ (์ฌ์ฉ์ ์ด๋ฆ๊ณผ ๊ถํ ๋ฌธ์์ด ํฌํจ)
public String createAccessToken(String username, String roles) {
Date now = new Date();
Date validity = new Date(now.getTime() + ACCESS_TOKEN_VALIDITY);
return Jwts.builder()
.subject(username)
.claim("roles", roles) // ์: "ROLE_USER,ROLE_ADMIN"
.issuedAt(now)
.expiration(validity)
.signWith(secretKey)
.compact();
}
// [2] ํ ํฐ์์ Authentication ๊ฐ์ฒด ์ญ์ถ์ถ
public Authentication getAuthentication(String token) {
Claims claims = getClaims(token);
String rolesStr = claims.get("roles", String.class);
Collection<? extends GrantedAuthority> authorities = (rolesStr == null || rolesStr.isBlank())
? java.util.Collections.emptyList()
: Arrays.stream(rolesStr.split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
// [3] ํ ํฐ ์ ํจ์ฑ ๊ฒ์ฆ
public boolean validateToken(String token) {
try {
getClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
// [4] ํด๋ ์ ํ์ฑ
private Claims getClaims(String token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
```
---
### โก JWT ์ ์ฉ ์์ฒญ ๊ฐ๋ก์ฑ๊ธฐ ํํฐ (`JwtAuthenticationFilter.java`)
๋ชจ๋ ์์ฒญ๋ง๋ค 1ํ ์คํ๋๋ `OncePerRequestFilter`๋ฅผ ์์๋ฐ์ HTTP `Authorization` ํค๋์์ ํ ํฐ์ ์ถ์ถํ๊ณ SecurityContext์ ๋ฑ๋กํฉ๋๋ค.
```java
package com.example.demo.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
// ์ ํจํ JWT ํ ํฐ์ด ์กด์ฌํ๋ฉด SecurityContext์ ์ธ์ฆ ์ ๋ณด ์ ์ฅ
if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
Authentication auth = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
// ๋ค์ ํํฐ๋ก ์์ฒญ ์ธ๊ณ
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
```
---
### โข ๋ฌด์ํ REST API์ฉ ๋ณด์ ์ค์ (`JwtSecurityConfig.java`)
```java
package com.example.demo.config;
import com.example.demo.security.JwtAuthenticationFilter;
import com.example.demo.security.JwtTokenProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // @PreAuthorize, @PostAuthorize ๋ฉ์๋ ๋ณด์ ํ์ฑํ
public class JwtSecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
public JwtSecurityConfig(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// [1] REST API์ด๋ฏ๋ก CSRF ๋ณดํธ ๋นํ์ฑํ (์ธ์
์ ์ฌ์ฉํ์ง ์์)
.csrf(AbstractHttpConfigurer::disable)
// [2] ํผ ๋ก๊ทธ์ธ ๋ฐ ๊ธฐ๋ณธ HTTP Basic ๋นํ์ฑํ
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
// [3] ์ธ์
์์ฑ ์ ์ฑ
์ ๋ฌด์ํ(STATELESS)๋ก ๊ฐ์ ์ง์
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// [4] ์๋ํฌ์ธํธ๋ณ ์ธ๊ฐ ์ค์
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // ๋ก๊ทธ์ธ, ํ์๊ฐ์
API
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
)
// [5] ์ปค์คํ
JWT ํํฐ๋ฅผ UsernamePasswordAuthenticationFilter ์๋จ์ ๋ฐฐ์น
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
```
---
## 5. [2๋จ๊ณ] ๋ฉ์๋ ๋ ๋ฒจ ๋ณด์ (Method Security)
์ปจํธ๋กค๋ฌ์ ๊ฐ๋ณ ์๋ํฌ์ธํธ ๋ฉ์๋ ๋๋ ์๋น์ค ๊ณ์ธต(Service Layer)์ ๋น์ฆ๋์ค ๋ก์ง ๋จ์๋ก ์ธ๋ฐํ๊ฒ ๊ถํ์ ๊ฒ์ฌํฉ๋๋ค.
```java
package com.example.demo.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
// [1] ROLE_ADMIN ์ญํ ์ ๊ฐ์ง ์ ์ ๋ง ์ฃผ๋ฌธ ์ ์ฒด ๋ชฉ๋ก ์กฐํ ๊ฐ๋ฅ
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public String getAllOrders() {
return "์ ์ฒด ์ฃผ๋ฌธ ๋ชฉ๋ก (๊ด๋ฆฌ์ ์ ์ฉ)";
}
// [2] USER ๋๋ ADMIN ์ญํ ์ ๊ฐ์ง ์ ์ ๋ง ์ฃผ๋ฌธ ์์ธ ์กฐํ ๊ฐ๋ฅ
@GetMapping("/{orderId}")
@PreAuthorize("hasAnyRole('USER', 'ADMIN')")
public String getOrderDetail(@PathVariable Long orderId) {
return orderId + "๋ฒ ์ฃผ๋ฌธ ์์ธ ๋ด์ญ";
}
// [3] SpEL ํํ์์ ํ์ฉํ์ฌ ํ์ฌ ๋ก๊ทธ์ธํ ์ ์ ๋ณธ์ธ์ ์ฃผ๋ฌธ๋ง ์ทจ์ ๊ฐ๋ฅ
@DeleteMapping("/{orderId}")
@PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
public String cancelOrder(@PathVariable Long orderId, @RequestParam String username) {
return orderId + "๋ฒ ์ฃผ๋ฌธ ์ทจ์ ์ฑ๊ณต (์์ ์: " + username + ")";
}
// [4] ํ์ฌ ๋ก๊ทธ์ธํ ์ฃผ์ฒด(Principal) ์ ๋ณด ํ๋ผ๋ฏธํฐ ์ง์ ์ฃผ์
@GetMapping("/my")
public String getMyInfo(@AuthenticationPrincipal UserDetails user) {
return "ํ์ฌ ๋ก๊ทธ์ธ ์ฌ์ฉ์: " + user.getUsername();
}
}
```
---
## 6. [3๋จ๊ณ] ์ค๋ฌด ์์ฉ: DB/JPA ์ฐ๋ & ์ปค์คํ
์์ธ ์๋ต (JPA & Exception)
### โ JPA ์ปค์คํ
UserDetailsService ์ฐ๋
์ค์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ์กฐํํ์ฌ Spring Security ํ์ค `UserDetails` ๊ฐ์ฒด๋ก ๋ณํํฉ๋๋ค.
```java
package com.example.demo.security;
import com.example.demo.entity.Member;
import com.example.demo.repository.MemberRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
public CustomUserDetailsService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("ํด๋น ์ด๋ฉ์ผ์ ํ์์ ์ฐพ์ ์ ์์ต๋๋ค: " + email));
// DB์ Role(์: "USER")์ "ROLE_USER" ๊ถํ์ผ๋ก ๋งคํ
return new User(
member.getEmail(),
member.getPassword(), // BCrypt ํด์๋ ๋น๋ฐ๋ฒํธ
Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + member.getRole().name()))
);
}
}
```
---
### โก REST API ์ ์ฉ ์ปค์คํ
401 / 403 JSON ์๋ฌ ํธ๋ค๋ฌ
์ธ์ฆ ์คํจ ์ ๊ธฐ๋ณธ HTML ๋ก๊ทธ์ธ ํผ์ผ๋ก ๋ฆฌ๋ค์ด๋ ํธ๋์ง ์๊ณ , ํ๋ก ํธ์๋๊ฐ ํ์ฑํ๊ธฐ ์ฌ์ด ํ์ค JSON ์๋ฌ ํฌ๋งท์ผ๋ก ์๋ตํฉ๋๋ค.
#### 1) ์ธ์ฆ ์คํจ (401 Unauthorized) ํธ๋ค๋ฌ
```java
package com.example.demo.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
Map<String, Object> body = Map.of(
"status", 401,
"error", "Unauthorized",
"message", "์ธ์ฆ ํ ํฐ์ด ์ ํจํ์ง ์๊ฑฐ๋ ๋๋ฝ๋์์ต๋๋ค.",
"path", request.getRequestURI()
);
response.getWriter().write(objectMapper.writeValueAsString(body));
}
}
```
#### 2) ๊ถํ ๋ถ์กฑ (403 Forbidden) ํธ๋ค๋ฌ
```java
package com.example.demo.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Map;
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
Map<String, Object> body = Map.of(
"status", 403,
"error", "Forbidden",
"message", "ํด๋น ๋ฆฌ์์ค์ ์ ๊ทผํ ๊ถํ์ด ๋ถ์กฑํฉ๋๋ค.",
"path", request.getRequestURI()
);
response.getWriter().write(objectMapper.writeValueAsString(body));
}
}
```
#### 3) SecurityConfig์ ์์ธ ํธ๋ค๋ฌ ๋ฑ๋ก
```java
http.exceptionHandling(ex -> ex
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401 ์๋ฌ
.accessDeniedHandler(jwtAccessDeniedHandler) // 403 ์๋ฌ
);
```
---
## 7. [3๋จ๊ณ] ์ค๋ฌด ์์ฉ: CORS ์ ๋ฐ ์ ์ด (Cross-Origin Policy)
SPA(React, Vue, Next.js ๋ฑ) ํ๋ก ํธ์๋์ ๋ถ๋ฆฌ๋ ํ๊ฒฝ์์ ๋ฐ์ํ๋ ๋ธ๋ผ์ฐ์ CORS(Cross-Origin Resource Sharing) ์ฐจ๋จ์ ํด๊ฒฐํ๊ธฐ ์ํด `CorsConfigurationSource`๋ฅผ SecurityFilterChain ์ต์๋จ์ ๋ฐ์ธ๋ฉํฉ๋๋ค.
```java
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// 1. ํ์ฉํ ํ๋ก ํธ์๋ Origin ์ถ์ฒ ๋ช
์ (์ด์ ํ๊ฒฝ์์๋ ์์ผ๋์นด๋ * ์ง์)
config.setAllowedOrigins(List.of("http://localhost:3000", "https://techworld.sulucky5.pe.kr"));
// 2. ํ์ฉํ HTTP ๋ฉ์๋
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
// 3. ํ์ฉํ ํค๋ ๋ชฉ๋ก
config.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Requested-With"));
// 4. ๋ธ๋ผ์ฐ์ ๊ฐ ์๋ต ํค๋ ์ค ์ฝ์ ์ ์๋๋ก ๋
ธ์ถํ ํค๋
config.setExposedHeaders(List.of("Authorization"));
// 5. ์ฟ ํค ๋ฐ Authorization ํค๋ ํฌํจ ์์ฒญ ํ์ฉ
config.setAllowCredentials(true);
// 6. ๋ธ๋ผ์ฐ์ Preflight(OPTIONS) ์์ฒญ ์บ์ ์๊ฐ (1์๊ฐ)
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
```
> **์ ์ฉ ๋๋ ํฐ๋ธ**: `SecurityConfig` ๋ด๋ถ์์ `.cors(cors -> cors.configurationSource(corsConfigurationSource()))`๋ก ์ฃผ์
ํฉ๋๋ค.
---
## 8. ์ค๋ฌด ์ฃผ์์ฌํญ & 5.x โ 6.x ๋ง์ด๊ทธ๋ ์ด์
๊ฐ์ด๋ (Migration & Best Practices)
### โ ๏ธ Spring Security 5.x โ 6.x ํต์ฌ ๋ณ๊ฒฝ ์ฌํญ ๋น๊ต
| ๊ธฐ๋ฅ ํญ๋ชฉ | ๊ตฌ๋ฒ์ ๋ฐฉ์ (Spring Security 5.x) | ์ต์ ๋ฐฉ์ (Spring Security 6.x / Boot 3.x) |
| :--- | :--- | :--- |
| **์ค์ ์์ ๊ตฌ์กฐ** | `extends WebSecurityConfigurerAdapter` | ์์ ์ ๊ฑฐ, **`@Bean SecurityFilterChain` ์ง์ ๋ฑ๋ก** |
| **์ฒด์ด๋ ๋ฌธ๋ฒ** | `.and().csrf().disable().and().cors()` | **์์ Lambda DSL ๊ฐ์ ** (`csrf(AbstractHttpConfigurer::disable)`) |
| **URL ์ธ๊ฐ ๋๋ ํฐ๋ธ** | `.authorizeRequests()` | **`.authorizeHttpRequests()`** ๋ก ๋ช
์นญ ๋ณ๊ฒฝ |
| **๊ฒฝ๋ก ๋งค์ฒ ๋ฉ์๋** | `antMatchers("/api/**")`, `mvcMatchers()` | **`requestMatchers("/api/**")`** ๋ก ๋จ์ผ ํตํฉ |
| **๋ฉ์๋ ๋ณด์ ์ด๋
ธํ
์ด์
** | `@EnableGlobalMethodSecurity(prePostEnabled=true)` | **`@EnableMethodSecurity`** ๋ก ๊ฐ์ํ |
---
### ๐ก๏ธ ์ด๋ณด์๊ฐ ์์ฃผ ๊ฒช๋ 4๋ ํจ์ ๋ฐ ํธ๋ฌ๋ธ์ํ
#### 1. `hasRole()` vs `hasAuthority()`์ `ROLE_` ์ ๋์ฌ ํผ๋
- **์์ธ**: `hasRole("ADMIN")`์ ๋ด๋ถ์ ์ผ๋ก ์ ๋์ฌ `ROLE_`์ ์๋์ผ๋ก ๋ง๋ถ์ฌ `"ROLE_ADMIN"`์ ๊ฒ์ฌํฉ๋๋ค.
- **ํด๊ฒฐ์ฑ
**:
```java
// UserDetailsService ๋๋ JWT ํด๋ ์์ "ROLE_ADMIN"์ผ๋ก ์ ์ฅ๋์ด ์์ ๋:
.hasRole("ADMIN") // โ
์ฌ๋ฐ๋ฆ (์๋์ผ๋ก "ROLE_ADMIN" ๋งค์นญ)
.hasRole("ROLE_ADMIN") // โ ์คํจ ("ROLE_ROLE_ADMIN"์ ์ฐพ์ผ๋ฏ๋ก 403 ์๋ฌ ๋ฐ์)
.hasAuthority("ROLE_ADMIN") // โ
์ฌ๋ฐ๋ฆ (์์ ์ผ์น ๊ฒ์ฌ)
```
#### 2. CORS ์ฌ์ ์์ฒญ(OPTIONS Preflight)์ 401/403 ์๋ฌ ๋ฐ์
- **์์ธ**: ๋ธ๋ผ์ฐ์ ๊ฐ ์ค์ ์์ฒญ ์ ๋ณด๋ด๋ `OPTIONS` ๋ฉ์๋ ์์ฒญ์ ์ธ์ฆ ํค๋๊ฐ ์์ด ์ํ๋ฆฌํฐ ํํฐ์์ ์ฐจ๋จ๋จ.
- **ํด๊ฒฐ์ฑ
**:
```java
.authorizeHttpRequests(auth -> auth
.requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll() // Preflight ์ ๋ฉด ํ์ฉ
.anyRequest().authenticated()
)
```
#### 3. `@Async` ๋น๋๊ธฐ ์ค๋ ๋์์ SecurityContext ์ ์ค
- **์์ธ**: `SecurityContextHolder`์ ๊ธฐ๋ณธ ์ ๋ต์ `ThreadLocal`์ด๋ฏ๋ก, ๋ณ๋์ ๋น๋๊ธฐ ์์ปค ์ค๋ ๋๋ก ๋์ด๊ฐ๋ฉด ์ฌ์ฉ์ ์ธ์ฆ ์ ๋ณด๊ฐ ์ ํ๋์ง ์๊ณ `null`์ด ๋ฉ๋๋ค.
- **ํด๊ฒฐ์ฑ
**: `SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);` ์ค์ ๋๋ `DelegatingSecurityContextAsyncTaskExecutor` ์ฌ์ฉ.
#### 4. ๋น๋ฐ๋ฒํธ ์ํธํ ์๋ฌธ ๋
ธ์ถ ๋ฐ ๋น๋ฐ๋ฒํธ ์ผ์น ๊ฒ์ฌ ์ค๋ฅ
- **์ฃผ์์ฌํญ**: `BCryptPasswordEncoder`๋ ๋จ๋ฐฉํฅ ์ํ
(Salting) ํด์์ด๋ฏ๋ก `encoder.matches(rawPassword, encodedPassword)` ๋ฉ์๋๋ฅผ ํตํด์๋ง ์ผ์น ์ฌ๋ถ๋ฅผ ๊ฒ์ฆํด์ผ ํฉ๋๋ค. ๋ฌธ์์ด์ `equals()` ๋น๊ต๋ ์ ๋ ๋ถ๊ฐ๋ฅํฉ๋๋ค.
์๊ฒฌ ๋ฐ ์ง๋ฌธ
0์์ง ๋ฑ๋ก๋ ์๊ฒฌ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋๊ธ์ ๋จ๊ฒจ๋ณด์ธ์!
๋๊ธ ์์
๋๊ธ ์ญ์