Spring Security 완전 정복

Yunsung·2025년 7월 3일
post-thumbnail

오늘은 백엔드 개발의 기초가 되는 Spring Security에 대해 공부하며 정리해보았습니다.

1. Spring Security란? 왜 필요한가?

1-1. Spring Security가 해결하는 문제들

웹 애플리케이션의 보안 위협들:

  • 무단 접근: 로그인하지 않은 사용자가 관리자 페이지 접근
  • 권한 남용: 일반 사용자가 관리자 기능 사용
  • 세션 하이재킹: 다른 사용자의 로그인 정보 탈취
  • CSRF 공격: 악성 사이트를 통한 무단 요청

Spring Security 없이 개발한다면?

// 매번 이런 코드를 모든 컨트롤러에 작성해야 함 😱
@GetMapping("/admin/users")
public List<User> getUsers(HttpSession session) {
    if (session.getAttribute("user") == null) {
        throw new RuntimeException("로그인이 필요합니다");
    }
    
    User user = (User) session.getAttribute("user");
    if (!user.getRole().equals("ADMIN")) {
        throw new RuntimeException("관리자 권한이 필요합니다");
    }
    
    return userService.getAllUsers();
}

Spring Security 사용하면?

// 깔끔하게 @PreAuthorize 어노테이션 하나로 해결! ✨
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/users")
public List<User> getUsers() {
    return userService.getAllUsers();
}

2. 인증(Authentication) vs 권한(Authorization) 구분하기

2-1. 인증(Authentication) - "당신이 누구인지"

  • 인증 = 신분증 확인 (여권, 주민등록증)
  • "이 사람이 정말 홍길동이 맞나요?"

예시:

// 로그인 과정
사용자: "이메일: user@test.com, 비밀번호: 123456"
서버: "이 정보가 맞는지 확인해보자"
       ↓
서버: "아, 맞네! 이 사람은 user@test.com 사용자구나"

2-2. 권한(Authorization) - "당신이 무엇을 할 수 있는지"

  • 권한 = 입장권 확인 (VIP석, 일반석)
  • "이 사람이 VIP석에 앉을 수 있나요?"

예시:

// 권한 확인 과정
사용자: "관리자 페이지 접근하고 싶어요"
서버: "이 사용자가 관리자 권한이 있나요?"
       ↓
서버: "아, 일반 사용자네요. 관리자 페이지는 접근 불가"

2-3. 실제 코드로 보는 차이점

// 인증: 로그인 성공 여부
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
    // 인증 과정: 이메일/비밀번호가 맞는지 확인
    User user = userService.authenticate(request.getEmail(), request.getPassword());
    if (user == null) {
        return ResponseEntity.badRequest().body("로그인 실패");
    }
    return ResponseEntity.ok("로그인 성공");
}

// 권한: 특정 기능 사용 가능 여부
@PreAuthorize("hasRole('ADMIN')")  // 권한 확인
@GetMapping("/admin/users")
public List<User> getUsers() {
    // 이미 인증된 사용자만 이 메서드에 접근 가능
    // 추가로 ADMIN 권한이 있는지 확인
    return userService.getAllUsers();
}

3. Spring Security 핵심 컴포넌트들

3-1. BCrypt 비밀번호 암호화 - 보안의 기초

3-1-1. 비밀번호를 평문으로 저장하면?

❌ 위험한 방식:

// 데이터베이스에 평문으로 저장
User user = new User();
user.setEmail("user@test.com");
user.setPassword("user123");  // 평문 저장! 😱
userRepository.save(user);

문제점:

  • DB 해킹 시: 모든 사용자 비밀번호 노출
  • 관리자도: 사용자 비밀번호를 볼 수 있음
  • 법적 문제: 개인정보보호법 위반

3-1-2. BCrypt란? 왜 BCrypt인가?

BCrypt의 특징:

  • 단방향 해시: 복호화 불가능
  • 솔트(Salt) 자동 추가: 같은 비밀번호도 다른 해시값
  • 적응형 알고리즘: 컴퓨터 성능 향상에 따라 보안 강화
  • 레인보우 테이블 공격 방지: 미리 계산된 해시 테이블 무력화

3-2. 인증 관련 컴포넌트

3-2-1. UserDetailsService - "사용자 정보를 찾는 역할"

@Service
public class CustomUserDetailsService implements UserDetailsService {
    
    @Autowired
    private UserRepository userRepository;
    
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        // 1. 데이터베이스에서 사용자 찾기
        User user = userRepository.findByEmail(email)
            .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + email));
        
        // 2. Spring Security 형식으로 변환
        return org.springframework.security.core.userdetails.User.builder()
            .username(user.getEmail())
            .password(user.getPassword())  // 이미 BCrypt로 암호화된 비밀번호
            .roles(user.getRoles().toArray(new String[0]))
            .build();
    }
}

3-2-2. PasswordEncoder - "비밀번호 암호화/검증"

// BCrypt 사용 예시
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

// 사용법
String rawPassword = "user123";
String encodedPassword = passwordEncoder.encode(rawPassword); // 암호화
boolean matches = passwordEncoder.matches("user123", encodedPassword); // 검증

3-3. 권한 관련 컴포넌트

3-3-1. GrantedAuthority - "권한 정보"

// 역할 기반 권한
SimpleGrantedAuthority adminRole = new SimpleGrantedAuthority("ROLE_ADMIN");
SimpleGrantedAuthority userRole = new SimpleGrantedAuthority("ROLE_USER");

// 세부 권한
SimpleGrantedAuthority readPermission = new SimpleGrantedAuthority("READ_USER");
SimpleGrantedAuthority writePermission = new SimpleGrantedAuthority("WRITE_USER");

4. SecurityConfig 설정하기 - 단계별로 이해하기

4-1. 기본 SecurityConfig 구조

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // 1. CSRF 설정
            .csrf(csrf -> csrf.disable())
            
            // 2. 세션 관리
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            
            // 3. URL 권한 설정
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            
            // 4. JWT 필터 추가
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        
        return http.build();
    }
}

4-2. 각 설정의 의미

CSRF 비활성화

.csrf(csrf -> csrf.disable())
  • CSRF: Cross-Site Request Forgery 공격 방지
  • 왜 비활성화?: REST API에서는 불필요 (JWT 사용 시)

세션 관리

.sessionManagement(session -> session
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
  • STATELESS: 세션을 사용하지 않음 (JWT 방식)
  • IF_REQUIRED: 필요시에만 세션 생성 (기본값)

5. URL 보안 설정 - 실전 예시

5-1. URL 패턴 매칭 규칙

.authorizeHttpRequests(authz -> authz
    // 1. 정적 리소스 (누구나 접근 가능)
    .requestMatchers("/css/**", "/js/**", "/images/**").permitAll()
    
    // 2. 공개 API (인증 불필요)
    .requestMatchers("/api/auth/**", "/api/public/**").permitAll()
    
    // 3. 관리자 전용 (ADMIN 역할 필요)
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    
    // 4. 사용자 전용 (USER 역할 필요)
    .requestMatchers("/api/user/**").hasRole("USER")
    
    // 5. 여러 역할 중 하나 (ADMIN 또는 MODERATOR)
    .requestMatchers("/api/moderator/**").hasAnyRole("ADMIN", "MODERATOR")
    
    // 6. 나머지 모든 요청 (로그인만 하면 접근 가능)
    .anyRequest().authenticated()
)

5-2. HTTP 메서드별 권한 설정

.authorizeHttpRequests(authz -> authz
    // GET 요청은 누구나 가능
    .requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
    
    // POST, PUT, DELETE는 로그인 필요
    .requestMatchers(HttpMethod.POST, "/api/posts/**").authenticated()
    .requestMatchers(HttpMethod.PUT, "/api/posts/**").authenticated()
    .requestMatchers(HttpMethod.DELETE, "/api/posts/**").hasRole("ADMIN")
)

5-3. 우선순위 주의사항

// ❌ 잘못된 예시 (anyRequest가 먼저 나오면 뒤의 설정이 무시됨)
.authorizeHttpRequests(authz -> authz
    .anyRequest().authenticated()  // 이게 먼저 나오면
    .requestMatchers("/admin/**").hasRole("ADMIN")  // 이 설정이 무시됨
)

// ✅ 올바른 예시 (구체적인 패턴을 먼저 작성)
.authorizeHttpRequests(authz -> authz
    .requestMatchers("/admin/**").hasRole("ADMIN")  // 구체적인 패턴 먼저
    .anyRequest().authenticated()  // 마지막에 작성
)

6. 메서드 레벨 보안 - @PreAuthorize 사용법

6-1. 기본 사용법

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    // 역할 기반 권한
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/all")
    public List<User> getAllUsers() {
        return userService.getAllUsers();
    }
    
    // 여러 역할 중 하나
    @PreAuthorize("hasAnyRole('ADMIN', 'MODERATOR')")
    @GetMapping("/moderate")
    public List<User> getUsersForModeration() {
        return userService.getUsersForModeration();
    }
    
    // 세부 권한
    @PreAuthorize("hasAuthority('READ_USER')")
    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        return userService.getUserById(id);
    }
}

6-2. 고급 표현식

// 현재 사용자와 요청 대상이 같은 경우만 허용
@PreAuthorize("#id == authentication.principal.id")
@GetMapping("/profile/{id}")
public User getProfile(@PathVariable Long id) {
    return userService.getUserById(id);
}

// 복합 조건
@PreAuthorize("hasRole('ADMIN') or (#id == authentication.principal.id)")
@PutMapping("/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) {
    return userService.updateUser(id, user);
}

// 메서드 파라미터 활용
@PreAuthorize("#user.email == authentication.principal.username")
@PostMapping("/validate")
public boolean validateUser(@RequestBody User user) {
    return userService.validateUser(user);
}

7. 예외 처리 - 사용자 친화적인 에러 메시지

7-1. 인증/권한 예외 처리

@RestControllerAdvice
public class SecurityExceptionHandler {
    
    // 인증 실패 (로그인 안됨)
    @ExceptionHandler(AuthenticationException.class)
    public ResponseEntity<ErrorResponse> handleAuthenticationException(AuthenticationException e) {
        ErrorResponse error = new ErrorResponse("AUTH001", "로그인이 필요합니다");
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
    }
    
    // 권한 부족 (로그인은 됐지만 권한 없음)
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedException e) {
        ErrorResponse error = new ErrorResponse("AUTH002", "접근 권한이 없습니다");
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
    }
}

7-2. 커스텀 인증 진입점(REST API에서는 JSON 응답이 필요)

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
    @Override
    public void commence(HttpServletRequest request, 
                        HttpServletResponse response, 
                        AuthenticationException authException) throws IOException {
        
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        
        ErrorResponse error = new ErrorResponse("AUTH001", "유효하지 않은 토큰입니다");
        String json = new ObjectMapper().writeValueAsString(error);
        
        response.getWriter().write(json);
    }
}

앞으로 더 공부할 것들

  • JWT 토큰 갱신 메커니즘
  • OAuth2 소셜 로그인
  • Spring Security의 내부 동작 원리
  • 실제 프로젝트에 적용해보기
profile
풀스택 개발자로서의 도전을 하는 중입니다. 많은 응원 부탁드립니다!!😁

0개의 댓글