Spring Security

Spring Security는 인증(Authentication, 너 누구야?)과 인가(Authorization, 그걸 할 권한이 있어?)를 담당하는 스프링의 보안 프레임워크다. 일련의 필터 체인으로 동작하며, 로그인·로그아웃·권한 제어·비밀번호 암호화 같은 보안 기능을 표준화된 방식으로 제공한다.


1. 사전 지식

CSRF (Cross-Site Request Forgery)

사용자의 의도와 관계없이, 공격자가 다른 웹사이트를 통해 사용자의 브라우저를 악용해 특정 요청을 서버로 보내게 하는 공격이다. (예: 로그인된 사용자가 악성 페이지를 열었을 때 자기도 모르게 송금 요청이 전송되는 상황)

XSS (Cross-Site Scripting)

웹 애플리케이션의 취약점을 악용해 악성 스크립트를 삽입하는 공격이다. 사용자가 입력한 데이터를 웹 페이지에 표시하는 과정에서 필터링이 제대로 이루어지지 않을 때 발생한다.

타임리프와 XSS

타임리프의 th:text는 기본적으로 HTML을 이스케이프하므로 XSS에 비교적 안전하다. 반대로 th:utext는 태그를 그대로 렌더링하므로, 사용자 입력에는 함부로 쓰지 않는다.


2. 스프링 시큐리티 인증 절차

Spring Security는 필터로 동작한다. 요청이 들어오면 인증·인가를 위한 필터들(Security Filter Chain)을 통과한다.

sequenceDiagram
    participant U as 사용자
    participant F as AuthenticationFilter
    participant M as AuthenticationManager<br/>(ProviderManager)
    participant S as UserDetailsService
    participant C as SecurityContext

    U->>F: ① 로그인 요청 (id/pw)
    F->>F: ② UsernamePasswordAuthenticationToken 생성
    F->>M: ③ 인증 위임
    M->>S: ④ loadUserByUsername(id)
    S-->>M: ⑤ UserDetails 반환 (pw, 권한)
    M-->>F: ⑥ 인증 성공 → 완전한 인증 객체
    F->>C: ⑦ SecurityContext에 인증 객체 저장
  1. HTTP 요청 수신: 요청 종류에 맞는 인증 필터를 통과한다. (폼 로그인 → UsernamePasswordAuthenticationFilter, HTTP 기본 인증 → BasicAuthenticationFilter 등)

  2. 인증 토큰 생성: username과 password로 UsernamePasswordAuthenticationToken을 생성한다.

  3. AuthenticationManager에 위임: 필터가 토큰을 AuthenticationManager(구현체 ProviderManager)에 넘긴다. ProviderManager는 여러 AuthenticationProvider 목록을 갖고 인증을 시도한다.

  4. UserDetailsService 호출: username 기반으로 사용자 정보(UserDetails)를 검색한다.

  5. User 정보 조회: UserDetailsService는 인터페이스이므로, 우리가 비즈니스 로직(DB 조회 등)을 직접 구현한다. loadUserByUsername은 필수 구현 메소드이며, 반환값으로 UserDetails(아이디·비밀번호·권한)를 넣어 반환한다.

  6. 인증 결과: 성공하면 전체 인증 정보를 반환하고, 실패하면 AuthenticationException이 발생한다.

  7. SecurityContext에 저장: 인증에 성공하면 인증 객체를 SecurityContext에 저장한다. 이후 요청에서 이 컨텍스트로 로그인 상태를 확인한다.

핵심 관계

Security Session → Authentication → UserDetails 순으로 사용자 정보가 감싸진다. 우리가 구현할 부분은 UserDetailsService(사용자 조회) 와 UserDetails(사용자 정보 표현) 두 가지다.


3. SecurityConfig

보안 설정의 중심 클래스다. SecurityFilterChain 빈으로 URL별 접근 권한, 로그인·로그아웃 동작을 정의한다.

Spring Security 6 (Boot 3.x)에서 바뀐 설정 방식

Spring Boot 3.5가 사용하는 Spring Security 6.x에서는 과거의 비-람다 설정 방식이 제거되었다. 아래 표의 옛 방식으로 쓰면 컴파일되지 않는다.

옛 방식 (Security 5)

새 방식 (Security 6)

WebSecurityConfigurerAdapter 상속

SecurityFilterChain 빈 등록

.csrf().disable().and()... 체이닝

람다 DSL (.csrf(csrf -> ...))

authorizeRequests()

authorizeHttpRequests()

antMatchers(...)

requestMatchers(...)

@EnableGlobalMethodSecurity

@EnableMethodSecurity

import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;

@Configuration
@EnableWebSecurity
// @Secured, @PreAuthorize 등 메소드 수준 권한 제어 활성화
// securedEnabled=true → @Secured 사용 / prePostEnabled는 기본값 true (@PreAuthorize 사용)
@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomAuthFailureHandler authenticationFailureHandler;

    // 정적 리소스는 시큐리티 검사에서 제외
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().requestMatchers("/css/**", "/js/**", "/favicon.ico");
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // CSRF 보호 비활성화 (학습용. 운영에서는 신중히 결정)
            .csrf(AbstractHttpConfigurer::disable)
            // URL별 접근 권한 정의 (람다 DSL)
            .authorizeHttpRequests(auth -> auth
                // permitAll: 인증 없이 허용
                .requestMatchers("/", "/member/join", "/member/login", "/member/login-fail", "/member/logout").permitAll()
                .requestMatchers("/css/**", "/favicon.ico").permitAll()
                // /admin 하위는 ADMIN 권한자만
                .requestMatchers("/admin/**").hasRole("ADMIN")
                // 그 외는 모두 인증 필요
                .anyRequest().authenticated()
            )
            // 폼 로그인 설정
            .formLogin(form -> form
                .usernameParameter("id")                    // username 파라미터명 (기본 username)
                .loginPage("/member/login")                 // 커스텀 로그인 페이지
                .loginProcessingUrl("/member/login")        // 로그인 처리 URL
                .defaultSuccessUrl("/member/login-success") // 성공 시 이동
                // 실패 시 처리 — 아래 둘은 택일한다 (failureHandler가 있으면 failureUrl은 무시됨)
                // .failureUrl("/member/login-fail")         // ① 단순히 실패 URL로만 이동
                .failureHandler(authenticationFailureHandler) // ② 커스텀 핸들러로 원인별 메시지 처리(8절)
            )
            // 로그아웃 설정
            .logout(logout -> logout
                .logoutUrl("/member/logout")                // 로그아웃 URL
                .logoutSuccessUrl("/")                      // 로그아웃 성공 후 이동
                .invalidateHttpSession(true)                // 세션 무효화
                .deleteCookies("JSESSIONID")                // 쿠키 삭제
            );

        return http.build();
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

@EnableMethodSecurity

메소드 수준에서 권한을 제어할 수 있게 해 준다(@Secured, @PreAuthorize). 특정 메소드마다 실행할 수 있는 역할을 제한할 수 있어, 단순 허용/거부보다 더 복잡한 규칙을 적용할 수 있다. Security 6에서 @EnableGlobalMethodSecurity를 대체한다.

CSRF는 학습 편의를 위해 끈 것

위 설정은 학습 중 POST 요청 테스트를 단순하게 하려고 CSRF를 비활성화했다. 운영 환경에서는 CSRF 보호를 켜두는 것이 원칙이다. Spring Security는 기본적으로 CSRF가 켜져 있고, Thymeleaf로 <form>을 만들면 CSRF 토큰이 hidden 필드로 자동 삽입되므로 폼 전송은 별도 처리 없이 동작한다. (REST API에서 토큰 기반 인증을 쓸 때는 CSRF를 끄는 경우가 많다.)

비밀번호 암호화 — PasswordEncoder

비밀번호는 절대 평문으로 저장하지 않고 단방향 해시로 암호화한다. BCryptPasswordEncoder는 PasswordEncoder의 대표 구현체다.

구현체

해시 함수

BCryptPasswordEncoder

BCrypt

Argon2PasswordEncoder

Argon2

Pbkdf2PasswordEncoder

PBKDF2

SCryptPasswordEncoder

SCrypt


4. UserDetailsService 구현

로그인 요청이 오면, 스프링이 UserDetailsService 타입 빈의 loadUserByUsername을 자동으로 호출한다. DB에서 사용자를 찾아 UserDetails로 감싸 반환한다.

@Slf4j
@RequiredArgsConstructor
@Service
public class UserService implements UserDetailsService {

    // 이 강의는 JPA를 사용하므로 Repository로 회원을 조회한다
    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 사용자가 없으면 null을 반환하지 말고 예외를 던지는 것이 권장 방식이다
        Member member = memberRepository.findByUserId(username)
                .orElseThrow(() -> new UsernameNotFoundException("계정이 존재하지 않습니다: " + username));
        return new UserInfo(member);
    }
}

여기서 MemberRepository에는 로그인 ID(문자열)로 회원을 찾는 메소드를 선언해 둔다. (PK인 @Id와 별개로, 로그인용 userId 필드가 있다고 가정)

public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByUserId(String userId);
}

5. UserDetails 구현 { #5-userdetails }

로그인이 완료되면 시큐리티 세션(SecurityContextHolder)이 만들어진다. 그 안의 사용자 정보는 UserDetails 타입이어야 한다.

@Data
public class UserInfo implements UserDetails {

    private Member member;

    public UserInfo(Member member) {
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 사용자의 권한 목록 반환 (role 값은 "ROLE_USER", "ROLE_ADMIN" 형태)
        return List.of(new SimpleGrantedAuthority(member.getRole().name()));
    }

    @Override public String getPassword() { return member.getPassword(); }
    @Override public String getUsername() { return member.getUserId(); }  // 로그인 ID

    @Override public boolean isAccountNonExpired()     { return true; }  // 계정 만료 안 됨
    @Override public boolean isAccountNonLocked()      { return true; }  // 계정 잠김 안 됨
    @Override public boolean isCredentialsNonExpired() { return true; }  // 비밀번호 만료 안 됨
    @Override public boolean isEnabled()               { return true; }  // 계정 사용 가능
}

정보 흐름

Security Session → Authentication → UserDetails 순으로 감싸지며, 비밀번호가 맞지 않으면 인증이 거부된다.


6. 회원가입과 권한 제어 — Controller

회원가입 시 비밀번호를 반드시 암호화해서 저장한다. 메소드 단위 권한 제어도 함께 살펴본다.

@RequiredArgsConstructor
@Controller
public class MemberController {

    private final MemberService memberService;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    @GetMapping("/")
    public String index() { return "index"; }

    // 로그인 폼(GET) 핸들러는 8절(로그인 실패 처리)에서 다룬다 — /member/login

    @GetMapping("/member/join")
    public String joinForm() { return "member/joinForm"; }

    @PostMapping("/member/join")
    public String join(Member member) {
        String rawPassword = member.getPassword();
        String encPassword = bCryptPasswordEncoder.encode(rawPassword);  // 암호화
        member.setPassword(encPassword);
        memberService.saveMember(member);
        return "redirect:/member/login";
    }

    // @Secured: 권한 문자열을 그대로(ROLE_ 포함) 지정
    @Secured("ROLE_ADMIN")
    @GetMapping("/info")
    public @ResponseBody String info() { return "개인정보"; }

    // @PreAuthorize: OR/AND 등 표현식 사용 가능. hasRole 인자에는 ROLE_ 접두사를 빼고 적는다
    @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
    @GetMapping("/data")
    public @ResponseBody String data() { return "데이터정보"; }
}

어노테이션

특징

@Secured

권한 문자열을 그대로(ROLE_ADMIN) 지정. OR/AND 조건 불가

@PreAuthorize

SpEL 표현식 사용. hasAnyRole('A', 'B') 같은 복합 조건 가능

hasRole 과 ROLE_ 접두사 주의

hasRole('ADMIN')/hasAnyRole('ADMIN')은 내부적으로 ROLE_ 접두사를 자동으로 붙여 ROLE_ADMIN 권한을 확인한다. 따라서 인자에 hasRole('ROLE_ADMIN')처럼 쓰면 ROLE_ROLE_ADMIN을 찾게 되어 항상 실패한다. 접두사까지 직접 지정하려면 hasAuthority('ROLE_ADMIN')을 쓴다. 반면 @Secured는 권한 문자열을 그대로 받으므로 @Secured("ROLE_ADMIN")이 맞다.


7. Thymeleaf에서 사용자 정보 출력

thymeleaf-extras-springsecurity6 라이브러리를 추가하면 sec: 네임스페이스로 인증 정보에 접근할 수 있다. (Spring Security 6 / Boot 3.x에서는 버전 6 아티팩트를 사용한다.)

// build.gradle
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
<html xmlns:sec="http://www.thymeleaf.org/extras/spring-security">

<!-- ADMIN 권한이면 보임 -->
<h1 sec:authorize="hasRole('ADMIN')">Has admin Role</h1>

<!-- 인증된 사용자면 보임 -->
<div sec:authorize="isAuthenticated()">
    Only Authenticated user can see this Text
</div>

<!-- 인증 객체의 username (ID) -->
<b>Authenticated username:</b>
<div sec:authentication="name"></div>

<!-- 객체의 권한 -->
<b>Authenticated user role:</b>
<div sec:authentication="principal.authorities"></div>
  • taglib 방식: 단순 정보만 표시할 때 편리하다.

  • DTO를 Model에 담아 사용: th:if 등 복잡한 분기가 필요할 때 사용한다.


8. 로그인 실패 처리

실패 원인별로 사용자에게 친절한 메시지를 보여주려면 커스텀 실패 핸들러를 만든다.

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Component
public class CustomAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        String errorMessage;
        if (exception instanceof BadCredentialsException) {
            errorMessage = "아이디 또는 비밀번호가 맞지 않습니다. 다시 확인해 주세요.";
        } else if (exception instanceof InternalAuthenticationServiceException) {
            errorMessage = "내부 시스템 문제로 요청을 처리할 수 없습니다. 관리자에게 문의하세요.";
        } else if (exception instanceof UsernameNotFoundException) {
            errorMessage = "계정이 존재하지 않습니다. 회원가입 후 로그인 해주세요.";
        } else if (exception instanceof AuthenticationCredentialsNotFoundException) {
            errorMessage = "인증 요청이 거부되었습니다. 관리자에게 문의하세요.";
        } else {
            errorMessage = "알 수 없는 이유로 로그인에 실패하였습니다. 관리자에게 문의하세요.";
        }
        errorMessage = URLEncoder.encode(errorMessage, "UTF-8");
        setDefaultFailureUrl("/member/login?error=true&exception=" + errorMessage);
        super.onAuthenticationFailure(request, response, exception);
    }
}
// SecurityConfig - 실패 핸들러 등록
.failureHandler(authenticationFailureHandler)
// 컨트롤러: 에러 메시지를 모델에 담아 화면에 전달
@GetMapping("login")
public String loginForm(@RequestParam(value = "error", required = false) boolean error,
                        @RequestParam(value = "exception", required = false) String exception,
                        Model model) {
    if (error) {
        model.addAttribute("error", true);
        model.addAttribute("exception", exception);
    }
    model.addAttribute("loginForm", new LoginForm());
    return "member/loginForm";
}

인증 실패 예외 종류

예외

디폴트 Provider가 리턴하는 결과

AuthenticationServiceException

null 값을 리턴

BadCredentialsException

UserDetails는 반환됐으나 아이디/비밀번호가 다름

LockedException

isAccountNonLocked()가 false

DisabledException

isEnabled()가 false

AccountExpiredException

isAccountNonExpired()가 false

CredentialsExpiredException

isCredentialsNonExpired()가 false

예외와 UserDetails의 연결

위 표의 마지막 네 가지는 5. UserDetails 구현에서 본 isAccountNonLocked(), isEnabled() 등의 반환값과 직접 연결된다. 계정 상태를 DB 컬럼으로 관리한다면 이 메소드들이 그 값을 반환하도록 구현하면 된다.


정리

  • Spring Security는 필터 체인으로 동작하며, 인증 결과는 SecurityContext에 저장된다.

  • 개발자가 구현할 핵심은 UserDetailsService(사용자 조회)와 UserDetails(사용자 정보 표현)다.

  • 보안 설정은 SecurityConfig의 SecurityFilterChain 빈에서 URL 권한·로그인·로그아웃을 정의한다.

  • 비밀번호는 BCryptPasswordEncoder 로 암호화해 저장한다.

  • 권한 제어는 URL 단위(requestMatchers)와 메소드 단위(@Secured·@PreAuthorize)로 할 수 있다.

  • 로그인 실패는 커스텀 AuthenticationFailureHandler로 원인별 메시지를 제공한다.

마지막 수정 2026년 10월 22일