← 개발일지

Spring Boot favicon.ico 404 Fix — The Catch-All Controller Trap


The Problem

You deploy your Spring Boot app to a server and /favicon.ico returns 404 — or worse, 500. Locally it works fine. You check the file path, confirm Spring Security permits it, and everything looks correct. Yet the server refuses to serve a simple .ico file.

If your project has a catch-all controller mapping like /**, that's likely your culprit.

What Was Happening

Here's the request flow I traced through the logs:

  1. Browser requests /favicon.ico
  2. Spring Security passes it through (confirmed by Will secure Ant [pattern='/favicon.ico'] with [])
  3. DispatcherServlet picks up the request
  4. The catch-all controller (/**) matches before the static resource handler
  5. Business logic runs — database queries for page templates, service config, popups
  6. Thymeleaf tries to resolve a template for "favicon.ico" and fails
  7. Error controller returns 404 or 500

The smoking gun was seeing business queries execute for a favicon request. Template lookups, service configuration queries — none of that should happen for a static file.

Why It Worked Locally

The app had a domain-based interceptor that identified the service context from the request's Host header. On localhost, it fell through to a default handler gracefully. On the deployed server with a real domain, the matching logic took a different path and broke.

The Root Cause

The catch-all controller hijacks /favicon.ico before Spring's static resource handler gets a chance. Even if the file exists in src/main/resources/static/, it doesn't matter — the controller mapping wins.

This is a handler mapping priority issue, not a missing file issue.

The Fix

Two changes, applied together:

1. Place the File in the Static Resource Path

Put favicon.ico in src/main/resources/static/ and verify it's included in the build artifact:

jar -tf app.jar | grep favicon

Check your build config for accidental excludes on .ico files.

2. Add a Filter to Short-Circuit the Request

This is the key fix. A OncePerRequestFilter at the highest priority intercepts /favicon.ico before it reaches any controller:

@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; // Skip the entire filter chain
        }
        filterChain.doFilter(request, response);
    }
}

Why a Filter instead of a dedicated @Controller? Because a controller still competes with the catch-all mapping for priority. Filters execute before the servlet layer entirely, so there's no ambiguity.

Other Files That Fall Into the Same Trap

If your app has a catch-all controller, robots.txt and sitemap.xml are likely affected too. You can extend the filter to handle all root-level static files, or serve them from Apache before the request ever reaches Tomcat:

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

The Takeaway

When a static file returns 404 on a deployed server, don't just check if the file exists. Trace which handler is actually processing the request. The request lifecycle in Spring — Security → Filter → Interceptor → Controller — has multiple layers where things can go wrong. In this case, the file was there all along. The request just never reached the code that serves it.