레이블이 Thymeleaf인 게시물을 표시합니다. 모든 게시물 표시
레이블이 Thymeleaf인 게시물을 표시합니다. 모든 게시물 표시

2019년 5월 18일 토요일

Thymeleaf 간단 매뉴얼

 웹 개발 시에 사용할 수 있는 Template Engine으로 JSP를 많이 사용하지만 직접 사용해보니 JSP보다 Thymeleaf가 훨씬 나은 듯 하다. 기본적인 문법도 간단하고 template 파일을 그대로 브라우저에서 열어봐도 문제가 없도록 template을 작성할 수 있다는 점도 내게는 큰 장점으로 보인다. 그래서 여기에 기본적인 문법을 간단한 샘플과 함께 정리해 보려고 한다.
 Thymeleaf를 처음 배우는 사람에게는 아무 것도 이해가지 않을 내용이지만 새롭게 기억을 되살려 보려고 한다면 큰 도움이 될 것이라고 생각된다. 모든 내용을 정리할 수는 없지만 많이 사용하는 기능들 위주로 최대한 성의껏 작성해 보겠다. 참고로 이 글에서 사용하는 대부분의 샘플 코드는 Thymeleaf의 공식 문서에서 발췌된 것임을 밝힌다.

1. Message Expression: #{ ... }


...
<p th:text="#{home.welcome}">Welcome to our grocery store!</p>
...
 message source로부터 home.welcome 키에 지정된 메시지를 가져와서 해당 tag의 text를 대체한다. th:text는 태그의 text를 지정된 값으로 대체하는 기능을 한다. 예를 들어 messages.properties에 아래와 같은 값이 있다면
...
home.welcome=¡Bienvenido a nuestra tienda de comestibles!
...
 아래와 같은 결과를 얻게될 것이다.
...
<p>¡Bienvenido a nuestra tienda de comestibles!</p>
...
 만약 문자열의 내용을 escape처리하고 싶지 않다면 th:utext를 사용한다. '<' 기호가 "&lt;" 로 변환되지 않고 그대로 남아있게 된다.
...
<p th:utext="#{home.welcome}">Welcome to our grocery store!</p>
...
 message 문자열이 아래와 같이 파라메터를 받도록 되어있다면
...
home.welcome=¡Bienvenido a nuestra tienda de comestibles, {0}!
...
 아래와 같이 괄호를 넣어서 파라메터를 넘길 수 있다.
...
<p th:utext="#{home.welcome(${session.user.name})}">
  Welcome to our grocery store, Sebastian Pepper!
</p>
...

2. Variable Expression: ${ ... }


...
<p>Today is: <span th:text="${today}">13 february 2011</span>.</p>
...
 모델에 저장된 today 속성값을 문자열로 변환(toString())하여 span tag의 text값을 대체한다.
 아래의 예제와 같이 .을 이용하여 속성값에 접근하거나 []을 이용하여 Map이나 Array의 원소에 접근하는 것들도 가능하고 객체의 메소드 호출도 가능하다.
${person.father.name}
${person['father']['name']}

${countriesByCode.ES}
${personsByName['Stephen Zucchini'].age}

${personsArray[0].name}

${person.createCompleteName()}
${person.createCompleteNameWithSeparator('-')}
 참고로 ${{ ... }}형태의 문법(double-brace syntax)이 있는데 이 문법은 최종적으로 산출된 결과 객체를 등록된 Conversion Service를 이용해서 formatting하도록 한다. 스프링과 함께 사용할 때는 스프링의 Conversion Service를 사용하므로 스프링에 익숙하다면 상당히 편리한 기능이다.

3. Selection Variable Expression: *{ ... }


 *{...} 표현식은 부모 태그의 th:object에 지정된 객체를 기준으로 해당 객체의 속성에 접근한다. 아래 예제를 보자.
...
<div th:object="${session.user}">
    <p>Name: <span th:text="*{firstName}">Sebastian</span>.</p>
    <p>Surname: <span th:text="*{lastName}">Pepper</span>.</p>
    <p>Nationality: <span th:text="*{nationality}">Saturn</span>.</p>
</div>
...
 위의 코드는 아래 코드와 정확히 동일하다.
...
<div>
  <p>Name: <span th:text="${session.user.firstName}">Sebastian</span>.</p>
  <p>Surname: <span th:text="${session.user.lastName}">Pepper</span>.</p>
  <p>Nationality: <span th:text="${session.user.nationality}">Saturn</span>.</p>
</div>
...
 지정된 객체(th:object)가 없는 경우 ${...}와 *{...}는 완전히 동일한 기능을 하며 지정된 객체가 있는 경우에도 ${ ... } 표현식을 섞어서 사용하는 데 문제가 없다.

 이 표현식도 역시 *{{ ... }}형태의 문법(double-brace syntax)을 지원한다. 즉, 결과 객체를 Conversion Service를 이용하여 변환시켜주므로 참고하도록 하자.

4. Link URL Expression: @{ ... }


 th:href, th:src, th:action 등과 같이 URL이 지정되는 속성에 사용한다. 아래 예제와 같이 여러가지 형태의 URL을 지정할 수 있다.
<!-- Page-relative URL. 일반적인 상대 경로 URL  -->
<a href="login.html" th:href="@{user/login.html}">login</a>

<!-- Context-relative URL. context name이 URL 앞에 자동으로 붙어서 생성된다. -->
<a href="login.html" th:href="@{/login.html}">login</a>

<!-- Server-relative URL. 서버의 다른 context에 접근할 수 있다. -->
<a href="login.html" th:href="@{~/other/login.html}">login</a>

<!-- Protocol-relative URL. -->
<a href="login.html" th:href="@{//www.other.com/login.html}">login</a>
 괄호를 사용해서 파라메터를 넘겨서 URL 파라메터를 지정하거나 URL path를 구성하는데 사용할 수 있다.
<!-- '/gtvg/order/details?orderId=3'을 생성. URL 파라메터로 사용됨. -->
<a href="details.html" th:href="@{/order/details(orderId=${o.id})}">view</a>

<!-- '/gtvg/order/3/details'를 생성. orderId를 local변수로 사용하여 URL path를 생성. -->
<a href="details.html" th:href="@{/order/{orderId}/details(orderId=${o.id})}">view</a>

5. Literals & Operators


 Thymeleaf의 Literal과 연산자들은 아래와 같다. 일반적인 개념에서 크게 벗어나는 것은 없으므로 추가 설명은 하지 않겠다.

  1. Literals
    • Text literals: 'one text', 'Another one!',…
    • Number literals: 0, 34, 3.0, 12.3,…
    • Boolean literals: true, false
    • Null literal: null
    • Literal tokens: one, sometext, main,…
  2. Text operations
    • String concatenation: +
    • Literal substitutions: |The name is ${name}|
  3. Arithmetic operations
    • Binary operators: +, -, *, /, %
    • Minus sign (unary operator): -
  4. Boolean operations
    • Binary operators: and, or
    • Boolean negation (unary operator): !, not
  5. Comparisons and equality
    • Comparators: >, <, >=, <= (gt, lt, ge, le)
    • Equality operators: ==, != (eq, ne)
  6. Conditional operators
    • If-then: (if) ? (then)
    • If-then-else: (if) ? (then) : (else)
    • Default: (value) ?: (defaultvalue)

6. Attribute 값 설정하기


 th:attr을 이용해서 어떤 attribute든지 값을 설정할 수 있다. tag에 이미 설정되어 있는 값은 th:attr에서 지정한 값으로 대체된다.
<form action="subscribe.html" th:attr="action=@{/subscribe}">
  <fieldset>
    <input type="text" name="email" />
    <input type="submit" value="Subscribe me!" th:attr="value=#{subscribe.submit}"/>
  </fieldset>
</form>
 위 예제는 Processing후에 아래와 같이 될 것이다.
<form action="/gtvg/subscribe">
  <fieldset>
    <input type="text" name="email" />
    <input type="submit" value="¡Suscríbeme!"/>
  </fieldset>
</form>
 하지만 html의 각 attribute에 대해서는 Thymeleaf에도 일대일 대응되는 th:xxx 형태의 속성이 있기 때문에 th:attr은 거의 사용하지 않는다. 이 경우 아래 예제와 같이 값을 지정한다. th:attr과 달리 "name=" 부분은 따로 지정할 필요가 없기때문에 훨씬 간단하게 표현된다.
<form action="subscribe.html" th:action="@{/subscribe}">
 checkbox 타입 버튼의 checked 속성이 check 상태를 표시하는데 이와 같이 boolean값의 역할을 하는 속성의 경우 값이 없이 속성만 지정되면 true의 역할을 하게된다. (값이 "checked"로 설정되어도 된다.) 이런 속성들은 th:checked 속성에 지정된 조건을 평가하여 true로 판단되는 경우 checked 속성이 남게되고 false인 경우 checked 속성자체가 사라지게 된다.
<input type="checkbox" name="active" th:checked="${user.active}" />
 위 예제에서 ${user.active}가 true냐 false냐에 따라 각각 아래와 같이 생성될 것이다.
<input type="checkbox" name="active" checked="checked" />
<input type="checkbox" name="active" />

7. Iteration


 Array나 List같은 Collection 객체를 iteration하는 간단한 예제는 아래와 같다.
...
<table>
  <tr>
    <th>NAME</th>
    <th>PRICE</th>
    <th>IN STOCK</th>
  </tr>
  <tr th:each="prod : ${prods}">
    <td th:text="${prod.name}">Onions</td>
    <td th:text="${prod.price}">2.41</td>
    <td th:text="${prod.inStock}? #{true} : #{false}">yes</td>
  </tr>
</table>
...
 th:each를 이용해서 원소의 갯수만큼 반복할 수 있으며 Iterable 구현 객체, Map 구현 객체 (이 경우 java.util.Map.Entry객체), Array 등의 객체를 사용할 수 있다.
 참고로 위 예제에서 prod 변수 이름에 Stat을 붙여서 'prodStat'이라는 Interaion 상태를 저장하는 로컬 변수가 자동으로 하나 생성되는데 이 변수에는 다음과 같은 상태값들이 저장된다.

  1. prodStat.index: 현재 index. (0부터 시작)
  2. prodStat.count: 현재 index. (1부터 시작)
  3. prodStat.size: 전체 원소 갯수
  4. prodStat.current: 현재 원소 객체. (prod와 같음)
  5. prodStat.even/odd: 현재 iteration이 짝수/홀수 인지를 나타내는 boolean값.
  6. prodStat.first: 현재 iteration이 첫번째 인지를 나타내는 boolean값.
  7. prodStat.last: 현재 iteration이 마지막 인지를 나타내는 boolean값.
 'prodStat' 변수의 이름을 다른 이름으로 지정하려면 <tr th:each="prod, iterStat : ${prods}">와 같이 prod 뒤에 comma를 찍고 이름을 써주면 된다.

8. Conditional Evaluation


 th:if를 이용하면 지정된 식의 결과 값에 따라 tag 전체를 없애거나 표시되도록 할 수 있다. 아래 예제의 <a> 태그는 prod.comments가 비어있지 않으면(not empty) 표시되고 그렇지 않으면 삭제된다.
...
<a href="comments.html" 
   th:href="@{/product/comments(prodId=${prod.id})}" 
   th:if="${not #lists.isEmpty(prod.comments)}">view</a>
...
th:if와 반대로 평가하는 th:unless도 있다. th:if의 결과식에 not을 적용한 것과 같다. 아래 예제는 위 예제와 동일하게 동작한다. (위의 th:if에는 not이 이미 있으므로 그것을 없앤 것과 같다)
...
<a href="comments.html"
   th:href="@{/comments(prodId=${prod.id})}" 
   th:unless="${#lists.isEmpty(prod.comments)}">view</a>
...

9. Template Layout


 완전한 html 문서가 아닌 html 문서의 일부분을 이루는 코드 조각을 fragment라고 한다. fragment를 선언하는 방법은 아래와 같다. (footer.html)
<!DOCTYPE html>

<html xmlns:th="http://www.thymeleaf.org">

  <body>
  
    <div th:fragment="copy">
      &copy; 2011 The Good Thymes Virtual Grocery
    </div>
  
  </body>
  
</html>
 위 예제는 footer.html 파일에 copy fragment를 선언한 것이다. fragment는 th:fragment를 이용하여 선언한다. 이렇게 선언된 fragment는 아래 예제와 같이 다른 문서의 일부분으로 삽입될 수 있다.
<body>

  ...

  <div th:insert="~{footer :: copy}"></div>
  
</body>
 <div>태그에 footer.html에 선언된 copy fragment를 삽입한다는 뜻이다. ~{ ... } 를 fragment expression이라고 하는데 아래 형태로 사용될 수 있다.

  1. "~{ templatename :: selector }"
  2. template 파일의 이름과 그 안에 선언된 Markup selector를 참조한다. 사실 Markup selector는 CSS selector와 비슷한 문법이 있지만 fragment를 참조하는 경우에는 fragment 이름만 써주면 된다. 대부분의 경우 이렇게 사용할 것이다. CSS selector와 비슷하기 때문에 <div id="myid" ... > 형태로 선언된 태그를 ~{ template :: #myid } 형태로 참조할 수 있다.
  3. "~{ templatename }"
  4. 전체 template 파일을 참조한다.
  5. "~{ ::selector }" or "~{ this :: selector }"
  6. 같은 파일 내에 있는 selector를 참조한다는 점을 제외하고 1번과 같다. 마찬가지로 selector로는 fragment 이름을 써주면 된다.
 공식적인 문법은 위와 같지만 단순한 fragment 참조의 경우에는 ~{ }를 생략할 수 있다. 아래 예제들은 대부분 ~{ }를 생략한 형태를 사용하고 있으므로 참고하도록 하자.

 선언된 fragment를 삽입하는 방법은 3가지가 있다. th:insert, th:replace, th:include를 사용할 수 있는데 각각의 차이점은 아래 예제를 보면 알 수 있다. 먼저 아래와 같이 fragment가 선언 되어있다고 하자.
<footer th:fragment="copy">
  &copy; 2011 The Good Thymes Virtual Grocery
</footer>
 아래와 같이 각각의 방법으로 fragment를 삽입하면
<body>
  ...

  <div th:insert="footer :: copy"></div>

  <div th:replace="footer :: copy"></div>

  <div th:include="footer :: copy"></div>
  
</body>
 아래와 같은 결과가 생성된다.
<body>
  ...

  <div>
    <footer>
      &copy; 2011 The Good Thymes Virtual Grocery
    </footer>
  </div>

  <footer>
    &copy; 2011 The Good Thymes Virtual Grocery
  </footer>

  <div>
    &copy; 2011 The Good Thymes Virtual Grocery
  </div>
  
</body>
 참고로 문서에서는 th:insert와 th:replace를 사용토록 권장하고 있다.

 fragment는 아래와 같이 선언하여 호출하는 쪽에서 파라메터를 받을 수 있다.
<div th:fragment="frag (onevar,twovar)">
    <p th:text="${onevar} + ' - ' + ${twovar}">...</p>
</div>
 이렇게 선언된 fragment를 호출할 때는 아래와 같이 파라메터를 넣어서 호출하면 된다.
<div th:replace="::frag (${value1},${value2})">...</div>
<div th:replace="::frag (onevar=${value1},twovar=${value2})">...</div>
 만일 위 예제의 두번째 방법으로 호출할 때 fragment에는 선언된 파라메터가 없다면 두 파라메터는 단순히 로컬변수가 되어 참조가 될 수 있다. 즉 아래와 같은 호출과 동일한 호출이 된다.
<div th:replace="::frag" th:with="onevar=${value1},twovar=${value2}">

10. Removing Template


 브라우저로 template 파일을 열어서 볼 때는 보이지만 processing후에는 특정 태그를 삭제하고 싶다면 th:remove를 사용한다. 사용법은 아래 예제와 같다.
<table>
  <tr>
    <th>NAME</th>
    <th>PRICE</th>
    <th>IN STOCK</th>
    <th>COMMENTS</th>
  </tr>
  <tr th:each="prod : ${prods}" th:class="${prodStat.odd}? 'odd'">
    <td th:text="${prod.name}">Onions</td>
    <td th:text="${prod.price}">2.41</td>
    <td th:text="${prod.inStock}? #{true} : #{false}">yes</td>
    <td>
      <span th:text="${#lists.size(prod.comments)}">2</span> comment/s
      <a href="comments.html" 
         th:href="@{/product/comments(prodId=${prod.id})}" 
         th:unless="${#lists.isEmpty(prod.comments)}">view</a>
    </td>
  </tr>
  <tr class="odd" th:remove="all">
    <td>Blue Lettuce</td>
    <td>9.55</td>
    <td>no</td>
    <td>
      <span>0</span> comment/s
    </td>
  </tr>
</table>
 template을 브라우저로 직접 열어보면 2개의 행이 보이겠지만 thymeleaf engine이 처리 후에는 prods의 원소 갯수만큼의 행이 생성되고 마지막 행은 삭제되어 보이지 않게될 것이다.
 th:remove의 값으로 아래와 같은 값들이 지정될 수 있다.

  • all: th:remove가 선언된 태그와 그 자식 태그들도 모두 삭제한다.
  • body: th:remove가 선언된 태그는 그대로 두고 자식 태그들만 삭제한다.
  • tag: th:remove가 선언된 태그만 삭제하고 자식 태그들은 그대로 둔다.
  • all-but-first: 첫 번째 자식 태그만 두고 나머지 자식 태그는 모두 삭제한다.
  • none: 아무것도 삭제하지 않고 그대로 둔다.
 마지막 none의 경우는 th:remove의 값으로 조건 표현식을 사용하여 조건에 따라 삭제를 할지 말지 결정할 때 사용될 수 있다.

11. Local Variables


 Thymeleaf는 특정 시점에 로컬 변수를 선언해서 사용할 수 있다.로컬 변수 선언은 th:with를 사용한다.
<div th:with="firstPer=${persons[0]},secondPer=${persons[1]}">
  <p>
    The name of the first person is <span th:text="${firstPer.name}">Julius Caesar</span>.
  </p>
  <p>
    But the name of the second person is 
    <span th:text="${secondPer.name}">Marcus Antonius</span>.
  </p>
</div>
 로컬 변수는 위와 같이 한 번에 2개 이상을 선언할 수도 있다. 로컬 변수는 자식 태그뿐 아니라 같은 태그 안에서도 사용할 수 있으며 심지어 th:with 선언 안에서도 첫 번째 변수를 두 번째 변수에서 사용해도 문제가 없다.

12. Attribute Precedence


 일반적으로 모든 언어들의 연산자에는 우선순위가 있다. Thymeleaf의 속성들 간에도 우선 순위가 있다. 아래 표를 참고하여 우선순위를 확인하도록 하자.

OrderFeatureAttributes
1Fragment inclusionth:insert
th:replace
2Fragment iterationth:each
3Conditional evaluationth:if
th:unless
th:switch
th:case
4Local variable definitionth:object
th:with
5General attribute modificationth:attr
th:attrprepend
th:attrappend
6Specific attribute modificationth:value
th:href
th:src
...
7Text (tag body modification)th:text
th:utext
8Fragment specificationth:fragment
9Fragment removalth:remove

13. Comments and Blocks


 Thymeleaf의 주석은 여러가지가 존재한다. 각각의 특성에 따라서 브라우저에서 직접 열었을 때는 주석으로 처리되고 processing 중에는 주석이 아닌 코드로 처리되도록 할 수도 있다. 각각에 대해서 살펴보도록 하자.

  • <!-- and -->
  • 일반적인 HTML의 주석이다. 이 주석은 브라우저에서나 thymeleaf에서나 무시된다. 하지만 processing후 삭제되는 것은 아니고 생성된 결과파일에도 그대로 복사되어 표시된다.
  • <!--/* and */-->
  • thymeleaf의 파서에서 주석으로 처리된다. 즉 파서가 template로부터 이 부분을 삭제하고 processing을 하게된다. 위의 HTML 주석과 별 차이가 없어보이지만 특정 부분을 브라우저에서 직접 열었을 때는 보이지만 thymeleaf가 처리중일 때는 보이지 않도록 할 수 있다. 아래 예제를 보면 어떻게 그렇게 할 수 있는지 알 수 있을 것이다..
    <!--/*-->
      <div> ...
      </div>
    <!--*/-->
    
  • <!--/*/ and /*/-->
  • 위와 반대로 브라우저에서 직접 열었을 때는 주석처리되지만 thymeleaf의 파서에서는 주석이 아닌 코드로 인식되도록 하기 위해서 사용한다. thymeleaf의 파서는 단순히 "<!--/*/", "/*/-->" 문자열을 삭제해 버린다. 따라서 그 안에 있던 모든 코드는 그대로 남겨진 채 처리가 이루어진다.
 thymeleaf가 제공하는 모든 th:xxx 속성들은 element가 아니라 attribute이기 때문에 속성을 지정할 tag가 적당치 않은 경우에 곤란할 때가 있다. 이런 경우 사용할 수 있는 태그가 th:block 태그다. 아래 예제처럼 테이블의 행 2개를 반복해야 할 때 사용할 수 있을 것이다.
<table>
  <th:block th:each="user : ${users}">
    <tr>
        <td th:text="${user.login}">...</td>
        <td th:text="${user.name}">...</td>
    </tr>
    <tr>
        <td colspan="2" th:text="${user.address}">...</td>
    </tr>
  </th:block>
</table>
 하지만 th:block 태그는 브라우저에서는 인식하지 못하는 태그이다. 결국 template 파일을 브라우저에서 열었을 때도 그럴 듯하게 보이도록 하려는 원래 의도가 망가질 수 있다. 하지만 바로 앞에서 설명했던 브라우저에서만 주석이 되도록하는 3번째 주석(<!--/*/ ... /*/-->)으로 th:block 태그 부분만 각각 감싸게되면 브라우저에서는 th:block 태그는 안보이게 되므로 원래 의도가 유지될 수 있다.

14. Spring Form


 스프링과 함께 사용할 때 form을 이용하여 데이터를 주고받을 때는 command 객체를 이용하게 된다. 아래 예제를 보자.
<form action="#" th:action="@{/seedstartermng}" th:object="${seedStarter}" method="post">
    ...
</form>
 위 예제는 전형적인 form태그의 예이다. action속성은 반드시 th:action을 사용하도록 하고 th:object에 command 객체를 지정한다.
 th:action은 단순히 action속성을 설정하는 것 이상의 일을 한다. CSRF 공격 등을 대비한 hidden field를 자동으로 넣어주는 등 추가적인 작업이 수행되게끔 해주므로 반드시 th:action을 사용하도록 하자.
 th:object에는 model attribute의 이름만 지정해야 한다. ${seedStarter.name} 같은 값은 유효하지 않다.

 일반적으로 command 객체의 각 속성값들은 form의 control들과 맵핑되어 자동으로 값을 주고 받게 된다. 이를 위해서 th:field 속성을 이용한다. 아래 예제를 보자.
<input type="text" th:field="*{datePlanted}" />
 th:field가 선언된 위 예제는 아래 예제와 비슷하다고 할 수 있다.
<input type="text" id="datePlanted" name="datePlanted" th:value="*{datePlanted}" />
 th:field는 input type에 맞게 적절히 필요한 속성을 알아서 처리해주며 필드의 객체를 표시하는데 Conversion이 필요하다면 알아서 Conversion Service를 이용하여 변환작업도 수행해준다.

15. Validation & Error Messages (String)


 스프링에서 form data는 서버로 전송되어 command 객체와 binding이 이루어지며 이 때 validation이 이루어진다. binding 결과는 bindingResult 객체에 저장되는데 thymeleaf는 이 값을 이용해서 에러가 발생했는지, 어떤 에러가 발생했는지 등등을 알 수 있으며 이 때 에러 메시지를 표시하도록 할 수 있다. 이러한 에러처리를 위한 thymeleaf 기능들에 대해 알아보자.

- #fields.hasErrors('field') : field에 에러가 있다면 true, 아니면 false. 모든 필드를 대상으로 하려면 '*' 또는 'all'을 지정한다. 특정 필드가 아닌 global에러를 검사하려면 'global'을 지정한다.
<input type="text" th:field="*{datePlanted}" 
                   th:class="${#fields.hasErrors('datePlanted')}? fieldError" />
- #fields.errors('field') : field의 모든 에러 메시지를 iteration할 수 있는 iterable 객체 반환. 모든 필드를 대상으로 하려면 '*' 또는 'all'을 지정한다. 특정 필드가 아닌 global 에러 메시지를 가져오려면 'global'을 지정한다.
<ul>
  <li th:each="err : ${#fields.errors('datePlanted')}" th:text="${err}" />
</ul>
- th:errors : 지정된 필드의 모든 에러 메시지를 <br /> 태그로 분리된 문자열로 반환. global 에러 메시지를 가져오려면 *{global}을 지정한다.
<input type="text" th:field="*{datePlanted}" />
<p th:if="${#fields.hasErrors('datePlanted')}" th:errors="*{datePlanted}">Incorrect date</p>
- th:errorclass : th:field에 지정된 필드에 에러가 있는 경우 class 속성에 지정된 값을 추가해 준다. 아래 예제에서 에러가 있다면 class는 "small fieldError"가 될 것이다.
<input type="text" th:field="*{datePlanted}" class="small" th:errorclass="fieldError" />
- #fields.hasAnyErrors() : #fields.hasErrors('*')와 동일.

- #fields.allErrors() : #fields.errors('*')와 동일.

- #fields.hasGlobalErrors() : #fields.hasErrors('global')와 동일.

- #fields.globalErrors() : #fields.errors('global')와 동일.

- #fields.detailedErrors() : 모든 에러에 대한 상세한 정보를 담고 있는 iterable 객체를 반환. 에러 객체는 fieldName(String), message(String), global(boolean) 속성을 갖는다.
<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>

마무리...


 처음 계획은 간단한 설명과 예제로 Thymeleaf 예제 모음 정도의 글을 쓰려고 했는데 쓰다보니 점점 일이 커져서 꽤나 고생해서 작성한 글이 되어버렸다. 사실 위에 정리된 내용 말고도 더 많은 기능들이 있지만 실제 현업에서 사용하는 기능은 이 정도면 90% 이상 커버할 수 있지 않을까 싶다. 사실 설명이 자세하진 않지만 예제가 있으므로 충분히 도움이 될 것으로 믿는다. 내가 나중에 다시 Thymeleaf로 작업을 하게되면 인터넷 검색이 선행되는 일이 없이 이 글만 읽고 바로 작업을 시작할 수 있을 것 같아 조금은 홀가분한 마음이다. Thymeleaf를 선호하는 많은 개발자들에게도 그러하기를 기대해 본다.

2018년 7월 26일 목요일

Spring Boot에 Security 적용하기


 이번 포스팅은 Spring Boot로 Web app을 개발할 때 Spring Security를 적용하는 방법에 대해서 정리해보려고 한다. 가능하면 가장 기본적인 설정만으로 구현할 것이고 Thymeleaf와의 연동에 대해서도 같이 정리할 것이다.

 일단 간단한 Web app이 개발된 상태라고 가정하는 게 Security에만 집중해서 설명할 수 있기 때문에 프로젝트를 생성한다던가 Spring Boot의 설정에 대해서는 건너 뛰도록 하겠다.

 가장 먼저 해야할 일은 Spring Security 모듈에대한 dependency를 설정하는 것이다. 아래 그림과 같이 build.gralde 파일을 열어서 2개의 모듈을 추가하도록 하자. (Maven은 pom.xml에 추가한다.)


 필자는 Thymeleaf를 이용해서 View를 렌더링할 것이기 때문에 thymeleaf-extras-springsecurity4를 추가했지만 Thymeleaf를 이용하지 않는다면 spring-boot-starter-security만 추가해도 상관없으므로 참고하기 바란다. 

 현재 사용되는 Spring 버전이 5.x이기 때문에 혹시 thymeleaf-extras-springsecurity5가 있는지 찾아봤으나 아직 없었다. 그래서 thymeleaf-extras-springsecurity4를 적용하였고 동작에는 아무런 문제가 없는 것으로 확인되었다. (현재는 thymeleaf-extras-springsecurity5가 릴리즈되었다. 따라서 이제는 4 대신 5를 사용해야 한다.)

 기존 프로젝트에 이렇게 모듈만 추가를 하고 실행을 해도 Security가 적용되어 어떤 URL에 접근을 해도 아래 그림과 같은 로그인 창이 뜨게된다. 등록된 사용자가 하나도 없으므로 당연히 로그인을 할 수 없고 페이지도 볼 수가 없다.


 이 기본 로그인 창은 Spring Security에서 제공하는 페이지인데 원한다면 사용할 수는 있지만 사실 별로 쓸모는 없다. 아마도 절대로 쓸 일은 없을 것이다.

 필요한 모듈을 추가했으니 이제 설정을 할 차례이다. 전에는 막연하게 이 부분이 복잡하다고 느꼈었지만 사실 설정 클래스를 하나 추가하면 된다. 그럼 Security 설정을 위한 설정 클래스를 추가하도록 하자. Spring에서 자동으로 scan을 할 수 있는 위치 아무 곳에나 아래와 같은 설정 클래스를 추가하도록 한다.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserAuthenticationProvider authenticationProvider;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/css/**", "/js/**", "/img/**").permitAll()
                .antMatchers("/auth/admin/**").hasRole("ADMIN") // 내부적으로 접두어 "ROLE_"가 붙는다.
                .antMatchers("/auth/**").hasAnyRole("ADMIN", "USER") // 내부적으로 접두어 "ROLE_"가 붙는다.
                .anyRequest().authenticated();

        http.formLogin()
                .loginPage("/login") // default
                .loginProcessingUrl("/authenticate")
                .failureUrl("/login?error") // default
                .defaultSuccessUrl("/home")
                .usernameParameter("email")
                .passwordParameter("password")
                .permitAll();

        http.logout()
                .logoutUrl("/logout") // default
                .logoutSuccessUrl("/login")
                .permitAll();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        auth.authenticationProvider(authenticationProvider);
    }
}

 위 설정 코드가 좀 복잡해 보일지도 모르지만 여기서는 샘플로서의 역할을 위해서 자주 사용될 만한 것들을 억지로 넣었기 때문에 그런 것이고 실제로는 더 간단할 수도 있고 더 복잡할 수도 있다. 일단 각 라인에 대한 설명을 해보도록 하겠다.

  • 1~3 : WebSecurityConfigurerAdapter를 상속하는 클래스를 하나 만들고 Annotation들도 위와 같이 붙여준다. (클래스 이름은 원하는 대로)
  • 6 : UserAuthenticationProvider는 커스텀 인증을 구현하는 클래스이다. AuthenticationProvider를 구현하는 클래스이며 자세한 사항은 아래에서 설명할 것이므로 일단 이렇게 Autowired로 주입되는 Component라는 사실만 이해하도록 하자.
  • 9 : Spring Security의 설정을 구현하는 메소드이다. 필수 메소드이며 설정가능한 값들은 여기서 제시하는 것 말고도 더 많은 것들이 있으므로 필요한 경우 관련 문서를 참고하기 바란다.
  • 10 ~14 : 인증이 필요하지 않은 경로와 인증이 필요한 경로를 설정한다. 여기서는 순서가 중요하다. 앞에서부터 검사해서 매칭이 일어나면 바로 규칙이 적용되어 그 뒤의 규칙은 무시되므로 우선순위를 고려하여 순서를 정해야 한다. 
  • 11 : 정적 자원에 대해서는 인증 없이 접근이 가능하도록 완전히 허용하고 있다.
  • 12 : "/auth/admin/**" 경로에 대해서는 "ROLE_ADMIN" 권한이 있어야 접근이 가능하도록 하고 있다.
  • 13 : "/auth/**" 경로에 대해서 ROLE_ADMIN, ROLE_USER 중에 어느 하나라도 권한이 있어야 접근이 가능하도록 하고 있다. 12라인의 규칙도 여기에 해당하지만 먼저 선언되어있기 때문에 이 규칙이 우선 적용될 일은 없다.
  • 14 : 기타 나머지 요청에 대해서는 인증된 사용자만 접근이 되도록 설정하고 있다.
  • 16~23 : 로그인 폼에 관련된 설정을 하고 있다. 
  • 17 : 로그인 UI를 제공하는 페이지의 경로를 설정한다. 여기서 지정된 경로가 Controller에 GET 요청으로 들어온다. 원하는 경로를 지정하면 된다.
  • 18 : 인증을 처리하는 경로를 지정한다. 즉, 로그인 페이지의 form 태그에서 action 속성에 지정할 URL이다. 이 경로는 Controller로 들어오는 요청은 아니고 Spring Security에서 요청을 가로채서 인증 루틴을 실행해줄 것이다. 
  • 19 : 인증 실패 시 돌아갈 경로를 설정한다. 위 예의 경우 "/login?error"가 원래 디폴트 값이기 때문에 사실 지정하지 않아도 된다. 
  • 20 : 인증이 성공하면 원래 접근하려고 했던 경로로 돌아가는 게 기본 동작이지만 처음부터 로그인 페이지를 요청한 경우라면 돌아갈 경로가 없으므로 이 때는 여기서 지정된 URL로 이동하게 된다.
  • 21 : 로그인 페이지에서 제공하는 username 파라메터의 이름이 무엇인지 지정한다. 여기서는 'email'이라는 이름의 파라메터를 사용하도록 지정하고 있다.
  • 22 : 로그인 페이지에서 제공하는 password 파라메터의 이름이 무엇인지 지정한다.
  • 23 : 로그인 페이지의 접근을 완전 허용하도록 설정한다.
  • 25 ~ 28 : 로그아웃 관련된 설정을 하고 있다. 기본 로그아웃 URL은 "/logout" 이다.
  • 26 : 로그아웃 경로를 지정한다. 위 예의 경우 "/logout"이 원래 디폴트 경로이기 때문에 사실 지정하지 않아도 된다.
  • 27 : 로그아웃 후에 이동할 경로를 지정한다.
  • 28 : 로그아웃 경로의 접근을 완전 허용하도록 설정한다.
  • 32 ~ 34 : 커스텀 인증을 구현한 (6 라인에서 Autowired로 주입된) authenticationProvider를 AuthenticationManagerBuilder에 AuthenticationProvider로서 추가한다.(UserAuthenticationProvider의 설명은 아래 계속.) 가끔 어떤 샘플에서는 이 메소드를 구현하지 않고 9라인의 configure() 메소드 내에서 http.authenticationProvider()를 이용하여 AuthenticationProvider를 설정하기도 하는데 테스트 결과 authenticate() 메소드가 두 번 호출되는 오동작이 있었다. 참고하기 바란다.
 이제 위에서 설명되지 않은 AuthenticationProvider를 추가할 차례이다. 아래와 같이 클래스를 만들어서 Spring이 scan할 수 있는 곳에 추가해 주도록 하자.

@Component
public class UserAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    UserService userService;

    @Override
    public Authentication authenticate(Authentication authentication) 
            throws AuthenticationException {
        String email = authentication.getName();
        String password = (String) authentication.getCredentials();

        UserVO userVO = userService.authenticate(email, password);
        if (userVO == null)
            throw new BadCredentialsException("Login Error !!");
        userVO.setPassword(null);

        ArrayList<SimpleGrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
        return new UsernamePasswordAuthenticationToken(userVO, null, authorities);
    }

    @Override
    public boolean supports(Class authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}

 그럼 간단하게 설명을 하도록 하겠다. 구체적인 인증 구현 방법은 상황에 따라서 알아서 구현하면 되고 이 예제에서는 인증의 성공, 실패시에 어떻게 처리하는지를 이해하는데 집중하도록 하자.

  • 1 ~ 2 : AuthenticationProvider를 상속하는 클래스를 하나 만들어준다. Autowired로 주입할 것이므로 scan이 될 수 있도록 @Component Annotation을 붙여주어야 한다.
  • 5 : 실질적으로 인증 로직을 구현하는 service 객체이다. 인증 로직은 각자 알아서 구현해야 하므로 따로 설명하지는 않겠다.
  • 8 ~ 21 : Spring Security가 인증을 수행하기 위해서 호출을 해주는 메소드이다. 이 메소드로 넘겨지는 authentication 인자를 이용해서 사용자의 입력값을 얻고 인증을 수행한다. 인증이 성공적인 경우 Authentication 인터페이스를 구현한 객체를 반환하고 실패한 경우 예외를 발생시키면 된다. 여기서 제시하는 로직은 사실 매우 부실하다. 하지만 어떤 동작을 해야 하는 것인지만 이해하면 어떻게든 응용가능하므로 인증의 성공과 실패에 대한 동작이 무엇인지에 대해서 확실히 하고 넘어가길  바란다.
  • 10 : 로그인 페이지에서 username 파라메터로 전송한 값을 얻어온다. 설정쪽 코드의 21라인에서 usernameParameter의 이름을 "email"로 설정했으므로 로그인 페이지에서 email 파라메터로 전송된 값이 여기서 얻어질 것이다.
  • 11 : 로그인 페이지에서 password 파라메터로 전송한 값을 얻어온다.
  • 13 : userService.authenticate()의 구체적인 코드는 여기서 따로 제공하지 않을 것이다. 여기에서는 userService.authenticate()가 인증을 수행한 후 인증이 성공적인 경우 등록된 사용자의 정보를 UserVO 객체에 담아서 반환한다고 가정하자. 인증이 실패한 경우에는 null을 반환한다고 가정한다.
  • 14 ~ 15 : 인증 실패 시 AuthenticationException 예외를 발생시키면 된다. 여기서는 AuthenticationException를 상속하는 BadCredentialsException을 발생시켰다. AuthenticationException을 상속하는 Exception은 여러가지가 있으므로 적당한 다른 Exception이 있다면 그것을 사용하면 되고 직접 AuthenticationException을 상속하는 클래스를 만들어서 사용해도 된다.
  • 16 : 반환된 UserVO 객체를 함수 마지막에 반환할 Authentication 객체의 pincipal 파라메터로 전달할 계획이므로 password 같은 민감한 정보는 삭제해두자. 나중에 설명하겠지만 이 객체는 다른 코드에서도 Security Context를 통해서 접근이 가능하기때문에 민감한 정보는 미리 삭제해 두는 것이다. 이 부분도 역시 예제일 뿐이므로 각자의 상황에 맞게 구현하면 된다.
  • 18 ~ 19 : 사용자 권한(authority) 정보를 만들어서 설정한다. 실제로는 DB 등의 여러가지 자원에서 가져와서 권한 정보를 만들어야 겠지만 여기서는 설정하는 방법을 보여주는 게 목적이므로 "ROLE_USER"라는 String을 직접 사용해서 설정하고 있다. 
  • 20 : Authentication 인터페이스를 구현한 UsernamePasswordAuthenticationToken 클래스를 생성해서 반환한다. 이 클래스는 Spring Security에서 제공되는 클래스인데 대부분의 경우에 사용하기 적당하다. UsernamePasswordAuthenticationToken을 생성할 때 첫 번째 인자(principal)로 userVO를 직접 설정했다.(16 라인에서 password 정보를 삭제한 이유가 여기서 파라메터로 전달하하면 다른 코드에서 접근이 가능하기 때문이다.) 흔히 principal 파라메터에 사용자 이름을 지정하는데 문자열 말고도 원하는 객체를 지정할 수 있으므로 참고하도록 하자. 두 번째 인자(credential)도 역시 인증이 완료된 후에는 지정하지 않는 게 보안상 안전하므로 null로 지정한다. 세 번째 인자는 18~19라인에서 생성한 authority list를 넘겨주면 된다.
  • 24 ~ 26 : 이 AuthenticationProvider가 지원하는 클래스를 판단하는 메소드이다. boilerplate 같은 코드이다. 그대로 사용하면 된다.
 Spring Security 설정과 인증 로직을 구현해 넣었으므로 이제 "/login" 요청을 처리할 수 있게 Controller에 Request Mapping을 하나 추가하도록 하자. 물론 기존에 이미 login 페이지를 가지고 있어서 Controller에 Request Mapping이 되어있었다면 그 경로를 Security 설정 코드에서 사용하면 된다.

@Controller
public class UserController {
    ...
    @GetMapping("/login")
    public String login(@ModelAttribute("loginForm") LoginForm loginForm, Model model) {
        return "login";
    }
    ...
}

 별로 특별할 건 없다. LoginForm 클래스 객체를 ModelAttribute로 사용하려고 한다는 것을 알 수 있다. LoginForm 클래스는 email, password를 property로 갖는 단순 DTO 객체이다.

 이제 접근이 허용되지 않은 경로를 요청하게되면 "/login"으로 리다이렉트 되어 여기로 요청이 들어오게 될 것이고 login.html (Thymeleaf 템플릿 파일)이 렌더링되어 보이게 될 것이다.

 그러면 간단하게 로그인 폼이 어떻게 생겼는지 보도록 하자. Thymeleaf 기준으로 대충 아래와 같은 코드면 된다. 실제로는 화려한 UI를 위해 더 복잡한 코드가 필요하겠지만 여기서는 이해하기 쉽도록 최대한 단순한 코드를 사용하겠다.

...
<form action="#" th:action="@{/authenticate}" method="post" th:object="${loginForm}">
  <div>
    Email: <input type="email" th:field="*{email}" autofocus />
  </div>
  <div>
    Password: <input type="password" th:field="*{password}" />
  </div>
  <div>
    <input type="submit" value="Log in" />
  </div>
  <!--<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>-->
</form>
...

 매우 간단한 폼이므로 이해가 어려운 부분은 없을 것이다. 이번에도 라인별로 간단히 설명해 보겠다.

  • 2 : form의 action 속성의 값(/authenticate)으로는 Security 설정할 때 login processing url의 값으로 지정했던 값(설정 코드의 18 라인)을 사용해야 한다. 여기서 한 가지 알아두어야 할 것은 반드시 th:action 속성을 사용하라는 것이다. th:action을 사용하면 Thymeleaf 엔진이 자동으로 CSRF를 위한 토큰값이 필요할 때 12번 라인의 주석과 같은 hidden 필드를 삽입해서 전송되도록 해준다. 만약에 th:action을 사용하지 않고 action 속성에 직접 "/authenticate"를 지정했다면 12라인의 주석을 해제해서 직접 hidden 필드를 넣어주어야 한다. 그리고 Thymeleaf에 익숙하다면 이미 알겠지만 Controller에서 ModelAttribute로 지정했던 "loginForm"이 th:object 속성에 지정되어야 하며 loginForm에는 email, password property가 있어야 한다.
  • 4 : E-mail 주소를 입력받는 input 필드. 설정 코드 21 라인에서 username 파라메터의 이름을 'email'로 지정했었음을 기억할 것이다. 이 필드의 값이 인증 시 username 값으로 사용될 것이다. 
  • 7 : Password를 입력받는 input 필드.
  • 10 : form의 submit 버튼.
  • 12 : 앞에서 설명한 것 처럼 form의 th:action 속성을 지정하면 Thymeleaf가 자동으로 전송해주기 때문에 현재는 주석처리가 되어있다. 만약 th:action을 사용하지 않는 경우라면 주석을 해제해서 직접 CSRF 토큰 전송을 위한 hidden 필드를 넣어주어야 한다. 이 경우 주석은 설명을 위해 남겨둔 것이며 당연히 없어도 된다. 기본적으로 Spring Security는 CSRF를 enable시킨 상태로 설정되어있다. CSRF가 enable 된 상태에서는 POST 요청을 할 때 위 예제처럼 hidden 필드를 넣어주어야 한다. CSRF를 disable 시켰다면 없어도 되지만 CSRF 공격에 대비하여 enable 시켜놓는게 안전하다. CSRF 공격이 무엇인지는 구글을 검색해 보도록 하자.
 위 예제에서 보여지는 것처럼 몇몇 필요한 값들과 설정만 있으면, 그리고 제대로 이해만 하고 있다면 로그인 창의 디자인은 얼마든지 자유롭게 해도 상관이 없다.

 이제 로그인을 성공해서 특정 페이지에 진입했을 때 페이지 내에서 (UserAuthenticationProvider.authenticate() 메소드가 반환했던) Authentication 객체에 접근하는 방법을 간단하게 보도록 하겠다. 아래 코드는 이 글 맨 처음에 언급한 대로 thymeleaf-extras-springsecurity4 모듈이 dependency에 추가되어 있어야 동작하므로 다시 한번 확인하길 바란다.

<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
...
<div sec:authorize="isAuthenticated()">
  Name: <span sec:authentication="name">SomeName</span><br />
  Principal: <span sec:authentication="principal">PrincipalString</span><br />
  Principal.email: <span sec:authentication="principal.email">Email</span><br />
  HasRole(USER): <span sec:authorize="hasRole('ROLE_USER')">YES</span><br />
  HasRole(ADMIN): <span sec:authorize="hasRole('ROLE_ADMIN')">YES</span>
</div>
<form action="#" th:action="@{/logout}" method="post">
  <input type="submit" value="Logout" />
  <!--<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>-->
</form>
...

 위 예제는 간단하게 Thymeleaf 템플릿에서 Security 관련하여 할 수 있는 것들을 보여주기 위한 예제 소스이다. 로그인 후에 위 정보들이 어떻게 표시될 지에 대해서 간단하게 설명을 해보자.

 일단 예제에서 보여지는 Security 관련 attribute는 "sec:authorize"와 "sec:authentication" 두 가지가 있다.

  • sec:authorize 속성은 th:if와 비슷한 역할을 한다. 값에 지정된 표현식을 평가하여 true인 경우에 해당 tag를 표시한다. 가능한 표현식은 위 예제에서 보듯이 매우 직관적으로 알 수 있을 것이다.
  • sec:authentication 속성은 AuthenticationProvider가 인증을 수행하고 반환했던 Authentication 객체의 특정 property값을 th:text 속성에 지정하는 것과 같은 일을 한다. 이전 예제에서 보여준 AuthenticationProvider의 authenticate() 함수에서는 UsernamePasswordAuthenticationToken객체를 반환했었으며 이 객체의 principal 파라메터로 UserVO 객체를 직접 지정했었다. 위 코드는 이 값을 조회하는 코드의 예이다. 
 그럼 각 라인에 대한 설명을 간단히 해보자.

  • 1 : spring security를 위한 thymeleaf 속성들의 namespace를 추가해주자. 
  • 3 : isAuthenticated()는 인증이 되었으면 true, 아니면 false를 반환한다. 즉 인증이 안된 상태라면 <div> 태그는 렌더링되지 않고 사라질 것이다.
  • 4 : Authentication 객체의 "name" property를 반환한다. 인증 함수가 반환했던 UsernamePasswordAuthenticationToken 클래스는 name property 값으로 생성자에 전달했던 principal 파라메터의 toString() 값을 반환하도록 구현되어있다. 따라서 우리는 이전 예제에서 UserVO 객체를 principal 파라메터로 지정했었으므로 그 객체의 toString() 값이 출력될 것이다. 자바 코드로 같은 값을 가져오려고 한다면 아래 코드를 참고하기 바란다.
    SecurityContextHolder.getContext().getAuthentication().getName()
  • 5 : 여기서는 "principal"을 직접 지정했으므로 principal 파라메터로 지정한 UserVO 객체가 반환될 것이고 결국 그 객체의 toString() 값이 출력될 것이다. 결국 4번 라인과 같은 값이 출력될 것이다. 자바코드로는 아래와 같다.
    SecurityContextHolder.getContext().getAuthentication().getPrincipal()
  • 6 : "principal.email"은 "principal"의 "email" property 값이 출력된다. 앞에서 얘기했듯이 principal 파라메터는 UserVO 객체이므로 UserVO객체의 email property가 출력될 것이다. 자바코드로는 아래와 같다.
    ((UserVO)SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getEmail()
    만약에 우리가 지정한 principal 객체에 email property가 없으면 예외가 발생하여 에러 페이지가 출력된다. 
  • 7 : hasRole('ROLE_USER') 는 authority 리스트에 'ROLE_USER'가 있는 경우에 true를 반환한다. 따라서 이 경우에는 'ROLE_USER' 권한이 있는 경우에만 span 태그가 표시되어 YES가 출력될 것이다. 
  • 8 : 'ROLE_ADMIN' 권한을 체크할 뿐 7번 라인과 기능은 같다.
  • 10 ~ 13 : 로그아웃 기능을 위한 <form> 예제이다. form의 action속성은 Security 설정코드에서 지정한 로그아웃용 경로를 사용해야 한다. 즉, 설정 코드 26라인에서 지정된 값이 여기서 action 값으로 사용되어야 한다. 여기서도 마찬가지로 CSRF가 enable 상태이면 logout도 post로 요청되어야 하며 form의 action값으로 th:action 속성을 이용해서 경로를 지정해야 한다. th:action을 사용하면 Thymeleaf 엔진에서 자동으로 12라인 주석과 같은 hidden 필드를 삽입해 줄 것이다. 만약 th:action을 사용하지 않았다면 12번 라인의 주석을 해제해서 직접 hidden 필드가 포함되도록 해야 한다. 종종 이 사실을 잊고 헤매는 경우가 있으므로 잊지말도록 하자. 
 위 예제가 모든 경우에 대한 예시를 제공하는 건 아니지만 위의 예제만 잘 이해하고 있으면 대부분의 경우를 커버할 수 있으므로 자세히 보고 익혀놓는 게 좋겠다.

 매번 Spring Security를 적용할 때 마다 꼭 블로그에 정리해 놓으리라 다짐만하고 실행하지 못했었는데 드디어 정리를 하게되서 매우 홀가분한 기분이다. 필요한 많은 사람들에게 도움이 되었으면 좋겠다.