개발

SecurityFilterChain 다중 구성과 URL 기반 인가 설계

2026년 01월 21일
59

왜 다중 SecurityFilterChain인가

Spring Security 6에서 보안 설정의 핵심은 SecurityFilterChain이다. HTTP 요청이 들어오면 필터 체인을 거쳐 인증과 인가가 수행된다.

처음에는 하나의 SecurityFilterChain으로 모든 요청을 처리했다. 하지만 운영하면서 서로 다른 인증 방식이 필요한 엔드포인트가 생겼다.

  • API 엔드포인트 (/api/**): JWT 토큰 인증
  • Actuator 엔드포인트 (/actuator/**): HTTP Basic 인증

Actuator는 서버 상태 모니터링용 엔드포인트다. Prometheus가 주기적으로 메트릭을 수집하는데, /actuator/health/actuator/prometheus는 공개로 두고, 그 외 민감한 엔드포인트는 Basic 인증으로 보호했다.

하나의 필터 체인에서 URL에 따라 인증 방식을 분기하는 것도 가능하지만, 코드가 복잡해지고 의도가 불명확해진다. 별도의 필터 체인으로 분리하면 각 체인이 담당하는 영역과 인증 방식이 명확해진다.


전체 필터 체인 구조

tmp

Spring Security는 여러 SecurityFilterChain이 등록되면 securityMatcher로 어떤 체인을 적용할지 결정한다. 매칭되는 첫 번째 체인이 사용되므로 순서가 중요하다. 더 구체적인 패턴(Actuator)을 먼저 정의하고, 포괄적인 패턴(API)을 나중에 정의했다.


API 필터 체인: 커스텀 필터 순서 설계

API 요청을 처리하는 필터 체인에는 두 개의 커스텀 필터가 있다.

요청 → LoginAttemptFilter → JwtAuthenticationFilter → ... → Controller
                │                       │
                │                       └── JWT 토큰 검증
                │                           SecurityContext 설정
                │
                └── 로그인 시도 횟수 확인
                    차단된 IP/사용자면 요청 거부

LoginAttemptFilter

브루트포스 공격을 방어하기 위한 필터다. 로그인 실패가 반복되면 일정 시간 동안 해당 IP나 사용자의 로그인을 차단한다.

이 필터를 JwtAuthenticationFilter 앞에 배치한 이유는 차단된 요청이 불필요하게 JWT 검증까지 가지 않도록 하기 위해서다. 차단된 IP에서 온 요청은 최대한 빨리 거부하는 게 서버 리소스 측면에서 효율적이다.

JwtAuthenticationFilter

Authorization 헤더에서 JWT를 추출하고 검증하는 필터다. 검증 성공 시 SecurityContext에 인증 정보를 설정해서 이후 인가 처리에서 사용할 수 있게 한다.

UsernamePasswordAuthenticationFilter 앞에 배치했다. Spring Security의 기본 폼 로그인 필터보다 먼저 JWT 인증을 시도하게 된다. JWT가 없거나 유효하지 않으면 다음 필터로 넘어간다.


URL 기반 인가 설계

어떤 엔드포인트에 어떤 권한이 필요한지 결정하는 것이 인가 설계의 핵심이다.

역할 정의

시스템에는 세 가지 역할이 있다.

역할설명할 수 있는 것
익명로그인하지 않은 사용자공개 콘텐츠 조회
USER일반 로그인 사용자댓글 작성, 좋아요
ADMIN관리자포스트 CRUD, 카테고리/태그 관리

ACTUATOR 역할은 별도로 존재하며 모니터링 시스템만 사용한다.

URL별 권한 매핑

공개 (permitAll)
├── GET  /api/posts/**          포스트 목록/상세 조회
├── GET  /api/categories/**     카테고리 목록 조회
├── GET  /api/tags/**           태그 목록 조회
├── GET  /api/comments/**       댓글 목록 조회
├── POST /api/auth/login        로그인
├── POST /api/auth/refresh      토큰 갱신
└── /**  /oauth2/**             OAuth2 로그인 흐름

인증 필요 (authenticated)
├── POST   /api/comments/**     댓글 작성
├── PUT    /api/comments/**     댓글 수정
├── DELETE /api/comments/**     댓글 삭제
├── POST   /api/posts/*/like    좋아요 추가
├── DELETE /api/posts/*/like    좋아요 취소
└── GET    /api/auth/me         내 정보 조회

ADMIN 전용 (hasRole('ADMIN'))
├── POST   /api/posts/**        포스트 작성
├── PUT    /api/posts/**        포스트 수정
├── DELETE /api/posts/**        포스트 삭제
├── POST   /api/categories/**   카테고리 생성
├── PUT    /api/categories/**   카테고리 수정
├── DELETE /api/categories/**   카테고리 삭제
├── POST   /api/tags/**         태그 생성
├── DELETE /api/tags/**         태그 삭제
└── /**    /api/admin/**        관리자 API

설계 원칙

1. 읽기는 공개, 쓰기는 인증

블로그는 공개 콘텐츠다. 포스트, 카테고리, 태그, 댓글 조회는 모두 로그인 없이 가능해야 한다. 하지만 무언가를 생성하거나 수정하려면 인증이 필요하다.

2. HTTP 메서드로 구분

같은 URL이라도 GET은 공개, POST/PUT/DELETE는 권한 필요하도록 설계했다. /api/posts를 예로 들면 GET은 누구나, POST는 ADMIN만 가능하다.

3. 계층적 권한

ADMIN은 USER가 할 수 있는 모든 것을 할 수 있다. Spring Security의 역할 계층(RoleHierarchy)을 설정해서 ADMIN > USER 관계를 명시했다. 덕분에 "USER 이상" 권한이 필요한 곳에 ADMIN도 접근할 수 있다.


인증 실패 시 응답 처리

인증이나 인가에 실패하면 Spring Security는 기본적으로 로그인 페이지로 리다이렉트하거나 HTML 에러 페이지를 반환한다. REST API에서는 JSON 응답이 필요하다.

AuthenticationEntryPoint

인증이 필요한데 인증 정보가 없을 때 호출된다. 401 Unauthorized와 함께 JSON 에러 응답을 반환하도록 커스터마이징했다.

요청: GET /api/auth/me (토큰 없음)
응답: 401 Unauthorized
{
  "success": false,
  "errorCode": "AUTH_001",
  "message": "인증이 필요합니다"
}

AccessDeniedHandler

인증은 되었지만 권한이 부족할 때 호출된다. 403 Forbidden과 함께 JSON 에러 응답을 반환한다.

요청: DELETE /api/posts/1 (USER 역할로 시도)
응답: 403 Forbidden
{
  "success": false,
  "errorCode": "AUTH_003",
  "message": "접근 권한이 없습니다"
}

두 핸들러 모두 프로젝트의 표준 API 응답 형식(ApiResponse)을 사용한다. 클라이언트는 성공이든 실패든 일관된 형식의 응답을 받게 된다.


겪었던 문제들

필터 순서와 예외 처리

커스텀 필터에서 발생한 예외는 @RestControllerAdvice로 처리되지 않는다. 필터는 DispatcherServlet 이전에 실행되기 때문이다.

처음에는 JwtAuthenticationFilter에서 토큰 검증 실패 시 BusinessException을 던졌는데, GlobalExceptionHandler가 잡지 못했다. 필터 내에서 직접 응답을 작성하거나, AuthenticationEntryPoint로 위임해야 했다.

결국 필터에서는 예외를 던지지 않고, SecurityContext를 설정하지 않은 채 다음 필터로 넘기도록 변경했다. 권한이 필요한 엔드포인트에 도달하면 Spring Security가 AuthenticationEntryPoint를 호출한다.

Stateless 설정과 OAuth2

세션을 사용하지 않도록 SessionCreationPolicy.STATELESS를 설정했더니 OAuth2 로그인이 깨졌다. OAuth2 인증 흐름에서 state 파라미터 검증에 세션이 필요했기 때문이다.

해결 방법으로 OAuth2 관련 URL(/oauth2/**, /login/oauth2/**)은 세션을 허용하고, 나머지 API URL은 stateless로 유지했다. 이렇게 하면 OAuth2 인증 과정에서만 임시 세션이 생성되고, 인증 완료 후 JWT가 발급되면 세션은 더 이상 사용되지 않는다.

permitAll과 anonymous의 차이

permitAll()은 인증 여부와 관계없이 접근을 허용한다. anonymous()는 인증되지 않은 사용자만 접근을 허용한다.

처음에 로그인 엔드포인트를 anonymous()로 설정했다가, 이미 로그인한 사용자가 토큰을 갱신하려 할 때 접근이 거부되는 문제가 있었다. permitAll()로 변경해서 해결했다.


현재 구조의 장단점

장점

  • 관심사 분리: API와 Actuator가 별도 체인으로 명확히 구분
  • 선언적 권한 설정: URL과 HTTP 메서드 조합으로 직관적인 권한 매핑
  • 일관된 에러 응답: 인증/인가 실패 시에도 표준 API 응답 형식 유지

단점

  • URL 기반 한계: 메서드 레벨 권한 체크가 필요한 경우 별도 처리 필요
  • 설정 분산: 권한 정보가 SecurityConfig와 컨트롤러에 분산될 수 있음
  • 테스트 복잡도: 다중 필터 체인 환경에서 통합 테스트 설정이 번거로움

댓글을 불러오는 중...