← 개발일지

Spring Boot favicon.ico 404 해결 — catch-all 컨트롤러 함정


문제 상황

Spring Boot + Thymeleaf + Tomcat + Apache 스택에서 /favicon.ico 직접 접근 시, 로컬에서는 정상 응답되지만 개발서버 배포 후에는 404 에러가 발생했다. 단순한 static resource 문제처럼 보이지만, 실제 원인은 다른 곳에 있었다.

환경

  • Java / Spring Boot / Thymeleaf
  • Tomcat (embedded 또는 외장)
  • Apache httpd (reverse proxy)
  • Spring Security 적용

진단 과정

Security부터 의심했지만 아니었다

Spring Security가 /favicon.ico를 차단하는 게 첫 번째 의심이었다. 하지만 기동 로그를 확인하면:

Will secure Ant [pattern='/favicon.ico'] with []

빈 필터 체인으로 이미 permit 처리되어 있었다. Security는 원인이 아니다.

에러 컨트롤러에 상세 로그 추가

원인을 좁히기 위해 ErrorController에 진단 로그를 추가했다.

Integer statusCode = (Integer) request.getAttribute("javax.servlet.error.status_code");
String requestUri = (String) request.getAttribute("javax.servlet.error.request_uri");
String serverName = request.getServerName();
Throwable throwable = (Throwable) request.getAttribute("javax.servlet.error.exception");

결과: Status 404, Servlet Name dispatcherServlet, Exception은 null. DispatcherServlet이 요청을 받았지만 매칭되는 핸들러가 없었다는 뜻이다.

결정적 단서 — 비즈니스 쿼리 실행

로그를 더 보니 /favicon.ico 요청에 대해 서비스 설정 조회, 템플릿 조회, 팝업 조회 등 페이지 렌더링용 비즈니스 로직이 전부 실행되고 있었다. 이건 static resource handler가 처리하는 게 아니라 catch-all 컨트롤러가 favicon 요청을 페이지 요청으로 처리하고 있다는 명확한 증거였다.

/favicon.ico → catch-all 컨트롤러(/**) → 비즈니스 로직 실행 → 템플릿 resolve 실패 → 에러

로컬에서는 왜 됐나

도메인 기반으로 서비스를 식별하는 interceptor가 있었는데, localhost는 default 처리로 통과했고, 개발서버 도메인은 DB에 매칭 값이 없어서 다른 경로로 빠진 것이다.

근본 원인

catch-all 컨트롤러(/** 매핑)가 /favicon.ico 요청을 가로채서 페이지로 처리하는 것이 근본 원인이었다. static resource 경로에 파일이 있어도, Spring MVC의 핸들러 매핑 우선순위에서 catch-all 컨트롤러가 먼저 매칭되면 static resource handler는 기회를 얻지 못한다.

해결

두 가지를 함께 적용했다.

1. static resource 경로에 파일 배치

src/main/resources/static/favicon.ico에 파일을 넣고, 빌드 산출물에 포함되는지 확인:

jar -tf app.jar | grep favicon

빌드 설정에서 .ico 파일이 exclude되는 경우도 있으니 확인이 필요하다.

2. Filter로 컨트롤러 도달 전 차단

핵심 해결책이다. OncePerRequestFilter를 최상위 우선순위로 등록해서 /favicon.ico 요청이 컨트롤러 체인에 도달하기 전에 직접 응답한다.

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class FaviconFilter extends OncePerRequestFilter {

    private byte[] faviconBytes;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain) throws ServletException, IOException {
        if ("/favicon.ico".equals(request.getRequestURI())) {
            if (faviconBytes == null) {
                ClassPathResource res = new ClassPathResource("static/favicon.ico");
                if (res.exists()) {
                    faviconBytes = StreamUtils.copyToByteArray(res.getInputStream());
                } else {
                    response.sendError(404);
                    return;
                }
            }
            response.setContentType("image/x-icon");
            response.setHeader("Cache-Control", "max-age=604800");
            response.setContentLength(faviconBytes.length);
            response.getOutputStream().write(faviconBytes);
            return; // filterChain을 타지 않음
        }
        filterChain.doFilter(request, response);
    }
}

@Controller로 처리하는 방법도 있지만, catch-all 컨트롤러와 매핑 우선순위 경쟁이 생길 수 있어서 Filter가 더 확실하다. Filter는 서블릿보다 먼저 실행되므로 우선순위 문제가 원천 차단된다.

같은 함정에 빠지는 다른 파일들

catch-all 컨트롤러 구조에서는 robots.txt, sitemap.xml 등 루트 경로의 static 파일도 동일한 문제가 발생할 수 있다. Filter에서 일괄 처리하거나, Apache 레벨에서 Tomcat으로 넘기지 않는 방법을 고려하면 된다.

Alias /favicon.ico /var/www/html/favicon.ico
ProxyPass /favicon.ico !

핵심 정리

이 이슈의 본질은 "파일이 없다"가 아니라 **"요청이 엉뚱한 핸들러에 도달한다"**는 것이다. 진단할 때 단순히 파일 존재 여부만 확인하면 원인을 놓친다. 요청이 어떤 경로로 처리되는지 — Security → Filter → Interceptor → Controller — 레이어별로 추적하는 것이 이런 류의 문제를 빠르게 잡는 방법이다.