1. 상황 정리 – 어떤 에러들이 터졌나?
Spring Boot + Thymeleaf + Spring Security + thymeleaf-extras-springsecurity5 조합에서 다음과 같은 문제들이 차례로 터질 수 있습니다.
- 템플릿 렌더링 시 NoSuchMethodError
java.lang.NoSuchMethodError:
org.thymeleaf.context.IWebContext.getExchange()Lorg/thymeleaf/web/IWebExchange;
- sec:authorize 사용 시 SecurityExpressionHandler 없음
No visible SecurityExpressionHandler instance could be found
in the application context. There must be at least one in order to
support expressions in Spring Security authorization queries.
(template: "account/sec_test" - line xx, col yy)
- 외장 Tomcat + WAR 배포 환경에서 sec:authorize 에러가 어디서 나는지 로그가 안 찍힘
- application.properties 만으로 로그를 설정하면,
- 외장 Tomcat 환경에서 로그 파일이 안 생기거나,
- 콘솔에만 애매하게 찍혀서 원인 파악이 어려움.
이 글에서는 위 세 가지를 실제로 겪었던 순서대로 원인 + 해결 방법을 정리합니다.
2. 원인 1 – Thymeleaf / extras 버전 불일치 (NoSuchMethodError)
2‑1. 증상
sec:authorize 를 쓰는 템플릿을 렌더링하는 순간, 서버 로그에 이런 에러가 터집니다.
java.lang.NoSuchMethodError:
org.thymeleaf.context.IWebContext.getExchange()Lorg/thymeleaf/web/IWebExchange;
스택 트레이스를 보면 thymeleaf-extras-springsecurity5 안쪽에서IWebContext.getExchange() 를 호출하려다가 죽는 모습이 보입니다.
2‑2. 진짜 원인
- 프로젝트의 Thymeleaf 버전: 3.0.12.RELEASE
- thymeleaf-extras-springsecurity5 버전: 3.1.3.RELEASE (처음 설정값)
3.1.x 버전의 thymeleaf-extras-springsecurity5 는 Thymeleaf 3.1.x 와 맞춰져 있습니다.Thymeleaf 3.0.x 프로젝트에서 3.1.x extras 를 쓰면, 내부 API 차이 때문에 NoSuchMethodError 가 납니다.
2‑3. 해결 방법: extras 버전을 3.0.x로 맞추기
build.gradle 예시:
dependencies {
implementation 'org.thymeleaf:thymeleaf-spring5:3.0.12.RELEASE'
// 잘못된 예: 3.1.x (NoSuchMethodError 발생 가능)
// implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE'
// 올바른 예: 3.0.x 라인으로 맞추기
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.0.5.RELEASE'
}
- 핵심: Thymeleaf 본체 버전과 extras 버전 메이저/마이너 라인을 맞춰야 합니다.
3. 원인 2 – SecurityExpressionHandler Bean 없음
3‑1. 증상
버전 문제를 해결한 뒤에도, sec:authorize 가 붙은 템플릿을 열면 이런 에러가 발생할 수 있습니다.
No visible SecurityExpressionHandler instance could be found in the application context.
There must be at least one in order to support expressions in Spring Security authorization queries.
(template: "account/sec_test" - line 43, col 35)
<div sec:authorize="isAnonymous()"> ... </div>
<div sec:authorize="isAuthenticated()"> ... </div>
이런 코드인데, 이걸 처리하는 과정에서 죽습니다.
3‑2. 왜 이런 일이 생기는가?
thymeleaf-extras-springsecurity5 는 내부적으로 Spring Security의 SecurityExpressionHandler<FilterInvocation> Bean 을 찾아서:
- hasAuthority('SCOPE_read')
등의 표현식을 평가합니다.그런데:
- 스프링 컨텍스트 안에 SecurityExpressionHandler 타입 Bean 이 하나도 없는 경우,
extras 쪽에서 “찾을 수 없다(No visible ...)" 라며 예외를 던집니다.일반적인 Spring Boot Security 설정에서는 자동으로 등록되기도 하는데,커스텀 SecurityConfig / WebSecurityConfigurerAdapter 설정을 많이 건드린 경우이 Bean 이 제대로 노출되지 않는 경우가 있습니다.
3‑3. 해결 방법: SecurityExpressionHandler Bean 등록
SecurityConfig 예시 (Spring Boot 2.x, WebSecurityConfigurerAdapter 기반):
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final AdminService adminService;
private final CustomOAuth2UserService customOAuth2UserService;
private final UserRepository userRepository;
private final ApplicationContext applicationContext;
@Autowired
public SecurityConfig(AdminService adminService,
CustomOAuth2UserService customOAuth2UserService,
UserRepository userRepository,
ApplicationContext applicationContext) {
this.adminService = adminService;
this.customOAuth2UserService = customOAuth2UserService;
this.userRepository = userRepository;
this.applicationContext = applicationContext;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* Thymeleaf sec:authorize 가 사용할 SecurityExpressionHandler Bean.
* (없으면 "No visible SecurityExpressionHandler instance" 에러 발생)
*/
@Bean
public SecurityExpressionHandler<FilterInvocation> webSecurityExpressionHandler() {
DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
handler.setApplicationContext(applicationContext);
return handler;
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(adminService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// http.authorizeRequests() ...
}
}
요점:
- DefaultWebSecurityExpressionHandler 를 Bean 으로 만들고,
- ApplicationContext 를 주입해 주면,
- thymeleaf-extras-springsecurity5 가 이 Bean 을 찾아서 안전하게 sec:authorize 표현식을 평가할 수 있습니다.
4. 원인 3 – 외장 Tomcat + WAR 환경에서 sec:authorize 에러 로그 안 보이는 문제
4‑1. 증상
- 개발 PC (내장 Tomcat, ./gradlew bootRun) 에서는 에러 로그가 잘 보이는데,
- 실제 운영 서버 (외장 Tomcat + WAR 배포) 에서는:
- application.properties 의 logging.file.name 설정이 무시되거나,
- catalina.out 에만 로그가 섞여 나와서
- sec:authorize / Thymeleaf 에러를 찾기 어렵습니다.
4‑2. 권장 패턴: logback-spring.xml로 명시적으로 관리
src/main/resources/logback-spring.xml 예시:
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds">
<!-- 로그 디렉터리: LOG_PATH 가 있으면 사용, 없으면 Tomcat logs 기본값 -->
<property name="LOG_PATH" value="${LOG_PATH:-${catalina.base:-.}/logs}" />
<!-- 콘솔 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 일자별 롤링 파일 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/application.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/application.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- Thymeleaf / Spring Security: 에러만 기록 -->
<logger name="org.thymeleaf" level="ERROR" />
<logger name="org.thymeleaf.extras.springsecurity5" level="ERROR" />
<logger name="org.springframework.security" level="ERROR" />
<!-- sec:authorize 템플릿 예외 전용 Advice -->
<logger name="kr.carz.savecar.config.ThymeleafErrorLoggingAdvice" level="ERROR" />
<!-- 우리 애플리케이션 -->
<logger name="kr.carz.savecar" level="WARN" />
<!-- 기본(root) 로거 -->
<root level="WARN">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>
그리고 application.properties 에서는 logging 설정을 최소만 남깁니다:
logging.level.root=WARN
# 세부 레벨은 logback-spring.xml에서 관리
4‑3. sec:authorize 템플릿 에러를 한 줄로 잡기 – ControllerAdvice
@ControllerAdvice 로 TemplateProcessingException 을 잡아서,어느 URI에서 어떤 Thymeleaf 에러가 났는지 짧게 남길 수 있습니다.
package kr.carz.savecar.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.thymeleaf.exceptions.TemplateProcessingException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import javax.servlet.http.HttpServletRequest;
@ControllerAdvice
public class ThymeleafErrorLoggingAdvice {
private static final Logger log = LoggerFactory.getLogger(ThymeleafErrorLoggingAdvice.class);
@ExceptionHandler(TemplateProcessingException.class)
public void logThymeleafError(TemplateProcessingException ex, HttpServletRequest request) {
String uri = request != null ? request.getRequestURI() : "unknown";
// URI와 핵심 메시지만 남김 (스택트레이스는 기존 Spring 로거에 맡김)
log.error("[Thymeleaf] 템플릿 처리 예외 - uri={}, message={}", uri, ex.getMessage());
// 기존 Spring MVC 흐름을 그대로 타도록 예외를 다시 던진다.
throw ex;
}
}
이렇게 해두면 application.log 에 예를 들어:
[Thymeleaf] 템플릿 처리 예외 - uri=/account/sec-test,
message=No visible SecurityExpressionHandler instance could be found ...
처럼 딱 한 줄씩 찍혀서 원인 파악과 검색이 아주 쉬워집니다.
5. 보너스 – sec:authorize 쪽 “내 코드 문제”도 같이 체크하기
설정 문제를 다 해결하고도, sec:authorize 쓰는 부분에서 이런 에러가 날 수 있습니다:
EL1008E: Property or field 'displayName' cannot be found on object of type
'org.springframework.security.core.userdetails.User'
이건 환경 문제가 아니라, 템플릿에서 principal 타입을 잘못 가정했기 때문입니다.예:
<!-- 이렇게 쓰면 기본 UserDetails(User)일 때 터질 수 있음 -->
<span th:text="${#authentication.principal.displayName}"></span>
- 어떤 로그인에서는 principal 이 PrincipalDetails (커스텀 타입) 이고,
- 어떤 로그인/환경에서는 org.springframework.security.core.userdetails.User 이라면,
- 후자의 경우 displayName 필드를 찾을 수 없어서 위와 같은 SpEL 에러가 납니다.
해결 방법:
- 공통적으로 존재하는 값만 바로 쓰기
<span sec:authentication="name"></span>
- 커스텀 필드를 쓰고 싶다면, 컨트롤러에서 Model 에 안전하게 넣어주기
model.addAttribute("displayName", computedDisplayName);
<span th:text="${displayName}">사용자</span>
- 혹은 principal 타입을 체크해서 분기 (SpEL에서는 조금 번거롭기 때문에 보통은 2번 방식을 추천)
6. 정리
→ Thymeleaf / extras 버전 불일치.→ Thymeleaf 3.0.x ↔ extras 3.0.x 로 맞춰준다.
- No visible SecurityExpressionHandler instance ...
→ 스프링 컨텍스트 안에 SecurityExpressionHandler<FilterInvocation> Bean 없음.→ DefaultWebSecurityExpressionHandler 를 Bean 으로 등록해 준다.
- 외장 Tomcat에서 sec:authorize 에러 로그 안 보이는 문제
→ logback-spring.xml 로 콘솔 + 파일 Appender 설정하고,ThymeleafErrorLoggingAdvice 로 TemplateProcessingException 을 한 줄씩 남긴다.
- 그 외 sec:authorize 관련 SpEL 에러
→ 템플릿에서 principal 의 실제 타입과 필드 존재 여부를 잘못 가정한 경우.→ 공통 필드(name)만 쓰거나, 컨트롤러에서 Model 로 안전하게 전달한다.위 조합으로 설정 + 코드까지 깔끔하게 정리하면,운영 서버의 외장 Tomcat + WAR 환경에서도 sec:authorize 를 안전하게 쓰고,문제가 생겨도 application.log 한 줄만 보고 바로 원인을 찾을 수 있습니다.