이 영역을 누르면 첫 페이지로 이동
일반인의 웹 개발일기 블로그의 첫 페이지로 이동

일반인의 웹 개발일기

페이지 맨 위로 올라가기

일반인의 웹 개발일기

웹 개발과 관련된 모든 이야기

[Spring Boot] Spring Security @PublicApi 어노테이션으로 공개 API 경로를 자동 관리하는 방법

  • 2026.02.22 21:05
  • 카테고리 없음
반응형

들어가며

Spring Security를 적용한 프로젝트를 운영하다 보면 어느 순간부터 SecurityConfig의 requestMatchers().permitAll() 목록이 길어지는 걸 느끼게 된다. 공개 API가 하나 늘 때마다 컨트롤러와 설정 파일, 두 곳을 동시에 수정해야 한다. 경로 하드코딩이 익숙해지면 나중에 경로가 바뀌었을 때 한 곳을 빠트리는 실수가 생기기 쉽다.

이 글에서는 다음 두 가지 문제를 어노테이션으로 해결한 과정을 정리한다.

  1. 경로 하드코딩 제거: 공개 API 경로를 SecurityConfig에 직접 적지 않고 컨트롤러 메서드에 어노테이션을 붙이는 것만으로 자동 등록되게 만든다.
  2. 이중 방어 구조: 어노테이션 기반 수집이 주된 판별자 역할을 하고, 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() 목록은 최소한의 안전망으로만 남겨두는 방식으로 두 레이어를 함께 운용했다.

비슷한 구조를 고민 중이라면 참고가 됐으면 한다.

반응형

댓글

이 글 공유하기

  • 구독하기

    구독하기

  • 카카오톡

    카카오톡

  • 라인

    라인

  • 트위터

    트위터

  • Facebook

    Facebook

  • 카카오스토리

    카카오스토리

  • 밴드

    밴드

  • 네이버 블로그

    네이버 블로그

  • Pocket

    Pocket

  • Evernote

    Evernote

다른 글

다른 글 더 둘러보기

정보

일반인의 웹 개발일기 블로그의 첫 페이지로 이동

일반인의 웹 개발일기

  • 일반인의 웹 개발일기의 첫 페이지로 이동
반응형

검색

메뉴

  • 홈
  • 태그
  • 방명록

카테고리

  • 분류 전체보기 (49) N
    • 사이드 프로젝트 (3)
      • 크롤링 (2)
    • 개발 이야기 (18)
      • MSA (7)
      • Spring Boot (3)
      • JPA (0)
      • Docker (1)
      • Javascript (2)
      • AWS (Amazon Web Services) (5)
      • Jenkins (0)
    • Database (4)
      • PostgreSQL (2)
      • MySQL (1)
      • Oracle (1)
    • 에러 정리 (4)
      • Docker (1)
      • JPA (1)
      • Python (1)
      • PostgreSQL (1)
    • 운영체제 (3)
      • Linux (3)
    • 게임 (8)
      • 마인크래프트(Minecraft) (2)
      • 팰월드(PalWorld) (6)
    • 워킹홀리데이 (6)
      • 일본 워킹 홀리데이 (6)
    • AI (1) N
      • Claude Code (1) N

공지사항

인기 글

최근 글

정보

흔하디흔한개발자의 일반인의 웹 개발일기

일반인의 웹 개발일기

흔하디흔한개발자

블로그 구독하기

  • 구독하기
  • RSS 피드

방문자

  • 전체 방문자
  • 오늘
  • 어제

티스토리

  • 티스토리 홈
  • 이 블로그 관리하기
  • 글쓰기
Powered by Tistory / Kakao. © 흔하디흔한개발자. Designed by Fraccino.

티스토리툴바