← 참고자료📚 전체 맵참고자료 →
Spring Framework

Spring Security

JwtUtil · JwtFilter · 로그인 구현 · 권한(Role) · @AuthenticationPrincipal · 401/403 · CORS · 실전 전체 코드

Part 1 — 개념 · Filter Chain · 인증 vs 인가 · UserDetails · JWT 흐름
Part 2 — JwtUtil · JwtFilter · 로그인 구현 · 권한 · CORS · 실전 코드
JwtUtil — 토큰 생성 / 검증

JWT 토큰을 만들고, 검증하고, 토큰에서 값을 꺼내는 유틸리티 클래스.

JwtUtil.java
@Component
public class JwtUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private long expiration;        // 1시간 = 3600000ms

    @Value("${jwt.refresh-expiration}")
    private long refreshExpiration;  // 7일 = 604800000ms

    // Secret Key 생성
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    // ① Access Token 생성
    public String generateAccessToken(String email, String role) {
        return Jwts.builder()
            .setSubject(email)                      // 사용자 식별자 (이메일)
            .claim("role", role)                   // 권한 정보
            .setIssuedAt(new Date())               // 발급 시간
            .setExpiration(new Date(
                System.currentTimeMillis() + expiration))  // 만료 시간
            .signWith(getSigningKey())             // 서명
            .compact();
    }

    // ② Refresh Token 생성 (subject만 넣음)
    public String generateRefreshToken(String email) {
        return Jwts.builder()
            .setSubject(email)
            .setIssuedAt(new Date())
            .setExpiration(new Date(
                System.currentTimeMillis() + refreshExpiration))
            .signWith(getSigningKey())
            .compact();
    }

    // ③ 토큰에서 이메일(subject) 꺼내기
    public String getEmail(String token) {
        return parseClaims(token).getSubject();
    }

    // ④ 토큰에서 role 꺼내기
    public String getRole(String token) {
        return parseClaims(token).get("role", String.class);
    }

    // ⑤ 토큰 유효성 검사
    public boolean isValid(String token) {
        try {
            parseClaims(token);
            return true;
        } catch (ExpiredJwtException e) {
            throw new TokenExpiredException("만료된 토큰입니다.");
        } catch (JwtException e) {
            throw new InvalidTokenException("유효하지 않은 토큰입니다.");
        }
    }

    // Claims 파싱 (내부 공통 메서드)
    private Claims parseClaims(String token) {
        return Jwts.parserBuilder()
            .setSigningKey(getSigningKey())
            .build()
            .parseClaimsJws(token)
            .getBody();
    }
}
Secret Key 길이 주의

HMAC-SHA256 알고리즘 사용 시 Secret Key는 최소 256비트(32바이트) 이상이어야 함.
너무 짧으면 서버 시작할 때 에러 발생.
예: "mySecretKey1234567890123456789012" (32자 이상)

JwtFilter — 요청마다 토큰 검사

모든 요청에서 Authorization 헤더를 꺼내 토큰을 검증하고, 인증 정보를 SecurityContext에 저장하는 필터.

JwtFilter.java
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
// OncePerRequestFilter → 요청당 딱 한 번만 실행 보장

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService userDetailsService;

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

        // ① Authorization 헤더에서 토큰 꺼내기
        String authHeader = request.getHeader("Authorization");

        // 토큰 없거나 "Bearer "로 시작 안 하면 그냥 통과
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // ② "Bearer " 떼고 순수 토큰만 꺼내기
        String token = authHeader.substring(7);

        try {
            // ③ 토큰 유효성 검사
            jwtUtil.isValid(token);

            // ④ 토큰에서 이메일 꺼내기
            String email = jwtUtil.getEmail(token);

            // ⑤ 이미 인증된 경우 skip
            if (SecurityContextHolder.getContext()
                    .getAuthentication() == null) {

                // ⑥ DB에서 사용자 조회
                UserDetails userDetails =
                    userDetailsService.loadUserByUsername(email);

                // ⑦ Authentication 객체 생성
                UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null,
                        userDetails.getAuthorities());

                // 요청 정보 추가 (IP 등)
                auth.setDetails(
                    new WebAuthenticationDetailsSource()
                        .buildDetails(request));

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

        } catch (TokenExpiredException | InvalidTokenException e) {
            // 토큰 에러는 여기서 응답 (FilterChain 안 탐)
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(
                "{\"status\":401,\"message\":\"" + e.getMessage() + "\"}");
            return;
        }

        // ⑨ 다음 Filter로 넘기기
        filterChain.doFilter(request, response);
    }
}
OncePerRequestFilter를 쓰는 이유

일반 Filter는 경우에 따라 같은 요청에서 여러 번 실행될 수 있음.
OncePerRequestFilter를 상속하면 요청당 반드시 한 번만 실행됨.
JWT 검증은 한 번만 해야 하니까 OncePerRequestFilter가 적합.

로그인 구현 전체 코드

회원가입 → 로그인 → 토큰 발급 전체 흐름을 코드로 연결.

DTO — 요청/응답
// 로그인 요청 DTO
@Getter
public class LoginRequest {
    @NotBlank private String email;
    @NotBlank private String password;
}

// 로그인 응답 DTO
@Getter @AllArgsConstructor
public class LoginResponse {
    private String accessToken;
    private String refreshToken;
    private String email;
    private String role;
}

// 회원가입 요청 DTO
@Getter
public class SignupRequest {
    @NotBlank private String name;
    @NotBlank @Email private String email;
    @NotBlank @Size(min=8) private String password;
}
AuthController.java
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    // 회원가입
    @PostMapping("/signup")
    public ResponseEntity<UserResponse> signup(
            @Valid @RequestBody SignupRequest request) {
        return ResponseEntity.status(201)
            .body(authService.signup(request));
    }

    // 로그인
    @PostMapping("/login")
    public ResponseEntity<LoginResponse> login(
            @Valid @RequestBody LoginRequest request) {
        return ResponseEntity.ok(authService.login(request));
    }

    // Access Token 재발급
    @PostMapping("/refresh")
    public ResponseEntity<LoginResponse> refresh(
            @RequestHeader("Refresh-Token") String refreshToken) {
        return ResponseEntity.ok(authService.refresh(refreshToken));
    }
}
AuthService.java
@Service
@RequiredArgsConstructor
public class AuthService {

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

    // 회원가입
    @Transactional
    public UserResponse signup(SignupRequest request) {
        if (userRepository.existsByEmail(request.getEmail()))
            throw new DuplicateEmailException();

        User user = User.builder()
            .name(request.getName())
            .email(request.getEmail())
            .password(passwordEncoder.encode(request.getPassword()))
            .role("USER")   // 기본 권한
            .build();

        User saved = userRepository.save(user);
        return UserResponse.from(saved);
    }

    // 로그인
    public LoginResponse login(LoginRequest request) {
        // AuthenticationManager가 자동으로
        // 1) UserDetailsService.loadUserByUsername(email) 호출
        // 2) passwordEncoder.matches(입력PW, DB해시PW) 비교
        // 3) 틀리면 BadCredentialsException 던짐
        Authentication auth = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.getEmail(),
                request.getPassword()));

        CustomUserDetails userDetails =
            (CustomUserDetails) auth.getPrincipal();
        User user = userDetails.getUser();

        // 토큰 발급
        String accessToken  = jwtUtil.generateAccessToken(user.getEmail(), user.getRole());
        String refreshToken = jwtUtil.generateRefreshToken(user.getEmail());

        return new LoginResponse(accessToken, refreshToken,
                                   user.getEmail(), user.getRole());
    }

    // Access Token 재발급
    public LoginResponse refresh(String refreshToken) {
        jwtUtil.isValid(refreshToken);
        String email = jwtUtil.getEmail(refreshToken);

        User user = userRepository.findByEmail(email)
            .orElseThrow(UserNotFoundException::new);

        String newAccessToken = jwtUtil.generateAccessToken(
            user.getEmail(), user.getRole());

        return new LoginResponse(newAccessToken, refreshToken,
                                   user.getEmail(), user.getRole());
    }
}
권한(Role) 관리

사용자마다 다른 권한을 부여하고, URL이나 메서드 단위로 권한을 체크하는 방법.

Role 설계
Role 설계 + Entity
// Role Enum으로 관리 (권장)
public enum Role {
    USER, ADMIN, MANAGER
}

// User Entity
@Entity
public class User {
    @Enumerated(EnumType.STRING)  // DB에 "USER", "ADMIN" 문자열로 저장
    private Role role;
}

// CustomUserDetails에서 권한 반환
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
    return List.of(new SimpleGrantedAuthority(
        "ROLE_" + user.getRole().name()));
    // ⚠️ Spring Security에서 역할은 반드시 "ROLE_" 접두사 필요
    // hasRole("USER") → 내부적으로 "ROLE_USER"를 찾음
}
URL 단위 권한 제어 — SecurityConfig
URL 단위 권한 설정
.authorizeHttpRequests(auth -> auth // 누구나 접근 가능 .requestMatchers("/api/auth/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll() // ADMIN만 .requestMatchers("/api/admin/**").hasRole("ADMIN") // USER 또는 ADMIN .requestMatchers("/api/mypage/**").hasAnyRole("USER", "ADMIN") // 로그인한 사람 전부 .anyRequest().authenticated() )
@PreAuthorize — 메서드 레벨 권한 제어

SecurityConfig URL 설정만으로 부족할 때. 메서드 하나하나에 더 세밀한 권한을 걸 수 있다.

@PreAuthorize 사용법
// 활성화 — SecurityConfig 또는 메인 클래스에 추가
@EnableMethodSecurity  // Spring Security 3.x (Spring Boot 3.x)
// 구버전: @EnableGlobalMethodSecurity(prePostEnabled = true)

// Controller에서 사용
@RestController
@RequestMapping("/api/admin")
public class AdminController {

    // ADMIN만 접근 가능
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/users")
    public ResponseEntity<?> 전체유저목록() { ... }

    // USER 또는 ADMIN
    @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
    @GetMapping("/mypage")
    public ResponseEntity<?> 마이페이지() { ... }

    // 현재 로그인한 사용자 본인 데이터만 수정 가능
    @PreAuthorize("#userId == authentication.principal.userId or hasRole('ADMIN')")
    @PutMapping("/users/{userId}")
    public ResponseEntity<?> 수정(@PathVariable int userId) { ... }
    // authentication.principal → CustomUserDetails 객체
    // .userId → CustomUserDetails의 getUserId() 메서드
}

// @PostAuthorize — 메서드 실행 후 결과에 대한 권한 체크
@PostAuthorize("returnObject.body.email == authentication.name")
@GetMapping("/users/{id}")
public ResponseEntity<UserResponse> 조회(@PathVariable int id) { ... }
// 반환된 결과가 현재 로그인 사용자 것인지 검사
표현식의미
hasRole('ADMIN')ADMIN 역할인 경우
hasAnyRole('USER','ADMIN')USER 또는 ADMIN 역할
isAuthenticated()로그인한 경우
isAnonymous()비로그인(익명)인 경우
authentication.name현재 로그인한 사용자의 username (email)
authentication.principal현재 로그인한 UserDetails 객체
#변수명메서드 파라미터를 표현식에서 참조
@AuthenticationPrincipal — Controller에서 현재 로그인 유저 꺼내기

SecurityContextHolder에서 직접 꺼내는 것보다 훨씬 간단하게 현재 로그인한 사용자를 받을 수 있다.

@AuthenticationPrincipal 사용법
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

    // ❌ 복잡한 방법 — SecurityContextHolder에서 직접 꺼내기
    @GetMapping("/me")
    public ResponseEntity<?> 내정보1() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        CustomUserDetails userDetails = (CustomUserDetails) auth.getPrincipal();
        return ResponseEntity.ok(userDetails.getUser());
    }

    // ✅ 간단한 방법 — @AuthenticationPrincipal
    @GetMapping("/me")
    public ResponseEntity<UserResponse> 내정보2(
            @AuthenticationPrincipal CustomUserDetails userDetails) {
        // SecurityContext에서 자동으로 꺼내줌!
        User user = userDetails.getUser();
        return ResponseEntity.ok(UserResponse.from(user));
    }

    // 내 게시글 목록 — 현재 로그인 유저 ID 기준 조회
    @GetMapping("/me/posts")
    public ResponseEntity<List<PostResponse>> 내게시글(
            @AuthenticationPrincipal CustomUserDetails userDetails) {
        int userId = userDetails.getUserId();
        return ResponseEntity.ok(postService.getMyPosts(userId));
    }

    // 내 정보 수정
    @PutMapping("/me")
    public ResponseEntity<UserResponse> 내정보수정(
            @AuthenticationPrincipal CustomUserDetails userDetails,
            @Valid @RequestBody UserUpdateRequest request) {
        int userId = userDetails.getUserId();
        return ResponseEntity.ok(userService.update(userId, request));
    }
}
401 / 403 커스텀 응답

토큰 없이 접근하면 401, 권한 없이 접근하면 403. 기본 응답이 HTML이라 REST API에서는 JSON으로 바꿔야 한다.

@ExceptionHandler로 잡히지 않는 이유

401/403은 Controller에 도달하기 전 Security Filter 단계에서 발생.
@ExceptionHandler는 Controller 이후에만 동작.
→ AuthenticationEntryPoint와 AccessDeniedHandler로 별도 처리해야 함.

401 — AuthenticationEntryPoint
// 로그인 안 했거나 토큰 없을 때 → 401
@Component
public class CustomAuthenticationEntryPoint
        implements AuthenticationEntryPoint {

    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException)
            throws IOException {

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(
            """
            {
              "status": 401,
              "message": "로그인이 필요합니다.",
              "code": "UNAUTHORIZED"
            }
            """);
    }
}
403 — AccessDeniedHandler
// 로그인은 됐지만 권한이 없을 때 → 403
@Component
public class CustomAccessDeniedHandler
        implements AccessDeniedHandler {

    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException)
            throws IOException {

        response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(
            """
            {
              "status": 403,
              "message": "접근 권한이 없습니다.",
              "code": "FORBIDDEN"
            }
            """);
    }
}
SecurityConfig에 등록

Part 1 SecurityConfig의 exceptionHandling() 부분에 이미 포함돼 있음.
.authenticationEntryPoint(authEntryPoint) → 401 처리
.accessDeniedHandler(accessDeniedHandler) → 403 처리

CORS 설정

프론트엔드(React 등)가 다른 도메인에서 API를 호출할 때 브라우저가 막는 것. 서버에서 허용해줘야 한다.

CORS란?

브라우저는 보안상 다른 출처(Origin)의 API 호출을 기본적으로 막음.
예: 프론트가 localhost:3000에서 백엔드 localhost:8080 API 호출 → 브라우저가 차단.
서버에서 "이 출처는 허용한다"고 응답 헤더에 명시해야 브라우저가 허용함.
서버끼리 통신(Postman 등)에는 CORS가 없음. 브라우저 전용 정책.

CorsConfig.java
@Configuration
public class CorsConfig {

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

        // 허용할 출처 (프론트엔드 주소)
        config.setAllowedOrigins(List.of(
            "http://localhost:3000",    // 개발 환경
            "https://myapp.com"          // 운영 환경
        ));

        // 허용할 HTTP 메서드
        config.setAllowedMethods(List.of(
            "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));

        // 허용할 헤더
        config.setAllowedHeaders(List.of("*"));  // 모든 헤더 허용

        // Authorization 헤더 노출 (프론트에서 읽을 수 있게)
        config.setExposedHeaders(List.of("Authorization"));

        // 쿠키 포함 여부 (Refresh Token을 쿠키로 관리할 때 true)
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source =
            new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);  // 전체 URL에 적용
        return source;
    }
}

// SecurityConfig에서 이 Bean을 참조해서 사용
// .cors(Customizer::withDefaults) → CorsConfigurationSource Bean을 자동으로 찾아 적용
⚠️ CORS 설정 주의사항

Spring Security가 있으면 CORS 설정을 반드시 Security 레벨에서 해야 함.
@CrossOrigin 애너테이션이나 WebMvcConfigurer만으론 부족함.
Security Filter가 CORS 전에 동작해서 OPTIONS preflight 요청을 막아버릴 수 있음.

실전 전체 코드 — 파일 구조 + 흐름 정리
파일 구조
프로젝트 파일 구조
src/main/java/com/myapp/
├── config/
│   ├── SecurityConfig.java          // Security 설정 전체
│   └── CorsConfig.java               // CORS 설정
│
├── auth/
│   ├── controller/
│   │   └── AuthController.java        // /api/auth/** 엔드포인트
│   ├── service/
│   │   └── AuthService.java           // 회원가입, 로그인, 토큰 재발급
│   ├── dto/
│   │   ├── LoginRequest.java
│   │   ├── LoginResponse.java
│   │   └── SignupRequest.java
│   └── jwt/
│       ├── JwtUtil.java               // 토큰 생성/검증
│       └── JwtFilter.java             // 요청마다 토큰 검사
│
├── security/
│   ├── CustomUserDetails.java         // UserDetails 구현
│   ├── CustomUserDetailsService.java   // UserDetailsService 구현
│   ├── CustomAuthenticationEntryPoint.java  // 401 처리
│   └── CustomAccessDeniedHandler.java       // 403 처리
│
├── user/
│   ├── controller/
│   │   └── UserController.java
│   ├── service/
│   │   └── UserService.java
│   ├── repository/
│   │   └── UserRepository.java
│   ├── entity/
│   │   └── User.java
│   └── dto/
│       ├── UserResponse.java
│       └── UserUpdateRequest.java
│
└── global/
    └── exception/
        ├── GlobalExceptionHandler.java
        ├── BusinessException.java
        ├── UserNotFoundException.java
        ├── DuplicateEmailException.java
        ├── TokenExpiredException.java
        └── InvalidTokenException.java
application.yml — Security + JWT 설정
application-dev.yml
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

jwt:
  secret: myDevSecretKey123456789012345678901234  # 32자 이상
  expiration: 3600000         # 1시간
  refresh-expiration: 604800000  # 7일
User Entity — Role 포함
User.java
@Entity
@Table(name = "users")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    @Column(nullable = false)
    private String name;

    @Column(unique = true, nullable = false)
    private String email;

    @Column(nullable = false)
    private String password;  // BCrypt 암호화된 값 저장

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    public enum Role {
        USER, ADMIN, MANAGER
    }
}
전체 흐름 요약
회원가입 흐름
POST /api/auth/signup
@Valid 유효성 검사
이메일 중복 체크
BCrypt 비밀번호 암호화
role = "USER"로 저장
UserResponse 반환 (201)
로그인 흐름
POST /api/auth/login
AuthenticationManager.authenticate()
UserDetailsService → DB 조회
BCrypt 비밀번호 비교
AccessToken + RefreshToken 발급
LoginResponse 반환 (200)
인증된 요청 흐름
GET /api/users/me
Header: Authorization: Bearer eyJ...
JwtFilter → 토큰 검증
SecurityContext에 인증 정보 저장
@AuthenticationPrincipal로 유저 꺼내기
UserResponse 반환 (200)
토큰 만료 시 흐름
API 요청 → 401 응답
POST /api/auth/refresh
Header: Refresh-Token: eyJ...
RefreshToken 검증
새 AccessToken 발급
재시도
Spring Security 완전 정복 — 2부 완결
Part 1
왜 필요한가 · Filter Chain · SecurityConfig · 인증 vs 인가 · HTTP Basic/Form/JWT 비교 · UserDetails · PasswordEncoder · SecurityContext · JWT 흐름
Part 2
JwtUtil · JwtFilter · 로그인 전체 구현 · Role 권한 관리 · @PreAuthorize · @AuthenticationPrincipal · 401/403 커스텀 · CORS · 실전 파일 구조
← 참고자료📚 전체 맵참고자료 →