1. 도입부 (Introduction)
백엔드 엔지니어링을 수행하면서 스프링 시큐리티(Spring Security)를 다룰 때 가장 흔하게 범하는 실수는 인증(Authentication)과 인가(Authorization)를 혼동하여 예외 처리를 대충 뭉뚱그려 반환하는 일이다. 사용자가 "자신이 누구인지"를 증명하는 인증 작업(Authentication)과 "증명된 사용자가 특정 자원에 접근할 자격이 있는지" 확인하는 인가 작업(Authorization)은 논리적으로 완전히 격리된 단계다.
이 두 개념이 정교하게 분리되지 않으면, 클라이언트(프론트엔드)는 토큰이 만료되어 재로그인 처리를 해야 하는 상황(401 Unauthorized)과 사용자의 권한이 부족하여 권한 부족 안내 토스트를 띄워야 하는 상황(403 Forbidden)을 구별할 수 없어 극심한 폴백 지옥에 빠지게 된다.
더 나아가, 실무에서는 단순히 특정 URL 패턴에 .hasRole("ADMIN") 설정을 적용하는 것만으로 보안 구멍을 완벽히 메우기 어렵다. 배포가 거듭되면서 새로운 어드민 경로가 추가되거나, 설정 파일의 와일드카드 매핑 실수 하나로 인해 누군가 관리자 전용 서비스 인터페이스에 노출될 위협이 잔존하기 때문이다. W3-D4에서는 "URL 보안 경계(Filter) + 서비스 내부 권한 강제(Domain Service)"로 이어지는 강력한 이중 방어(Defense in Depth) 아키텍처를 설계하고 실무 코드로 완벽하게 구현하는 전략을 알아본다.
2. 주요 특징 및 핵심 로직 (Main Features & Logic)
안전한 권한 제어 모델의 뼈대는 시큐리티 필터 스택에서 시작하여 도메인 논리 내부까지 방어벽을 이어붙이는 다단계 심층 격리 방식에 있다.

이 파이프라인의 핵심은 인증을 판별하는 수식과 인가를 판별하는 수식을 철저하게 분리하는 데 있다.
| 검증 단계 | 관련 클래스 및 컴포넌트 | 핵심 비즈니스 규칙 및 예외 제어 | 비즈니스 가치 |
| 인증 획득 (JWT Filter) | JwtAuthenticationFilter | 토큰 누락 및 손상 시 Context 설정 비우고 패스 | 애매한 권한으로 우회 침투하는 행위 원천 차단 |
| 인증 실패 처리 (401) | AuthenticationEntryPoint | 401 Unauthorized 포맷팅 응답 가공 | 클라이언트에게 신속한 토큰 재발급 또는 로그인 트리거 유도 |
| 인가 실패 처리 (403) | AccessDeniedHandler | 403 Forbidden 포맷팅 응답 가공 | 일반 사용자의 관리자 권한 상승 공격 조기 탐지 및 모니터링 |
| 이중 방어 (Double Defense) | ReportService.validateAdmin() | 서비스 레이어 내부 자바 코드 권한 재검증 | 시큐리티 설정 누수 상황을 예방하는 최종 방어선 수립 |
3. 상세 가이드 및 심층 분석 (Detailed Guide)
A) 보안 커맨드 센터 설정 (SecurityConfig.java)
스프링 시큐리티의 설정 블록에서 예외 처리 지점(EntryPoint, Handler)을 명확하게 이원화하여 구현한 정교한 구조체다.
package com.example.platform.config;
import com.example.platform.auth.config.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// Stateless REST API를 지향하므로 CSRF 및 기본적인 HTTP Basic Auth 비활성화
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
// 토큰 방식을 사용하므로 세션을 사용하지 않음 (STATELESS 정책 강제)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 엔드포인트별 인가(Authorization) 가이드라인 규칙 매핑
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/api/public/**").permitAll() // 공개 URL 허용
.requestMatchers("/api/admin/**").hasRole("ADMIN") // 어드민 경로 권한 강제
.anyRequest().authenticated() // 그 외의 모든 요청은 로그인(인증) 필수
)
// 시큐리티의 기본 필터 UsernamePasswordAuthenticationFilter 바로 직전에 커스텀 JWT 필터 삽입
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
// 핵심 분리: 인증 실패(401)와 인가 실패(403) 예외 처리 분기
.exceptionHandling(exception -> exception
// [1번째 확인] 인증(Authentication) 자체가 실패했을 때 동작 (토큰 만료, 누락 등)
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 코드 명시
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"success\": false, \"code\": \"UNAUTHORIZED\", \"message\": \"인증 정보가 비어있거나 무효합니다. 재인증이 필요합니다.\"}"
);
})
// [2번째 확인] 인증은 성공했으나, 리소스에 요구되는 권한(ADMIN)이 부족할 때 동작
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403 코드 명시
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"success\": false, \"code\": \"FORBIDDEN\", \"message\": \"해당 권한으로는 해당 리소스에 접근할 수 없습니다.\"}"
);
})
);
return http.build();
}
}
B) Fail-Closed 기반의 문지기 필터 (JwtAuthenticationFilter.java)
클라이언트가 토큰을 이상하게 보냈을 때 어설픈 허용 없이 철저하게 컨텍스트를 비워 인가 게이트웨이로 돌려보내는 핵심 필터 로직이다.
package com.example.platform.auth.config;
import com.example.platform.auth.config.JwtTokenProvider;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
// 1. 헤더 포맷 검증: 헤더가 비어 있거나 Bearer 스키마로 시작하지 않는 비정상 형태 가드
if (header == null || !header.startsWith("Bearer ")) {
// 이 단계에서 예외를 강제로 던져 HTTP 스택을 중단시키지 않고 다음 필터로 토스한다.
// 컨텍스트에 인증 정보가 바인딩되지 않았으므로, 이후 SecurityConfig의 URL 인가 단계에서 401 예외가 자동으로 작동하게 된다.
chain.doFilter(request, response);
return;
}
String token = header.substring(7); // "Bearer " 접두사 이후 순수 JWT 토큰만 소거 후 슬라이싱
// 2. JWT 토큰 암호화 무결성 및 만료 여부 판별
if (!jwtTokenProvider.validateToken(token)) {
// 유효하지 않은 비정상 토큰인 경우에도 조용히 통과시킴 (SecurityContext에 바인딩하지 않음)
chain.doFilter(request, response);
return;
}
// 3. 토큰이 완벽하게 안전한 경우에 한하여 시큐리티 컨텍스트에 인증 정보(Authentication) 바인딩 수행
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
- 심층 분석: 여기서 조용히 chain.doFilter(...)를 타게 만들고 이어서 실행시키는 설계는 Fail-Closed 철학을 따르는 가장 이상적인 패턴이다. 여기서 직접 하드코딩으로 "401 에러" JSON을 직접 조립해서 HttpServletResponse에 써서 강제로 반환하면 안 된다. 시큐리티 필터 체인이 제공하는 자체 에러 수집 플로우(AuthenticationEntryPoint)를 완벽하게 타게 하려면, 비정상 토큰 발견 시 컨텍스트만 정갈하게 비워둔 채 스프링 시큐리티의 기본 인가 처리 프로세서로 바통을 부드럽게 전달해 주어야 비로소 아름다운 공통 보안 모듈의 일관된 응답 처리를 달성할 수 있다.
C) 서비스 레벨 최종 방어막 구축 (ReportService 및 validateAdmin)
보안 설정 파일의 오타나 경로 오조작으로 인한 잠재적 우회를 차단하기 위해 비즈니스 중심 레이어에서 자바 코드로 인가를 한 번 더 강제한다.
package com.example.platform.report.service;
import com.example.platform.member.domain.Member; // 비즈니스 유저 모델
import com.example.platform.member.domain.Role; // USER, ADMIN 이넘 클래스
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class ReportService {
/**
* 어드민 전용 지점의 핵심 신고/제재 처리 로직 (실무 2차 수문장 영역)
* @param actor 어드민 작업을 수용하고자 하는 주체 유저
*/
@Transactional
public void executeAdminSanction(Member actor, Long targetMemberId) {
// [이중 방어] 설정 파일의 사소한 실수로 일반 유저의 API 진입이 뚫렸더라도,
// 이곳에서 한 번 더 자바 객체의 내부 등급 데이터를 판별하여 즉시 프로세스를 파괴한다.
validateAdmin(actor);
// 신고 처리 및 제재 비즈니스 상태 머신 흐름 계속 진행...
}
/**
* 서비스 레벨 2차 어드민 권한 검증 수문장 메서드
*/
private void validateAdmin(Member member) {
if (member == null || member.getRole() != Role.ADMIN) {
// JVM 메모리 레벨에서 즉시 차단 예외 발산
throw new AccessDeniedException("정당한 관리자 권한을 보유하고 있지 않은 비정상 행위자입니다.");
}
}
}
4. 실무 팁 및 주의사항 (Tips & Notes)

실패 시나리오 및 핵심 디버깅 장치
- 소지한 토큰이 아예 유효하지 않거나 없는데, 401이 아닌 403 Forbidden으로 응답이 나갈 때:
- 가장 흔하게 겪는 버그 지점이다. 스프링 시큐리티의 설정 내부에서 anonymous() 혹은 인증받지 않은 요청이 유효한 토큰 없이 어드민 경로 /api/admin/에 접근할 때, 시큐리티가 "권한을 부여받지 못한 비인가자"로 잘못 오용하여 AccessDeniedHandler로 흘려보내는 경우가 존재한다.
- 이를 방어하려면 SecurityConfig에서 AuthenticationEntryPoint가 인가 단계보다 우선하여 401 Unauthorized 코드를 조립하는지 테스트 코드 및 에외 반환 세션을 확실히 분리 확인하고, 예외 스택 추적을 정적 로깅으로 검출해 봐야 한다.
- 유효한 토큰을 온전하게 넘겼음에도 계속해서 권한 인가 에러(403)를 마주할 때:
- JPA가 DB에서 사용자를 찾아 권한 정보를 인메모리 권한(GrantedAuthority) 셋에 바인딩할 때, 스프링 표준 스펙인 ROLE_ 접두사 규칙을 지켰는지 보아야 한다.
- DB 컬럼에는 그냥 ADMIN으로 기록되어 있어도, 시큐리티 컨텍스트 인증 토큰에 인가할 때는 반드시 new SimpleGrantedAuthority("ROLE_ADMIN") 형식으로 가공되어 저장되어야 시큐리티 기본 .hasRole("ADMIN") 매처와 호환성이 정합하게 유지된다.
5. 마무리 (Conclusion)
보안의 절대 규칙은 Fail-Closed(보수적 불허)와 심층 방어(Defense in Depth)다. 1차 방어선인 SecurityConfig의 URL 필터 가드로 대부분의 무모한 무권한 요청을 외부 필터 단에서 효율적으로 튕겨내고, 2차 방어선인 서비스 레벨 validateAdmin 검증 코드로 내부에서 물샐틈없는 원자성을 사수해야 한다. 이처럼 인증과 인가의 의미론적 구분을 준수하고 다단계 방벽을 조화롭게 이끌 때, 진정으로 비즈니스를 주도하는 견고한 시스템 보안이 비로소 제 자리를 찾게 된다.
6. 복습 질문과 해답 (Review Q&A)
Q1. 401 Unauthorized와 403 Forbidden 오류 코드가 지닌 비즈니스적 본질과 쓰임새의 차이를 설명해 보라.
A1. 401 Unauthorized는 사용자의 아이덴티티가 유효하게 증명되지 않은 상태(즉, "비인증" 상태)임을 알리는 응답이다. 클라이언트는 이 응답을 보고 만료된 액세스 토큰을 재발급하는 사후 절차를 밟거나 사용자를 다시 로그인 화면으로 유도해야 한다. 반면 403 Forbidden은 주체가 누구인지는 완전하게 증명되었으나, 해당 주체가 요청한 타겟 자원을 다룰 만큼의 자격 등급(예: ROLE_ADMIN)을 갖추지 못했음(즉, "비인가" 상태)을 나타내는 알림이다. 클라이언트는 이 신호를 보고 접근 불허 토스트 메시지를 출력하거나 해당 메뉴 진입을 화면 단에서 확실히 소거해야 한다.
Q2. 시큐리티 설정파일(SecurityConfig)의 URL 경로 제어만으로 어드민 보안 관리를 끝내면 발생할 수 있는 잠재적 리스크는 무엇인가?
A2. 시큐리티 매처에 주소를 잘못 매핑하는 사소한 오타나, 관리자 메뉴용 경로가 추가될 때 설정 코드 파일 변경을 누락하는 휴먼 에러에 의해 비공개 관리자 API가 공인 대중망 전체에 그대로 유출될 수 있다. 또한 서비스 메서드를 다른 컴포넌트(예: 배치 대량 프로세스나 비동기 태스크)에서 내부 호출로 사용할 때, 필터 체인 자체를 안 타고 도는 상황이 발생하여 권한이 전무한 람다 객체 등에 의해 관리자 전용 트랜잭션 비즈니스가 부당하게 가동될 여지가 생긴다. 따라서 서비스 내부의 이중 방어 코드는 비즈니스 불변식을 수호하는 강력한 최종 가드레일이 된다.
Q3. JwtAuthenticationFilter에서 토큰 검증 시 부적절하거나 무효한 토큰인 경우, 즉시 예외 응답을 쓰지 않고 시큐리티 컨텍스트를 비워둔 채 다음 체인으로 보내는 이유는 무엇인가?
A3. 보안 예외 응답 제어의 중앙 일원화(Single Source of Truth)와 Fail-Closed 메커니즘을 스프링 고유 스택에 부합하게 맞추기 위함이다. 필터 초입부에서 401 JSON을 하드코딩해서 서블릿 아웃풋 스트림에 직접 반환하면 예외의 라이프사이클 훅이 시큐리티의 공통 entry point 체계를 탈피하게 되며, 전역으로 선언된 예외 가로채기 도구들이 일관성 있게 로그를 정렬하지 못하게 차단한다. 컨텍스트만 영리하게 비워두면, 시큐리티 인가 프로세서가 최종 인가 시점에 해당 요청을 "비인증 객체"로 정합하게 자동 간주하여 AuthenticationEntryPoint에 의해 약속된 규격의 JSON 응답 패킷을 클라이언트에 오차 없이 한결같이 배출하게 된다.
'Spring > Security' 카테고리의 다른 글
| [Spring Security] ProviderAwareAuthorizationRequestResolver로 구현하는 정교한 OAuth2 보안 전략 (1) | 2026.05.14 |
|---|---|
| [Spring Security] OAuth2 로그인 최종장: 토큰 정책과 앱 이동 흐름 (0) | 2026.05.11 |
| [Spring Security] OAuth2 로그인 성공 후: 신규와 기존 사용자를 가르는 설계의 모든 것 (0) | 2026.05.11 |
| [Spring Security] JwtAuthenticationFilter 해부: 인증 문지기의 설계와 역할 분리 (1) | 2026.05.09 |
| [Spring Security] 운영 보안 설계 : subject 규약과 Access/Refresh 토큰 분리 (0) | 2026.05.09 |