[Spring Boot] Spring Security @PublicApi 어노테이션으로 공개 API 경로를 자동 관리하는 방법
들어가며
Spring Security를 적용한 프로젝트를 운영하다 보면 어느 순간부터 SecurityConfig의 requestMatchers().permitAll() 목록이 길어지는 걸 느끼게 된다. 공개 API가 하나 늘 때마다 컨트롤러와 설정 파일, 두 곳을 동시에 수정해야 한다. 경로 하드코딩이 익숙해지면 나중에 경로가 바뀌었을 때 한 곳을 빠트리는 실수가 생기기 쉽다.
이 글에서는 다음 두 가지 문제를 어노테이션으로 해결한 과정을 정리한다.
- 경로 하드코딩 제거: 공개 API 경로를
SecurityConfig에 직접 적지 않고 컨트롤러 메서드에 어노테이션을 붙이는 것만으로 자동 등록되게 만든다. - 이중 방어 구조: 어노테이션 기반 수집이 주된 판별자 역할을 하고,
SecurityConfig의permitAll()은 안전망 역할을 하도록 두 레이어를 함께 운용한다.
1. 문제 인식 — SecurityConfig 경로 하드코딩의 한계
일반적인 Spring Security 설정은 아래처럼 생겼다.
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/v3/api-docs/**",
"/swagger-ui/**",
"/v1/auth/login",
"/v1/auth/join",
"/v1/auth/reset-password/**"
).permitAll()
.anyRequest().authenticated()
)
공개 API가 5개면 괜찮다. 그런데 API 수가 늘어나고 경로가 변경되기 시작하면 문제가 생긴다.
- 컨트롤러에서 경로를
/v1/auth/join→/v1/member/signup으로 바꾸면SecurityConfig도 함께 수정해야 한다. - 실수로
SecurityConfig를 빠트리면 공개 API가 403을 반환한다. - 어떤 경로가 공개인지 파악하려면
SecurityConfig와 컨트롤러를 동시에 봐야 한다.
공개 여부는 컨트롤러에 가장 가까이 두는 게 맞다. 컨트롤러 메서드를 보는 것만으로 "이 엔드포인트는 인증이 필요 없다"는 걸 알 수 있어야 한다.
2. 설계 방향 — 어노테이션 기반 자동 등록
목표는 간단하다.
@PublicApi // 이 어노테이션 하나로 공개 API 등록 완료
@PostMapping("/login")
public ApiResponse<LoginResponse> login(...) { ... }
어노테이션이 붙은 메서드를 런타임에 탐색해서 URL 패턴을 자동으로 수집하고, JwtAuthenticationFilter에서 그 경로 목록을 참조해 인증을 건너뛴다.
전체 흐름은 다음과 같다.
[앱 시작 완료 — ApplicationReadyEvent]
│
▼
PublicApiPathRegistry
— RequestMappingHandlerMapping 순회
— @PublicApi 또는 @PublicController가 붙은 핸들러 탐색
— URL 패턴 Set에 수집 후 불변 Set으로 교체
│
▼
[요청 진입 — JwtAuthenticationFilter]
— publicApiPathRegistry.isPublicPath(path) 로 판별
— 공개 경로: 익명 인증 컨텍스트 설정 후 통과
— 보호 경로: JWT 검증 진행
3. 구현 — @PublicApi / @PublicController 어노테이션 정의
3-1. @PublicApi
메서드 단위로 공개 경로를 선언할 때 사용한다.
@Target(ElementType.METHOD) // 메서드 레벨에만 적용
@Retention(RetentionPolicy.RUNTIME) // 런타임 리플렉션으로 읽어야 함
@Documented
public @interface PublicApi {
}
@Retention(RetentionPolicy.RUNTIME) 설정이 핵심이다. 기본값인 CLASS로 두면 JVM이 클래스를 로딩할 때 어노테이션 정보가 사라져서 런타임에 탐색할 수 없다.
3-2. @PublicController
클래스 전체의 엔드포인트를 공개할 때 사용한다. 예를 들어 외부 Webhook 수신용 컨트롤러처럼 모든 메서드가 인증 없이 접근 가능한 경우에 유용하다.
@Target(ElementType.TYPE) // 클래스 레벨에만 적용
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PublicController {
}
두 어노테이션을 쌍으로 운용하면 메서드 단위 / 클래스 단위 모두 유연하게 대응할 수 있다.
4. 구현 — PublicApiPathRegistry (경로 수집기)
어노테이션이 붙은 핸들러를 탐색해서 URL 패턴 목록을 수집하는 컴포넌트다.
@Slf4j
@Component
public class PublicApiPathRegistry
implements ApplicationListener<ApplicationReadyEvent>,
ApplicationContextAware {
private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
private ApplicationContext applicationContext;
// volatile — 멀티스레드 환경에서 메인 메모리 가시성 보장
private volatile Set<String> publicPaths = Collections.emptySet();
@Override
public void setApplicationContext(ApplicationContext ctx) {
this.applicationContext = ctx;
}
// 앱이 완전히 뜬 뒤 실행 — 모든 빈이 준비된 시점
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
RequestMappingHandlerMapping handlerMapping =
applicationContext.getBean(
"requestMappingHandlerMapping",
RequestMappingHandlerMapping.class
);
Set<String> paths = new HashSet<>();
// 등록된 모든 핸들러를 순회하며 어노테이션 탐색
handlerMapping.getHandlerMethods().forEach((info, handlerMethod) -> {
if (isPublicHandler(handlerMethod)) {
info.getPatternValues().forEach(paths::add);
}
});
// 불변 Set으로 교체 — 이후 외부 변경 불가
this.publicPaths = Collections.unmodifiableSet(paths);
log.info("공개 API 경로 등록 완료: {}", this.publicPaths);
}
// 메서드 레벨 @PublicApi 또는 클래스 레벨 @PublicController 확인
private boolean isPublicHandler(HandlerMethod handlerMethod) {
if (handlerMethod.hasMethodAnnotation(PublicApi.class)) return true;
return handlerMethod.getBeanType()
.isAnnotationPresent(PublicController.class);
}
// AntPath 패턴 매칭 — /v1/auth/{id} 같은 경로 변수도 처리
public boolean isPublicPath(String path) {
return publicPaths.stream()
.anyMatch(pattern -> PATH_MATCHER.match(pattern, path));
}
}
핵심 포인트
ApplicationReadyEvent를 쓰는 이유
@PostConstruct나 InitializingBean은 해당 빈 자신이 초기화되는 시점에 실행된다. 문제는 RequestMappingHandlerMapping이 아직 완전히 준비되지 않았을 수 있다는 점이다. ApplicationReadyEvent는 Spring ApplicationContext의 모든 빈 초기화가 끝나고 애플리케이션이 요청을 받을 준비가 된 이후에 발생하므로 안전하다.
AntPathMatcher로 경로 변수 처리
/v1/users/{userId}/profile 같은 경로는 단순 String.equals() 비교로는 실제 요청 경로 /v1/users/42/profile과 매칭할 수 없다. AntPathMatcher.match()는 {변수}, *, ** 패턴을 모두 처리한다.
volatile Set의 가시성
publicPaths는 앱 시작 시 한 번 쓰고 이후에는 읽기만 한다. volatile을 붙이지 않으면 각 스레드의 CPU 캐시에 초기화 전 빈 Set이 남아있을 수 있다. volatile을 선언하면 항상 메인 메모리에서 읽어오므로 스레드 간 일관성이 보장된다.
5. 구현 — JwtAuthenticationFilter에서 활용
JwtAuthenticationFilter에서 PublicApiPathRegistry를 주입받아 경로 판별에 사용한다.
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String path = request.getServletPath();
// Swagger 경로 — 정적 prefix로 빠르게 처리
if (path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs")) {
filterChain.doFilter(request, response);
return;
}
// @PublicApi 경로 — 익명 인증 컨텍스트 설정 후 통과
if (publicApiPathRegistry.isPublicPath(path)) {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(
"public", null, Collections.emptyList()
)
);
filterChain.doFilter(request, response);
return;
}
// 이후 JWT 검증 로직 ...
}
핵심 포인트
공개 경로에 익명 인증 컨텍스트를 설정하는 이유
SecurityFilterChain에 anyRequest().authenticated()가 걸려 있기 때문에 SecurityContext에 Authentication이 없으면 공개 API 경로도 인증 실패로 처리된다. 빈 권한 목록을 가진 익명 Authentication을 직접 설정해서 authenticated() 조건을 통과시킨다.
6. 이중 방어(Defense-in-Depth) 전략
PublicApiPathRegistry만으로 경로 판별을 처리하더라도, SecurityConfig의 requestMatchers().permitAll() 목록을 완전히 비우는 것은 권장하지 않는다.
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/v3/api-docs/**",
"/swagger-ui/**",
"/v1/auth/login" // 이중 방어 — 안전망
).permitAll()
.anyRequest().authenticated()
)
두 레이어가 각각 다른 위치에서 동작한다.
requestMatchers().permitAll()— Security FilterChain 레벨.JwtAuthenticationFilter가 실행되기 이전에 처리된다.PublicApiPathRegistry—JwtAuthenticationFilter내부. 런타임 어노테이션 탐색 기반.
PublicApiPathRegistry는 ApplicationReadyEvent 이후에 경로를 수집하는데, 이론적으로 빈 초기화 순서에 따라 극히 드문 타이밍 이슈가 발생할 수 있다. requestMatchers() 목록을 안전망으로 남겨두면 어떤 상황에서도 핵심 공개 경로가 막히지 않는다. 관리 포인트가 늘어나는 것처럼 보이지만, 자주 바뀌지 않는 핵심 경로 몇 개만 유지하면 충분하다.
7. 실제 사용 예시
7-1. 메서드 단위 적용
@RestController
@RequestMapping("/v1/auth")
public class AuthController {
// 공개 API — 어노테이션 하나로 자동 등록
@PublicApi
@PostMapping("/login")
public ApiResponse<LoginResponse> login(@RequestBody LoginRequest req) {
// ...
}
// 인증 필요 — 어노테이션 없음
@PostMapping("/logout")
public ApiResponse<Void> logout() {
// ...
}
}
7-2. 클래스 단위 적용
컨트롤러의 모든 엔드포인트를 공개해야 한다면 클래스 레벨에 @PublicController를 붙인다.
@PublicController
@RestController
@RequestMapping("/v1/public")
public class PublicController {
@GetMapping("/health")
public String health() {
return "ok";
}
@GetMapping("/terms")
public ApiResponse<TermsResponse> terms() {
// ...
}
}
@PublicController가 붙은 클래스의 모든 메서드 경로가 PublicApiPathRegistry에 자동으로 수집된다.
8. 정리 — 개선 전/후 비교
| 항목 | 기존 방식 | @PublicApi 방식 |
|---|---|---|
| 공개 경로 추가 시 | SecurityConfig + 컨트롤러 동시 수정 | 컨트롤러에 어노테이션만 추가 |
| 경로 동기화 | 수동 (누락 위험) | 자동 수집 |
| 코드 응집도 | 두 곳에 분산 | 컨트롤러에 응집 |
| 적용 단위 | URL 패턴 문자열 | 메서드 / 클래스 선택 가능 |
| 경로 변수 지원 | 패턴으로 직접 작성 (/**) |
AntPathMatcher가 자동 처리 |
마치며
핵심은 공개 API 여부를 컨트롤러에 가장 가까운 곳에서 선언하도록 만드는 것이다. @PublicApi 어노테이션 하나를 붙이는 것만으로 SecurityConfig에 손대지 않아도 된다.
이번 구현에서 중요했던 세 가지 선택을 정리한다.
첫 번째는 ApplicationReadyEvent 시점에 경로를 수집한 것이다. @PostConstruct로는 빈 초기화 순서 문제가 생길 수 있어서 모든 빈이 준비된 이후 시점을 선택했다.
두 번째는 AntPathMatcher 도입이다. 경로 변수가 포함된 패턴도 처리할 수 있어서 실제 서비스에 바로 쓸 수 있는 수준이 됐다.
세 번째는 이중 방어 구조를 유지한 것이다. 어노테이션 기반 수집이 주된 판별자이고, SecurityConfig의 permitAll() 목록은 최소한의 안전망으로만 남겨두는 방식으로 두 레이어를 함께 운용했다.
비슷한 구조를 고민 중이라면 참고가 됐으면 한다.