
커스텀 필터는 왜 필요할까?
제가 프로젝트에서 커스텀 필터를 구현한 이유는 다음과 같습니다.
/group/{groupPk}/** 형식의 API 엔드포인트에 대해서는 groupPk에 해당하는 그룹에 유저가 포함되어 있어야 했습니다. 처음에 팀원과 이 이슈에 대해서 논의를 할 때 '프론트엔드 단에서 처리할 수 있을까?'에 대해 이야기해보았지만 아무리 생각해도 인증/인가 절차는 백엔드 단에서 처리하는게 맞다고 생각이 들었습니다. 그래서 커스텀 필터를 도입하기로 결정하였습니다.
전체적인 흐름
내가 구현한 커스텀 필터가 호출되고 적용되기까지의 흐름은 다음과 같습니다.
| [Client] ↓ JWT 인증 필터 ↓ Authentication 생성 ↓ AuthorizationManager(GroupAuthorizationManager) ↓ PageAuth ↓ Controller ↓ Service |
일단 클라이언트가 요청을 보내면 JWT 필터를 통과하면서 인증 절차를 수행합니다. 인증이 성공하면 Authentication 객체를 생성합니다. 이후 AuthorizationManager에서 경로 변수와 사용자 정보로 인가를 판단합니다. 이때 AuthorizationManager은 단순히 "허용/거부" 결정을 내리는 역할만 하고 실제 규칙은 PageAuth로 위임합니다. 따라서 PageAuth에서 정의한 규칙에 의해 인가를 판단하고 통과되면 Service/Controller에서 비즈니스 로직을 수행합니다.
이때 인증과 인가는 다음과 같이 정의할 수 있습니다.
인증은 신원을 확인하는 과정과 같습니다. 나라에서 ID카드를 확인하는 것과 동일한 작업이죠. 이 과정으로는 단순히 접근할 수 있는가에 대해서만 확인합니다.
인가는 권한을 부여하는 과정입니다. 누가 무엇을 할 수 있는가에 대해서 확인합니다.
그리고 커스텀 필터를 구성할 때 꼭 알아야할 요소들이 있습니다.
| JwtAuthenticationFilter | JWT 토큰 검증 → Authentication 생성 |
| PageAuth | DB 기반 권한 판단 (커스텀 규칙 / 그룹, 여행, 카드, 거래내역 소유 여부 확인) |
| GroupAuthorizationManager | 권한 체크 실행 |
| SecurityConfig | 요청 경로별 권한 설정 + AuthorizationManager 연결 |
커스텀 필터는 어떻게 구현할까
이제 실제 코드를 살펴보겠습니다. 먼저 PageAuth입니다. 이는 제가 구현한 커스텀 권한 체크 로직입니다.
먼저, 앞에서 언급한 것처럼 그룹인지 아닌지에 대해서만 확인하는 필터는 다음과 같이 간단하게 구현할 수 있습니다. GroupPk와 UserPk를 이용해서 해당 유저가 해당 그룹에 속해있는지 DB에서 확인해서 반환하는 로직입니다.
@Component("groupAuth")
@RequiredArgsConstructor
public class GroupAuth {
private final GroupMemberRepository groupMemberRepository;
public boolean memberOf(Long groupPk, Long userPk) {
return groupMemberRepository.existsByGroup_GroupPkAndUser_UserPkAndIsAcceptedTrueAndIsOutFalse(groupPk, userPk);
}
}
하지만 제가 참여한 프로젝트에서는 이뿐만 아니라 더 많은 케이스에 대해서 권한 체크 절차가 필요했기 때문에 다음과 같이 구현했습니다.
@Slf4j
@Component("pageAuth")
@RequiredArgsConstructor
public class PageAuth {
private final GroupMemberRepository groupMemberRepository;
private final TravelRepository travelRepository;
private final TransactionRepository transactionRepository;
private final CardRepository cardRepository;
/* 그룹 멤버인지 확인 */
public boolean canAccessGroup(Long groupPk, Long userPk) {
return groupMemberRepository.existsByGroup_GroupPkAndUser_UserPkAndIsAcceptedTrueAndIsOutFalse(groupPk, userPk);
}
/* 그룹 여행인지 확인 */
public boolean canAccessTravel(Long groupPk, Long travelPk) {
return travelRepository.existsByGroup_GroupPkAndTpPk(groupPk, travelPk);
}
/* 이용 내역 확인 */
public boolean canAccessTransaction(Long groupPk, Long transactionPk) {
return transactionRepository.existsBySatPkAndGmPk_Group_GroupPk(transactionPk, groupPk);
}
/* 카드 확인 */
public boolean canAccessCard(Long groupPk, Long cardPk, Long userPk) {
return cardRepository.existsBySacPkAndSaPk_Group_GroupPkAndGmPk_User_UserPkAndCancellationFalse(cardPk, groupPk, userPk);
}
public boolean isAllowed(String groupPk, Long userPk, String travelPk, String transactionPk, String cardPk) {
//그룹 메인 (groupPk)
if(groupPk == null) return false;
if(travelPk == null && transactionPk == null && cardPk == null) {
log.info("groupAuth");
return canAccessGroup(Long.valueOf(groupPk), userPk);
}
//여행 (groupPk, travelPk)
if(transactionPk == null && cardPk == null) {
log.info("travelAuth");
return canAccessTravel(Long.valueOf(groupPk), Long.valueOf(travelPk));
}
//이용내역 (groupPk, transactionPk)
if(travelPk == null && cardPk == null) {
log.info("transactionAuth");
return canAccessTransaction(Long.valueOf(groupPk), Long.valueOf(transactionPk));
}
//카드 (groupPk, cardPk)
if(travelPk == null && transactionPk == null) {
log.info("cardAuth");
return canAccessCard(Long.valueOf(groupPk), Long.valueOf(cardPk), userPk);
}
return false;
}
}
다음은 GroupAuthorization Manager입니다. 이는 Spring Security와 PageAuth를 연결해주는 역할로도 볼 수 있겠네요.
@Slf4j
@RequiredArgsConstructor
public class GroupAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
private final PageAuth pageAuth;
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
//인증 객체로부터 유저 정보 GET
Authentication auth = authentication.get();
if(auth == null || !auth.isAuthenticated()) {
return new AuthorizationDecision(false);
}
var principal = (JwtPrincipal) auth.getPrincipal();
Long userPk = principal.userPk();
//API Endpoint로부터 값 GET
String groupPk = object.getVariables().get("groupId");
String travelPk = object.getVariables().get("tpPk");
String transactionPk = object.getVariables().get("satPk");
String cardPk = object.getVariables().get("sacPk");
//권한 존재 여부 확인 (PageAuth)
boolean isAllowed = pageAuth.isAllowed(groupPk, userPk, travelPk, transactionPk, cardPk);
log.info("PageAuth: {} {}", isAllowed, groupPk);
return new AuthorizationDecision(isAllowed);
}
}
먼저 인증 객체에서 유저 정보를, 클라이언트가 요청한 API의 엔드포인트에서 필요한 객체들을 꺼냅니다. 이때 존재하지 않는 값들은 null로 저장됩니다. 이후 PageAuth의 isAllowed 메서드에 모든 값들을 넘겨줘 권한을 확인합니다. PageAuth가 판단한 권한 존재 여부에 따라 AuthorizationDecision 객체를 생성하여 반환합니다.
마지막으로 SecurityFilterChain에 연결해주어야합니다.
먼저 필요한 RequestMatcher를 선언해주겠습니다. 저는 4가지 상황에 대해 특수하게 권한 확인을 해주어야 하기 때문에 다음과 같이 작성해주었습니다.
var pp = PathPatternRequestMatcher.withDefaults();
RequestMatcher groupMatcher = pp.matcher("/api/group/{groupId:\\d+}/**");
RequestMatcher TravelMatcher = pp.matcher("/api/group/{groupId:\\d+}/travel/{tpPk:\\d+}/**");
RequestMatcher TransactionMatcher = pp.matcher("/api/group/{groupId:\\d+}/account/transaction/{satPk:\\d+}/**");
RequestMatcher CardMatcher = pp.matcher("/api/group/{groupId:\\d+}/account/card/{sacPk:\\d+}/**");
그리고 다음과 같이 필터를 구성해주었습니다.
http
.csrf(AbstractHttpConfigurer::disable) //CSRF 비활성
.cors(cors -> {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("*"); //모든 ORIGIN 허용
config.addAllowedMethod("*"); //모든 METHOD 허용
config.addAllowedHeader("*"); //모든 HEADER 허용
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
cors.configurationSource(source);
}) //CORS 허용
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) //세션 사용 해제, JWT 토큰 만으로 핸들링
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// Swagger & 헬스
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/actuator/health").permitAll()
// 인증 발급/회원가입/소셜 콜백 등 공개
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/categories/**").permitAll()
//커스텀 AuthorizationManager 적용
//여행 인증
.requestMatchers(TravelMatcher).access(new GroupAuthorizationManager(pageAuth))
//트랜잭션 인증
.requestMatchers(TransactionMatcher).access(new GroupAuthorizationManager(pageAuth))
//카드 인증
.requestMatchers(CardMatcher).access(new GroupAuthorizationManager(pageAuth))
//그룹 멤버 인증
.requestMatchers(groupMatcher).access(new GroupAuthorizationManager(pageAuth))
// WebSocket 핸드셰이크 허용
.requestMatchers("/ws").permitAll()
.requestMatchers("/ws/**").permitAll()
// 그 외는 인증 필요
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(handlers.authenticationEntryPoint())
.accessDeniedHandler(handlers.accessDeniedHandler())
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
여기서 주의해야하는 점은 requestMatchers() 의 순서입니다. Spring Security는 위에서 아래로 순차적으로 매칭을 검사하고, 가장 먼저 매칭되는 규칙을 적용하기 때문입니다.
마무리
이렇게 커스텀 필터를 사용하는 방법 외에도 메서드 별로 권한을 검사하는 방법도 존재합니다.
@PreAAuthorize / @PostAuthorize를 활용하는 방식입니다. 저는 특정 URL이 아니라 특정 URL로 시작하는 하위 API까지 모두 확인하고 싶었기 때문에 커스텀 필터로 한 번에 처리해주었지만 URL 별로 권한 검사가 따로 필요한 경우에는 이 방식을 사용해주어도 좋을 것 같습니다. 혹시 몰라 아래에 정리해두겠습니다 :)
| @PreAuthorize | 메서드 실행 전 검사 | 실행 전 검사하므로 실패 시 메서드 자체가 호출이 되지 않음 |
| @PostAuthorize | 메서드 실행 후 검사 | 실행 후 결과 객체까지 확인하고 접근 허가 여부를 결정 |
'자바 > 스프링부트' 카테고리의 다른 글
| [Java / Spring] Redis 이용한 좌석 선점/결제 로직 구현 (0) | 2025.10.21 |
|---|---|
| [JAVA / SSE] 스프링부트에서 SSE 구현하기 (SSE + Redis pub/sub) - 2 (0) | 2025.10.21 |
| [JAVA / 스프링부트] UTC 저장 + KST 응답 구조로 타임존 문제 해결하기 (Spring Boot + MariaDB) (0) | 2025.10.20 |
| [JAVA / SSE] 스프링부트에서 SSE 구현하기 (SSE + Redis pub/sub) - 1 (0) | 2025.10.14 |
| [JAVA / 스프링부트] API 공통 응답 형식 지정 + 공통 에러 처리 - 2 (0) | 2025.10.13 |