Spring Security로 인증/인가 맛보기

Let's take a break·2026년 9월 19일

오늘은 Spring Security를 처음 배우며 알게 된 것들을 정리한다. 폼 로그인, 인메모리 사용자, 비밀번호 암호화, 요청 경로별 인가, 예외 처리까지 한 바퀴 돌았다.


SecurityFilterChain은 DispatcherServlet 앞단에서 요청을 가로채는 필터 체인이다

Spring MVC는 요청이 들어오면 DispatcherServlet이 컨트롤러로 라우팅해주는데, Spring Security는 그보다 먼저 동작하는 서블릿 필터들의 묶음(체인)으로 끼어든다. 그래서 컨트롤러 코드는 보안 로직을 전혀 몰라도 되고, 필터 단계에서 "이 요청을 통과시킬지 말지"가 이미 결정된다.

@Bean
SecurityFilterChain filterChain(HttpSecurity http,
                                CustomAccessDeniedHandler customAccessDeniedHandler) {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/").permitAll()
            .requestMatchers("/error/**").permitAll()
            .requestMatchers("/free/1", "/free/2").hasRole("PREMIUM")
            .anyRequest().authenticated()
        )
        .formLogin(form -> form
            .loginPage("/login")
            .loginProcessingUrl("/login")
            .usernameParameter("username")
            .passwordParameter("password")
            .defaultSuccessUrl("/")
            .failureUrl("/login?error")
            .permitAll()
        )
        .logout(logout -> logout
            .logoutUrl("/logout")
            .logoutSuccessUrl("/login?logout")
            .invalidateHttpSession(true)
            .deleteCookies("JSESSIONID")
        )
        .exceptionHandling(ex -> ex
            .accessDeniedHandler(customAccessDeniedHandler));
    return http.build();
}

HttpSecurity는 이 필터 체인을 조립하는 빌더다. authorizeHttpRequests, formLogin, logout 같은 메서드를 체이닝해서 하나씩 규칙을 쌓고, 마지막에 http.build()를 호출하면 실제로 동작하는 SecurityFilterChain 빈이 완성된다.

어떻게 동작하는지 (동작 순서)

  1. 요청이 들어오면 인증 필터들을 거치면서 "이 사용자가 누구인지"(Authentication)를 확인한다.
  2. authorizeHttpRequests에 등록된 규칙을 위에서부터 순서대로 검사해서 "이 요청을 허용할지"(Authorization)를 결정한다.
  3. 허용되면 그제서야 DispatcherServlet으로 넘어가서 평소처럼 컨트롤러가 실행된다.
  4. 인증이 안 됐으면 로그인 페이지로, 인증은 됐는데 권한이 부족하면 AccessDeniedHandler로 빠진다.

왜 컨트롤러가 아니라 필터에서 막는지

만약 인가 체크를 각 컨트롤러 메서드 안에서 if (권한 없음) return 403; 식으로 직접 짜면, 컨트롤러가 100개면 그 체크 로직을 100번 반복하게 되고 하나라도 빠뜨리면 그게 바로 보안 구멍이 된다. 필터 단에서 URL 패턴 기준으로 일괄 관리하면 규칙이 한 곳에 모이고, 새 컨트롤러를 추가해도 규칙만 추가하면 되니 빠뜨릴 걱정이 줄어든다.


authorizeHttpRequests의 규칙은 등록된 순서대로 먼저 매치되는 것이 이긴다

.authorizeHttpRequests(auth -> auth
        .requestMatchers("/").permitAll()
        .requestMatchers("/error/**").permitAll()
        .requestMatchers("/free/1", "/free/2").hasRole("PREMIUM")
        .anyRequest().authenticated()
)

이 블록은 위에서 아래로 순서대로 읽힌다. /는 누구나(permitAll), /free/1과 /free/2는 PREMIUM 역할을 가진 사용자만, 나머지는 로그인만 되어 있으면(authenticated) 통과시킨다.

왜 순서가 중요한지

만약 .anyRequest().authenticated()를 맨 위에 놓으면 그 아래 있는 /free/1에 대한 hasRole("PREMIUM") 규칙은 절대 실행되지 않는다. anyRequest()가 모든 경로에 이미 매치되어버려서 뒤에 있는 더 구체적인 규칙까지 요청이 도달하지 못하기 때문이다. 그래서 이런 설정은 항상 "더 구체적인 경로가 먼저, 가장 넓은 범위(anyRequest)가 마지막"이라는 원칙을 지켜야 한다.

직접 확인해본 것

application.yaml을 보면 app.security.role: ADMIN으로 되어 있는데, 정작 /free/1, /free/2는 hasRole("PREMIUM")을 요구한다. 즉 지금 등록된 admin 계정으로 로그인해도 두 경로는 403이 난다. 역할 이름이 스펙(SecurityProperty)과 인가 규칙(SecurityConfig) 두 군데에 나뉘어 있다 보니 이렇게 서로 어긋나기 쉽다는 걸 직접 겪었다. 나중에 실제 프로젝트에서는 역할 상수를 한 군데(enum 등)로 모아둬야겠다고 느꼈다.


사용자 정보는 InMemoryUserDetailsManager에 UserDetails 객체로 등록한다

@Bean
InMemoryUserDetailsManager userDetailService(
        SecurityProperty p,
        PasswordEncoder passwordEncoder
) {
    UserDetails admin = User.builder()
            .username(p.username())
            .password(passwordEncoder.encode(p.password()))
            .roles(p.role())
            .build();
    return new InMemoryUserDetailsManager(admin);
}

Spring Security는 로그인 폼에서 넘어온 아이디/비밀번호를 검증할 때 UserDetailsService라는 인터페이스를 통해 "이 아이디를 가진 사용자 정보를 가져와줘"라고 요청한다. InMemoryUserDetailsManager는 이 인터페이스를 구현한 가장 단순한 형태로, DB 없이 메모리에 사용자 목록을 들고 있다가 그 요청에 응답해준다.

User.builder()로 만드는 UserDetails는 아이디, (암호화된) 비밀번호, 역할(role) 세 가지를 최소한으로 담는 값 객체다. .roles(p.role())을 호출하면 내부적으로 ROLE_ADMIN처럼 ROLE_ 접두사를 자동으로 붙여준다. 그래서 hasRole("PREMIUM")을 쓸 때도 "ROLE_PREMIUM"이라고 직접 쓰지 않아도 되는 것이다.

왜 굳이 DB 없이 인메모리로 시작하는지

Spring Security를 처음 배울 때는 "인증/인가 흐름 자체"를 이해하는 게 목적이지, 회원 DB 스키마를 설계하는 게 목적이 아니다. InMemoryUserDetailsManager를 쓰면 DB 연결 없이도 로그인 폼 → 인증 → 인가로 이어지는 전체 파이프라인을 바로 눈으로 확인할 수 있다. 실제 서비스로 넘어갈 때는 이 자리를 JPA 기반의 UserDetailsService 구현체로 바꿔 끼우기만 하면 되는 구조라, 지금 배운 흐름이 그대로 재사용된다.

어떻게 설정값을 외부에서 주입받는지

SecurityProperty는 이렇게 생겼다.

@ConfigurationProperties(prefix = "app.security")
public record SecurityProperty(
        String username,
        String password,
        String role
) {
}

application.yaml의 app.security.username, app.security.password, app.security.role 값을 자동으로 이 record에 매핑해준다. 클래스 상단에 @ConfigurationProperties만 붙여도 되는 이유는, SecApplication에 @ConfigurationPropertiesScan이 붙어 있어서 Spring이 앱을 띄울 때 이런 record/클래스를 찾아 자동으로 빈으로 등록해주기 때문이다. 계정 정보를 자바 코드에 하드코딩하지 않고 설정 파일로 빼두면, 나중에 운영 환경에서는 환경 변수(${SECURITY_USER}처럼)로 갈아끼우기만 하면 되니 코드를 건드릴 필요가 없다.


비밀번호는 평문이 아니라 PasswordEncoder를 거쳐 저장한다

@Bean
public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

그리고 사용자를 만들 때도 passwordEncoder.encode(p.password())로 감싸서 넣는다. 즉 application.yaml에는 admin1234라는 평문이 적혀 있지만, 실제로 메모리에 등록되는 UserDetails의 비밀번호는 해시된 문자열이다.

어떻게 DelegatingPasswordEncoder가 동작하는지

createDelegatingPasswordEncoder()로 만든 인코더는 인코딩할 때 문자열 앞에 {bcrypt} 같은 접두사를 붙인다. 그래서 나중에 로그인 시도가 들어와서 비밀번호를 비교할 때, 저장된 해시 문자열의 접두사를 보고 "아, 이건 bcrypt로 인코딩된 거구나"를 판단해서 그에 맞는 알고리즘으로 검증한다. 이 덕분에 나중에 더 강력한 알고리즘으로 정책이 바뀌어도 기존에 저장된 다른 방식의 해시들과 공존할 수 있다.

왜 BCryptPasswordEncoder를 직접 쓰지 않고 이걸 쓰는지

코드에 주석 처리된 new BCryptPasswordEncoder()를 보면, 처음엔 그냥 BCrypt를 직접 쓰려고 했던 흔적이 남아있다. 문제는 BCrypt 하나만 고정해서 쓰면 나중에 알고리즘을 교체하고 싶을 때(예: 더 안전한 알고리즘이 나왔을 때) 기존 사용자들의 비밀번호 해시를 어떻게 처리할지가 골치아파진다. DelegatingPasswordEncoder는 알고리즘을 문자열에 태그로 남겨두는 방식이라 이런 마이그레이션에 유연하다는 걸 이번에 찾아보면서 알게 됐다. 그래서 Spring Security 공식 문서에서도 특별한 이유가 없으면 이 팩토리 메서드를 기본값으로 쓰라고 권장한다.


폼 로그인은 loginPage와 loginProcessingUrl을 분리해서 이해해야 헷갈리지 않는다

.formLogin(form -> form
        .loginPage("/login")
        .loginProcessingUrl("/login")
        .usernameParameter("username")
        .passwordParameter("password")
        .defaultSuccessUrl("/")
        .failureUrl("/login?error")
        .permitAll()
)

loginPage("/login")은 "로그인 폼 화면을 보여주는 GET 요청 주소"이고, loginProcessingUrl("/login")은 "그 폼이 제출됐을 때(POST) Spring Security가 가로채서 실제 인증을 처리하는 주소"다. 이번 프로젝트에서는 둘 다 /login으로 같은 경로를 쓰지만, 실제로는 서로 다른 HTTP 메서드(GET vs POST)에 대한 설정이라 값이 같아도 괜찮다.

@Controller
@RequestMapping("/login")
public class LoginController {
    @GetMapping
    public String login() {
        return "login";
    }
    // 별도로 post를 만들 필요는 없음
}

그래서 컨트롤러에는 @GetMapping만 있고 POST 핸들러가 없다. /login으로 오는 POST 요청은 컨트롤러까지 가지도 않고 Spring Security의 인증 필터가 중간에서 가로채서 처리해버리기 때문이다.

왜 login.html의 form도 신경 써서 봐야 하는지

<form th:action="@{/login}" method="post">
    <input name="username" placeholder="username">
    <input type="password" name="password" placeholder="password">
    <button>로그인</button>
</form>

usernameParameter("username"), passwordParameter("password") 설정은 "이 파라미터 이름으로 넘어온 값을 아이디/비밀번호로 취급하겠다"는 약속이다. 폼의 input name 속성이 이 값과 정확히 일치해야만 Spring Security가 값을 읽어갈 수 있다. 이름이 하나라도 다르면 로그인 시도 자체가 인식이 안 되고 계속 로그인 실패로 튕겨나가는데, 처음에는 원인을 못 찾다가 이 매핑 관계를 알고 나서야 이해가 됐다.

어떻게 성공/실패가 갈리는지

로그인이 성공하면 defaultSuccessUrl("/")에 따라 메인 페이지로 리다이렉트되고, 실패하면 failureUrl("/login?error")로 돌아간다. 쿼리 파라미터 ?error는 화면에서 "아이디/비밀번호가 틀렸다"는 안내를 보여줄 때 사용할 수 있는 신호다(지금 login.html에는 아직 이 처리를 추가하지 않았는데, 다음에 th:if로 에러 메시지를 붙여봐야겠다).


로그아웃은 기본적으로 POST만 허용하고, 세션과 쿠키까지 함께 정리한다

.logout(logout -> logout
        .logoutUrl("/logout")
        .logoutSuccessUrl("/login?logout")
        .invalidateHttpSession(true)
        .deleteCookies("JSESSIONID")
)
<a href="/logout">로그아웃 (GET -> X)</a>
<form th:action="@{/logout}" method="post">
    <button>로그아웃 (POST)</button>
</form>

index.html에 일부러 GET 링크와 POST 폼을 둘 다 만들어놨는데, 실제로 눌러보면 GET 링크는 동작하지 않고 POST 버튼만 로그아웃이 된다.

왜 GET으로 로그아웃이 안 되는지

로그아웃을 GET으로 열어두면 <img src="/logout">처럼 그냥 페이지 안에 링크나 이미지 태그만 심어도 사용자가 모르는 사이에 로그아웃(혹은 더 위험한 작업)이 실행될 수 있다. 이런 공격을 CSRF(사이트 간 요청 위조)라고 부르는데, Spring Security는 상태를 변경하는 요청(로그인, 로그아웃 등)은 기본적으로 POST와 CSRF 토큰 검증을 요구해서 이런 위조 요청을 막는다. 그래서 로그아웃 버튼은 반드시 <form method="post">로 감싸야 한다는 걸 이번에 직접 눈으로 확인했다.

어떻게 세션 정리가 이루어지는지

invalidateHttpSession(true)는 서버 쪽 세션(HttpSession)을 무효화해서 세션에 저장돼 있던 로그인 정보를 완전히 날려버린다. deleteCookies("JSESSIONID")는 브라우저에 내려간 세션 쿠키까지 지우라고 응답 헤더에 지시를 담는다. 둘 중 하나만 하면 서버는 로그아웃 처리했는데 브라우저에는 예전 세션 쿠키가 남아있는 식의 애매한 상태가 될 수 있어서, 이 둘을 같이 설정하는 게 맞다는 걸 알게 됐다.


인가 실패는 AccessDeniedHandler로 원하는 화면으로 보낼 수 있다

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.sendRedirect("/");
    }
}

로그인은 했지만 권한이 부족해서 막히는 경우(예: PREMIUM 역할이 필요한 페이지에 ADMIN 계정으로 접근), Spring Security는 기본적으로 403 에러 페이지를 보여준다. 그런데 여기서는 AccessDeniedHandler를 직접 구현해서 403 페이지 대신 메인 페이지(/)로 그냥 리다이렉트하도록 바꿔놨다.

.exceptionHandling(ex -> ex
        .accessDeniedHandler(customAccessDeniedHandler));

이렇게 exceptionHandling에 등록해줘야 필터 체인이 기본 403 처리 대신 이 핸들러를 사용한다.

왜 인증 실패와 인가 실패를 다르게 다뤄야 하는지

"로그인이 안 된 사용자"(비인증, AuthenticationException)와 "로그인은 했지만 권한이 없는 사용자"(인가 실패, AccessDeniedException)는 서로 다른 상황이다. 전자는 로그인 페이지로 보내는 게 자연스럽고, 후자는 이미 로그인된 사용자니까 로그인 페이지로 다시 보내는 게 오히려 어색하다. 그래서 Spring Security도 이 둘을 AuthenticationEntryPoint와 AccessDeniedHandler라는 서로 다른 인터페이스로 분리해서 처리하게 만들어 놨다는 걸 이번에 코드를 뜯어보면서 이해했다.


오늘 삽질하며 배운 것 (요약 메모)

  • authorizeHttpRequests는 순서가 곧 우선순위다. anyRequest()는 항상 제일 마지막에 둬야 한다.
  • 설정 파일의 역할 이름(app.security.role)과 인가 규칙의 역할 이름(hasRole(...))은 서로 다른 곳에 적혀 있어서, 값이 어긋나도 컴파일 에러가 나지 않는다. 실행해보고 403이 뜨는 걸 보고서야 눈치챘다. 나중에는 역할 이름을 enum이나 상수로 한 곳에 모아둬야겠다.
  • 로그아웃은 GET으로 절대 안 뚫린다는 걸 직접 링크를 눌러보고 확인했다. CSRF 방어 때문이라는 걸 알고 나니 왜 POST 폼으로 감싸야 하는지 납득이 됐다.
  • .roles("PREMIUM")을 쓰면 내부적으로 ROLE_PREMIUM으로 저장된다는 걸 몰랐다면 hasAuthority("ROLE_PREMIUM")과 hasRole("PREMIUM")을 헷갈렸을 것 같다.

0개의 댓글