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 — 레이어별로 추적하는 것이 이런 류의 문제를 빠르게 잡는 방법이다.