[Spring Boot / 백엔드] Spring Security 이해하기 - 구조, 인증/인가, 폼 로그인

백엔드 개발을 공부하다 보면 기능 구현만큼이나 사용자를 어떻게 보호할지를 고민하게 됩니다.

 

처음에는 저도 로그인 기능을 단순히
“이메일과 비밀번호를 확인해서 맞으면 통과시키는 것” 정도로만 생각했습니다.

 

그런데 실제로 서비스를 만든다고 생각해보면 단순히 로그인만 되는 것으로 끝나지 않습니다.

 

누가 누구인지 확인하는 과정은 어떻게 처리할지,
로그인한 사용자가 어떤 API까지 접근할 수 있는지 어떻게 구분할지,
인증되지 않은 요청이나 권한이 없는 요청은 어떤 방식으로 막을지까지 함께 설계해야 합니다.

 

이때 자주 등장하는 것이 바로 Spring Security입니다.

그래서 이번 글에서는 워크북 내용을 바탕으로

 

Spring Security가 무엇인지,
인증(Authentication)과 인가(Authorization)는 어떻게 다른지,
그리고 
스프링 시큐리티가 어떤 구조로 동작하고, 폼 로그인을 어떻게 구현하는지를 정리해보려고 합니다.

 

이번 글에서 다룰 내용은 다음과 같습니다.

  • Spring Security란 무엇인가
  • 인증과 인가는 어떻게 다른가
  • Spring Security는 어떤 구조로 동작하는가
  • AuthenticationManager, UserDetailsService, SecurityContext는 어떤 역할을 하는가
  • Filter Chain은 왜 중요한가
  • 폼 로그인을 어떻게 구현하는가
  • 인증/인가 실패 응답은 어떻게 통일할 수 있을까

Spring Security란?

Spring Security는 자바 기반 웹 애플리케이션에서 인증과 인가를 처리하고, 다양한 보안 기능을 제공하는 프레임워크입니다.

조금 더 쉽게 말하면,
개발자가 인증과 권한 처리를 처음부터 전부 직접 구현하지 않도록
보안 관련 기능을 체계적으로 제공해주는 도구라고 볼 수 있습니다.

 

백엔드 서비스를 만들다 보면 이런 요구사항이 자연스럽게 생깁니다.

  • 회원가입과 로그인을 구현해야 한다
  • 로그인하지 않은 사용자는 특정 API에 접근하면 안 된다
  • 로그인했더라도 권한이 없는 사용자는 특정 기능을 사용할 수 없어야 한다
  • 비밀번호는 안전하게 암호화해서 저장해야 한다
  • 인증 실패나 권한 부족 상황에서 적절한 응답을 내려줘야 한다

Spring Security는 이런 문제들을 비교적 일관된 방식으로 처리할 수 있게 도와줍니다.

즉, Spring Security는 단순히 로그인 화면을 띄워주는 라이브러리가 아니라
애플리케이션의 인증, 인가, 보안 흐름 전체를 관리하는 프레임워크라고 이해하는 것이 더 자연스럽습니다.


인증(Authentication)과 인가(Authorization)는 뭐가 다를까?

Spring Security를 공부할 때 가장 먼저 정리해야 하는 개념이 바로 인증 인가입니다.

둘은 비슷하게 들리지만 의미가 다릅니다.

1. 인증(Authentication)

인증은 이 사용자가 누구인지 확인하는 과정입니다.

 

예를 들어 사용자가 이메일과 비밀번호를 입력했을 때
서버가 그 정보가 맞는지 확인하고 “이 사용자는 **이다”라고 식별하는 과정이 인증입니다.

즉, 로그인은 대표적인 인증 과정이라고 볼 수 있습니다.

2. 인가(Authorization)

인가는 인증된 사용자가 특정 리소스에 접근할 권한이 있는지 확인하는 과정입니다.

예를 들어 로그인은 했지만 관리자만 접근할 수 있는 API에 일반 사용자가 요청을 보내면
이때는 “누구인지는 확인됐지만, 이 기능을 사용할 권한은 없다”고 판단하게 됩니다.

 

즉,

  • 인증은 누구인지 확인하는 것
  • 인가는 무엇을 할 수 있는지 확인하는 것

이라고 정리할 수 있습니다.

처음에는 이 둘이 비슷하게 느껴졌는데, 정리해보니 로그인 자체는 인증이고
로그인 이후 특정 API 접근 가능 여부를 판단하는 것은 인가라는 점이 더 분명해졌습니다.


Spring Security는 어떤 문제를 해결할까?

보안 이야기를 할 때는 인증과 인가 외에도 여러 보안 이슈가 함께 언급됩니다.

대표적으로는 다음과 같은 것들이 있습니다.

  • CSRF
  • XSS
  • CORS

여기서 CSRF와 XSS는 대표적인 웹 보안 취약점이고,
CORS는 다른 출처 간 요청을 제어하는 정책으로 보안과 함께 자주 다뤄지는 개념입니다.

 

즉, Spring Security를 공부한다는 것은 단순히 로그인 기능만 배우는 것이 아니라
웹 애플리케이션이 요청을 어떻게 신뢰하고, 어떻게 보호할지에 대한 흐름을 함께 이해하는 과정이라고 느꼈습니다.


Spring Security는 어떤 구조로 동작할까?

Spring Security는 요청이 들어왔을 때
그 요청을 바로 Controller로 보내는 것이 아니라,
먼저 여러 보안 필터를 거치도록 만듭니다.

 

즉, 사용자의 요청은 애플리케이션에 도달하기 전에
Security Filter Chain을 통과하면서 인증과 인가 관련 검사를 받게 됩니다.

 

로그인 요청 기준으로 보면 흐름은 대략 이렇게 이해할 수 있습니다.

  1. 사용자가 로그인 폼에 아이디와 비밀번호를 입력한다
  2. 인증 관련 필터가 요청을 가로챈다
  3. 인증 객체를 만들고 인증 매니저에게 전달한다
  4. 인증 매니저가 적절한 인증 제공자에게 인증을 맡긴다
  5. 인증 제공자는 사용자 정보를 조회하고 비밀번호를 검증한다
  6. 인증에 성공하면 인증 정보를 SecurityContext에 저장한다

즉, 로그인은 단순히 Controller에서 if문으로 비밀번호를 비교하는 것이 아니라
필터 → 매니저 → 프로바이더 → 사용자 조회 → 인증 저장 흐름으로 처리됩니다.

 

이 구조를 알고 나니 Spring Security가 왜 처음에는 어렵게 느껴지는지도 조금 이해됐습니다.
실제로 내부에서 여러 객체가 역할을 나눠서 움직이기 때문입니다.


주요 객체들은 각각 어떤 역할을 할까?

Spring Security의 구조를 볼 때 자주 등장하는 객체들이 있습니다.
처음에는 이름이 비슷해서 헷갈렸는데, 역할을 기준으로 나눠서 보면 조금 더 이해하기 쉬웠습니다.

1. AuthenticationManager

인증 과정을 관리하는 중심 객체입니다.

사용자가 로그인 요청을 보내면,
AuthenticationManager가 이 인증 요청을 어떻게 처리할지 판단합니다.

쉽게 말하면 “이 요청을 처리할 수 있는 인증 방식이 무엇인지 찾고, 적절한 곳에 위임하는 역할”에 가깝습니다.


2. AuthenticationProvider

실제로 인증 로직을 처리하는 객체입니다.

 

예를 들어 이메일/비밀번호 기반 로그인이라면
사용자 정보를 조회하고 비밀번호가 맞는지 검증하는 과정이 이 단계에서 일어납니다.

 

즉, AuthenticationManager가 흐름을 조율하는 역할이라면
AuthenticationProvider는 실제 인증 실행 담당자라고 볼 수 있습니다.


3. UserDetailsService

사용자 정보를 조회하는 서비스입니다.

보통 데이터베이스에서 사용자를 조회해서
Spring Security가 이해할 수 있는 형태의 UserDetails 객체로 반환합니다.

 

즉, “이 이메일을 가진 사용자가 누구인지”를 찾아오는 역할이라고 보면 됩니다.

폼 로그인을 구현할 때는 보통 UserDetailsService를 직접 구현해서
DB에 저장된 회원 정보를 조회하도록 만듭니다.


4. SecurityContext

인증이 완료된 사용자 정보를 저장하는 공간입니다.

로그인에 성공하면 인증 정보가 SecurityContext에 들어가고,
이후 요청 처리 과정에서 현재 사용자가 누구인지 참조할 수 있게 됩니다.

 

즉, Spring Security는 인증 결과를 어딘가에 저장해두고
필요할 때 꺼내서 쓰는데, 그 저장소 역할을 하는 것이 SecurityContext입니다.


5. SecurityContextHolder

SecurityContext에 접근할 수 있도록 도와주는 객체입니다.

 

보통 현재 로그인한 사용자 정보를 꺼낼 때 아래처럼 접근하게 됩니다.

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();

 

즉, 애플리케이션 어디에서든 현재 인증된 사용자 정보를 확인할 수 있게 해주는 진입점이라고 볼 수 있습니다.


Filter Chain은 왜 중요할까?

Spring Security를 이해할 때 빠질 수 없는 개념이 바로 Filter Chain입니다.

 

필터는 쉽게 말해, 요청이 애플리케이션 내부 로직에 도달하기 전에
앞에서 한 번씩 검사하고 처리하는 관문 역할을 합니다.

 

커피 필터를 떠올리면 조금 이해하기 편합니다.
들어온 요청이 여러 필터를 순서대로 지나면서 필요한 작업을 거치고,
조건에 맞지 않으면 중간에서 막히기도 합니다.

 

대표적인 필터를 간단히 보면 다음과 같습니다.

1. UsernamePasswordAuthenticationFilter

폼 로그인 요청을 처리합니다.
사용자가 제출한 username, password를 바탕으로 인증을 시도합니다.

2. AnonymousAuthenticationFilter

인증되지 않은 요청에 대해 익명 사용자 정보를 부여합니다.

3. ExceptionTranslationFilter

인증 실패나 인가 실패 같은 보안 예외를 적절한 HTTP 응답으로 변환합니다.

4. FilterSecurityInterceptor

현재 사용자가 요청한 리소스에 접근할 권한이 있는지 최종적으로 확인합니다.

 

즉, Spring Security는 단일 객체가 보안을 처리하는 것이 아니라
여러 필터가 역할을 나눠 요청을 검사하는 구조로 동작합니다.

 

이 부분을 이해하고 나니, 인증 실패 시 왜 Controller까지 오지 않고
중간에서 바로 응답이 바뀌는지도 더 자연스럽게 이해됐습니다.


폼 로그인을 어떻게 구현할까?

이제 실제로 Spring Security를 적용해 폼 로그인을 구현하는 흐름을 정리해보겠습니다.

 

전체 흐름은 대략 다음과 같습니다.

  1. Spring Security 의존성을 추가한다
  2. SecurityConfig를 만든다
  3. 어떤 API를 공개할지, 어떤 API를 보호할지 설정한다
  4. 비밀번호 암호화를 위한 PasswordEncoder를 등록한다
  5. UserDetails UserDetailsService를 구현한다
  6. 로그인 성공 후 인증 정보가 저장되도록 확인한다

1. 의존성 추가

먼저 Spring Security 의존성을 추가해야 합니다.

implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

이 의존성을 추가하면 Spring Security 관련 기본 기능들을 사용할 수 있습니다.


2. SecurityConfig 설정

그다음에는 보안 정책을 정의할 SecurityConfig를 만듭니다.

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    private final String[] allowUris = {
            "/swagger-ui/**",
            "/swagger-resources/**",
            "/v3/api-docs/**",
            "/auth/**"
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(requests -> requests
                        .requestMatchers(allowUris).permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .defaultSuccessUrl("/swagger-ui/index.html", true)
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutUrl("/logout")
                        .logoutSuccessUrl("/login?logout")
                        .permitAll()
                );

        return http.build();
    }

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

이 설정에서 핵심은 다음과 같습니다.

  • /auth/**, Swagger 관련 경로는 Public API로 허용
  • 그 외의 요청은 모두 인증 필요
  • 폼 로그인을 사용
  • 로그아웃 경로를 설정
  • 비밀번호 암호화를 위해 BCryptPasswordEncoder 등록

즉, 이 설정 하나로
“어떤 요청은 누구나 접근 가능하고, 어떤 요청은 로그인해야만 가능하다”는 보안 정책을 정의할 수 있습니다.


Public API와 Private API를 나누는 이유

실제로 서비스를 만들 때는 모든 API가 같은 성격을 가지지 않습니다.

 

예를 들어

  • 회원가입 API
  • 로그인 API

같은 것은 로그인하지 않은 사용자도 접근할 수 있어야 합니다.

 

반면에

  • 마이페이지 조회
  • 미션 생성
  • 미션 조회

같은 기능은 로그인한 사용자만 접근해야 자연스럽습니다.

 

그래서 보통은 API를 Public API Private API로 나누어 관리하게 됩니다.

이 구분을 직접 Controller마다 if문으로 처리하는 것이 아니라
Spring Security 설정에서 일관되게 처리한다는 점이 꽤 편리하다고 느꼈습니다.


비밀번호는 왜 BCrypt로 암호화할까?

회원가입을 구현할 때 중요한 것 중 하나가 비밀번호 저장 방식입니다.

비밀번호를 그대로 저장하는 것은 당연히 위험하기 때문에 보통은 해시 기반 암호화를 사용합니다.

Spring Security에서는 PasswordEncoder를 통해 이를 처리할 수 있고,

 

대표적으로 BCryptPasswordEncoder를 많이 사용합니다.

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

 

이렇게 Bean으로 등록해두면 회원가입 로직에서 DI 받아 사용할 수 있습니다.

 

즉, 사용자가 입력한 비밀번호를 그대로 저장하는 것이 아니라
암호화된 형태로 저장하고, 로그인 시에는 입력값을 같은 방식으로 검증하게 됩니다.


UserDetails와 UserDetailsService는 왜 구현할까?

폼 로그인에서 핵심이 되는 부분이 바로 Spring Security가 사용자 정보를 어떻게 이해할 것인가입니다.

Spring Security는 사용자를 다룰 때 UserDetails 타입을 사용합니다.

 

그래서 보통 우리 서비스의 회원 정보를 감싸는 AuthMember 같은 클래스를 만들고,
이 클래스가 UserDetails를 구현하도록 합니다. 그리고 UserDetailsService를 구현해서
이메일 같은 식별자를 기준으로 DB에서 회원을 조회한 뒤 UserDetails 객체를 반환하게 만듭니다.

 

흐름을 단순하게 보면 이렇습니다.

  • 로그인 요청이 들어온다
  • Spring Security가 UserDetailsService를 호출한다
  • DB에서 회원 정보를 조회한다
  • UserDetails 형태로 반환한다
  • 비밀번호를 검증한다
  • 인증에 성공하면 SecurityContext에 저장한다

즉, UserDetailsService
Spring Security와 우리 회원 테이블을 이어주는 다리 역할을 한다고 볼 수 있습니다.


로그인이 실제로 성공하면 어떤 일이 일어날까?

폼 로그인 페이지에서 이메일과 비밀번호를 입력하고 요청을 보내면
Spring Security가 내부적으로 인증을 시도합니다.

 

이때 CustomUserDetailsService의 loadUserByUsername()이 호출되고,
DB에 저장된 사용자 정보를 조회한 뒤 비밀번호를 비교합니다.

 

비밀번호가 일치하면 인증이 성공하고,
그 결과가 SecurityContextHolder에 저장됩니다.

 

즉, 로그인 성공 이후에는 현재 사용자가 누구인지에 대한 정보가 보안 컨텍스트에 저장되기 때문에
이후 요청에서도 인증 상태를 참조할 수 있게 됩니다.

 

여기까지 흐름을 정리하고 나니,
왜 로그인을 한 뒤에는 별도의 사용자 확인 없이도 보호된 API를 호출할 수 있는지 조금 더 분명하게 보였습니다.


인증 실패와 인가 실패는 어떻게 다를까?

Spring Security를 적용하다 보면
로그인하지 않은 상태에서 Private API를 호출했을 때와,
로그인은 했지만 권한이 부족한 경우를 구분해서 처리해야 합니다.

이 두 상황은 비슷해 보이지만 다릅니다.

1. 인증 실패

아직 로그인하지 않았거나, 인증 정보가 유효하지 않은 경우입니다.
보통 401 Unauthorized로 처리합니다.

2. 인가 실패

로그인은 했지만 해당 리소스에 접근할 권한이 없는 경우입니다.
보통 403 Forbidden으로 처리합니다.

 

즉,

  • 401은 누군지 확인되지 않음
  • 403은 누군지는 알지만 권한이 없음

으로 이해할 수 있습니다.


기본 응답 대신 JSON으로 통일하려면?

Spring Security를 기본 설정으로 사용하면
인증되지 않은 요청이 들어왔을 때 HTML 로그인 페이지 응답이 내려오는 경우가 있습니다.

 

하지만 API 서버에서는 보통 HTML보다
JSON 형태의 에러 응답을 일관되게 내려주는 것이 더 자연스럽습니다.

 

이때 사용하는 것이 다음 두 가지입니다.

  • AuthenticationEntryPoint
  • AccessDeniedHandler

1. AuthenticationEntryPoint

인증 실패 상황에서 호출됩니다.
즉, 로그인하지 않은 사용자가 보호된 리소스에 접근했을 때 401 응답을 내려주는 역할을 합니다.

2. AccessDeniedHandler

인가 실패 상황에서 호출됩니다.
즉, 로그인은 했지만 권한이 부족할 때 403 응답을 내려주는 역할을 합니다.

 

예를 들어 아래처럼 구현할 수 있습니다.

public class CustomEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException authException
    ) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        ApiResponse<Void> errorResponse = ApiResponse.onFailure(GeneralErrorCode.UNAUTHORIZED, null);
        objectMapper.writeValue(response.getOutputStream(), errorResponse);
    }
}
public class CustomAccessDenied implements AccessDeniedHandler {

    @Override
    public void handle(
            HttpServletRequest request,
            HttpServletResponse response,
            AccessDeniedException accessDeniedException
    ) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);

        ApiResponse<Void> errorResponse = ApiResponse.onFailure(GeneralErrorCode.FORBIDDEN, null);
        objectMapper.writeValue(response.getOutputStream(), errorResponse);
    }
}

 

이렇게 해두면 인증/인가 실패 시에도

우리가 사용하는 공통 응답 형식으로 내려줄 수 있습니다.

 

즉, Spring Security를 적용하더라도
전체 API 응답 형식을 서비스에 맞게 통일할 수 있다는 점이 중요했습니다.


SecurityConfig에 예외 처리까지 연결하면

위에서 만든 AuthenticationEntryPoint AccessDeniedHandler
SecurityConfig에 등록해서 사용하게 됩니다.

 

즉, Spring Security 필터 체인에서 발생하는 인증/인가 예외를
우리가 원하는 방식으로 처리하도록 연결하는 것입니다.

 

이 과정을 거치면 로그인하지 않은 요청이 들어왔을 때 HTML 페이지 대신 JSON 에러 응답을 내려줄 수 있고,
권한이 없는 요청도 마찬가지로 일관된 형식으로 처리할 수 있습니다.

 

API 서버를 만든다는 관점에서는 이 부분이 꽤 중요하다고 느꼈습니다.
보안도 중요하지만, 에러 응답의 일관성 역시 협업과 유지보수에서 큰 역할을 하기 때문입니다.


정리해보면

이번 내용을 정리하면서 느낀 것은
Spring Security는 단순히 “로그인 기능을 넣는 라이브러리”가 아니라는 점이었습니다.

 

실제로는

  • 요청을 필터 체인으로 먼저 검사하고
  • 인증 관련 객체들이 역할을 나눠 동작하고
  • 인증 성공 시 보안 컨텍스트에 정보를 저장하고
  • 이후 요청에서 권한까지 확인하는 구조

로 이루어져 있었습니다.

 

즉, Spring Security는 단순한 API 하나가 아니라
웹 애플리케이션의 보안 흐름 전체를 관리하는 구조라고 보는 것이 더 맞다고 느꼈습니다.

 

특히 이번에 공부하면서 AuthenticationManager, AuthenticationProvider, UserDetailsService 같은 객체들이
각자 어떤 책임을 가지는지 조금씩 분리해서 보게 되었고,

 

폼 로그인도 단순히 로그인 페이지를 띄우는 기능이 아니라
그 뒤에 있는 인증 구조를 이해해야 제대로 사용할 수 있다는 점이 더 와닿았습니다.


마무리

처음에는 저도 Spring Security가
어노테이션 몇 개 붙이고 설정 클래스 하나 만들면 끝나는 줄 알았습니다.

 

하지만 내용을 정리해보니,
그 안에는 

 

인증과 인가의 차이,
필터 체인의 흐름,
사용자 정보를 조회하고 검증하는 구조,
인증 실패와 인가 실패를 나누어 처리하는 방식

 

등, 생각보다 많은 개념이 들어 있었습니다.

특히 이번 내용을 공부하면서 느낀 것은

Spring Security를 잘 활용하려면

 

“이 요청이 어디서 막히는지”,
“현재 인증 정보는 어디에 저장되는지”,
“왜 401과 403을 다르게 처리해야 하는지”

 

를 함께 이해해야 한다는 점이었습니다.

 

앞으로 시큐리티 관련 기능을 구현할 때는 단순히 로그인만 되는지 보는 데서 끝나지 않고,

 

현재 요청이 인증 단계인지, 인가 단계인지,
응답 형식은 일관되게 내려가고 있는지,
보안 설정이 API 구조와 잘 맞는지

 

까지 함께 고민하면서 구현해야겠다고 느꼈습니다.