[스프링 MVC 2편] 타임리프 - 기본 기능
포스트
취소

[스프링 MVC 2편] 타임리프 - 기본 기능

프로젝트 생성

  • 스프링 이니셜라이저를 통해 프로젝트를 생성하자.
    • 프로젝트 선택
      • Project
        • Gradle - Groovy Project
      • Language
        • Java
      • Spring Boot
        • 3.x.x
    • Project Metadata
      • Group
        • hello
      • Artifact
        • thymeleaf-basic
      • Name
        • thymeleaf-basic
      • Package name
        • hello.thymeleaf
      • Packaging
        • Jar (주의!)
      • Java
        • 17 또는 21
    • Dependencies
      • Spring Web
      • Thymeleaf
      • Lombok

홈 화면

  • src/main/resources/static/index.html
<html>
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <ul>
      <li>텍스트
        <ul>
          <li><a href="/basic/text-basic">텍스트 출력 기본</a></li>
          <li><a href="/basic/text-unescaped">텍스트 text, utext</a></li>
        </ul>
      </li>
      <li>표준 표현식 구문
        <ul>
          <li><a href="/basic/variable">변수 - SpringEL</a></li>
          <li><a href="/basic/basic-objects?paramData=HelloParam">기본 객체들</a></li>
          <li><a href="/basic/date">유틸리티 객체와 날짜</a></li>
          <li><a href="/basic/link">링크 URL</a></li>
          <li><a href="/basic/literal">리터럴</a></li>
          <li><a href="/basic/operation">연산</a></li>
        </ul>
      </li>
      <li>속성 값 설정
        <ul>
          <li><a href="/basic/attribute">속성 값 설정</a></li>
        </ul>
      </li>
      <li>반복
        <ul>
          <li><a href="/basic/each">반복</a></li>
        </ul>
      </li>
      <li>조건부 평가
        <ul>
          <li><a href="/basic/condition">조건부 평가</a></li>
        </ul>
      </li>
      <li>주석 및 블록
        <ul>
          <li><a href="/basic/comments">주석</a></li>
          <li><a href="/basic/block">블록</a></li>
        </ul>
      </li>
      <li>자바스크립트 인라인
        <ul>
          <li><a href="/basic/javascript">자바스크립트 인라인</a></li>
        </ul>
      </li>
      <li>템플릿 레이아웃
        <ul>
          <li><a href="/template/fragment">템플릿 조각</a></li>
          <li><a href="/template/layout">유연한 레이아웃</a></li>
          <li><a href="/template/layoutExtend">레이아웃 상속</a></li>
        </ul>
      </li>
    </ul>
  </body>
</html>

타임리프 소개

타임리프 특징

  • 서버 사이드 HTML 렌더링 (SSR)
  • 네츄럴 템플릿
  • 스프링 통합 지원

서버 사이드 HTML 렌더링 (SSR)

  • 타임리프는 백엔드 서버에서 HTML을 동적으로 렌더링 하는 용도로 사용된다.

네츄럴 템플릿

  • 타임리프는 순수 HTML을 최대한 유지하는 특징이 있다.
  • 타임리프로 작성한 파일은 HTML을 유지하기 때문에 웹 브라우저에서 파일을 직접 열어도 내용을 확인할 수 있다.
    • 물론 이 경우 동적으로 결과가 렌더링 되지는 않는다.
    • 하지만 HTML 마크업 결과가 어떻게 되는지는 파일만 열어도 바로 확인할 수 있다.
  • 타임리프는 서버를 통해 뷰 템플릿을 거치면 동적으로 변경된 결과를 확인할 수 있다.
  • 이렇게 순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있는 타임리프의 특징을 네츄럴 템플릿(natural templates)이라 한다.

스프링 통합 지원

  • 타임리프는 스프링과 자연스럽게 통합되고, 스프링의 다양한 기능을 편리하게 사용할 수 있게 지원한다.

타임리프 기본 기능

타임리프 사용 선언

  • 타임리프를 사용하려면 html 태그에 다음과 같은 속성을 명시해주면 된다.
    • <html xmlns:th="http://www.thymeleaf.org">

기본 표현식

  • 타임리프는 다음과 같은 기본 표현식들을 제공한다.
  • 간단한 표현:
    • 변수 표현식 : ${...}
    • 선택 변수 표현식 : *{...}
    • 메시지 표현식 : #{...}
    • 링크 URL 표현식 : @{...}
    • 조각 표현식 : ~{...}
  • 리터럴
    • 텍스트 : 'one text', 'Another one!',…
    • 숫자 : 0, 34, 3.0, 12.3,…
    • 불린 : true, false
    • 널 : null
    • 리터럴 토큰 : one, sometext, main,…
  • 문자 연산
    • 문자 합치기 : +
    • 리터럴 대체 : |The name is ${name}|
  • 산술 연산
    • Binary operators : +, -, *, /, %
    • Minus sign (unary operator) : -
  • 불린 연산
    • Binary operators : and, or
    • Boolean negation (unary operator) : !, not
  • 비교와 동등
    • 비교 : >, <, >=, <= (gt, lt, ge, le)
    • 동등 연산 : ==, != (eq, ne)
  • 조건 연산
    • If-then : (if) ? (then)
    • If-then-else : (if) ? (then) : (else)
    • Default : (value) ?: (defaultvalue)
  • 특별한 토큰 :
    • No-Operation: _

텍스트 - text, utext

  • 텍스트를 출력하는 기능
  • HTML의 콘텐츠(content)에 데이터를 출력할 때는 다음과 같이 th:text를 사용하면 된다.
    • <span th:text="${data}">
  • HTML 테그의 속성이 아니라 HTML 콘텐츠 영역안에서 직접 데이터를 출력하고 싶으면 다음과 같이 [[...]]를 사용하면 된다.
    • [[${data}]]

컨트롤러

package hello.thymeleaf.basic;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/basic")
public class BasicController {
    @GetMapping("/text-basic")
    public String textBasic(Model model) {
        model.addAttribute("data", "Hello Spring!");
        return "basic/text-basic";
    }
}

HTML

  • src/main/resources/templates/basic/text-basic.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
        <h1>컨텐츠에 데이터 출력하기</h1>
        <ul>
            <li>th:text 사용 <span th:text="${data}"></span></li>
            <li>컨텐츠 안에서 직접 출력하기 = [[${data}]]</li>
        </ul>
    </body>
</html>

Escape

  • 만약에 Hello <b>Spring!</b>처럼 문자열 도중에 강조 처리를 하고 싶으면 어떻게 해야 할까?
  • 컨트롤러 단에서 위 문자열을 모델에 추가해서 해당 페이지에 확인해보면 실제로는 아래와 같이 나온다.
    • Hello &lt;b&gt;Spring!&lt;/b&gt;
    • 그 원인은 HTML 엔티티와 이스케이프에 있다.
  • HTML 엔티티
    • <&lt;로, >&gt;로 바꾸듯이 HTML에서 사용하는 특수문자를 화면에서 기능 없이 문자로 표현하는 방법
  • 이스케이프
    • 타임리프의 th:text[[${...}]]의 문자열 도중에 HTML에서 사용하는 특수문자를 HTML 엔티티로 치환하는 방법
    • 기본 적용된다.
  • 웹 브라우저는 <를 HTML 태그의 시작으로 인식한다.
    • 그런데 타임리프의 이스케이프때문에 태그가 아니라 HTML 엔티티로 치환되서 강조 표시가 적용되지 않은 것이다.

Unescape

  • 이스케이프는 th:text[[${...}]]에 적용되는 거라서 단순히 다른 기능을 사용하면 태그를 적용할 수 있다.
  • 타임리프에서 HTML 태그를 적용하려면 아래 2가지 방법 중에서 하나를 사용하면 된다.
    • th:utext
    • [(...)]
      • 안쪽 괄호가 소괄호다.

컨트롤러

@GetMapping("/text-unescaped")
public String textUnescaped(Model model) {
    model.addAttribute("data", "Hello <b>Spring!</b>");
    return "basic/text-unescaped";
}

HTML

  • src/main/resources/templates/basic/text-unescaped.html
  • th:inline="none"
    • 타임리프는 [[...]]를 해석하기 때문에, 화면에 [[...]] 글자를 보여줄 수 없다.
    • 이 테그 안에서는 타임리프가 해석하지 말라는 옵션이다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
        <h1>text vs utext</h1>
        <ul>
            <li>th:text = <span th:text="${data}"></span></li>
            <li>th:utext = <span th:utext="${data}"></span></li>
        </ul>
        <h1><span th:inline="none">[[...]] vs [(...)]</span></h1>
        <ul>
            <li><span th:inline="none">[[...]] = </span>[[${data}]]</li>
            <li><span th:inline="none">[(...)] = </span>[(${data})]</li>
        </ul>
    </body>
</html>

변수 - SpringEL

  • 타임리프에서 변수를 사용할 때는 변수 표현식을 사용한다.
    • ${...}
  • 이 변수 표현식에는 스프링 EL이라는 스프링이 제공하는 표현식을 사용할 수 있다.

컨트롤러

@GetMapping("/variable")
public String variable(Model model) {
    User userA = new User("userA", 10);
    User userB = new User("userB", 20);
    
    List<User> list = new ArrayList<>();
    list.add(userA);
    list.add(userB);
    
    Map<String, User> map = new HashMap<>();
    map.put("userA", userA);
    map.put("userB", userB);
    
    model.addAttribute("user", userA);
    model.addAttribute("users", list);
    model.addAttribute("userMap", map);
    
    return "basic/variable";
}

@Data
static class User {
    private String username;
    private int age;
    
    public User(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

HTML

  • src/main/resources/templates/basic/variable.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
        <h1>SpringEL 표현식</h1>
        <ul>Object
            <li>${user.username} = <span th:text="${user.username}"></span></li>
            <li>${user['username']} = <span th:text="${user['username']}"></span></li>
            <li>${user.getUsername()} = <span th:text="${user.getUsername()}"></span></li>
        </ul>
        <ul>List
            <li>${users[0].username} = <span th:text="${users[0].username}"></span></li>
            <li>${users[0]['username']} = <span th:text="${users[0]['username']}"></span></li>
            <li>${users[0].getUsername()} = <span th:text="${users[0].getUsername()}"></span></li>
        </ul>
        <ul>Map
            <li>${userMap['userA'].username} = <span th:text="${userMap['userA'].username}"></span></li>
            <li>${userMap['userA']['username']} = <span th:text="${userMap['userA']['username']}"></span></li>
            <li>${userMap['userA'].getUsername()} = <span th:text="${userMap['userA'].getUsername()}"></span></li>
        </ul>
    </body>
</html>

SpringEL 다양한 표현식 사용

  • Object
    • user.username
      • user의 username을 프로퍼티 접근 → user.getUsername()
    • user['username']
      • user의 username을 프로퍼티 접근 → user.getUsername()
    • user.getUsername()
      • user의 getUsername() 을 직접 호출
  • List
    • users[0].username
      • List에서 첫 번째 회원을 찾고 username 프로퍼티 접근 → list.get(0).getUsername()
    • users[0]['username']
      • List에서 첫 번째 회원을 찾고 username 프로퍼티 접근 → list.get(0).getUsername()
    • users[0].getUsername()
      • List에서 첫 번째 회원을 찾고 메서드 직접 호출 → list.get(0).getUsername()
  • Map
    • userMap['userA'].username
      • Map에서 userA를 찾고, username 프로퍼티 접근 → map.get(“userA”).getUsername()
    • userMap['userA']['username']
      • Map에서 userA를 찾고, username 프로퍼티 접근 → map.get(“userA”).getUsername()
    • userMap['userA'].getUsername()
      • Map에서 userA를 찾고 메서드 직접 호출 → map.get(“userA”).getUsername()

지역 변수

  • th:with 를 사용하면 지역 변수를 선언해서 사용할 수 있다.
    • 지역 변수는 선언한 테그 안에서만 사용할 수 있다.
  • src/main/resources/templates/basic/variable.html에 추가
<h1>지역 변수 - (th:with)</h1>
<div th:with="first=${users[0]}">
    <p>처음 사람의 이름은 <span th:text="${first.username}"></span></p>
</div>

기본 객체들

  • 타임리프는 기본 객체들을 제공한다.
    • ${#request}
    • ${#response}
    • ${#session}
    • ${#servletContext}
    • ${#locale}
  • 하지만 스프링 부트 3.0부터는 ${#request}, ${#response}, ${#session}, ${#servletContext}를 지원하지 않는다.
    • 대신에 model에 해당 객체를 추가해서 사용해야 한다.
    • 하지만 ${#request}같은 경우에는 HttpServletRequest 객체가 그대로 제공된다.
    • 그래서 데이터를 조회하려면 request.getParameter("xxx")처럼 불편하게 접근해야 한다.
  • 그래서 편의 객체가 제공된다.
    • HTTP 요청 파라미터 접근
      • param
      • 예시
        • ${param.paramData}
    • HTTP 세션 접근
      • session
      • 예시
        • ${session.sessionData}
    • 스프링 빈 접근
      • @
      • 예시
        • ${@helloBean.hello('Spring!')}

컨트롤러 (스프링 부트 3.0 이상 기준)

@GetMapping("/basic-objects")
public String basicObjects(Model model, HttpServletRequest request, HttpServletResponse response, HttpSession session) {
    session.setAttribute("sessionData", "Hello Session");
    model.addAttribute("request", request);
    model.addAttribute("response", response);
    model.addAttribute("servletContext", request.getServletContext());
    return "basic/basic-objects";
}

@Component("helloBean")
static class HelloBean {
    public String hello(String data) {
        return "Hello " + data;
    }
}

HTML (스프링 부트 3.0 이상 기준)

  • src/main/resources/templates/basic/basic-objects.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <h1>식 기본 객체 (Expression Basic Objects)</h1>
    <ul>
      <li>request = <span th:text="${request}"></span></li>
      <li>response = <span th:text="${response}"></span></li>
      <li>session = <span th:text="${session}"></span></li>
      <li>servletContext = <span th:text="${servletContext}"></span></li>
      <li>locale = <span th:text="${#locale}"></span></li>
    </ul>
    <h1>편의 객체</h1>
    <ul>
      <li>Request Parameter = <span th:text="${param.paramData}"></span></li>
      <li>session = <span th:text="${session.sessionData}"></span></li>
      <li>spring bean = <span th:text="${@helloBean.hello('Spring!')}"></span></
      li>
    </ul>
  </body>
</html>

유틸리티 객체와 날짜

  • 타임리프는 문자, 숫자, 날짜, URI등을 편리하게 다루는 다양한 유틸리티 객체들을 제공한다.
  • 종류
    • #message
      • 메시지, 국제화 처리
    • #uris
      • URI 이스케이프 지원
    • #dates
      • java.util.Date 서식 지원
    • #calendars
      • java.util.Calendar 서식 지원
    • #temporals
      • 자바8 날짜 서식 지원
    • #numbers
      • 숫자 서식 지원
    • #strings
      • 문자 관련 편의 기능
    • #objects
      • 객체 관련 기능 제공
    • #bools
      • boolean 관련 기능 제공
    • #arrays
      • 배열 관련 기능 제공
    • #lists, #sets, #maps
      • 컬렉션 관련 기능 제공
    • #ids
      • 아이디 처리 관련 기능 제공

자바8 날짜

  • 타임리프에서 자바8 날짜인 LocalDate , LocalDateTime , Instant 를 사용하려면 추가 라이브러리가 필요하다.
    • 스프링 부트 타임리프를 사용하면 해당 라이브러리가 자동으로 추가되고 통합된다.
    • thymeleaf-extras-java8time
  • 스프링 부트 3.2 이상을 사용한다면, 타임리프 자바8 날짜 지원 라이브러리가 이미 포함되어 있다.

컨트롤러

@GetMapping("/date")
public String date(Model model) {
    model.addAttribute("localDateTime", LocalDateTime.now());
    return "basic/date";
}

HTML

  • src/main/resources/templates/basic/date.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <h1>LocalDateTime</h1>
    <ul>
      <li>default = <span th:text="${localDateTime}"></span></li>
      <li>yyyy-MM-dd HH:mm:ss = <span th:text="${#temporals.format(localDateTime, 'yyyy-MM-dd HH:mm:ss')}"></span></li>
    </ul>
    <h1>LocalDateTime - Utils</h1>
    <ul>
      <li>${#temporals.day(localDateTime)} = <span th:text="${#temporals.day(localDateTime)}"></span></li>
      <li>${#temporals.month(localDateTime)} = <span th:text="${#temporals.month(localDateTime)}"></span></li>
      <li>${#temporals.monthName(localDateTime)} = <span th:text="${#temporals.monthName(localDateTime)}"></span></li>
      <li>${#temporals.monthNameShort(localDateTime)} = <span th:text="${#temporals.monthNameShort(localDateTime)}"></span></li>
      <li>${#temporals.year(localDateTime)} = <span th:text="${#temporals.year(localDateTime)}"></span></li>
      <li>${#temporals.dayOfWeek(localDateTime)} = <span th:text="${#temporals.dayOfWeek(localDateTime)}"></span></li>
      <li>${#temporals.dayOfWeekName(localDateTime)} = <span th:text="${#temporals.dayOfWeekName(localDateTime)}"></span></li>
      <li>${#temporals.dayOfWeekNameShort(localDateTime)} = <span th:text="${#temporals.dayOfWeekNameShort(localDateTime)}"></span></li>
      <li>${#temporals.hour(localDateTime)} = <span th:text="${#temporals.hour(localDateTime)}"></span></li>
      <li>${#temporals.minute(localDateTime)} = <span th:text="${#temporals.minute(localDateTime)}"></span></li>
      <li>${#temporals.second(localDateTime)} = <span th:text="${#temporals.second(localDateTime)}"></span></li>
      <li>${#temporals.nanosecond(localDateTime)} = <span th:text="${#temporals.nanosecond(localDateTime)}"></span></li>
    </ul>
  </body>
</html>

URL 링크

  • 타임리프에서 URL을 생성할 때는 @{…} 문법을 사용하면 된다.
  • 상대경로, 절대경로, 프로토콜 기준을 표현할 수 도 있다.

사용 방법

  • 단순한 URL
    • @{/hello}/hello
  • 쿼리 파라미터
    • @{/hello(param1=${param1}, param2=${param2})}/hello?param1=data1&param2=data2
    • ()에 있는 부분은 쿼리 파라미터로 처리된다.
  • 경로 변수
    • @{/hello/{param1}/{param2}(param1=${param1}, param2=${param2})}/hello/data1/data2
    • URL 경로상에 변수가 있으면 () 부분은 경로 변수로 처리된다.
  • 경로 변수 + 쿼리 파라미터
    • @{/hello/{param1}(param1=${param1}, param2=${param2})}/hello/data1?param2=data2
    • 경로 변수와 쿼리 파라미터를 함께 사용할 수 있다.

컨트롤러

@GetMapping("/link")
public String link(Model model) {
    model.addAttribute("param1", "data1");
    model.addAttribute("param2", "data2");
    return "basic/link";
}

HTML

  • src/main/resources/templates/basic/link.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <h1>URL 링크</h1>
    <ul>
      <li><a th:href="@{/hello}">basic url</a></li>
      <li><a th:href="@{/hello(param1=${param1}, param2=${param2})}">hello query param</a></li>
      <li><a th:href="@{/hello/{param1}/{param2}(param1=${param1}, param2=${param2})}">path variable</a></li>
      <li><a th:href="@{/hello/{param1}(param1=${param1}, param2=${param2})}">path variable + query parameter</a></li>
    </ul>
  </body>
</html>

리터럴

  • 리터럴(Literals)은 소스 코드상에 고정된 값을 말하는 용어이다.
    • String a = "Hello";에서 "Hello가 문자 리터럴에 해당한다.
    • int a = 10 * 20;에서 1020이 숫자 리터럴에 해당한다.
  • 타임리프는 다음과 같은 리터럴이 있다.
    • 문자
      • 'hello'
      • 'World'
    • 숫자
      • 10
      • 30
    • 불린
      • true
      • false
    • null
      • null
  • 타임리프에서 문자 리터럴은 항상 문자열임을 알리는 작은 따옴표로 감싸야 한다.
    • 그런데 문자를 항상 작은 따옴표로 감싸는 것은 너무 귀찮은 일이다.
    • 그래서 공백 없이 쭉 이어진다면 하나의 의미있는 토큰으로 인지해서 작은 따옴표를 생략해도 된다.
    • 대신에 허용된 문자열로만 이루어졌을 때만 가능하다.
      • A-Z, a-z, 0-9, [], ., -, _
    • 만약 중간에 허용되지 않은 문자가 있거나 공백이 있는데 작은 따옴표로 감싸지 않으면 오류가 발생한다.

컨트롤러

@GetMapping("/literal")
public String literal(Model model) {
    model.addAttribute("data", "Spring!");
    return "basic/literal";
}

HTML

  • src/main/resources/templates/basic/literal.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <h1>리터럴</h1>
    <ul>
      <!--주의! 다음 주석을 풀면 예외가 발생함-->
      <!-- <li>"hello world!" = <span th:text="hello world!"></span></li>-->
      <li>'hello' + ' world!' = <span th:text="'hello' + ' world!'"></span></li>
      <li>'hello world!' = <span th:text="'hello world!'"></span></li>
      <li>'hello ' + ${data} = <span th:text="'hello ' + ${data}"></span></li>
      <li>리터럴 대체 |hello ${data}| = <span th:text="|hello ${data}|"></span></li>
    </ul>
  </body>
</html>

리터럴 대체 (Literal substitutions)

  • |...|처럼 사용한다.
  • <span th:text="|hello ${data}|">
  • 작은 따옴표로 감싸는 대신에 리터럴 대체 문법을 사용해도 된다.

연산

  • 타임리프 연산은 자바와 크게 다르지 않다.
  • HTML 안에서 사용하기 때문에 HTML 엔티티를 사용하는 부분만 주의하자.
  • 종류
    • 비교연산: HTML 엔티티를 사용해야 하는 부분을 주의하자,
      • > (gt), < (lt), >= (ge), <= (le), ! (not), == (eq), != (neq, ne)
    • 조건식
      • 자바의 조건식과 유사하다.
    • Elvis 연산자
      • 조건식의 편의 버전
    • No-Operation
      • _인 경우 마치 타임리프가 실행되지 않는 것 처럼 동작한다.
      • 이것을 잘 사용하면 HTML의 내용 그대로 활용할 수 있다.
      • 마지막 예를 보면 데이터가 없습니다. 부분이 그대로 출력된다.

산술 연산

  • 값을 더하거나 빼는 등의 단순한 연산이다.
  • 당연한 얘기지만 th:text="10 + 2th:text="10 + '2'가 다른 것을 인지하자.
    • 헷갈릴만한 케이스가 나올 일이 잘 없긴 하지만 알고 있어서 나쁠 일은 없다.

비교 연산

  • 말 그대로 값을 비교하는 연산이다.
  • 연산자의 종류가 많으니 조심해서 사용하자.
    • <로 사용해도 동작하고 lt를 사용해도 동작한다.
    • 심지어 HTML 엔티티인 &lt;를 사용해도 동작한다.

조건식

  • 단순히 자바의 삼항 연산자라고 생각하면 된다.

Elvis 연산자

  • ?:를 의미한다.
  • 일반적으로 ${변수명} ?: (대체할 값)처럼 사용된다.
  • 변수의 값이 null이거나 모델에 해당 변수 자체가 등록되지 않은 경우에 미리 명시해둔 대체 값이 대신 대입된다.

No-Operation

  • _를 사용해서 적용한다.
  • 타임리프가 적용되지 않는 것처럼 동작한다.
    • <span th:text="${nullData}?: _">데이터가 없습니다.</span>의 결과는 데이터가 없습니다.인데 이것을 보면 좀 더 이해하기 쉽다.

컨트롤러

@GetMapping("/operation")
public String operation(Model model) {
    model.addAttribute("nullData", null);
    model.addAttribute("data", "Spring!");
    return "basic/operation";
}

HTML

  • src/main/resources/templates/basic/operation.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <ul>
      <li>산술 연산
        <ul>
          <li>10 + 2 = <span th:text="10 + 2"></span></li>
          <li>10 % 2 == 0 = <span th:text="10 % 2 == 0"></span></li>
        </ul>
      </li>
      <li>비교 연산
        <ul>
          <li>1 > 10 = <span th:text="1 &gt; 10"></span></li>
          <li>1 gt 10 = <span th:text="1 gt 10"></span></li>
          <li>1 >= 10 = <span th:text="1 >= 10"></span></li>
          <li>1 ge 10 = <span th:text="1 ge 10"></span></li>
          <li>1 == 10 = <span th:text="1 == 10"></span></li>
          <li>1 != 10 = <span th:text="1 != 10"></span></li>
        </ul>
      </li>
      <li>조건식
        <ul>
          <li>(10 % 2 == 0)? '짝수':'홀수' = <span th:text="(10 % 2 == 0)? '짝수':'홀수'"></span></li>
        </ul>
      </li>
      <li>Elvis 연산자
        <ul>
          <li>${data}?: '데이터가 없습니다.' = <span th:text="${data}?: '데이터가 없습니다.'"></span></li>
          <li>${nullData}?: '데이터가 없습니다.' = <span th:text="${nullData}?: '데이터가 없습니다.'"></span></li>
        </ul>
      </li>
      <li>No-Operation
        <ul>
          <li>${data}?: _ = <span th:text="${data}?: _">데이터가 없습니다.</span></li>
          <li>${nullData}?: _ = <span th:text="${nullData}?: _">데이터가 없습니다.</span></li>
        </ul>
      </li>
    </ul>
  </body>
</html>

속성 값 설정

  • 타임리프는 주로 HTML 태그에 th:xxx처럼 속성을 지정하는 방식으로 동작한다.
  • 만약에 명시한 HTML 태그에 이미 해당 속성이 있다면 th:xxxxxx라는 기존 속성을 대체한다.
    • 기존 속성이 없다면 동일한 방식으로 xxx라는 속성으로 랜더링된다.

속성 추가

  • th:attrappend
    • 속성 값의 뒤에 값을 추가한다.
  • th:attrprepend
    • 속성 값의 앞에 값을 추가한다.
  • th:classappend
    • class 속성에 자연스럽게 추가한다.
    • 비교적 많이 사용하는 방법

checked 처리

  • HTML에서는 체크박스에서 checked 속성이 값이 무엇이든 간에 속성이 존재만 한다면 체크 처리를 해버린다.
  • 하지만 th:checked 속성의 경우에는 값이 false면 아예 속성 자체가 사라져버려서 체크 처리가 되어 있지 않아서 편리하다.

컨트롤러

@GetMapping("/attribute")
public String attribute() {
    return "basic/attribute";
}

HTML

  • src/main/resources/templates/basic/attribute.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <h1>속성 설정</h1>
    <input type="text" name="mock" th:name="userA" />
    <h1>속성 추가</h1>
    - th:attrappend = <input type="text" class="text" th:attrappend="class='large'" /><br/>
    - th:attrprepend = <input type="text" class="text" th:attrprepend="class='large'" /><br/>
    - th:classappend = <input type="text" class="text" th:classappend="large" /><br/>
    <h1>checked 처리</h1>
    - checked o <input type="checkbox" name="active" th:checked="true" /><br/>
    - checked x <input type="checkbox" name="active" th:checked="false" /><br/>
    - checked=false <input type="checkbox" name="active" checked="false" /><br/>
  </body>
</html>

반복

  • 타임리프에서 반복은 th:each 를 사용한다.
  • 추가로 반복에서 사용할 수 있는 여러 상태 값을 지원한다.

반복 기능

  • th:each="변수명 : ${컬렉션명}"처럼 사용한다.
  • 컬렉션명에는 말 그대로 컬렉션의 이름이 들어간다.
    • 컨트롤러 단에서 모델에 추가한 이름 그대로 적용된다.
  • 컬렉션은 List 뿐만 아니라 배열, Iterable, Enumeration을 구현한 모든 객체를 반복에 사용할 수 있다.
    • Map 도 사용할 수 있는데 이 경우 변수에 담기는 값은 Map.Entry다.
  • 변수명은 말 그대로 변수명이다.
    • 일반적인 자바의 for-each를 생각하면 된다.
    • 변수명.속성명으로 값을 꺼내서 쓸 수 있다.
  • 하위 태그도 함께 반복된다.
    • 예시 : tr에 th:each 사용 시 tr과 tr 아래의 td도 함께 반복

반복 상태 유지

  • 반복의 두번째 파라미터를 설정해서 반복의 상태를 확인 할 수 있다.
    • 생략 가능하다.
    • 다만 생략한다고 사용하지 못 하는 건 아니고, 지정한 변수명 + Stat으로 사용할 수 있다.
      • 만약 th:each="user : ${users}"라면 반복 상태를 나타내는 변수명은 userStat이 된다.
  • th:each="변수명, 반복상태명 : ${컬렉션명}"처럼 사용한다.
  • 반복상태명.속성명으로 다양한 값을 사용할 수 있다.
  • 종류
    • index
      • 0부터 시작하는 값
    • count
      • 1부터 시작하는 값
    • size
      • 전체 사이즈
    • odd
      • 홀수 여부 (boolean)
    • even
      • 짝수 여부 (boolean)
    • first
      • 처음 여부 (boolean)
    • last
      • 마지막 여부 (boolean)
    • current
      • 현재 객체

컨트롤러

@GetMapping("/each")
public String each(Model model) {
    addUsers(model);
    return "basic/each";
}

private void addUsers(Model model) {
    List<User> list = new ArrayList<>();
    list.add(new User("userA", 10));
    list.add(new User("userB", 20));
    list.add(new User("userC", 30));
    model.addAttribute("users", list);
}

HTML

  • src/main/resources/templates/basic/each.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <h1>기본 테이블</h1>
    <table border="1">
      <tr>
        <th>username</th>
        <th>age</th>
      </tr>
      <tr th:each="user : ${users}">
        <td th:text="${user.username}">username</td>
        <td th:text="${user.age}">0</td>
      </tr>
    </table>
    <h1>반복 상태 유지</h1>
    <table border="1">
      <tr>
        <th>count</th>
        <th>username</th>
        <th>age</th>
        <th>etc</th>
      </tr>
      <tr th:each="user, userStat : ${users}">
        <td th:text="${userStat.count}">username</td>
        <td th:text="${user.username}">username</td>
        <td th:text="${user.age}">0</td>
        <td>
          index = <span th:text="${userStat.index}"></span>
          count = <span th:text="${userStat.count}"></span>
          size = <span th:text="${userStat.size}"></span>
          even? = <span th:text="${userStat.even}"></span>
          odd? = <span th:text="${userStat.odd}"></span>
          first? = <span th:text="${userStat.first}"></span>
          last? = <span th:text="${userStat.last}"></span>
          current = <span th:text="${userStat.current}"></span>
        </td>
      </tr>
    </table>
  </body>
</html>

조건부 평가

  • 타임리프의 조건식은 3가지 종류가 있다.
    • th:if
    • th:unless
      • if의 반대
    • th:switch + th:case

if

  • HTML 태그에 th:if="조건식"처럼 명시해서 사용한다.
  • 조건식의 결과가 true일 때만 해당 HTML 태그가 랜더링된다.

unless

  • HTML 태그에 th:unless="조건식"처럼 명시해서 사용한다.
  • 조건식의 결과가 false일 때만 해당 HTML 태그가 랜더링된다.

switch + case

  • 상위 HTML 태그에 th:switch="${변수명}", 하위 HTML 태그에 th:case="값"처럼 명시해서 사용한다.
  • 자바의 switch-case와 동일한 원리다.
  • th:switch쪽에 명시한 변수의 값에 따라서 하위 HTML 태그에서 랜더링되는 HTML 태그가 달라진다.
    • 만약 th:case="*"처럼 명시하면 해당 HTML 태그는 자바 switch-case문의 default와 같은 역할을 한다.

컨트롤러

@GetMapping("/condition")
public String condition(Model model) {
    addUsers(model);
    return "basic/condition";
}

HTML

  • src/main/resources/templates/basic/condition.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
        <h1>if, unless</h1>
        <table border="1">
            <tr>
                <th>count</th>
                <th>username</th>
                <th>age</th>
            </tr>
            <tr th:each="user, userStat : ${users}">
                <td th:text="${userStat.count}">1</td>
                <td th:text="${user.username}">username</td>
                <td>
                    <span th:text="${user.age}">0</span>
                    <span th:text="'미성년자'" th:if="${user.age lt 20}"></span>
                    <span th:text="'미성년자'" th:unless="${user.age ge 20}"></span>
                </td>
            </tr>
        </table>
        <h1>switch</h1>
        <table border="1">
            <tr>
                <th>count</th>
                <th>username</th>
                <th>age</th>
            </tr>
            <tr th:each="user, userStat : ${users}">
                <td th:text="${userStat.count}">1</td>
                <td th:text="${user.username}">username</td>
                <td th:switch="${user.age}">
                    <span th:case="10">10살</span>
                    <span th:case="20">20살</span>
                    <span th:case="*">기타</span>
                </td>
            </tr>
        </table>
    </body>
</html>

주석

주석을 사용하는 방법

  1. 표준 HTML 주석
    • 표준 HTML 주석을 사용하는 방법이다.
    • <!-- xxx -->처럼 사용한다.
    • 자바스크립트의 표준 HTML 주석은 타임리프가 렌더링 하지 않고, 그대로 남겨둔다.
    • 그래서 실제로 실행해보면 <!--<span th:text="${data}">html data</span>-->은 그대로 <!--<span th:text="${data}">html data</span>-->가 된다.
  2. 타임리프 파서 주석
    • 타임리프 파서 주석은 타임리프가 제공하는 기본적인 주석이다.
    • <!--/* xxx */--> 또는 <!--/*-->xxx<!--/*-->처럼 사용한다.
    • 서버 사이드에서 렌더링할 때 주석 부분을 완전히 제거한다.
  3. 타임리프 프로토타입 주석
    • 타임리프 프로토타입은 타임 리프에서 제공하는 약간 특이한 주석 방법이다.
      • HTML 주석에 약간의 구문을 더했다.
    • <!--/*/ xxx /*/-->처럼 사용한다.
    • HTML 파일을 웹 브라우저에서 그대로 열어보면 HTML 주석이기 때문에 이 부분이 웹 브라우저가 렌더링하지 않는다.
      • 타임리프 렌더링을 거치면 이 부분이 정상 렌더링 된다.
      • 즉, HTML 파일을 그대로 열어보면 주석처리가 되지만, 타임리프를 렌더링 한 경우에만 보이는 기능이다.

컨트롤러

@GetMapping("/comments")
public String comments(Model model) {
    model.addAttribute("data", "Spring!");
    return "basic/comments";
}

HTML

  • src/main/resources/templates/basic/comments.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <h1>예시</h1>
    <span th:text="${data}">html data</span>
    <h1>1. 표준 HTML 주석</h1>
    <!--
    <span th:text="${data}">html data</span>
    -->
    <h1>2. 타임리프 파서 주석</h1>
    <!--/* [[${data}]] */-->
    <!--/*-->
    <span th:text="${data}">html data</span>
    <!--*/-->
    <h1>3. 타임리프 프로토타입 주석</h1>
    <!--/*/
    <span th:text="${data}">html data</span>
    /*/-->
  </body>
</html>

블록

  • th:block을 사용해서 영역을 구분하는 방법이다.
    • HTML 태그가 아닌 타임리프의 유일한 자체 태그다.
  • 순수히 영역 구분용이라서 다른 HTML 태그로 치환된다거나 그러지는 않는다.
    • 서버 사이드에서 렌더링시 완전히 제거된다.
  • 주로 th:each, th:if 등과 결합해서 사용한다.

컨트롤러

@GetMapping("/block")
public String block(Model model) {
    addUsers(model);
    return "basic/block";
}

HTML

  • src/main/resources/templates/basic/block.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <th:block th:each="user : ${users}">
      <div>
        사용자 이름1 <span th:text="${user.username}"></span>
        사용자 나이1 <span th:text="${user.age}"></span>
      </div>
      <div>
        요약 <span th:text="${user.username} + ' / ' + ${user.age}"></span>
      </div>
    </th:block>
  </body>
</html>

자바스크립트 인라인

  • 타임리프는 자바스크립트에서 타임리프를 편리하게 사용할 수 있는 자바스크립트 인라인 기능을 제공한다.
  • 자바스크립트 인라인 기능은 다음과 같이 적용하면 된다.
    • <script th:inline="javascript">
  • 자바스크립트 인라인을 적용하지 않으면 예상치 못 한 결과가 나올 수 있으니 반드시 적용하는 것이 좋다.
    • 인라인을 적용하지 않은 경우
      • 문자열
        • 따옴표 없이 그대로 노출
      • 자바스크립트 내추럴 템플릿
        • 주석 안에 값이 채워진다.
        • 만약에 디폴트 값을 명시하지 않았다면 자바스크립트 오류가 발생할 수 있다.
        • var username3 = /*[[${user.username}]]*/;var username3 = /*userA*/;로 랜더링된다.
          • 로직 상으로는 var username3 = ;로 랜더링되버린다.
      • 객체
        • 해당 객체의 toString()이 호출된다.
        • ` BasicController.User(username=userA, age=10);`처럼 출력된다.
    • 인라인을 적용한 경우
      • 문자열
        • 따옴표가 붙어서 출력된다.
      • 자바스크립트 내추럴 템플릿
        • 값이 존재할 경우 디폴트 값 대신에 해당 값이 노출된다.
        • 만약 노출할 변수의 값이 null이라면 위와 동일하게 null로 대체된다.
      • 객체
        • 객체를 JSON으로 변환해준다.
1
2
3
4
5
| 원본 | 인라인 적용 X | 인라인 적용 O |
|-------|--------|---------|
| `var username = [[${user.username}]];` | `var username = userA;` | `var username = "userA";` |
| `var username2 = /*[[${user.username}]]*/ "test username";` | `var username2 = /*userA*/ "test username";` | `var username2 = "userA";` |
| var user = [[${user}]]; |  BasicController.User(username=userA, age=10); | `var user = {"username":"userA","age":10};` |

컨트롤러

@GetMapping("/javascript")
public String javascript(Model model) {
    model.addAttribute("user", new User("userA", 10));
    addUsers(model);
    return "basic/javascript";
}

HTML

  • src/main/resources/templates/basic/javascript.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <!-- 자바스크립트 인라인 사용 전 -->
    <script>
      var username = [[${user.username}]];
      var age = [[${user.age}]];
      //자바스크립트 내추럴 템플릿
      var username2 = /*[[${user.username}]]*/ "test username";
      //객체
      var user = [[${user}]];

      var username3 = /*[[${temp}]]*/ "default for temp";
    </script>

    <!-- 자바스크립트 인라인 사용 후 -->
    <script th:inline="javascript">
      var username = [[${user.username}]];
      var age = [[${user.age}]];
      //자바스크립트 내추럴 템플릿
      var username2 = /*[[${user.username}]]*/ "test username";
      //객체
      var user = [[${user}]];

      var username3 = /*[[${temp}]]*/ "default for temp";
    </script>
  </body>
</html>

자바스크립트 인라인 each

  • 자바스크립트 인라인도 반복문인 each를 제공한다.

  • src/main/resources/templates/basic/javascript.html에 추가

<!-- 자바스크립트 인라인 each -->
<script th:inline="javascript">
 [# th:each="user, stat : ${users}"]
 var user[[${stat.count}]] = [[${user}]];
 [/]
</script>

템플릿 조각

  • 웹 페이지를 개발할 때는 GNB나 푸터처럼 공통 영역이 많이 있다.
  • 이런 부분을 코드를 복사해서 사용한다면 변경시 여러 페이지를 다 수정해야 하므로 상당히 비효율 적이다.
  • 타임리프는 이런 문제를 해결하기 위해 템플릿 조각과 레이아웃 기능을 지원한다.

fragment 정의

  • GNB나 푸터처럼 반복되는 코드 조각을 fragment라고 부른다.
  • th:fragment가 있는 태그는 다른곳에 포함되는 코드 조각으로 이해하면 된다.

fragment 사용

  • 부분 포함 insert
    • 속성을 명시한 HTML 태그 내부에 코드 조각을 추가한다.
    • th:insert="~{template/fragment/footer :: copy}"처럼 사용한다.
  • 부분 포함 replace
    • 속성을 명시한 HTML 태그를 코드 조각으로 대체한다.
    • th:replace="~{template/fragment/footer :: copy}"처럼 사용한다.
  • 부분 포함 단순 표현식
    • ~{...} 를 사용하는 것이 원칙이지만 템플릿 조각을 사용하는 코드가 단순하면 이 부분을 생략할 수 있다.
    • th:replace="template/fragment/footer :: copy"처럼 사용한다.
  • 파라미터 사용
    • 파라미터를 전달해서 동적으로 조각을 렌더링 할 수도 있다.
    • th:replace="~{template/fragment/footer :: copyParam ('데이터1', '데이터2')}">처럼 사용한다.

컨트롤러

package hello.thymeleaf.basic;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/template")
public class TemplateController {
    @GetMapping("/fragment")
    public String template() {
        return "template/fragment/fragmentMain";
    }
}

HTML (공통 영역)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
  <footer th:fragment="copy">
    푸터 자리 입니다.
  </footer>
  <footer th:fragment="copyParam (param1, param2)">
    <p>파라미터 자리 입니다.</p>
    <p th:text="${param1}"></p>
    <p th:text="${param2}"></p>
  </footer>
</body>
</html>

HTML (메인 컨텐츠)

  • src/main/resources/templates/template/fragment/fragmentMain.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body>
    <h1>부분 포함</h1>
    <h2>부분 포함 insert</h2>
    <div th:insert="~{template/fragment/footer :: copy}"></div>
    <h2>부분 포함 replace</h2>
    <div th:replace="~{template/fragment/footer :: copy}"></div>
    <h2>부분 포함 단순 표현식</h2>
    <div th:replace="template/fragment/footer :: copy"></div>
    <h1>파라미터 사용</h1>
    <div th:replace="~{template/fragment/footer :: copyParam ('데이터1', '데이터2')}"></div>
  </body>
</html>

템플릿 레이아웃1

  • 이번에는 일부 코드 조각을 가져와서 사용하는 것이 아닌, 코드 조각을 레이아웃에 넘겨서 사용해보자.

동작 방식

  • 아래의 common_header(~{::title},~{::link})가 핵심이다.
    • ::title은 현재 페이지의 title 태그들을 전달한다.
    • ::link는 현재 페이지의 link 태그들을 전달한다.

컨트롤러

@GetMapping("/layout")
public String layout() {
    return "template/layout/layoutMain";
}

HTML (공통 영역)

  • src/main//resources/templates/template/layout/base.html
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="common_header(title,links)">
  <title th:replace="${title}">레이아웃 타이틀</title>

  <!-- 공통 -->
  <link rel="stylesheet" type="text/css" media="all" th:href="@{/css/awesomeapp.css}">
  <link rel="shortcut icon" th:href="@{/images/favicon.ico}">
  <script type="text/javascript" th:src="@{/sh/scripts/codebase.js}"></script>

  <!-- 추가 -->
  <th:block th:replace="${links}" />
</head>

HTML (메인 컨텐츠)

  • src/main/resources/templates/template/layout/layoutMain.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head th:replace="template/layout/base :: common_header(~{::title},~{::link})">
    <title>메인 타이틀</title>
    <link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
    <link rel="stylesheet" th:href="@{/themes/smoothness/jquery-ui.css}">
  </head>
  <body>
  메인 컨텐츠
  </body>
</html>

HTML 랜더링 결과

<!DOCTYPE html>
<html>
  <head>
    <title>메인 타이틀</title>
    
    <!-- 공통 -->
    <link rel="stylesheet" type="text/css" media="all" href="/css/awesomeapp.css">
    <link rel="shortcut icon" href="/images/favicon.ico">
    <script type="text/javascript" src="/sh/scripts/codebase.js"></script>
    
    <!-- 추가 -->
    <link rel="stylesheet" href="/css/bootstrap.min.css">
    <link rel="stylesheet" href="/themes/smoothness/jquery-ui.css">
  </head>
  <body>
  메인 컨텐츠
  </body>
</html>

템플릿 레이아웃2

  • 앞서 이야기한 개념을 <head>정도에만 적용하는게 아니라 <html> 전체에 적용할 수도 있다.

동작 방식

  • layoutFile.html을 보면 기본 레이아웃을 가지고 있는데, <html>th:fragment 속성이 정의되어 있다.
    • 이 레이아웃 파일을 기본으로 하고 여기에 필요한 내용을 전달해서 부분부분 변경한다.
  • layoutExtendMain.html는 현재 페이지인데, <html> 자체를 th:replace를 사용해서 변경한다.
    • 즉, layoutFile.html에 필요한 내용을 전달하면서 <html> 자체를 layoutFile.html로 변경한다.

컨트롤러

@GetMapping("/layoutExtend")
public String layoutExtends() {
    return "template/layoutExtend/layoutExtendMain";
}

HTML (공통 영역)

  • src/main/resources/templates/template/layoutExtend/layoutFile.html
<!DOCTYPE html>
<html th:fragment="layout (title, content)" xmlns:th="http://www.thymeleaf.org">
  <head>
    <title th:replace="${title}">레이아웃 타이틀</title>
  </head>
  <body>
    <h1>레이아웃 H1</h1>
    <div th:replace="${content}">
      <p>레이아웃 컨텐츠</p>
    </div>
    <footer>
      레이아웃 푸터
    </footer>
  </body>
</html>

HTML (메인 컨텐츠)

  • src/main/resources/templates/template/layoutExtend/layoutExtendMain.html
<!DOCTYPE html>
<html th:replace="~{template/layoutExtend/layoutFile :: layout(~{::title},~{::section})}" xmlns:th="http://www.thymeleaf.org">
  <head>
    <title>메인 페이지 타이틀</title>
  </head>
  <body>
    <section>
      <p>메인 페이지 컨텐츠</p>
      <div>메인 페이지 포함 내용</div>
    </section>
  </body>
</html>

HTML 랜더링 결과

<!DOCTYPE html>
<html>
  <head>
    <title>메인 페이지 타이틀</title>
  </head>
  <body>
    <h1>레이아웃 H1</h1>
    <section>
      <p>메인 페이지 컨텐츠</p>
      <div>메인 페이지 포함 내용</div>
    </section>
    <footer>
      레이아웃 푸터
    </footer>
  </body>
</html>

출처

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.