스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 : Thymeleaf

jkky98·2024년 7월 26일
0

Spring

목록 보기
14/77

텍스트 - text, utext

1... <span th:text="${data}">
2... <li> [[${data}]] <li>
  1. th:text 속성을 사용하여 태그의 text 영역에 데이터 반영

    • th:text는 타임리프 엔진이 data 값을 태그의 text 내용으로 대체하는 방식이다.
    • HTML 태그 내부의 text만 변경되며 태그 구조는 그대로 유지된다.
  2. HTML 콘텐츠 영역에 직접 데이터 출력

    • [[${data}]]는 데이터가 HTML 콘텐츠 영역 안에 직접 반영되는 방식이다.
    • 타임리프 엔진은 [[ ]]를 해석하여 해당 데이터를 출력한다.
    • 태그의 속성이 아닌 콘텐츠 영역에 값을 표시할 때 유용하다.

Escape

HTML Escape는 HTML 태그 내에서 특수 문자가 HTML로 안전하게 변환되는 과정을 말하며, <, >, &, " 등의 문자가 포함된 사용자 입력을 HTML 특수 문자로 변환해 XSS(교차 사이트 스크립팅) 공격을 방지한다. Thymeleaf는 기본적으로 HTML Escape를 수행하기 때문에 data<b>Spring!</b>와 같은 값을 전달하면 <b> 태그는 해석되지 않고 &lt;b&gt;Spring!&lt;/b&gt;로 출력된다. 만약 개발자가 HTML 태그를 해석하도록 의도했다면 th:utext를 사용해야 하며, 이를 통해 브라우저에서 "Spring!"이 진하게 표시되도록 할 수 있다.

아래 예시를 보자.

"<b>Spring!</b>"

실제로 위 텍스트를 템플릿으로 넘길 경우(th:text) 위의 String이 태그를 포함해서 그대로 브라우저에서 렌더링되는 것을 볼 수 있다. 즉, <b>Spring!</b>가 HTML 태그처럼 작동하지 않고 텍스트로 표시된다. 소스를 확인하면 콘텐츠 영역에 &lt;b&gt;Spring!&lt;/b&gt;와 같이 HTML 특수 문자로 변환된 형태가 작성된 것을 확인할 수 있다. 이는 Thymeleaf의 기본 동작인 HTML Escape 때문이며, 특수 문자를 안전하게 처리하여 브라우저에서 태그가 해석되지 않도록 한다.

웹 브라우저는 <와 같은 태그를 HTML 태그의 시작으로 인식하기에 이를 태그의 시작이 아닌 문자로 표현할 수 있는 방법이 필요한데 이것을 HTML 엔티티라고 하며 특수문자로 하여금 HTML 엔티티로 변경하는 것을 escape라 한다. 타임리프는 기본적으로 이스케이프를 제공한다.

타임리프는 특수문자들이 HTML문법으로 작동되는 것을 막기 위해 태그에 활용되는 문자들을 HTML 엔티티로 탈출(이스케이프)시키는 것을 디폴트로 한다.

th:utext & [(...)]

타임리프는 기본적으로 HTML 이스케이프가 적용되어 있어 특수문자가 HTML 문법으로 작동되는 것을 방지한다. 이를 해제하려면 utext 또는 [(...)]를 사용해야 한다. 타임리프가 이스케이프를 디폴트로 설계한 이유는 이스케이프를 사용하지 않을 경우 발생할 수 있는 HTML 렌더링 문제나 보안 문제(예: XSS 공격)와 같은 다양한 문제를 방지하기 위함이다. 따라서 이스케이프는 기본으로 유지하고, 꼭 필요한 경우에만 언이스케이프(unescape)를 사용해야 한다.

변수 - SpringEL

스프링 컨트롤러에서 넘어온 변수를 템플릿에서 사용하기 위해 타임리프는 ${...}이라는 변수 표현식을 제공한다. 이 변수 표현식 안에서는 SpringEL이라는 스프링이 제공하는 자바스러운 문법을 사용할 수 있다.

아래에는 ${}에 템플릿으로 전달된 user 객체가 활용되는 예시를 나타낸다. user의 username필드를 사용하기 위해 private으로 선언되었지만 user.username이 사용가능하다.(타임리프가 알아서 Getter를 모셔온다.) 그에 더불어 당연히 Getter 사용도 가능하다. 객체에 Getter는 생성되어있어야 한다. 또한 user['username']과 같은 표현으로도 가져올 수 있다.

user가 맵이나 리스트인 컬렉션인 경우에도 타임리프는 이에 맞춘 표현을 제공한다.(아래 태그 참고)

<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>

지역 변수 th:with

<h1>지역 변수 - (th:with)</h1>
<div th:with="first=${users[0]}">
 <p>처음 사람의 이름은 <span th:text="${first.username}"></span></p>
</div>

th:with는 타임리프에서 변수 선언을 위해 사용되는 속성이다. key=value 형식으로 변수를 선언하면 해당 변수를 th:with가 선언된 범위와 그 하위 태그들에서 사용할 수 있다. 범위를 벗어나면 선언된 변수는 접근할 수 없다. 선언된 값에는 다른 변수, 표현식, 리터럴 값 등을 사용할 수 있으며, 변수의 유효 범위는 th:with가 선언된 태그와 그 하위 태그로 제한된다.

기본 객체 접근

Spring Boot 3.0 이전에는 기본 객체인 request, response, session, servletContext 등을 타임리프에서 ${#request}와 같은 표현식을 사용하여 가져올 수 있었다. 하지만 Spring Boot 3.0 이후부터는 이러한 기본 객체 접근이 제거되었기 때문에 더 이상 사용할 수 없다.

  • 직접적인 웹 객체 접근의 위험: 이전 Thymeleaf 템플릿에서 #request, #response, #session, #servletContext와 같은 객체를 직접 접근할 수 있었으나 이러한 접근은 보안 상의 문제를 야기할 수 있으며, 응용 프로그램의 상태를 직접적으로 조작할 수 있는 위험이 있었다. 이러한 직접적인 접근은 보안 취약점을 발생시키거나 비즈니스 로직과 뷰 로직 간의 경계를 모호하게 만들 수 있기 때문에 3.0부터는 직접적 사용을 지원하지 않게 되었다.

  • 정확한 책임 분리: #request, #response, #session, #servletContext 객체에 대한 직접 접근은 View 레이어에서 애플리케이션의 상태를 직접적으로 수정할 수 있는 문제를 야기할 수 있. Spring MVC 및 Spring Boot는 일반적으로 비즈니스 로직과 View 로직의 분리를 강조하며, 이로 인해 템플릿에서 이러한 객체들에 직접 접근하는 방식은 지양되고 있다.

${#session} VS ${session}

#request는 막혔다. 그러므로 템플릿에서 request를 받으려면 모델에 컨트롤러가 직접 request를 넣어주는 방법 말고는 타임리프가 이를 자동으로 주입해주는 경우는 없다. 반면에 세션의 경우는 template에서 필요할 수 있는 부분이 존재하기에 HttpSession객체 자체에 직접적인 접근을 하는 #session은 3.0이후 막혔고, Spring이 보안과 책임분리를 신경써서 템플릿에서 사용가능한 정도로 제공하는 session은 주입이 되므로 자유롭게 사용할 수 있다.

  • #request -> 막힘
  • #response -> 막힘
  • #session -> 막힘
  • #servletContext -> 막힘
  • #locale -> 살아남음

편의 기본 객체

  • session : 한정된 세션 기능 사용 가능
  • param : 요청 파라미터 사용 가능
  • @Bean.method(), @Bean : 빈 객체 사용 가능

tips : ${...}변수 표현식 내부에서 @가 쓰일 경우 빈에 접근한다고 보면 된다. #은 스프링의 내장 객체들에 접근한다고 보면 된다.

유틸리티 객체

  • #message : 메시지, 국제화 처리
  • #uris : URI 이스케이프 지원
  • #dates : java.util.Date 서식 지원
  • #calendars : java.util.Calendar 서식 지원
  • #temporals : 자바8 날짜 서식 지원
  • #numbers : 숫자 서식 지원
  • #strings : 문자 관련 편의 기능
  • #objects : 객체 관련 기능 제공
  • #bools : boolean 관련 기능 제공
  • #arrays : 배열 관련 기능 제공
  • #lists , #sets , #maps : 컬렉션 관련 기능 제공
  • #ids : 아이디 처리 관련 기능 제공

URL 링크

변수에 빈을 활용하는 것과 헷갈릴 수 있음에 주의하자. 타임리프에서 URL을 생성할 때는 @{...} 문법을 사용한다.

<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>

PathVariable 적용이 가능하며 PathVariable에 매핑되지 않는다면 쿼리 파라미터로 들어감을 알 수 있다.

${@...}@{...}은 각각 Bean 객체와 URL이니 헷갈리지 않도록 하자.

Literal

타임리프에서 문자 리터럴은 항상 '(작은 따옴표)로 감싸야 한다. 공백없이 쭉 이어진다면 작은 따옴표를 생략 가능하다.

1... <span th:text="'hello'">
2... <span th:text="hello world!"></span>  

2...의 경우는 에러가 발생한다. 크리티컬한 에러가 아니지만, 타임리프 리터럴 문법에 무지하다면 찾는데 시간이 오래걸릴지도 모른다.

연산

자바와 크게 다르지 않다. ""내에서 연산식을 작성하면 된다.

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

속성 값 설정

속성 추가
- 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/>

checked 처리
- 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/>
  • th:attrappend : 속성 값의 뒤에 값을 추가한다.
  • th:attrprepend : 속성 값의 앞에 값을 추가한다.
  • th:classappend : class 속성에 자연스럽게 추가한다.

HTML에서 체크박스의 checked 속성은 값 없이 선언되면 체크 상태로 렌더링되며, 속성을 아예 작성하지 않으면 기본적으로 체크되지 않은 상태가 된다. 그러나 이런 방식은 동적인 템플릿을 만드는 데 불편할 수 있다.

이를 해결하기 위해 타임리프는 th:checked 속성을 제공하며, true 또는 false 값을 기반으로 체크 여부를 제어할 수 있게 한다. th:checked는 조건이 true일 때 checked 속성을 추가하고, false

반복

타임리프에서 반복은 th:each 를 활용한다.

<tr th:each="user : ${users}">
 <td th:text="${user.username}">username</td>
 <td th:text="${user.age}">0</td>
</tr>

타임리프의 th:each 문법을 사용하면 반복 변수와 함께 반복에 대한 정보를 담는 상태 변수를 추가로 받을 수 있다. 예를 들어 <tr th:each="user, userStat : ${users}">에서 userStat은 반복 상태 정보를 제공하는 변수이다.

userStat 변수는 반복의 인덱스, 순서, 첫 번째 여부, 마지막 여부 등의 정보를 포함한다. 만약 상태 변수를 생략하면 타임리프는 기본적으로 반복 변수 뒤에 "Stat"을 붙여 자동으로 상태 변수를 생성한다. 예를 들어 user라는 반복 변수만 선언하면 타임리프는 자동으로 userStat을 생성한다.

<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>

조건부 평가

타임리프의 조건식 if,unless를 사용한다.

<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 <= 20}"></span>
            <span th:text="'미성년자'" th:unless="${user.age ge 20}"></span>
        </td>
    </tr>

(ge, >)로 하여금 알 수 있듯 이스케이프 표현도 적용 가능하다.

주석

1.. <!--
<span th:text="${data}">html data</span>
-->

2.. 
<!--/*-->
<span th:text="${data}">html data</span>
<!--*/-->

3..
<!--/*/
<span th:text="${data}">html data</span>
/*/-->
  1. 표준 HTML 주석
    표준 HTML 주석은 타임리프가 랜더링하지 않고 그대로 남겨둔다.
  2. 타임리프 파서 주석
    타임리프의 주석으로 주석 부분을 제거해서 랜더링한다.
  3. 타임리프 프로토타입 주석
    정적 HTML로서는 랜더링되지 않지만 타임리프 랜더링을 거치면 정상 랜더링 된다.

블록

th:each를 사용하면 타임리프는 특정 태그에 반복을 적용하며, 반복 변수는 해당 태그 안에서 활용된다. 이렇게 하면 해당 태그만 반복되지만, 여러 태그를 동시에 반복하고 싶을 때는 <th:block> 태그를 사용할 수 있다.

<th:block> 태그는 타임리프의 논리적 그룹핑 태그로, 내부에 있는 태그들을 하나의 블록으로 묶어 반복을 적용할 수 있게 한다. 중요한 점은 <th:block> 태그는 실제 렌더링 시 HTML 결과에서 제외되기 때문에 최종 HTML에는 영향을 주지 않는다.

이를 통해 코드의 구조를 명확히 하면서도 불필요한 HTML 태그를 생성하지 않고 반복 작업을 수행할 수 있다.

<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>

자바스크립트 인라인

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

템플릿 내에서 선언한 <script>이다 위의 스크립트와 달리 아래의 스크립트 태그에는 th:inline="javascript"가 달려있다. 이를 통해 자바스크립트와 스프링을 바인딩할 수 있다 소스코드를 보자.

인라인잉 적용되지 않은 위의 경우 UserA라는 것이 전달되지만 이는 자바스크립트가 알지 못하는 객체이다. 반면 아래에서는 "UserA"로 스트링이 된다 또한 객체를 가져올 때 인라인 선언시 json으로 받도록 바인딩 된다. 이스케이프 처리도 가능하며 주석표현을 통해 조건식 사용도 가능하다.

자바스크립트 인라인을 통해 우리는 자바스크립트 내추럴 템플릿의 특성까지 알 수 있게 되었다.

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

위와 같이 # th:each로 하여금 자바스크립트 내에서도 each를 지원한다.

템플릿 조각

<footer th:fragment="copy">
 푸터 자리 입니다.
</footer>

위와 같이 특정 태그에 th:fragment = "임의의 템플릿 조각 이름" 속성을 부여하고 다른 템플릿 파일에서 <div th:insert="~{template/fragment/footer :: "임의의 템플릿 조각 이름"}"></div>와 같이 작성한다면 div태그 내에 연결된 th:fragment의 태그를 삽입해준다.

<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>

위와 같이 insert뿐만 아니라 replace도 사용 가능하며 ~{}이 아닌 단순 표현식으로도 사용 가능하나, 파라미터를 받는 등 표현식이 복잡해질 경우 에러가 발생가능하므로 ~{}안에 경로 :: 이름을 작성하여 활용하도록 하자.

레이아웃

코드조각 개념의 확장이다. 작은 부품 보관소를 만들어서 필요한 시점에 부품들을 가져다 쓰는 것이 아니라 하나의 플랫폼을 만드는 것이다.

예로 헤드 태그에서 공통으로 사용하는 css, javascript 정보들이 존재할 때 각 페이지마다 필요한 정보를 더 추가해야할 경우 공통으로 사용하는 것을 각 페이지에 끌어들어올 것이 아니라 공통적인 부분에 각 페이지의 비공통적인 부분을 추가해주는 식이다.

만약 이것이 거꾸로 적용되어 각 페이지에 공통적인 부분을 적용한다고 한다면 각 페이지의 수가 매우 많을 경우 유지보수가 힘들어질 수 있다.

<!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 태그 자체가 fragment로서 조각이 된다.

<!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파일의 div가 replace에 의해 아래 코드의 section 태그로 치환된다. title 태그또한 파라미터로 입력되어 플랫폼 html파일의 title부분을 동적으로 치환한다.

profile
자바집사의 거북이 수련법

0개의 댓글