Thymeleaf

튜토리얼: Thymeleaf + Spring

서문

이 튜토리얼은 Thymeleaf가 Spring Framework, 특히 (하지만 이에 국한되지 않고) Spring MVC와 어떻게 통합될 수 있는지 설명합니다.

Thymeleaf는 Spring Framework의 5.x와 6.x 버전 모두에 대한 통합을 제공하며, 이는 thymeleaf-spring5thymeleaf-spring6라는 두 개의 별도 라이브러리로 제공됩니다. 이 라이브러리들은 별도의 .jar 파일(thymeleaf-spring5-{version}.jarthymeleaf-spring6-{version}.jar)로 패키지되어 있으며, 애플리케이션에서 Thymeleaf의 Spring 통합을 사용하려면 이를 클래스패스에 추가해야 합니다.

이 튜토리얼의 코드 샘플과 예제 애플리케이션은 Spring 6.x와 그에 해당하는 Thymeleaf 통합을 사용하지만, 이 텍스트의 내용은 Spring 5.x에도 유효합니다. 애플리케이션이 Spring 5.x를 사용하는 경우, 코드 샘플에서 org.thymeleaf.spring6 패키지를 org.thymeleaf.spring5로 바꾸기만 하면 됩니다.

1 Thymeleaf를 Spring과 통합하기

Thymeleaf는 Spring MVC 애플리케이션에서 JSP를 완전히 대체할 수 있는 기능을 갖춘 Spring 통합 세트를 제공합니다.

이러한 통합을 통해 다음과 같은 작업을 수행할 수 있습니다:

  • Spring MVC @Controller 객체의 매핑된 메소드가 JSP와 마찬가지로 Thymeleaf가 관리하는 템플릿으로 포워드하도록 합니다.
  • 템플릿에서 OGNL 대신 Spring Expression Language (Spring EL)를 사용합니다.
  • 폼 백업 빈 및 결과 바인딩과 완전히 통합된 템플릿에서 폼을 만들며, 속성 편집기, 변환 서비스 및 유효성 검사 오류 처리 사용을 포함합니다.
  • 일반적인 MessageSource 객체를 통해 Spring이 관리하는 메시지 파일의 국제화 메시지를 표시합니다.
  • Spring의 자체 리소스 해결 메커니즘을 사용하여 템플릿을 해결합니다.

이 튜토리얼을 완전히 이해하려면 먼저 표준 방언을 깊이 있게 설명하는 “Using Thymeleaf” 튜토리얼을 읽어야 합니다.

2 Spring 표준 방언

더 쉽고 나은 통합을 위해 Thymeleaf는 Spring과 올바르게 작동하는 데 필요한 모든 기능을 특별히 구현하는 방언을 제공합니다.

이 특정 방언은 Thymeleaf 표준 방언을 기반으로 하며 org.thymeleaf.spring6.dialect.SpringStandardDialect라는 클래스에서 구현됩니다. 이 클래스는 실제로 org.thymeleaf.standard.StandardDialect를 확장합니다.

표준 방언에 이미 존재하는 모든 기능 외에도 SpringStandard 방언은 다음과 같은 특정 기능을 도입합니다:

  • OGNL 대신 Spring Expression Language (Spring EL 또는 SpEL)를 변수 표현 언어로 사용합니다. 따라서 모든 ${...}*{...} 표현식은 Spring의 Expression Language 엔진에 의해 평가됩니다. 또한 Spring EL 컴파일러에 대한 지원도 가능합니다.
  • SpringEL의 구문을 사용하여 애플리케이션 컨텍스트의 모든 빈에 액세스할 수 있습니다: ${@myBean.doSomething()}
  • 폼 처리를 위한 새로운 속성: th:field, th:errorsth:errorclass, 그리고 폼 명령 선택에 사용할 수 있는 th:object의 새로운 구현.
  • spring:theme JSP 사용자 정의 태그와 동등한 표현 객체 및 메소드인 #themes.code(...).
  • spring:mvcUrl(...) JSP 사용자 정의 함수와 동등한 표현 객체 및 메소드인 #mvc.uri(...).

대부분의 경우 구성의 일부로 일반 TemplateEngine 객체에서 이 방언을 직접 사용해서는 안 됩니다. 매우 특정한 Spring 통합 요구 사항이 없는 한, 대신 필요한 모든 구성 단계를 자동으로 수행하는 새로운 템플릿 엔진 클래스의 org.thymeleaf.spring6.SpringTemplateEngine 인스턴스를 만들어야 합니다.

빈 구성 예시:

@Bean
public SpringResourceTemplateResolver templateResolver(){
    // SpringResourceTemplateResolver는 Spring의 자체 리소스 해결 인프라와 자동으로 통합되므로 매우 권장됩니다.
    SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
    templateResolver.setApplicationContext(this.applicationContext);
    templateResolver.setPrefix("/WEB-INF/templates/");
    templateResolver.setSuffix(".html");
    // HTML은 기본값이며, 여기에는 명확성을 위해 추가되었습니다.
    templateResolver.setTemplateMode(TemplateMode.HTML);
    // 템플릿 캐시는 기본적으로 true입니다. 수정 시 템플릿을 자동으로 업데이트하려면 false로 설정하세요.
    templateResolver.setCacheable(true);
    return templateResolver;
}

@Bean
public SpringTemplateEngine templateEngine(){
    // SpringTemplateEngine은 자동으로 SpringStandardDialect를 적용하고
    // Spring의 자체 MessageSource 메시지 해결 메커니즘을 활성화합니다.
    SpringTemplateEngine templateEngine = new SpringTemplateEngine();
    templateEngine.setTemplateResolver(templateResolver());
    // Spring 4.2.4 이상에서 SpringEL 컴파일러를 활성화하면
    // 대부분의 시나리오에서 실행 속도를 높일 수 있지만,
    // 한 템플릿의 표현식이 다른 데이터 타입에서 재사용되는 특정 경우와
    // 호환되지 않을 수 있으므로 이 플래그는 더 안전한 하위 호환성을 위해
    // 기본적으로 "false"입니다.
    templateEngine.setEnableSpringELCompiler(true);
    return templateEngine;
}

또는 Spring의 XML 기반 구성을 사용하는 경우:

<!-- SpringResourceTemplateResolver는 Spring의 자체 리소스 해결 인프라와 자동으로 통합되므로 매우 권장됩니다. -->
<bean id="templateResolver"
       class="org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver">
  <property name="prefix" value="/WEB-INF/templates/" />
  <property name="suffix" value=".html" />
  <!-- HTML은 기본값이며, 여기에는 명확성을 위해 추가되었습니다. -->
  <property name="templateMode" value="HTML" />
  <!-- 템플릿 캐시는 기본적으로 true입니다. 수정 시 템플릿을 자동으로 업데이트하려면 false로 설정하세요. -->
  <property name="cacheable" value="true" />
</bean>

<!-- SpringTemplateEngine은 자동으로 SpringStandardDialect를 적용하고 -->
<!-- Spring의 자체 MessageSource 메시지 해결 메커니즘을 활성화합니다. -->
<bean id="templateEngine"
      class="org.thymeleaf.spring6.SpringTemplateEngine">
  <property name="templateResolver" ref="templateResolver" />
  <!-- SpringEL 컴파일러를 활성화하면 대부분의 시나리오에서 실행 속도를 높일 수 있지만, -->
  <!-- 한 템플릿의 표현식이 다른 데이터 타입에서 재사용되는 특정 경우와 -->
  <!-- 호환되지 않을 수 있으므로 이 플래그는 더 안전한 하위 호환성을 위해 -->
  <!-- 기본적으로 "false"입니다. -->
  <property name="enableSpringELCompiler" value="true" />
</bean>

3 뷰와 뷰 리졸버

3.1 Spring MVC의 뷰와 뷰 리졸버

Spring MVC에는 템플릿 시스템의 핵심을 구성하는 두 가지 인터페이스가 있습니다:

  • org.springframework.web.servlet.View
  • org.springframework.web.servlet.ViewResolver

View는 애플리케이션의 페이지를 모델링하고 빈으로 정의하여 동작을 수정하고 미리 정의할 수 있게 합니다. View는 실제 HTML 인터페이스를 렌더링하는 역할을 하며, 일반적으로 Thymeleaf와 같은 템플릿 엔진의 실행을 통해 이루어집니다.

ViewResolver는 특정 작업 및 로케일에 대한 View 객체를 얻는 역할을 하는 객체입니다. 일반적으로 컨트롤러는 ViewResolver에게 특정 이름(컨트롤러 메소드가 반환하는 문자열)의 뷰로 포워드하도록 요청하고, 그런 다음 애플리케이션의 모든 뷰 리졸버가 순서대로 실행되어 그 중 하나가 해당 뷰를 해결할 수 있을 때까지 진행됩니다. 이 경우 View 객체가 반환되고 HTML 렌더링을 위해 제어권이 넘어갑니다.

애플리케이션의 모든 페이지를 View로 정의할 필요는 없으며, 비표준적이거나 특정 방식으로 구성하고자 하는 동작을 가진 페이지만 View로 정의하면 됩니다(예: 특별한 빈을 연결하는 경우). 일반적으로 ViewResolver가 해당하는 빈이 없는 뷰를 요청받으면, 새로운 View 객체가 임시로 생성되어 반환됩니다.

과거 Spring MVC 애플리케이션에서 JSP+JSTL ViewResolver의 일반적인 구성은 다음과 같았습니다:

<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
  <property name="viewClass" value="org.springframework.web.servlet.view.JstlView" />
  <property name="prefix" value="/WEB-INF/jsps/" />
  <property name="suffix" value=".jsp" />
  <property name="order" value="2" />
  <property name="viewNames" value="*jsp" />
</bean>

속성을 빠르게 살펴보면 구성 방법을 알 수 있습니다:

  • viewClass는 View 인스턴스의 클래스를 설정합니다. 이는 JSP 리졸버에 필요하지만 Thymeleaf를 사용할 때는 전혀 필요하지 않습니다.
  • prefixsuffix는 Thymeleaf의 TemplateResolver 객체에 있는 동일한 이름의 속성과 유사하게 작동합니다.
  • order는 체인에서 ViewResolver가 쿼리될 순서를 설정합니다.
  • viewNames를 사용하면 이 ViewResolver가 해결할 뷰 이름을 (와일드카드를 사용하여) 정의할 수 있습니다.

3.2 Thymeleaf의 뷰와 뷰 리졸버

Thymeleaf는 위에서 언급한 두 인터페이스에 대한 구현을 제공합니다:

  • org.thymeleaf.spring6.view.ThymeleafView
  • org.thymeleaf.spring6.view.ThymeleafViewResolver

이 두 클래스는 컨트롤러 실행 결과로 Thymeleaf 템플릿을 처리하는 역할을 합니다.

Thymeleaf View Resolver의 구성은 JSP의 구성과 매우 유사합니다:

@Bean
public ThymeleafViewResolver viewResolver(){
    ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
    viewResolver.setTemplateEngine(templateEngine());
    // 참고: 'order'와 'viewNames'는 선택사항입니다
    viewResolver.setOrder(1);
    viewResolver.setViewNames(new String[] {".html", ".xhtml"});
    return viewResolver;
}

…또는 XML에서:

<bean class="org.thymeleaf.spring6.view.ThymeleafViewResolver">
  <property name="templateEngine" ref="templateEngine" />
  <!-- 참고: 'order'와 'viewNames'는 선택사항입니다 -->
  <property name="order" value="1" />
  <property name="viewNames" value="*.html,*.xhtml" />
</bean>

templateEngine 파라미터는 물론 이전 장에서 정의한 SpringTemplateEngine 객체입니다. 다른 두 개(orderviewNames)는 모두 선택사항이며, 이전에 본 JSP ViewResolver와 동일한 의미를 가집니다.

prefixsuffix 파라미터가 필요하지 않다는 점에 유의하세요. 이는 이미 Template Resolver에 지정되어 있기 때문입니다(이는 다시 Template Engine에 전달됩니다).

그렇다면 View 빈을 정의하고 여기에 몇 가지 정적 변수를 추가하고 싶다면 어떻게 해야 할까요? 간단합니다. 그냥 프로토타입 빈으로 정의하면 됩니다:

@Bean
@Scope("prototype")
public ThymeleafView mainView() {
    ThymeleafView view = new ThymeleafView("main"); // templateName = 'main'
    view.setStaticVariables(
        Collections.singletonMap("footer", "The ACME Fruit Company"));
    return view;
}

이렇게 하면 빈 이름(mainView, 이 경우)으로 선택하여 특정하게 이 뷰 빈을 실행할 수 있게 됩니다.

4 Spring Thyme 씨앗 발아기 관리자(Seed Starter Manager)

이 가이드의 현재 및 향후 장에서 보여주는 예제의 소스 코드는 Spring Thyme Seed Starter Manager (STSM) 예제 앱에서 찾을 수 있습니다:

4.1 개념

Thymeleaf에서 우리는 타임의 열렬한 팬이며, 매 봄마다 좋은 흙과 우리가 좋아하는 씨앗으로 씨앗 발아 키트를 준비하고, 스페인 태양 아래에 두고 새로운 식물이 자라기를 인내심 있게 기다립니다.

하지만 올해는 컨테이너의 각 셀에 어떤 씨앗이 있는지 알기 위해 씨앗 발아 컨테이너에 라벨을 붙이는 것에 지쳐서, Spring MVC와 Thymeleaf를 사용하여 발아기를 카탈로그화하는 데 도움이 되는 애플리케이션을 준비하기로 했습니다: The Spring Thyme SeedStarter Manager.

STSM 프론트 페이지

Using Thymeleaf 튜토리얼에서 개발한 Good Thymes Virtual Grocery 애플리케이션과 유사하게, STSM은 Spring MVC의 템플릿 엔진으로서 Thymeleaf 통합의 가장 중요한 측면을 예시하는 데 도움이 될 것입니다.

4.2 비즈니스 계층

우리 애플리케이션에는 매우 간단한 비즈니스 계층이 필요할 것입니다. 먼저 모델 엔티티를 살펴보겠습니다:

STSM 모델

몇 가지 매우 간단한 서비스 클래스가 필요한 비즈니스 메서드를 제공할 것입니다. 예를 들면:

@Service
public class SeedStarterService {

    @Autowired
    private SeedStarterRepository seedstarterRepository;

    public List<SeedStarter> findAll() {
        return this.seedstarterRepository.findAll();
    }

    public void add(final SeedStarter seedStarter) {
        this.seedstarterRepository.add(seedStarter);
    }

}

그리고:

@Service
public class VarietyService {

    @Autowired
    private VarietyRepository varietyRepository;

    public List<Variety> findAll() {
        return this.varietyRepository.findAll();
    }

    public Variety findById(final Integer id) {
        return this.varietyRepository.findById(id);
    }

}

4.3 Spring MVC 구성

다음으로 애플리케이션을 위한 Spring MVC 구성을 설정해야 합니다. 이는 리소스 처리나 어노테이션 스캐닝과 같은 표준 Spring MVC 아티팩트뿐만 아니라 Template Engine과 View Resolver 인스턴스의 생성도 포함할 것입니다.

@Configuration
@EnableWebMvc
@ComponentScan
public class SpringWebConfig
        extends WebMvcConfigurerAdapter implements ApplicationContextAware {

    private ApplicationContext applicationContext;


    public SpringWebConfig() {
        super();
    }


    public void setApplicationContext(final ApplicationContext applicationContext)
            throws BeansException {
        this.applicationContext = applicationContext;
    }



    /* ******************************************************************* */
    /*  일반적인 구성 아티팩트                                               */
    /*  정적 리소스, i18n 메시지, 포맷터 (변환 서비스)                        */
    /* ******************************************************************* */

    @Override
    public void addResourceHandlers(final ResourceHandlerRegistry registry) {
        super.addResourceHandlers(registry);
        registry.addResourceHandler("/images/**").addResourceLocations("/images/");
        registry.addResourceHandler("/css/**").addResourceLocations("/css/");
        registry.addResourceHandler("/js/**").addResourceLocations("/js/");
    }

    @Bean
    public ResourceBundleMessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("Messages");
        return messageSource;
    }

    @Override
    public void addFormatters(final FormatterRegistry registry) {
        super.addFormatters(registry);
        registry.addFormatter(varietyFormatter());
        registry.addFormatter(dateFormatter());
    }

    @Bean
    public VarietyFormatter varietyFormatter() {
        return new VarietyFormatter();
    }

    @Bean
    public DateFormatter dateFormatter() {
        return new DateFormatter();
    }



    /* **************************************************************** */
    /*  THYMELEAF-특정 아티팩트                                          */
    /*  TemplateResolver <- TemplateEngine <- ViewResolver              */
    /* **************************************************************** */

    @Bean
    public SpringResourceTemplateResolver templateResolver(){
        // SpringResourceTemplateResolver는 Spring의 자체 리소스 해결 인프라와 자동으로
        // 통합되며, 이는 매우 권장됩니다.
        SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
        templateResolver.setApplicationContext(this.applicationContext);
        templateResolver.setPrefix("/WEB-INF/templates/");
        templateResolver.setSuffix(".html");
        // HTML은 기본값이며, 여기에는 명확성을 위해 추가되었습니다.
        templateResolver.setTemplateMode(TemplateMode.HTML);
        // 템플릿 캐시는 기본적으로 true입니다. 수정 시 템플릿을
        // 자동으로 업데이트하려면 false로 설정하세요.
        templateResolver.setCacheable(true);
        return templateResolver;
    }

    @Bean
    public SpringTemplateEngine templateEngine(){
        // SpringTemplateEngine은 자동으로 SpringStandardDialect를 적용하고
        // Spring의 자체 MessageSource 메시지 해결 메커니즘을 활성화합니다.
        SpringTemplateEngine templateEngine = new SpringTemplateEngine();
        templateEngine.setTemplateResolver(templateResolver());
        // Spring 4.2.4 이상에서 SpringEL 컴파일러를 활성화하면
        // 대부분의 시나리오에서 실행 속도를 높일 수 있지만,
        // 한 템플릿의 표현식이 다른 데이터 타입에서 재사용되는 특정 경우와
        // 호환되지 않을 수 있으므로 이 플래그는 더 안전한 하위 호환성을 위해
        // 기본적으로 "false"입니다.
        templateEngine.setEnableSpringELCompiler(true);
        return templateEngine;
    }


    @Bean
    public ThymeleafViewResolver viewResolver(){
        ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
        viewResolver.setTemplateEngine(templateEngine());
        return viewResolver;
    }

}

4.4 컨트롤러

물론, 우리 애플리케이션을 위한 컨트롤러도 필요할 것입니다. STSM은 씨앗 발아기 목록과 새로운 발아기를 추가하기 위한 폼이 있는 웹 페이지 하나만 포함할 것이므로, 모든 서버 상호작용을 위해 하나의 컨트롤러 클래스만 작성할 것입니다:

@Controller
public class SeedStarterMngController {

    @Autowired
    private VarietyService varietyService;

    @Autowired
    private SeedStarterService seedStarterService;

    ...

}

이제 이 컨트롤러 클래스에 무엇을 추가할 수 있는지 살펴보겠습니다.

모델 속성

먼저 페이지에서 필요한 몇 가지 모델 속성을 추가할 것입니다:

@ModelAttribute("allTypes")
public List<Type> populateTypes() {
    return Arrays.asList(Type.ALL);
}

@ModelAttribute("allFeatures")
public List<Feature> populateFeatures() {
    return Arrays.asList(Feature.ALL);
}

@ModelAttribute("allVarieties")
public List<Variety> populateVarieties() {
    return this.varietyService.findAll();
}

@ModelAttribute("allSeedStarters")
public List<SeedStarter> populateSeedStarters() {
    return this.seedStarterService.findAll();
}

매핑된 메서드

그리고 이제 컨트롤러의 가장 중요한 부분인 매핑된 메서드입니다: 폼 페이지를 보여주는 메서드 하나와 새로운 SeedStarter 객체를 추가하는 처리를 위한 메서드 하나입니다.

@RequestMapping({"/","/seedstartermng"})
public String showSeedstarters(final SeedStarter seedStarter) {
    seedStarter.setDatePlanted(Calendar.getInstance().getTime());
    return "seedstartermng";
}

@RequestMapping(value="/seedstartermng", params={"save"})
public String saveSeedstarter(
        final SeedStarter seedStarter, final BindingResult bindingResult, final ModelMap model) {
    if (bindingResult.hasErrors()) {
        return "seedstartermng";
    }
    this.seedStarterService.add(seedStarter);
    model.clear();
    return "redirect:/seedstartermng";
}

4.5 변환 서비스 구성

뷰 레이어에서 DateVariety 객체를 쉽게 포맷팅할 수 있도록 하기 위해, 우리는 애플리케이션을 구성하여 Spring ConversionService 객체가 생성되고 초기화되도록 했습니다(우리가 확장한 WebMvcConfigurerAdapter에 의해). 이 객체는 우리가 필요로 하는 두 개의 포맷터 객체로 초기화됩니다. 다시 한 번 살펴보겠습니다:

@Override
public void addFormatters(final FormatterRegistry registry) {
    super.addFormatters(registry);
    registry.addFormatter(varietyFormatter());
    registry.addFormatter(dateFormatter());
}

@Bean
public VarietyFormatter varietyFormatter() {
    return new VarietyFormatter();
}

@Bean
public DateFormatter dateFormatter() {
    return new DateFormatter();
}

Spring 포맷터org.springframework.format.Formatter 인터페이스의 구현체입니다. Spring 변환 인프라가 어떻게 작동하는지에 대한 더 자세한 정보는 spring.io의 문서를 참조하세요.

DateFormatter를 살펴보겠습니다. 이는 Messages.propertiesdate.format 메시지 키에 있는 포맷 문자열에 따라 날짜를 포맷팅합니다:

public class DateFormatter implements Formatter<Date> {

    @Autowired
    private MessageSource messageSource;


    public DateFormatter() {
        super();
    }

    public Date parse(final String text, final Locale locale) throws ParseException {
        final SimpleDateFormat dateFormat = createDateFormat(locale);
        return dateFormat.parse(text);
    }

    public String print(final Date object, final Locale locale) {
        final SimpleDateFormat dateFormat = createDateFormat(locale);
        return dateFormat.format(object);
    }

    private SimpleDateFormat createDateFormat(final Locale locale) {
        final String format = this.messageSource.getMessage("date.format", null, locale);
        final SimpleDateFormat dateFormat = new SimpleDateFormat(format);
        dateFormat.setLenient(false);
        return dateFormat;
    }

}

VarietyFormatter는 우리의 Variety 엔티티와 폼에서 사용하고자 하는 방식(기본적으로 id 필드 값) 사이를 자동으로 변환합니다:

public class VarietyFormatter implements Formatter<Variety> {

    @Autowired
    private VarietyService varietyService;


    public VarietyFormatter() {
        super();
    }

    public Variety parse(final String text, final Locale locale) throws ParseException {
        final Integer varietyId = Integer.valueOf(text);
        return this.varietyService.findById(varietyId);
    }


    public String print(final Variety object, final Locale locale) {
        return (object != null ? object.getId().toString() : "");
    }

}

이러한 포맷터가 우리의 데이터 표시 방식에 어떤 영향을 미치는지 나중에 더 자세히 알아보겠습니다.

5 씨앗 발아기 데이터 나열하기

우리의 /WEB-INF/templates/seedstartermng.html 페이지가 처음 보여줄 것은 현재 저장된 씨앗 발아기의 목록입니다. 이를 위해 우리는 몇 가지 외부화된 메시지와 모델 속성에 대한 표현식 평가가 필요할 것입니다. 다음과 같습니다:

<div class="seedstarterlist" th:unless="${#lists.isEmpty(allSeedStarters)}">
  <h2 th:text="#{title.list}">List of Seed Starters</h2>

  <table>
    <thead>
      <tr>
        <th th:text="#{seedstarter.datePlanted}">Date Planted</th>
        <th th:text="#{seedstarter.covered}">Covered</th>
        <th th:text="#{seedstarter.type}">Type</th>
        <th th:text="#{seedstarter.features}">Features</th>
        <th th:text="#{seedstarter.rows}">Rows</th>
      </tr>
    </thead>
    <tbody>
      <tr th:each="sb : ${allSeedStarters}">
        <td th:text="${{sb.datePlanted}}">13/01/2011</td>
        <td th:text="#{|bool.${sb.covered}|}">yes</td>
        <td th:text="#{|seedstarter.type.${sb.type}|}">Wireframe</td>
        <td
          th:text="${#strings.arrayJoin(
                           #messages.arrayMsg(
                               #strings.arrayPrepend(sb.features,'seedstarter.feature.')),
                           ', ')}"
        >
          Electric Heating, Turf
        </td>
        <td>
          <table>
            <tbody>
              <tr th:each="row,rowStat : ${sb.rows}">
                <td th:text="${rowStat.count}">1</td>
                <td th:text="${row.variety.name}">Thymus Thymi</td>
                <td th:text="${row.seedsPerCell}">12</td>
              </tr>
            </tbody>
          </table>
        </td>
      </tr>
    </tbody>
  </table>
</div>

여기에는 볼 것이 많습니다. 각 부분을 따로 살펴보겠습니다.

먼저, 이 섹션은 씨앗 발아기가 있는 경우에만 표시됩니다. 이는 th:unless 속성과 #lists.isEmpty(...) 함수를 사용하여 달성합니다.

<div
  class="seedstarterlist"
  th:unless="${#lists.isEmpty(allSeedStarters)}"
></div>

#lists와 같은 모든 유틸리티 객체는 표준 방언의 OGNL 표현식에서와 마찬가지로 Spring EL 표현식에서도 사용 가능하다는 점에 주목하세요.

다음으로 볼 수 있는 것은 많은 국제화(외부화)된 텍스트입니다. 예를 들면:

<h2 th:text="#{title.list}">List of Seed Starters</h2>

<table>
  <thead>
    <tr>
      <th th:text="#{seedstarter.datePlanted}">Date Planted</th>
      <th th:text="#{seedstarter.covered}">Covered</th>
      <th th:text="#{seedstarter.type}">Type</th>
      <th th:text="#{seedstarter.features}">Features</th>
      <th th:text="#{seedstarter.rows}">Rows</th>
      ...
    </tr>
  </thead>
</table>

이것이 Spring MVC 애플리케이션이므로, 우리는 이미 Spring 구성에서 MessageSource 빈을 정의했습니다(MessageSource 객체는 Spring MVC에서 외부화된 텍스트를 관리하는 표준 방법입니다):

@Bean
public ResourceBundleMessageSource messageSource() {
    ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
    messageSource.setBasename("Messages");
    return messageSource;
}

…그리고 그 basename 속성은 우리의 클래스패스에 Messages_es.propertiesMessages_en.properties와 같은 파일이 있을 것임을 나타냅니다. 스페인어 버전을 살펴보겠습니다:

title.list=Lista de semilleros

date.format=dd/MM/yyyy
bool.true=sí
bool.false=no

seedstarter.datePlanted=Fecha de plantación
seedstarter.covered=Cubierto
seedstarter.type=Tipo
seedstarter.features=Características
seedstarter.rows=Filas

seedstarter.type.WOOD=Madera
seedstarter.type.PLASTIC=Plástico

seedstarter.feature.SEEDSTARTER_SPECIFIC_SUBSTRATE=Sustrato específico para semilleros
seedstarter.feature.FERTILIZER=Fertilizante
seedstarter.feature.PH_CORRECTOR=Corrector de PH

표 목록의 첫 번째 열에는 씨앗 발아기가 준비된 날짜를 표시할 것입니다. 하지만 우리의 DateFormatter에서 정의한 방식으로 포맷팅하여 표시할 것입니다. 이를 위해 우리는 이중 중괄호 구문(${{...}})을 사용할 것입니다. 이는 자동으로 Spring Conversion Service를 적용하며, 여기에는 우리가 구성에 등록한 DateFormatter가 포함됩니다.

<td th:text="${{sb.datePlanted}}">13/01/2011</td>

다음은 씨앗 발아기 컨테이너가 덮개로 덮여 있는지 여부를 표시하는 것입니다. 이는 boolean covered 빈 속성의 값을 리터럴 대체 표현식을 사용하여 국제화된 “예” 또는 “아니오”로 변환합니다:

<td th:text="#{|bool.${sb.covered}|}">yes</td>

이제 씨앗 발아기 컨테이너의 종류를 표시해야 합니다. Type은 두 개의 값(WOODPLASTIC)을 가진 Java enum이며, 이것이 우리가 Messages 파일에 seedstarter.type.WOODseedstarter.type.PLASTIC이라는 두 개의 속성을 정의한 이유입니다.

하지만 타입의 국제화된 이름을 얻기 위해, 우리는 표현식을 통해 enum 값에 seedstarter.type. 접두사를 추가해야 하며, 그 결과를 메시지 키로 사용할 것입니다:

<td th:text="#{|seedstarter.type.${sb.type}|}">Wireframe</td>

이 목록에서 가장 어려운 부분은 특징 열입니다. 여기서는 우리 컨테이너의 Feature enum의 배열 형태로 제공되는 모든 특징을 쉼표로 구분하여 표시하고자 합니다. 예를 들면 “Electric Heating, Turf”와 같이 말입니다.

이것이 특히 어려운 이유는 이 enum 값들도 Type과 마찬가지로 외부화되어야 하기 때문입니다. 따라서 처리 과정은 다음과 같습니다:

  1. features 배열의 모든 요소에 해당 접두사를 붙입니다.
  2. 1단계에서 얻은 모든 키에 해당하는 외부화된 메시지를 얻습니다.
  3. 2단계에서 얻은 모든 메시지를 쉼표를 구분자로 사용하여 결합합니다.

이를 위해 다음과 같은 코드를 만듭니다:

<td
  th:text="${#strings.arrayJoin(
                   #messages.arrayMsg(
                       #strings.arrayPrepend(sb.features,'seedstarter.feature.')),
                   ', ')}"
>
  Electric Heating, Turf
</td>

우리 목록의 마지막 열은 실제로 꽤 간단할 것입니다. 컨테이너의 각 행의 내용을 보여주는 중첩 테이블이 있더라도 말입니다:

<td>
  <table>
    <tbody>
      <tr th:each="row,rowStat : ${sb.rows}">
        <td th:text="${rowStat.count}">1</td>
        <td th:text="${row.variety.name}">Thymus Thymi</td>
        <td th:text="${row.seedsPerCell}">12</td>
      </tr>
    </tbody>
  </table>
</td>

6 폼 만들기

6.1 커맨드 객체 다루기

커맨드 객체는 Spring MVC가 폼 백업 빈에 부여하는 이름으로, 폼의 필드를 모델링하고 브라우저 측에서 사용자가 입력한 값을 설정하고 얻기 위해 프레임워크가 사용할 getter와 setter 메서드를 제공하는 객체를 말합니다.

Thymeleaf는 <form> 태그에 th:object 속성을 사용하여 커맨드 객체를 지정해야 합니다:

<form
  action="#"
  th:action="@{/seedstartermng}"
  th:object="${seedStarter}"
  method="post"
>
  ...
</form>

이는 th:object의 다른 용도와 일관되지만, 실제로 이 특정 시나리오는 Spring MVC의 인프라와 올바르게 통합하기 위해 몇 가지 제한사항을 추가합니다:

  • 폼 태그의 th:object 속성 값은 모델 속성의 이름만을 지정하는 변수 표현식(${...})이어야 하며, 속성 탐색은 허용되지 않습니다. 이는 ${seedStarter}와 같은 표현식은 유효하지만 ${seedStarter.data}는 유효하지 않다는 것을 의미합니다.
  • <form> 태그 내에서는 다른 th:object 속성을 지정할 수 없습니다. 이는 HTML 폼이 중첩될 수 없다는 사실과 일치합니다.

6.2 입력 필드

이제 폼에 입력 필드를 추가하는 방법을 살펴보겠습니다:

<input type="text" th:field="*{datePlanted}" />

보시다시피, 여기에 새로운 속성인 th:field를 소개하고 있습니다. 이는 Spring MVC 통합을 위한 매우 중요한 기능입니다. 왜냐하면 이 속성이 입력을 폼 백업 빈의 속성과 바인딩하는 모든 복잡한 작업을 수행하기 때문입니다. Spring MVC의 JSP 태그 라이브러리에서 태그의 path 속성과 동등한 것으로 볼 수 있습니다.

th:field 속성은 <input>, <select> 또는 <textarea> 태그에 부착되었는지 (그리고 <input> 태그의 특정 유형에 따라서도) 다르게 동작합니다. 이 경우(input[type=text])에는 위의 코드 줄은 다음과 유사합니다:

<input
  type="text"
  id="datePlanted"
  name="datePlanted"
  th:value="*{datePlanted}"
/>

…하지만 실제로는 이보다 조금 더 많은 것을 수행합니다. th:field는 등록된 Spring Conversion Service를 적용하며, 여기에는 우리가 이전에 본 DateFormatter도 포함됩니다(필드 표현식이 이중 대괄호로 묶여 있지 않더라도). 이 덕분에 날짜가 올바르게 포맷팅되어 표시됩니다.

th:field 속성의 값은 선택 표현식(*{...})이어야 합니다. 이는 폼 백업 빈에서 평가되고 컨텍스트 변수(또는 Spring MVC 용어로 모델 속성)에서 평가되지 않기 때문에 의미가 있습니다.

th:object의 표현식과는 달리, 이 표현식들은 속성 탐색을 포함할 수 있습니다(사실 <form:input> JSP 태그의 path 속성에 허용되는 모든 표현식이 여기서도 허용됩니다).

th:field<input type="datetime" ... />, <input type="color" ... /> 등 HTML5에서 도입된 새로운 유형의 <input> 요소도 이해한다는 점에 주목하세요. 이는 Spring MVC에 완전한 HTML5 지원을 효과적으로 추가합니다.

6.3 체크박스 필드

th:field를 사용하여 체크박스 입력도 정의할 수 있습니다. HTML 페이지의 예를 살펴보겠습니다:

<div>
  <label th:for="${#ids.next('covered')}" th:text="#{seedstarter.covered}"
    >Covered</label
  >
  <input type="checkbox" th:field="*{covered}" />
</div>

여기에는 체크박스 자체 외에도 외부화된 레이블과 체크박스 입력의 id 속성에 적용될 값을 얻기 위한 #ids.next('covered') 함수의 사용과 같은 세밀한 내용이 있습니다.

왜 이 필드의 id 속성을 동적으로 생성해야 할까요? 체크박스는 잠재적으로 다중 값을 가질 수 있기 때문에, 동일한 속성에 대한 각 체크박스 입력이 서로 다른 id 값을 가지도록 하기 위해 id 값에는 항상 시퀀스 번호가 접미사로 붙습니다(내부적으로 #ids.seq(...) 함수를 사용합니다).

이를 더 쉽게 이해하려면 이러한 다중 값 체크박스 필드를 살펴보겠습니다:

<ul>
  <li th:each="feat : ${allFeatures}">
    <input type="checkbox" th:field="*{features}" th:value="${feat}" />
    <label
      th:for="${#ids.prev('features')}"
      th:text="#{${'seedstarter.feature.' + feat}}"
      >Heating</label
    >
  </li>
</ul>

이번에는 th:value 속성을 추가했습니다. 왜냐하면 features 필드는 covered와 같은 불리언이 아니라 값의 배열이기 때문입니다.

이 코드로 생성된 HTML 출력을 살펴보겠습니다:

<ul>
  <li>
    <input
      id="features1"
      name="features"
      type="checkbox"
      value="SEEDSTARTER_SPECIFIC_SUBSTRATE"
    />
    <input name="_features" type="hidden" value="on" />
    <label for="features1">Seed starter-specific substrate</label>
  </li>
  <li>
    <input id="features2" name="features" type="checkbox" value="FERTILIZER" />
    <input name="_features" type="hidden" value="on" />
    <label for="features2">Fertilizer used</label>
  </li>
  <li>
    <input
      id="features3"
      name="features"
      type="checkbox"
      value="PH_CORRECTOR"
    />
    <input name="_features" type="hidden" value="on" />
    <label for="features3">PH Corrector used</label>
  </li>
</ul>

여기서 각 입력의 id 속성에 시퀀스 접미사가 추가되는 것을 볼 수 있으며, #ids.prev(...) 함수를 사용하여 특정 입력 id에 대해 생성된 마지막 시퀀스 값을 검색할 수 있습니다.

name="_features"를 가진 숨겨진 입력에 대해 걱정하지 마세요: 이들은 브라우저가 폼 제출 시 서버로 체크되지 않은 체크박스 값을 보내지 않는 문제를 피하기 위해 자동으로 추가됩니다.

또한 폼 백업 빈의 features 속성에 선택된 값이 있었다면, th:field가 이를 처리하여 해당 입력 태그에 checked="checked" 속성을 추가했을 것입니다.

6.4 라디오 버튼 필드

라디오 버튼 필드는 불리언이 아닌(다중 값) 체크박스와 유사한 방식으로 지정됩니다 —물론 다중 값이 아니라는 점을 제외하고:

<ul>
  <li th:each="ty : ${allTypes}">
    <input type="radio" th:field="*{type}" th:value="${ty}" />
    <label
      th:for="${#ids.prev('type')}"
      th:text="#{${'seedstarter.type.' + ty}}"
      >Wireframe</label
    >
  </li>
</ul>

6.5 드롭다운/리스트 셀렉터

셀렉트 필드는 두 부분으로 구성됩니다: <select> 태그와 그 안에 중첩된 <option> 태그들입니다. 이런 종류의 필드를 생성할 때는 <select> 태그에만 th:field 속성을 포함해야 하지만, 중첩된 <option> 태그의 th:value 속성은 매우 중요합니다. 이 속성들이 현재 선택된 옵션을 알 수 있는 수단을 제공하기 때문입니다(불리언이 아닌 체크박스와 라디오 버튼과 유사한 방식으로).

type 필드를 드롭다운 셀렉트로 다시 만들어 보겠습니다:

<select th:field="*{type}">
  <option
    th:each="type : ${allTypes}"
    th:value="${type}"
    th:text="#{${'seedstarter.type.' + type}}"
  >
    Wireframe
  </option>
</select>

이 시점에서 이 코드를 이해하는 것은 꽤 쉽습니다. 속성 우선순위가 <option> 태그 자체에 th:each 속성을 설정할 수 있게 해준다는 점만 주목하세요.

6.6 동적 필드

Spring MVC의 고급 폼 필드 바인딩 기능 덕분에, 복잡한 Spring EL 표현식을 사용하여 동적 폼 필드를 폼 백업 빈에 바인딩할 수 있습니다. 이를 통해 SeedStarter 빈에 새로운 Row 객체를 생성하고, 사용자 요청에 따라 이러한 행의 필드를 폼에 추가할 수 있습니다.

이를 위해 컨트롤러에 새로운 매핑된 메서드 몇 개가 필요합니다. 이 메서드들은 특정 요청 파라미터의 존재 여부에 따라 SeedStarter에 행을 추가하거나 제거합니다:

@RequestMapping(value="/seedstartermng", params={"addRow"})
public String addRow(final SeedStarter seedStarter, final BindingResult bindingResult) {
    seedStarter.getRows().add(new Row());
    return "seedstartermng";
}

@RequestMapping(value="/seedstartermng", params={"removeRow"})
public String removeRow(
        final SeedStarter seedStarter, final BindingResult bindingResult,
        final HttpServletRequest req) {
    final Integer rowId = Integer.valueOf(req.getParameter("removeRow"));
    seedStarter.getRows().remove(rowId.intValue());
    return "seedstartermng";
}

이제 폼에 동적 테이블을 추가할 수 있습니다:

<table>
  <thead>
    <tr>
      <th th:text="#{seedstarter.rows.head.rownum}">Row</th>
      <th th:text="#{seedstarter.rows.head.variety}">Variety</th>
      <th th:text="#{seedstarter.rows.head.seedsPerCell}">Seeds per cell</th>
      <th>
        <button type="submit" name="addRow" th:text="#{seedstarter.row.add}">
          Add row
        </button>
      </th>
    </tr>
  </thead>
  <tbody>
    <tr th:each="row,rowStat : *{rows}">
      <td th:text="${rowStat.count}">1</td>
      <td>
        <select th:field="*{rows[__${rowStat.index}__].variety}">
          <option
            th:each="var : ${allVarieties}"
            th:value="${var.id}"
            th:text="${var.name}"
          >
            Thymus Thymi
          </option>
        </select>
      </td>
      <td>
        <input
          type="text"
          th:field="*{rows[__${rowStat.index}__].seedsPerCell}"
        />
      </td>
      <td>
        <button
          type="submit"
          name="removeRow"
          th:value="${rowStat.index}"
          th:text="#{seedstarter.row.remove}"
        >
          Remove row
        </button>
      </td>
    </tr>
  </tbody>
</table>

여기에는 이해해야 할 많은 것들이 있지만, 우리가 이미 알고 있는 것들 외에는 한 가지 ‘이상한’ 점이 있습니다:

<select th:field="*{rows[__${rowStat.index}__].variety}">
  ...
</select>

“Using Thymeleaf” 튜토리얼에서 기억하신다면, 저 __${...}__ 구문은 전처리 표현식입니다. 이는 전체 표현식을 실제로 평가하기 전에 평가되는 내부 표현식입니다. 하지만 왜 이런 방식으로 행 인덱스를 지정해야 할까요? 다음과 같이 하면 충분하지 않을까요:

<select th:field="*{rows[rowStat.index].variety}">
  ...
</select>

…실제로는 그렇지 않습니다. 문제는 Spring EL이 배열 인덱스 대괄호 안의 변수를 평가하지 않는다는 것입니다. 따라서 위의 표현식을 실행하면 rows[0], rows[1] 등 대신 rows[rowStat.index]가 rows 컬렉션의 유효한 위치가 아니라는 오류가 발생합니다. 그래서 여기에 전처리가 필요한 것입니다.

‘행 추가’ 버튼을 몇 번 누른 후의 결과 HTML 일부를 살펴보겠습니다:

<tbody>
  <tr>
    <td>1</td>
    <td>
      <select id="rows0.variety" name="rows[0].variety">
        <option selected="selected" value="1">Thymus vulgaris</option>
        <option value="2">Thymus x citriodorus</option>
        <option value="3">Thymus herba-barona</option>
        <option value="4">Thymus pseudolaginosus</option>
        <option value="5">Thymus serpyllum</option>
      </select>
    </td>
    <td>
      <input
        id="rows0.seedsPerCell"
        name="rows[0].seedsPerCell"
        type="text"
        value=""
      />
    </td>
    <td>
      <button name="removeRow" type="submit" value="0">Remove row</button>
    </td>
  </tr>
  <tr>
    <td>2</td>
    <td>
      <select id="rows1.variety" name="rows[1].variety">
        <option selected="selected" value="1">Thymus vulgaris</option>
        <option value="2">Thymus x citriodorus</option>
        <option value="3">Thymus herba-barona</option>
        <option value="4">Thymus pseudolaginosus</option>
        <option value="5">Thymus serpyllum</option>
      </select>
    </td>
    <td>
      <input
        id="rows1.seedsPerCell"
        name="rows[1].seedsPerCell"
        type="text"
        value=""
      />
    </td>
    <td>
      <button name="removeRow" type="submit" value="1">Remove row</button>
    </td>
  </tr>
</tbody>

7 유효성 검사와 오류 메시지

대부분의 폼에서는 사용자에게 그들이 만든 오류를 알려주기 위해 유효성 검사 메시지를 표시해야 할 것입니다.

Thymeleaf는 이를 위한 몇 가지 도구를 제공합니다: #fields 객체의 몇 가지 함수, th:errorsth:errorclass 속성입니다.

7.1 필드 오류

필드에 오류가 있을 경우 특정 CSS 클래스를 어떻게 설정할 수 있는지 살펴보겠습니다:

<input
  type="text"
  th:field="*{datePlanted}"
  th:class="${#fields.hasErrors('datePlanted')}? fieldError"
/>

보시다시피, #fields.hasErrors(...) 함수는 필드 표현식을 매개변수로 받아(datePlanted), 해당 필드에 유효성 검사 오류가 있는지 여부를 나타내는 불리언을 반환합니다.

또한 해당 필드의 모든 오류를 가져와 반복할 수도 있습니다:

<ul>
  <li th:each="err : ${#fields.errors('datePlanted')}" th:text="${err}" />
</ul>

반복하는 대신 th:errors를 사용할 수도 있습니다. 이는 지정된 선택자에 대한 모든 오류를 <br /> 로 구분된 목록으로 만드는 특수한 속성입니다:

<input type="text" th:field="*{datePlanted}" />
<p th:if="${#fields.hasErrors('datePlanted')}" th:errors="*{datePlanted}">
  Incorrect date
</p>

오류 기반 CSS 스타일링 단순화: th:errorclass

위에서 본 예시, 해당 필드에 오류가 있을 경우 폼 입력에 CSS 클래스 설정하기는 매우 일반적이어서 Thymeleaf는 이를 정확히 수행하기 위한 특정 속성을 제공합니다: th:errorclass.

폼 필드 태그(input, select, textarea…)에 적용되면, 동일한 태그의 기존 name 또는 th:field 속성에서 검사할 필드의 이름을 읽고, 해당 필드에 관련된 오류가 있으면 지정된 CSS 클래스를 태그에 추가합니다:

<input
  type="text"
  th:field="*{datePlanted}"
  class="small"
  th:errorclass="fieldError"
/>

datePlanted에 오류가 있다면, 이는 다음과 같이 렌더링될 것입니다:

<input
  type="text"
  id="datePlanted"
  name="datePlanted"
  value="2013-01-01"
  class="small fieldError"
/>

7.2 모든 오류

그렇다면 폼의 모든 오류를 보여주고 싶다면 어떻게 해야 할까요? #fields.hasErrors(...)#fields.errors(...) 메서드를 '*' 또는 'all' 상수(이 둘은 동등합니다)와 함께 쿼리하면 됩니다:

<ul th:if="${#fields.hasErrors('*')}">
  <li th:each="err : ${#fields.errors('*')}" th:text="${err}">
    Input is incorrect
  </li>
</ul>

위의 예제처럼, 모든 오류를 가져와 반복할 수 있습니다…

<ul>
  <li th:each="err : ${#fields.errors('*')}" th:text="${err}" />
</ul>

…또는 <br />로 구분된 목록을 만들 수 있습니다:

<p th:if="${#fields.hasErrors('all')}" th:errors="*{all}">Incorrect date</p>

마지막으로 #fields.hasErrors('*')#fields.hasAnyErrors()와 동등하고, #fields.errors('*')#fields.allErrors()와 동등하다는 점을 주목하세요. 선호하는 구문을 사용하세요:

<div th:if="${#fields.hasAnyErrors()}">
  <p th:each="err : ${#fields.allErrors()}" th:text="${err}">...</p>
</div>

7.3 전역 오류

Spring 폼에는 세 번째 유형의 오류가 있습니다: 전역 오류입니다. 이는 폼의 특정 필드와 연관되지 않지만 여전히 존재하는 오류입니다.

Thymeleaf는 이러한 오류에 접근하기 위한 global 상수를 제공합니다:

<ul th:if="${#fields.hasErrors('global')}">
  <li th:each="err : ${#fields.errors('global')}" th:text="${err}">
    Input is incorrect
  </li>
</ul>
<p th:if="${#fields.hasErrors('global')}" th:errors="*{global}">
  Incorrect date
</p>

…그리고 동등한 #fields.hasGlobalErrors()#fields.globalErrors() 편의 메서드도 있습니다:

<div th:if="${#fields.hasGlobalErrors()}">
  <p th:each="err : ${#fields.globalErrors()}" th:text="${err}">...</p>
</div>

7.4 폼 외부에 오류 표시하기

폼 유효성 검사 오류는 선택(*{...}) 표현식 대신 변수(${...}) 표현식을 사용하고 폼 백업 빈의 이름을 접두사로 붙여 폼 외부에도 표시할 수 있습니다:

<div th:errors="${myForm}">...</div>
<div th:errors="${myForm.date}">...</div>
<div th:errors="${myForm.*}">...</div>

<div th:if="${#fields.hasErrors('${myForm}')}">...</div>
<div th:if="${#fields.hasErrors('${myForm.date}')}">...</div>
<div th:if="${#fields.hasErrors('${myForm.*}')}">...</div>

<form th:object="${myForm}">...</form>

7.5 풍부한 오류 객체

Thymeleaf는 폼 오류 정보를 (단순한 문자열 대신) 빈 형태로 얻을 수 있는 가능성을 제공합니다. 이 빈은 fieldName (String), message (String), global (boolean) 속성을 가집니다.

이러한 오류는 #fields.detailedErrors() 유틸리티 메서드를 통해 얻을 수 있습니다:

<ul>
  <li
    th:each="e : ${#fields.detailedErrors()}"
    th:class="${e.global}? globalerr : fielderr"
  >
    <span th:text="${e.global}? '*' : ${e.fieldName}">The field name</span> |
    <span th:text="${e.message}">The error message</span>
  </li>
</ul>

8 아직은 프로토타입입니다!

이제 우리의 애플리케이션이 준비되었습니다. 하지만 우리가 만든 .html 페이지를 다시 한 번 살펴보겠습니다…

Thymeleaf로 작업하는 가장 좋은 결과 중 하나는 우리가 HTML에 추가한 모든 기능에도 불구하고 여전히 프로토타입으로 사용할 수 있다는 것입니다(우리는 이를 자연 템플릿이라고 부릅니다). 애플리케이션을 실행하지 않고 seedstartermng.html을 브라우저에서 직접 열어보겠습니다:

STSM 자연 템플릿

바로 이겁니다! 작동하는 애플리케이션이 아니고, 실제 데이터도 아닙니다… 하지만 완벽하게 표시 가능한 HTML 코드로 만들어진 완벽한 프로토타입입니다.

9 변환 서비스

9.1 구성

앞서 설명했듯이, Thymeleaf는 애플리케이션 컨텍스트에 등록된 변환 서비스를 사용할 수 있습니다. 우리의 애플리케이션 구성 클래스는 Spring의 WebMvcConfigurerAdapter 헬퍼를 확장함으로써 자동으로 이러한 변환 서비스를 등록하며, 우리가 필요로 하는 포맷터를 추가하여 구성할 수 있습니다. 다시 한 번 어떻게 생겼는지 살펴보겠습니다:

@Override
public void addFormatters(final FormatterRegistry registry) {
    super.addFormatters(registry);
    registry.addFormatter(varietyFormatter());
    registry.addFormatter(dateFormatter());
}

@Bean
public VarietyFormatter varietyFormatter() {
    return new VarietyFormatter();
}

@Bean
public DateFormatter dateFormatter() {
    return new DateFormatter();
}

9.1 이중 중괄호 구문

변환 서비스는 모든 객체를 문자열로 변환/포맷하기 위해 쉽게 적용될 수 있습니다. 이는 이중 중괄호 표현 구문을 통해 수행됩니다:

  • 변수 표현식의 경우: ${{...}}
  • 선택 표현식의 경우: *{{...}}

예를 들어, 천 단위 구분자로 쉼표를 추가하는 Integer-to-String 변환기가 주어졌다고 가정하면, 다음과 같습니다:

<p th:text="${val}">...</p>
<p th:text="${{val}}">...</p>

…이는 다음과 같은 결과를 낼 것입니다:

<p>1234567890</p>
<p>1,234,567,890</p>

9.2 폼에서의 사용

이전에 모든 th:field 속성이 항상 변환 서비스를 적용한다는 것을 보았습니다. 따라서 이것은:

<input type="text" th:field="*{datePlanted}" />

…실제로 다음과 동등합니다:

<input type="text" th:field="*{{datePlanted}}" />

Spring의 요구사항에 따라, 이는 단일 중괄호 구문을 사용하는 표현식에서 변환 서비스가 적용되는 유일한 시나리오임을 주목하세요.

9.3 #conversions 유틸리티 객체


#conversions 표현 유틸리티 객체를 사용하면 필요한 곳에서 변환 서비스를 수동으로 실행할 수 있습니다:

<p th:text="${'Val: ' + #conversions.convert(val,'String')}">...</p>

이 유틸리티 객체의 구문:

  • #conversions.convert(Object,Class): 객체를 지정된 클래스로 변환합니다.
  • #conversions.convert(Object,String): 위와 동일하지만, 대상 클래스를 문자열로 지정합니다 (java.lang. 패키지는 생략할 수 있습니다).

10 템플릿 조각(Fragment) 렌더링

Thymeleaf는 템플릿의 일부만을 실행 결과로 렌더링할 수 있는 가능성을 제공합니다: 조각(Fragment)이라고 합니다.

이는 유용한 컴포넌트화 도구가 될 수 있습니다. 예를 들어, AJAX 호출에서 실행되는 컨트롤러에서 사용될 수 있으며, 이미 브라우저에 로드된 페이지의 마크업 조각을 반환할 수 있습니다(셀렉트 업데이트, 버튼 활성화/비활성화 등을 위해).

조각 렌더링은 Thymeleaf의 조각 스펙을 사용하여 달성할 수 있습니다: org.thymeleaf.fragment.IFragmentSpec 인터페이스를 구현하는 객체입니다.

이러한 구현 중 가장 일반적인 것은 org.thymeleaf.standard.fragment.StandardDOMSelectorFragmentSpec으로, th:include 또는 th:replace에서 사용되는 것과 정확히 같은 DOM 선택자를 사용하여 조각을 지정할 수 있습니다.

10.1 뷰 빈(Bean)에서 조각(Fragment) 지정하기

뷰 빈은 애플리케이션 컨텍스트에서 선언된 org.thymeleaf.spring6.view.ThymeleafView 클래스의 빈입니다(Java 구성을 사용하는 경우 @Bean 선언). 이를 통해 다음과 같이 조각을 지정할 수 있습니다:

@Bean(name="content-part")
@Scope("prototype")
public ThymeleafView someViewBean() {
    ThymeleafView view = new ThymeleafView("index"); // templateName = 'index'
    view.setMarkupSelector("content");
    return view;
}

위의 빈 정의가 주어졌을 때, 우리의 컨트롤러가 content-part(위 빈의 이름)를 반환한다면…

@RequestMapping("/showContentPart")
public String showContentPart() {
    ...
    return "content-part";
}

…thymeleaf는 index 템플릿의 content 조각만 반환할 것입니다 – 이 위치는 아마도 접두사와 접미사가 적용된 후 /WEB-INF/templates/index.html과 같을 것입니다. 따라서 결과는 index :: content를 지정하는 것과 완전히 동등할 것입니다:

<!DOCTYPE html>
<html>
  ...
  <body>
    ...
    <div th:fragment="content">Only this div will be rendered!</div>
    ...
  </body>
</html>

또한 Thymeleaf 마크업 선택자의 힘 덕분에 th:fragment 속성 없이도 템플릿에서 조각을 선택할 수 있습니다. 예를 들어 id 속성을 사용해 보겠습니다:

@Bean(name="content-part")
@Scope("prototype")
public ThymeleafView someViewBean() {
    ThymeleafView view = new ThymeleafView("index"); // templateName = 'index'
    view.setMarkupSelector("#content");
    return view;
}

…이는 다음을 완벽하게 선택할 것입니다:

<!DOCTYPE html>
<html>
  ...
  <body>
    ...
    <div id="content">Only this div will be rendered!</div>
    ...
  </body>
</html>

10.2 컨트롤러 반환 값에서 조각 지정하기

뷰 빈을 선언하는 대신, 컨트롤러 자체에서 조각 표현식의 구문을 사용하여 조각을 지정할 수 있습니다. th:insert 또는 th:replace 속성에서와 마찬가지로:

@RequestMapping("/showContentPart")
public String showContentPart() {
    ...
    return "index :: content";
}

물론, 다시 한 번 DOM 선택자의 모든 기능을 사용할 수 있으므로 id="content"와 같은 표준 HTML 속성을 기반으로 조각을 선택할 수 있습니다:

@RequestMapping("/showContentPart")
public String showContentPart() {
    ...
    return "index :: #content";
}

그리고 매개변수도 사용할 수 있습니다:

@RequestMapping("/showContentPart")
public String showContentPart() {
    ...
    return "index :: #content ('myvalue')";
}

11 고급 통합 기능

11.1 RequestDataValueProcessor와의 통합

Thymeleaf는 Spring의 RequestDataValueProcessor 인터페이스와 원활하게 통합됩니다. 이 인터페이스는 링크 URL, 폼 URL 및 폼 필드 값이 마크업 결과에 작성되기 전에 이를 가로채고, CSRF(Cross-Site Request Forgery)와 같은 보안 기능을 가능하게 하는 숨겨진 폼 필드를 투명하게 추가할 수 있습니다.

RequestDataValueProcessor의 구현은 애플리케이션 컨텍스트에서 쉽게 구성할 수 있습니다. org.springframework.web.servlet.support.RequestDataValueProcessor 인터페이스를 구현하고 빈 이름을 requestDataValueProcessor로 지정해야 합니다:

@Bean
public RequestDataValueProcessor requestDataValueProcessor() {
  return new MyRequestDataValueProcessor();
}

…그리고 Thymeleaf는 다음과 같이 사용합니다:

  • th:hrefth:src는 URL을 렌더링하기 전에 RequestDataValueProcessor.processUrl(...)을 호출합니다.

  • th:action은 폼의 action 속성을 렌더링하기 전에 RequestDataValueProcessor.processAction(...)을 호출하고, 추가로 이 속성이 <form> 태그에 적용되고 있는지 감지합니다(어쨌든 유일한 장소여야 합니다). 이 경우 RequestDataValueProcessor.getExtraHiddenFields(...)를 호출하고 반환된 숨겨진 필드를 닫는 </form> 태그 바로 앞에 추가합니다.

  • th:value는 참조하는 값을 렌더링하기 위해 RequestDataValueProcessor.processFormFieldValue(...)를 호출합니다. 단, 동일한 태그에 th:field가 있는 경우는 예외입니다(이 경우 th:field가 처리합니다).

  • th:field는 적용되는 필드의 값을 렌더링하기 위해 RequestDataValueProcessor.processFormFieldValue(...)를 호출합니다(<textarea>인 경우 태그 본문).

애플리케이션에서 RequestDataValueProcessor를 명시적으로 구현해야 하는 시나리오는 매우 드뭅니다. 대부분의 경우, 이는 Spring Security의 CSRF 지원과 같이 투명하게 사용하는 보안 라이브러리에 의해 자동으로 사용됩니다.

11.1 컨트롤러에 대한 URI 구축

Spring 4.1 버전부터는 컨트롤러가 매핑된 URI를 알 필요 없이 뷰에서 직접 어노테이션이 달린 컨트롤러로의 링크를 구축할 수 있는 기능을 제공합니다.

Thymeleaf에서는 #mvc.url(...) 표현식 객체 메서드를 통해 이를 달성할 수 있습니다. 이 메서드를 통해 컨트롤러 메서드를 컨트롤러 클래스의 대문자와 메서드 이름으로 지정할 수 있습니다. 이는 JSP의 spring:mvcUrl(...) 사용자 정의 함수와 동등합니다.

예를 들어, 다음과 같은 경우:

public class ExampleController {

    @RequestMapping("/data")
    public String getData(Model model) { ... return "template" }

    @RequestMapping("/data")
    public String getDataParam(@RequestParam String type) { ... return "template" }

}

다음 코드는 이에 대한 링크를 생성합니다:

<a th:href="${(#mvc.url('EC#getData')).build()}">Get Data Param</a>
<a th:href="${(#mvc.url('EC#getDataParam').arg(0,'internal')).build()}"
  >Get Data Param</a
>

이 메커니즘에 대해 더 자세히 알아보려면 http://docs.spring.io/spring-framework/docs/4.1.2.RELEASE/spring-framework-reference/html/mvc.html#mvc-links-to-controllers-from-views 를 참조하세요.

12 Spring WebFlow 통합

12.1 기본 구성

Thymeleaf + Spring 통합 패키지에는 Spring WebFlow와의 통합이 포함되어 있습니다.

참고: Thymeleaf를 Spring 6와 함께 사용할 때는 Spring WebFlow 3.0+ 이상이 필요하며, Spring 5와 함께 사용할 때는 Spring WebFlow 2.5가 필요합니다.

WebFlow는 특정 이벤트(전환)가 트리거될 때 표시된 페이지의 조각을 렌더링하기 위한 일부 AJAX 기능을 포함하고 있으며, Thymeleaf가 이러한 AJAX 요청을 처리할 수 있도록 하기 위해 다른 ViewResolver 구현을 사용해야 합니다. 다음과 같이 구성됩니다:

@Bean
public FlowDefinitionRegistry flowRegistry() {
    // 참고: 앱에 추가 구성이 필요할 수 있습니다
    return getFlowDefinitionRegistryBuilder()
            .addFlowLocation("...")
            .setFlowBuilderServices(flowBuilderServices())
            .build();
}

@Bean
public FlowExecutor flowExecutor() {
    // 참고: 앱에 추가 구성이 필요할 수 있습니다
    return getFlowExecutorBuilder(flowRegistry()).build();
}

@Bean
public FlowBuilderServices flowBuilderServices() {
    // 참고: 앱에 추가 구성이 필요할 수 있습니다
    return getFlowBuilderServicesBuilder()
            .setViewFactoryCreator(viewFactoryCreator())
            .build();
}

@Bean
public ViewFactoryCreator viewFactoryCreator() {
    MvcViewFactoryCreator factoryCreator = new MvcViewFactoryCreator();
    factoryCreator.setViewResolvers(
            Collections.singletonList(thymeleafViewResolver()));
    factoryCreator.setUseSpringBeanBinding(true);
    return factoryCreator;
}

@Bean
public ViewResolver thymeleafViewResolver() {
    AjaxThymeleafViewResolver viewResolver = new AjaxThymeleafViewResolver();
    // 특별한 ThymeleafView 구현체를 설정해야 합니다: FlowAjaxThymeleafView
    viewResolver.setViewClass(FlowAjaxThymeleafView.class);
    viewResolver.setTemplateEngine(templateEngine());
    return viewResolver;
}

위의 구성은 완전한 구성이 아닙니다: 핸들러 등을 추가로 구성해야 합니다. 자세한 내용은 Spring WebFlow 문서를 참조하세요.

여기서부터는 view-state에서 Thymeleaf 템플릿을 지정할 수 있습니다:

<view-state id="detail" view="bookingDetail">
    ...
</view-state>

위의 예에서 bookingDetailTemplateEngine에 구성된 Template Resolvers 중 하나가 이해할 수 있는 일반적인 방식으로 지정된 Thymeleaf 템플릿입니다.

12.2 Spring WebFlow에서의 AJAX 조각

여기서 설명하는 것은 Spring WebFlow와 함께 사용할 AJAX 조각을 만드는 방법일 뿐입니다. WebFlow를 사용하지 않는 경우, AJAX 요청에 응답하고 HTML 조각을 반환하는 Spring MVC 컨트롤러를 만드는 것은 다른 템플릿을 반환하는 컨트롤러를 만드는 것과 마찬가지로 간단합니다. 단, 컨트롤러 메서드에서 "main :: admin"과 같은 조각을 반환할 가능성이 높다는 점만 다릅니다.

WebFlow는 <render> 태그를 사용하여 AJAX를 통해 렌더링할 조각을 지정할 수 있습니다:

<view-state id="detail" view="bookingDetail">
    <transition on="updateData">
        <render fragments="hoteldata"/>
    </transition>
</view-state>

이러한 조각들(hoteldata의 경우)은 마크업에서 th:fragment로 지정된 쉼표로 구분된 조각 목록일 수 있습니다:

<div id="data" th:fragment="hoteldata">
    This is a content to be changed
</div>

지정된 조각에는 항상 id 속성이 있어야 브라우저에서 실행되는 Spring JavaScript 라이브러리가 마크업을 대체할 수 있다는 점을 항상 기억하세요.

<render> 태그는 DOM 선택자를 사용하여 지정할 수도 있습니다:

<view-state id="detail" view="bookingDetail">
  <transition on="updateData">
    <render fragments="[//div[@id='data']]" />
  </transition>
</view-state>

…이 경우 th:fragment가 필요하지 않습니다:

<div id="data">This is a content to be changed</div>

updateData 전환을 트리거하는 코드는 다음과 같습니다:

<script type="text/javascript" th:src="@{/resources/dojo/dojo.js}"></script>
<script type="text/javascript" th:src="@{/resources/spring/Spring.js}"></script>
<script
  type="text/javascript"
  th:src="@{/resources/spring/Spring-Dojo.js}"
></script>

...

<form id="triggerform" method="post" action="">
  <input
    type="submit"
    id="doUpdate"
    name="_eventId_updateData"
    value="Update now!"
  />
</form>

<script type="text/javascript">
  Spring.addDecoration(
    new Spring.AjaxEventDecoration({
      formId: "triggerform",
      elementId: "doUpdate",
      event: "onclick",
    })
  );
</script>