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를 선호하는 많은 개발자들에게도 그러하기를 기대해 본다.

2019년 1월 13일 일요일

CentOS 7에 MariaDB 10.3 설치하기

 최근 CentOS 7에 Maria DB를 설치할 일이 생겨서 설치하는 김에 여기에 설치 과정을 정리해 보려고 한다.

 환경에 대해서 간단히 설명하자면 Amazon Lightsail 가상 서버에 CentOS 7을 설치하고 Maria DB 10.3 버전을 설치할 것이다. 현재 Maria DB의 최신 버전은 10.3이다.

Maria DB repository 설정


 https://downloads.mariadb.org/mariadb/repositories/ 에 접속하면 리눅스 배포본에 따라 각 repository로 부터 설치할 수 있는 방법을 제시해준다. 여기서 제시하는 방법대로 따라하도록 하자.

 가장 먼저 해야할 것이 yum repository를 설정하는 것이다. 아래 명령으로 /etc/yum.repos.d/ 디렉토리에 MariaDB.repo 라는 파일을 만들고 그 아래 내용을 넣도록 하자.

$ sudo vi /etc/yum.repos.d/MariaDB.repo

# MariaDB 10.3 CentOS repository list - created 2019-01-13 00:47 UTC
# http://downloads.mariadb.org/mariadb/repositories/
[mariadb]
name = MariaDB
baseurl = http://yum.mariadb.org/10.3/centos7-amd64
gpgkey=https://yum.mariadb.org/RPM-GPG-KEY-MariaDB
gpgcheck=1

Maria DB 설치


 repository 설정을 했으면 아래 명령으로 YUM을 통해서 Maria DB를 설치할 수 있다.

$ sudo yum install MariaDB-server MariaDB-client

 설치가 진행되면서 몇 번 묻는 질문에는 모두 y를 선택하면 된다. 설치가 완료되고 나면 아무런 설정을 하지 않아도 기본 설정값으로 실행할 수 있다.

Maria DB 서버 시작


 설정 파일을 열어보면 사실 아무것도 설정하고 있는 게 없다. 하지만 그래도 기본값을 가지고 있어서 바로 시작시켜보면 정상적으로 서버가 시작된다. 아래 명령으로 시작 시켜보도록 하자.

# mariadb가 부팅 시 자동 시작되도록 한다.
$ sudo systemctl enable mariadb

# mariadb를 시작시킨다.
$ sudo systemctl start mariadb
# 또는 service 명령으로 시작시킬 수도 있다.
$ sudo service mariadb start

 정상적으로 시작되었는지 아래 명령으로 알아보도록 한다.

$ sudo service mariadb status
Redirecting to /bin/systemctl status mariadb.service
● mariadb.service - MariaDB 10.3.12 database server
   Loaded: loaded (/usr/lib/systemd/system/mariadb.service; enabled; vendor preset: disabled)
  Drop-In: /etc/systemd/system/mariadb.service.d
           └─migrated-from-my.cnf-settings.conf
   Active: active (running) since 일 2019-01-13 02:46:47 UTC; 9min ago
     Docs: man:mysqld(8)
           https://mariadb.com/kb/en/library/systemd/
  Process: 29208 ExecStartPost=/bin/sh -c systemctl unset-environment _WSREP_START_POSITION (code=exited, status=0/SUCCESS)
  Process: 29164 ExecStartPre=/bin/sh -c [ ! -e /usr/bin/galera_recovery ] && VAR= ||   VAR=`/usr/bin/galera_recovery`; [ $? -eq 0 ]   && systemctl set-environment _WSREP_START_POSITION=$VAR || exit 1 (code=exited, status=0/SUCCESS)
  Process: 29162 ExecStartPre=/bin/sh -c systemctl unset-environment _WSREP_START_POSITION (code=exited, status=0/SUCCESS)
 Main PID: 29176 (mysqld)
   Status: "Taking your SQL requests now..."
   CGroup: /system.slice/mariadb.service
           └─29176 /usr/sbin/mysqld
....
 정상적으로 시작되었으면 active(running) 상태 문구가 보일 것이다.

Maria DB 보안 설정하기


 서버가 정상적으로 시작되었으면 이제 기본적인 보안 설정을 해야한다. 보안 설정이라니 까다로울 것 같지만 사실 스크립트 하나 실행해주면 된다. 초기 mysql 시절에는 매번 수작업으로 했었던 것 같은데 이제는 제공되는 스크립트 하나를 실행해주면 되니 정말 간단해졌다. 아래 명령을 실행해보자.

$ sudo mysql_secure_installation

NOTE: RUNNING ALL PARTS OF THIS SCRIPT IS RECOMMENDED FOR ALL MariaDB
      SERVERS IN PRODUCTION USE!  PLEASE READ EACH STEP CAREFULLY!

In order to log into MariaDB to secure it, we'll need the current
password for the root user.  If you've just installed MariaDB, and
you haven't set the root password yet, the password will be blank,
so you should just press enter here.

Enter current password for root (enter for none): [Enter]
OK, successfully used password, moving on...

Setting the root password ensures that nobody can log into the MariaDB
root user without the proper authorisation.

Set root password? [Y/n] 
New password: [root 계정을 위한 비밀번호 설정]
Re-enter new password: [바로 위에서 설정한 값 다시 입력]
Password updated successfully!
Reloading privilege tables..
 ... Success!


By default, a MariaDB installation has an anonymous user, allowing anyone
to log into MariaDB without having to have a user account created for
them.  This is intended only for testing, and to make the installation
go a bit smoother.  You should remove them before moving into a
production environment.

Remove anonymous users? [Y/n] [Enter]
 ... Success!

Normally, root should only be allowed to connect from 'localhost'.  This
ensures that someone cannot guess at the root password from the network.

Disallow root login remotely? [Y/n] [Enter]
 ... Success!

By default, MariaDB comes with a database named 'test' that anyone can
access.  This is also intended only for testing, and should be removed
before moving into a production environment.

Remove test database and access to it? [Y/n] [Enter]
 - Dropping test database...
 ... Success!
 - Removing privileges on test database...
 ... Success!

Reloading the privilege tables will ensure that all changes made so far
will take effect immediately.

Reload privilege tables now? [Y/n] [Enter]
 ... Success!

Cleaning up...

All done!  If you've completed all of the above steps, your MariaDB
installation should now be secure.

Thanks for using MariaDB!

 최초 설치 후에는 root 계정에 비밀번호가 없으므로 root 비밀번호를 물으면 그냥 Enter 키를 치고 진행하면 된다. 이 후 바로 root 계정 password를 설정할거냐고 묻는데 당연히 설정하는 게 좋을 것이다. 사실 root 비밀번호를 입력하는 것 말고는 모든 질문에 그냥 Enter를 치면 Y가 기본값으로 되어있기 때문에 아무 문제없이 완료된다. 그리고 모두 Y를 선택하는 게 보안상으로도 안전한 선택이다. 그래도 혹시 원하는 바가 다를 수 있으니 한 번쯤 질문을 읽어보고 적절히 답을 하면 된다.

Maria DB 기본 설정


 이제 서버의 기본 설정 상태가 어떤지 한 번 보도록 하자. 아래 명령으로 서버에 접속해서 상태를 출력해보자.

$ mysql -u root -p
Enter password: [기 설정한 root password]
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 16
Server version: 10.3.12-MariaDB MariaDB Server

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [(none)]> status
--------------
mysql  Ver 15.1 Distrib 10.3.12-MariaDB, for Linux (x86_64) using readline 5.1

Connection id:  16
Current database: 
Current user:  root@localhost
SSL:   Not in use
Current pager:  stdout
Using outfile:  ''
Using delimiter: ;
Server:   MariaDB
Server version:  10.3.12-MariaDB MariaDB Server
Protocol version: 10
Connection:  Localhost via UNIX socket
Server characterset: latin1
Db     characterset: latin1
Client characterset: utf8
Conn.  characterset: utf8
UNIX socket:  /var/lib/mysql/mysql.sock
Uptime:   25 min 58 sec

 앞에서도 얘기했지만 초기 설정 파일에는 아무 설정값도 정의되어있지 않다. 하지만 그래도 기본 설정값은 있을 것이다. Maria DB 사용자들이 흔히 설정하는 값들의 기본값은 아래와 같다.
  • datadir = /var/lib/mysql/ 
  • socket  = /var/lib/mysql/mysql.sock
  • port = 3306
  • server characterset = latin1
 datadir, socket, port 등은 사실 기본값 그대로 써도 무방하다. 하지만 요즘같은 Global 시대 latin1 characterset은 utf8로 바꿔줘야 할 것 같다. 사실 Client characterset도 latin1으로 되어있을 줄 알았는데 utf8로 되어있다는게 의외였다.

 먼저 간단하게 설정 파일의 위치와 설정값들에 대해서 얘기해 보도록 하겠다.
  • /etc/my.cnf  ==> 주 설정 파일. 파일의 내용은 /etc/my.cnf.d/ 폴더의 파일들을 include하는 것 뿐이다.
  • /etc/my.cnf.d/  ==> 이 폴더 안의 모든 .cnf 파일들이 실제 설정 파일들이며 이 파일들이 my.cnf 파일에서 include 된다.
  • /etc/my.cnf.d/server.cnf  ==> 서버를 위한 실질적인 설정 파일이다. 서버의 characterset 설정을 여기서 해주면 된다.
  • /etc/my.cnf.d/mysql-clients.cnf  ==> mysql, mysqldump 같은 client app들을 위한 설정 파일이다.
 그럼 예상한 대로 server.cnf 파일을 열어서 아래 내용을 추가해 주도록 하자.

...
[mysqld]
character-set-server = utf8
collation-server     = utf8_general_ci
...

 보면 알겠지만 mysqld 옵션 그룹에 characterset 설정만 추가해준 것이다. 이제 서버를 재시작하고 다시 mysql로 접속하여 서버의 상태를 조회해 보도록 하자.

$ sudo service mariadb restart
Redirecting to /bin/systemctl restart mariadb.service
$ mysql -u root -p
Enter password: 
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 8
Server version: 10.3.12-MariaDB MariaDB Server

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

MariaDB [(none)]> status
--------------
mysql  Ver 15.1 Distrib 10.3.12-MariaDB, for Linux (x86_64) using readline 5.1

Connection id:  8
Current database: 
Current user:  root@localhost
SSL:   Not in use
Current pager:  stdout
Using outfile:  ''
Using delimiter: ;
Server:   MariaDB
Server version:  10.3.12-MariaDB MariaDB Server
Protocol version: 10
Connection:  Localhost via UNIX socket
Server characterset: utf8
Db     characterset: utf8
Client characterset: utf8
Conn.  characterset: utf8
UNIX socket:  /var/lib/mysql/mysql.sock
Uptime:   24 sec

 이제 서버의 characterset 설정도 utf8로 바뀌었다. 혹시 client의 characterset를 바꾸고 싶다거나 추가적인 설정을 원하면 server.cnf, mysql-clients.cnf 파일을 열어서 적절한 option group에 값을 추가하면 된다. 자세한 설정 사항에 대해서는 구글링으로 해결하도록 하자.

 아주 기본적인 서버의 설치작업은 이제 끝났다. 하지만 실제 사용을 위해서는 추가적인 계정을 추가하고 방화벽 3306 포트를 열어주는 등의 작업이 필요할 것이다. 몇 가지 참고로 알아보도록 하자.

Maria DB 접속 포트 변경하기


 Maria DB의 포트 설정은 server.cnf 의 [mysqld] 옵션 그룹에 port 값을 지정해주면 된다.

...
[mysqld]
port = [포트번호]
...

 설정값을 추가하고 서버를 재시작해보면 아마도 서버 시작이 안될 것이다. CentOS 7에 기본 설정되어있는 SELinux 가 허용되지 않은 포트 access를 막기 때문이다.  물론 SELinux가 꺼진 상태라면 문제없이 시작된다.

 아래 명령으로 SELinux 설정을 수정해준다.

$ sudo semanage port -a -t mysqld_port_t -p tcp [포트번호]

 이제 서버를 다시 시작해보자. 아무 문제 없이 시작될 것이다. 그리고 방화벽에서도 새로 변경한 포트를 열어주는 걸 잊지 말자.

Maria DB 기본 명령어


 최초 설치 후에 간단한 몇 가지 작업을 하려면 매 번 명령어가 생각나지 않아서 인터넷을 검색하곤 한다. 그래서 여기에 몇 가지 자주 사용하는 명령어를 정리해보려고 한다. 아래 명령어들은 모두 mysql로 접속해서 실행할 수 있는 명령어이다.

  • Database 목록 보기
    > show databases;
  • Database 생성하기
    > create database [DB 이름];
  • Database 삭제하기
    > drop database [DB이름];
  • 사용할 Database 선택
    > use [DB이름];
  • 사용자 계정 조회
    > select user, host from mysql.user;
  • 사용자 계정 생성
    > create user 'userid'@'host' identified by 'password';
    • userid와 password는 원하는 값으로 설정.
    • host는 접속 클라이언트의 주소.('%'를 지정하면 anywhere)
  • 사용자 계정 권한 부여
    > grant all privileges on [DB이름].[Table이름] to 'userid'@'host';
    • all은 insert, select, delete, update 등으로 바꿀 수 있다. 여러 개 지정시 콤마로 분리하여 한 꺼번에 지정하면된다.
    • [DB이름].[Table이름]은 wildcard(*)를 사용할 수 있다. (*, *.*, db_name.*, ... )
    • 보안상의 이유로 일반 사용자 계정은 [DB이름].* 이상의 권한을 주지 않도록 한다.
  • 사용자 권한 확인
    >  show grants for 'userid'@'host';
  • 사용자 권한 삭제
    > revoke all on [DB이름].[Table이름] from 'userid'@'host';
  • 사용자 계정 삭제
    > drop user 'userid'@'host';
  • 권한 변경사항 적용
    > flush privileges;

 일단 이 정도로 설치 & 설정 작업을 마치도록 하겠다. 추후에 추가적으로 필요하다고 생각되는 사항이 생기면 글을 계속 업데이트할 예정이다. 많은 이에게 도움이 되길 바란다.

2018년 7월 31일 화요일

Java Enum 사용하기

 자바에서 제공하는 Enum 타입은 C++에서의 enum이 정수형 상수의 역할을 하는 것과는 달리 거의 완전한 클래스로서 새로운 타입을 정의하는 것과 같다. 덕분에 C나 C++ 같은 언어에 익숙한 개발자들이 처음 Java의 Enum을 접하게되면 약간 당황스러움을 느끼게 된다.

 그래서 이번 포스팅에서는 Java의 Enum을 이해하기 쉽도록 일반적인 Enum의 사용 코드 샘플을 보여주고 간단하게 설명 해볼까 한다.

 그럼 먼저 가장 간단한 형태의 Enum의 선언을 보자.

public enum Order {
    FIRST, SECOND, THIRD;
}

 지극히 간단한 Enum 선언 예제이다. 이렇게 선언된 Enum 타입을 어떻게 사용하는지는 아래 샘플 코드를 보면 간단하게 이해할 수 있다.

Order order = Order.SECOND;
...
// switch문
switch (order) {
    case FIRST:
        System.out.println("First !");
        break;
    case SECOND:
        System.out.println("Second !");
        break;
    case THIRD:
        System.out.println("Third !");
        break;
    default:
        System.out.println("Ooops !");
        break;
}

// if문
if (order == Order.SECOND)
    System.out.println("Second !");

 switch나 if문의 조건문에서 상수처럼 사용하는 일반적인 형태의 예제이다. 아마도 예상과 크게 다르지 않을 것이다.

 Java의 Enum은 거의 클래스와 같기 때문에 기본적으로 구현되어 제공되는 몇 가지 메소드가 있다. 실제 사용하다보면 바로 필요하게 될 것이므로 아래 예제 코드에서 사용하는 메소드의 출력 결과를 자세히 보고 숙지해 놓는게 좋겠다.

Order order = Order.SECOND;

System.out.println(order.toString()); // "SECOND"
System.out.println(order.name()); // "SECOND"
System.out.println(order.ordinal()); // 1

if (order == Order.valueOf("SECOND")) // Order.valueOf("SECOND") = Order.SECOND
    System.out.println("Same !!!");

for (Order val : Order.values()) // [FIRST, SECOND, THIRD]
    System.out.println(val);

 간단히 설명을 하자면...

  • toString()의 기본 구현은 해당 상수의 이름을 문자열로 반환한다.
  • name()도 기본 구현은 해당 상수의 이름을 문자열로 반환한다.
  • ordinal()은 해당 상수의 선언 순서에 따른 인덱스(Zero based)값을 반환한다.
  • valueOf()는 인자로 받은 이름과 같은 Enum값을 반환한다.
  • values()는 선언된 모든 Enum값을 순서대로 배열에 담아서 반환한다.
 Java의 다른 원시 타입과 함께 사용하다보면 종종 이런 메소드들이 필요하다. 하지만 매우 직관적이고 단순하므로 어려울 것은 없다. 다만 문제가 되는 것은 Enum의 기본 구현이 이런 메소드들을 제공한다는 사실을 잘 몰라서 헤메는 경우가 많으므로 반드시 숙지하기를 바란다. 이 메소드들은 overriding도 가능하므로 참고하도록 하자.

 기본적인 Enum 구현은 사실 문자열값과 함께 사용하기 편하지만 간혹 Enum의 값들이 내부적으로 정수값 같은 다른 값을 가지고 있기를 원하는 경우가 종종 있다. 이런 경우에 많이 사용하는 패턴의 샘플 코드를 보도록 하자.

public enum IntEnum {
    PENNY(1), NICKEL(5), DIME(10), QUARTER(25);

    private final int value;

    // Constructor
    IntEnum(int value) {
        this.value = value;
    }

    // Getter
    public int value() {
        return value;
    }

    public static IntEnum valueOf(Integer value) {
        if (value == null)
            throw new NullPointerException();

        if (value == 1)
            return PENNY;
        else if (value == 5)
            return NICKEL;
        else if (value == 10)
            return DIME;
        else if (value == 25)
            return QUARTER;

        throw new IllegalArgumentException();
    }
}

 처음 보여준 Order와 크게 다른 점은 내부적으로 int형 값을 가질 final 멤버 변수 'value'가 선언되어 있다는 것이다. 이렇게 final 멤버 변수를 하나 갖게 되면 Enum 상수를 정의할 때 그 값이 초기화가 되어야 하므로 반드시 생성자가 따로 추가되어야 하고 그 생성자는 반드시 private이어야 한다. 위 예제에서 PENNY, NICKEL 같은 Enum의 설계자가 정의한 값 말고 다른 값들이 외부에서 선언되어서는 안되므로 생성자는 당연히 private으로 선언되어야 할 것이다. 만약 public으로 선언을 하면 에러가 발생하므로 참고하길 바란다.

 멤버와 생성자가 추가되었으면 값을 선언할 때 인자로 정수값을 넘겨주어야 한다. 위 예제에서는 'PENNY(1)'과 같이 괄호와 정수 '1'을 값으로 넘겨서 선언하고 있음을 주목하자.

 이렇게 선언했다는 것은 당연히 다른 정수형 값들과 함께 사용하기 위함이므로 getter 메소드인 value()를 추가해주고 valueOf() 메소드를 overloading하여 문자열이 아닌 정수값으로 부터 Enum 값을 얻을 수 있도록 하였다. 반드시 필요한 건 아니지만 사용하다보면 금방 필요성을 느끼게 될 것이다.

 참고로 정수값을 보관하기 위한 'value'가 final로 선언된 이유는 실행중에 이 값이 바뀌어도 Enum값이 바뀌지는 않기 때문이다. 즉, 위 예에서 value의 final 키워드를 삭제하고 setter 메소드를 따로 추가한 후에 Runtime시에 PENNY의 value값을 10으로 바꿔줘도 PENNY는 여전히 PENNY일 뿐 DIME이 되지는 않는다. 아래 코드를 보면 이해가 쉬울 것이다.

IntEnum coin = IntEnum.PENNY;
coin.setValue(10); // coin = PENNY. not changed.
if (coin == IntEnum.DIME) // (false)
    System.out.println(coin); // not executed !!

 setValue(10)을 호출해도 PENNY인 coin 변수가 DIME으로 바뀌지는 않는다. 내부의 value값만 10으로 바뀔 뿐이다. 이렇게 value값이 바뀌게 되면 혼란만 초래할 뿐이므로 value는 final로 선언되는 게 맞으며 따라서 getter 메소드만 선언하면 되는 것이다. Java의 Enum이 거의 클래스와 같다고 하는 이유가 이런 멤버 변수와 메소드를 자유롭게 추가/변경할 수 있기 때문이지만 필요이상으로 남용하게되면 코드의 혼란만 야기할 뿐이다. 필자는 예제가 제시하는 수준의 사용이 적절하다고 생각한다. 그리고 위 예제에서는 int형을 예로 들었지만 다른 타입에 대해서 응용하는 건 이해만 제대로 하고 있다면 전혀 문제가 되진 않을 것이므로 따로 설명을 하진 않겠다.

 일반적인 상수의 사용보다 Enum을 사용하는 게 실제로 상당한 도움이 된다. Enum 사용이 익숙하지 않은 개발자들에게 간단한 샘플로서 많은 도움이 되기 바란다.

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

2018년 7월 20일 금요일

Spring Boot Internationalization(다국어 지원)

 Spring Boot로 Web app을 개발할 때 다국어 지원을 어떻게 할 것인가에 대해서 대부분 한 번쯤은 고민을 해보았을 것이다. 그 때마다 항상 인터넷을 뒤져서 처리하곤 했는데 생각이 난 김에 필요할 때 참고하기 좋도록 여기에 정리해볼까 한다.

 Spring Boot의 가장 큰 장점은 대부분의 설정이 이미 되어있어서 별로 설정하지 않아도 웬만큼 쓸만하게 돌아간다는 것이다. MessageSource 설정도 역시 이미 되어있다. 기억해두어야 할 건 한 가지 뿐이다. 아래 그림과 같이 resources 폴더 아래에 messages.properties 파일이 MessageSource의 리소스 파일로 사용된다는 것이다. 그림은 IntelliJ 에서의 캡쳐 화면이다.


 확장자를 보면 알겠지만 포맷은 properties 파일 포맷(key=value)이다. 이 파일에 다국어 대상의 메시지들을 등록해 놓으면 JSP나 Thymeleaf 엔진에서 바로 사용이 된다.

 간단하게 Thymeleaf를 예로 들어보자. 아래 내용을 messages.properties 파일에 넣는다.

login.welcome-msg=Welcome!

messages.properties 파일의 내용이 위와 같다고 하면 Thymeleaf용 template 파일에서는 아래와 같이 사용이 가능하다.

  ...
  <div>
    <span th:text="#{login.welcome-msg}">Some Text</span>
  </div>
  ...

 위 파일에서 th:text 속성에 #{login.welcome-msg}를 지정함으로써 messages.properties 파일에 등록된 login.welcome-msg의 값이 span 태그의 텍스트 값(Some Text) 대신에 교체되어 html 파일이 생성되는 것이다. JSP에서의 사용법은 많이들 알고 있을 것이므로 따로 설명하지 않겠다.

 현재 상태에서는 messages.properties 파일 하나밖에 없기 때문에 모든 locale에 대해서 messages.properties 파일이 사용된다. 사실 messages.properties 파일은 locale을 판단할 수 없을 때 default로 사용되는 파일이고 원하는 locale에 대해서 messages_XX.properties 형태로 파일을 추가함으로써 해당 언어를 지원할 수 있다. 예를 들어 한국어를 지원하려면 messages_ko.properties 형태로 파일을 만들어서 resources 폴더 안에 넣어두기만 하면 된다. ko는 언어코드만 지정되었지만 구체적으로 국가 코드까지 포함된 ko-KR을 지정하고 싶다면 messages_ko_KR.properties 형태로 파일을 만들면 된다.

 messages_ko.properties 파일을 추가해주면 IntelliJ에서는 관리하기 편리하도록 아래 그림과 같이 묶어서 보여준다.


 그럼 Locale을 판단하는 방법은 무엇일까? Spring에서는 LocaleResolver 인터페이스를 구현한 Bean을 등록하여 등록된 Bean이 Locale을 판단하도록 하고 있다. 아무런 설정을 하지 않은 상태에서는 AcceptHeaderLocaleResolver가 기본으로 등록되어 사용된다. AcceptHeaderLocaleResolver는 HTTP Request의 "accept-language" 헤더의 값을 이용해서 Locale을 판단한다. 현재 대부분의 Major 브라우저들은 요청에 이 헤더를 포함시켜서 요청을 하기때문에 별 설정을 하지 않아도 잘 동작한다.

 그런데 만약 사용자가 직접 언어를 선택할 수 있는 기능이 필요하다면 사용자가 선택한 Locale 값을 Session이나 Cookie에 저장해서 그 값을 이용하도록 해야한다. 이런 경우라면 SessionLocaleResolver나 CookieLocaleResolver를 LocaleResolver로 등록해주면 된다. 이것들은 각각 Session이나 Cookie에 포함된 특정 attribute의 값을 기준으로 Locale을 판단하며 해당 attribute가 없으면 AcceptHeaderLocaleResolver와 같이 "accept-language" 헤더의 값으로 Locale을 판단한다. 자세한 사항은 Spring Framework 문서를 참조하길 바란다.

 Spring Boot에서 원하는 LocaleResolver를 bean으로 등록하는 방법은 Application 클래스에 아래와 같은 메소드를 등록해주면 된다.

@SpringBootApplication
public class UserServiceApplication {

    ...

    @Bean
    public LocaleResolver localeResolver() {
        SessionLocaleResolver localeResolver = new SessionLocaleResolver();
        localeResolver.setDefaultLocale(Locale.US);
        return localeResolver;
    }
}

 HTTP request의 헤더나 Session, Cookie가 아닌 HTTP request의 parameter를 이용해서 Locale을 바꾸는 방법도 있다. HTTP request의 parameter를 이용하려면 LocaleChangeInterceptor를 Interceptor로 등록해주면된다. LocaleChangeInterceptor는 "locale=xx" 형태의 parameter를 받아서 Locale을 바꿔준다. 예를 들어 요청 URL이 "http://www.somesite.com/?locale=ko"라면 Locale을 한국어로 바꾸어주는 것이다. parameter의 이름을 "locale"이 아닌 다른 값으로 바꿀 수도 있다. 자세한 사항은 Spring Framework의 문서를 참조하길 바란다.

 LocaleChangeInterceptor를 등록하는 방법도 간단하다. 아래 코드에서 보듯이 Application 클래스가 WebMvcConfigurer를 implement하도록 하고 두 메소드를 추가하는 것이다.

@SpringBootApplication
public class UserServiceApplication implements WebMvcConfigurer {

    ...

    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
        LocaleChangeInterceptor lci = new LocaleChangeInterceptor();
        return lci;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(localeChangeInterceptor());
    }
}

 메소드 이름에서 짐작하겠지만 첫 번째 메소드는 LocaleChangeInterceptor를 bean으로 등록하는 것이고 두 번째 메소드는 등록된 LocaleChangeInterceptor bean을 Interceptor로 등록(추가)하는 것이다.

 한 가지 더 추가로 얘기하자면 스프링 5.0 이전 버전에서는 WebMvcConfigurerAdapter를 상속해야 했지만 5.0부터는 WebMvcConfigurer 인터페이스에 default method가 추가되어 WebMvcConfigurer 인터페이스를 직접 구현하도록 바뀌었으므로 참고하도록 하자.