Config Server에 간단한 관리용 페이지를 하나 붙이려 했는데, /tools/properties-manager.html을 요청하자 HTML 대신 JSON 설정 응답이 내려왔어요. Spring REST Docs로 API 문서까지 깔끔하게 정리해뒀는데, 정작 그 문서를 보여줄 페이지도 열리지 않는 상황이었습니다.

처음엔 정적 리소스 경로 설정 문제인 줄 알았습니다. classpath:/static/에 파일도 잘 있고, Spring Boot의 정적 리소스 서빙 설정도 건드린 게 없는데 왜 동작하지 않는 걸까요?

이 글은 그 원인을 Spring MVC의 요청 처리 흐름부터 차근차근 파고든 기록입니다.

Spring Cloud Config Server의 HTTP API 구조

먼저 Config Server가 어떤 경로를 점유하고 있는지 알아야 합니다.

Config Server는 @EnableConfigServer 어노테이션 하나로 활성화되지만, 내부적으로는 EnvironmentController라는 @RestController가 여러 경로 패턴을 등록해요. GitHub 소스를 보면 클래스 선언부가 이렇게 되어 있어요.

@RestController
@RequestMapping(method = RequestMethod.GET, path = "${spring.cloud.config.server.prefix:}")
public class EnvironmentController {
    ...
}

클래스 레벨에서 prefix를 잡고, 메서드마다 아래와 같은 경로들이 추가로 붙습니다.

/{name}/{profiles}
/{name}/{profiles}/{label}
/{name}-{profiles}.properties
/{name}-{profiles}.yml
/{label}/{name}-{profiles}.properties
/{label}/{name}-{profiles}.yml
...

{name}{profiles}는 경로 변수(path variable)이기 때문에, 어떤 문자열이 들어와도 매칭될 수 있습니다.

즉, /tools/config-manager.html을 요청하면 Config Server 입장에서는 이렇게 해석할 수 있어요.

  • /{name}/{profiles} 패턴과 매칭되어 name=tools, profiles=config-manager.html로 인식

그런데 왜 정적 리소스보다 이쪽이 먼저 선택되는 걸까요?

DispatcherServlet의 동작 방식

Spring MVC에서 HTTP 요청이 들어오면 DispatcherServlet이 가장 먼저 받습니다. DispatcherServlet은 요청을 직접 처리하지 않고, 등록된 HandlerMapping 목록을 순서대로 순회하면서 이 요청을 처리할 수 있는 핸들러를 찾아요.

마치 공항의 탑승구 안내 직원 같은 역할이에요. 티켓(요청 URL)을 보고 “몇 번 게이트(핸들러)로 가세요”를 알려주는 거죠.

HandlerMappingOrdered 인터페이스를 구현해서 우선순위를 가질 수 있습니다. 숫자가 낮을수록 먼저 확인해요. 주요 구현체의 기본 order 값은 아래와 같습니다.

HandlerMappingOrder역할
RequestMappingHandlerMapping0@RequestMapping 처리
SimpleUrlHandlerMapping (정적 리소스)Integer.MAX_VALUE - 1정적 파일 서빙
Spring Cloud Config Server가 정적 페이지를 응답하지 않은 이유
RequestMappingHandlerMapping의 우선순위가 더 높다

@RequestMapping으로 등록된 컨트롤러가 항상 정적 리소스보다 먼저 확인된다는 의미예요. Config Server의 EnvironmentController@RestController + @RequestMapping으로 등록된 빈이기 때문에 같은 규칙을 따라요.

깊이 1 경로는 어떨까?

여기서 한 가지 실험을 해봤어요.

  • /tools/index.html → Config 응답 반환 (실패)
  • /index.html → 정적 페이지 응답 (성공)

같은 RequestMappingHandlerMapping이 먼저 확인하는데, 왜 결과가 다를까요?

이건 EnvironmentController가 등록한 경로 패턴의 구조에 달려 있습니다.

Config Server의 패턴들을 보면 대부분 /{variable1}/{variable2} 또는 /{variable1}-{variable2}.properties 와 유사한 형태예요. 특정 파일명 패턴에 완전히 일치하거나 최소한 깊이 2 이상의 경로 패턴입니다.

/tools.html은 슬래시 없이 단일 세그먼트이기 때문에, /{name}/{profiles} 같은 패턴에 매칭되지 않아요. Config Server의 어떤 패턴도 이 경로를 잡지 못하면, DispatcherServlet은 다음 HandlerMapping으로 넘어가고 결국 정적 리소스 핸들러가 응답하게 되죠.

반면 /tools/index.html은 두 세그먼트(tools, properties-manager.html)로 쪼개지기 때문에 /{name}/{profiles} 패턴에 매칭됩니다. 그래서 Config Server 패턴이 먼저 이 요청을 낚아채는 거예요.

실제로 curl로 요청해서 응답을 확인해보면 차이가 명확해요.

# 깊이 2 경로 — Config Server가 가로챔
$ curl http://localhost:8888/tools/index.html
{
  "name": "tools",
  "profiles": ["index.html"],
  "label": null,
  "version": null,
  "state": null,
  "propertySources": []
}

# 깊이 1 경로 — 정적 리소스 핸들러가 응답
$ curl http://localhost:8888/tools.html
<!DOCTYPE html>
<html>
  <head><title>Tools</title></head>
  <body>...</body>
</html>

/docs/index.html에서 name=docs, profiles=index.html로 파싱된 걸 볼 수 있어요. Config Server 입장에서는 완벽히 유효한 Properties 조회 요청이에요. propertySources가 비어있는 건 요청에 해당하는 설정 값이 실제로 없기 때문입니다.

참고: Spring Boot 2.6부터 경로 매칭 전략이 기존 AntPathMatcher에서 PathPatternParser로 변경되었어요. 두 방식 모두 패턴 특이성(specificity) 기반으로 더 구체적인 패턴을 우선 선택하지만, 이 경우처럼 경로 변수가 포함된 패턴은 와일드카드처럼 넓은 범위로 매칭될 수 있어요.

해결 방향과 트레이드오프

원인을 알았으니, 해결 방법을 살펴볼게요. 각각 장단점이 있습니다.

1. 단일 세그먼트 경로로 타협

/tools/properties-handler.html 대신 /properties-handler.html처럼 경로 깊이를 1로 유지하는 방법이에요.

장점단점
별도 설정 없이 즉시 동작URL 구조가 제한되어 관리가 불편할 수 있음
파일이 많아지면 루트에 HTML이 쌓이는 구조가 됨

2. prefix 설정 적용

Config Server의 모든 엔드포인트에 prefix를 붙이는 방법이에요.

spring:
  cloud:
    config:
      server:
        prefix: /config

공식 문서에 따르면 이 설정은 EnvironmentController를 포함한 Config Server의 모든 @RequestMapping에 적용돼요. 즉 기존 /{name}/{profiles} 패턴이 /config/{name}/{profiles}로 이동하기 때문에, /docs/index.html 같은 경로가 Config Server 패턴과 충돌하지 않게 돼요.

단, Config Client들도 접속 주소를 바꿔줘야 해요.

# Config Client 설정 변경
spring:
  config:
    import: "optional:configserver:http://localhost:8888/config"
장점단점
경로 충돌을 구조적으로 해결기존 Config Client 설정 변경 필요 (운영 중 변경은 부담될 수 있음)
URL 구조를 자유롭게 설계 가능

3. 정적 페이지 서버 분리

Config Server와 정적 페이지 서빙을 아예 다른 서버로 분리하는 방법이에요. Config Server는 설정 전용으로만 쓰고, 관리 UI는 별도 서버(혹은 Nginx)에서 서빙해요.

장점단점
관심사 분리로 구조가 깔끔해짐운영 복잡도 증가
Config Server에 불필요한 의존성이 없어짐작은 관리용 페이지를 위해 서버를 늘리는 건 과한 선택일 수 있음

정리

Spring Cloud Config Server에 정적 페이지를 올릴 때 발생하는 경로 충돌은 사실 Spring MVC의 HandlerMapping 우선순위 구조 때문에 발생하는 현상이에요.

@RequestMapping(order=0)은 정적 리소스 핸들러(order=MAX-1)보다 항상 먼저 확인된다. Config Server의 경로 변수 패턴이 예상치 못한 URL을 매칭할 수 있다.

상황에 따른 선택 기준은 다음과 같습니다.

  • 빠른 해결이 필요하다 → 단일 세그먼트 경로로 타협
  • URL 구조를 자유롭게 쓰고 싶다spring.cloud.config.server.prefix 설정
  • Config Server는 Properties 서빙 전용이어야 한다 → 서버 분리 고려

참고 자료

스스로 경험하며 얻은 깨달음을 공유하기 좋아하며, 세상이 필요로 하는 코드를 작성하기 위해 노력하는 개발자입니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다