스프링 MVC 2편 - 섹션1~섹션3 후기

soso·2023년 6월 5일
0

김영한의 스프링 완전 정복 로드맵
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술
섹션1~섹션3 정리입니다.

섹션1. 타임리프 - 기본 기능

타임리프 소개

# 타임리프 특징

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

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

타임리프는 백엔드 서버에서 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 테그의 속성에 기능을 정의해서 동작
  • HTML의 콘텐츠(content)에 데이터를 출력할 때는 다음과 같이 th:text를 사용
    • <span th:text="${data}">
  • HTML 테그의 속성이 아니라 HTML 콘텐츠 영역안에서 직접 데이터를 출력하고 싶으면 [[...]] 를 사용하면 된다
    • 컨텐츠 안에서 직접 출력하기 = [[${data}]]

# Escape

HTML 문서는 < , > 같은 특수 문자를 기반으로 정의, 따라서 뷰 템플릿으로 HTML 화면을 생성할 때는 출력하는 데이터에 이러한 특수 문자가 있는 것을 주의해서 사용해야 한다

# HTML 엔티티

  • 웹 브라우저는 <를 HTML 테그의 시작으로 인식한다
    따라서 <를 테그의 시작이 아니라 문자로 표현할 수있는 방법이 필요
    • 이것을 HTML 엔티티라 한다
    • 그리고 이렇게 HTML에서 사용하는 특수 문자를 HTML 엔티티로 변경하는 것을 이스케이프(escape)라 한다
    • 그리고 타임리프가 제공하는 th:text , [[...]]는 기본적으로 이스케이스(escape)를 제공한다.

변수 - SpringEL

변수 표현식 : ${...}

SpringEL 다양한 표현식 사용

# Object

  • user.username : user의 username을 프로퍼티 접근 → user.getUsername()
  • user['username'] : 위와 같음 → user.getUsername()
  • user.getUsername() : user의 getUsername() 을 직접 호출

# List

  • users[0].username : List에서 첫 번째 회원을 찾고 username 프로퍼티 접근 → list.get(0).getUsername()
  • users[0]['username'] : 위와 같음
  • users[0].getUsername() : List에서 첫 번째 회원을 찾고 메서드 직접 호출

# Map

  • userMap['userA'].username : Map에서 userA를 찾고, username 프로퍼티 접근 → map.get("userA").getUsername()
  • userMap['userA']['username'] : 위와 같음
  • userMap['userA'].getUsername() : Map에서 userA를 찾고 메서드 직접 호출

지역 변수 선언

th:with 를 사용하면 지역 변수를 선언해서 사용 가능
지역 변수는 선언한 테그 안에서만 사용

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

기본 객체들

타임리프는 기본 객체들을 제공

  • ${#request} - 스프링 부트 3.0부터 제공하지 않음X
  • ${#response} - 스프링 부트 3.0부터 제공하지 않음X
  • ${#session} - 스프링 부트 3.0부터 제공하지 않음X
  • ${#servletContext} - 스프링 부트 3.0부터 제공하지 않음X
  • ${#locale}

유틸리티 객체와 날짜

타임리프는 문자, 숫자, 날짜, 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 날짜

  • 라이브러리
    • thymeleaf-extras-java8time
  • 자바8 날짜용 유틸리티 객체
    • #temporals
  • 사용 예시
    • <span th:text="${#temporals.format(localDateTime, 'yyyy-MM-dd HH:mm:ss')}"></span>

URL 링크

타임리프에서 URL을 생성할 때는 @{...} 문법을 사용


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

#단순한 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
    • 경로 변수와 쿼리 파라미터를 함께 사용할 수 있음

# 상대경로, 절대경로, 프로토콜 기준 표현가능

  • /hello : 절대 경로
  • hello : 상대 경로

리터럴

# Literals

리터럴은 소스 코드상에 고정된 값을 말하는 용어 -> 문자, 숫자, null, 불린 리터럴이 존재.

  • 타임리프에서 문자 리터럴은 항상 ' (작은 따옴표)로 감싸야 한다
    • <span th:text="'hello'">
  • 공백 없이 쭉 이어진다면 하나의 의미있는 토큰으로 인지해서 다음과 같이 작은 따옴표를 생략가능
    • <span th:text="hello">
  • 중간에 공백이 있어서 하나의 의미있는 토큰으로도 인식되지 않음, 문자 리터럴은 원칙상 ' 로 감싸야 한다.
    • <span th:text="hello world!"></span>
  • ' (작은 따옴표)로 감싸면 정상 동작한다
    • <span th:text="'hello world!'"></span>

연산

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

속성 값 설정

타임리프 태그 속성(Attribute)

  • 타임리프는 주로 HTML 태그에 th:* 속성을 지정하는 방식으로 동작
  • th:* 로 속성을 적용하면 기존 속성을 대체, 기존 속성이 없으면 새로 만든다
<input type="text" name="mock" th:name="userA" />
→ 타임리프 렌더링 후 <input type="text" name="userA" />

#속성 추가

th:attrappend : 속성 값의 에 값을 추가한다.
th:attrprepend : 속성 값의 에 값을 추가한다.
th:classappend : class 속성에 자연스럽게 추가한다.

# checked 처리

HTML에서 checked 속성은 checked 속성의 값과 상관없이 checked라는 속성만 있어도 체크가 된다
이런 부분이 true , false 값을 주로 사용하는 개발자 입장에서는 불편,
타임리프의 th:checked값이 false 인 경우 checked 속성 자체를 제거한다
<input type="checkbox" name="active" th:checked="false" />
→ 타임리프 렌더링 후: <input type="checkbox" name="active" />

반복 th:each

<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>
  • 반복시 오른쪽 컬렉션(${users})의 값을 하나씩 꺼내서 왼쪽 변수(user)에 담아서 태그를 반복 실행
  • 반복의 두번째 파라미터를 설정해서 반복의 상태를 확인 가능

조건부 평가 if , unless ( if 의 반대)

  • 타임리프는 해당 조건이 맞지 않으면 태그 자체를 렌더링하지 않는다
  • 만약 다음 조건이 false인 경우 <span>...<span> 부분 자체가 렌더링 되지 않고 사라진다
    <span th:text="'미성년자'" th:if="${user.age lt 20}"></span>

# switch

<td th:switch="${user.age}">
   <span th:case="10">10</span>
   <span th:case="20">20</span>
   <span th:case="*">기타</span>
 </td>

*은 만족하는 조건이 없을 때 사용하는 디폴트이다

주석

# 1. 표준 HTML 주석

<h1>1. 표준 HTML 주석</h1>
<!--
<span th:text="${data}">html data</span>
-->

자바스크립트의 표준 HTML 주석은 타임리프가 렌더링 하지 않고, 그대로 남겨둔다.

# 2. 타임리프 파서 주석

<h1>2. 타임리프 파서 주석</h1>
<!--/* [[${data}]] */-->

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

타임리프 파서 주석은 타임리프의 진짜 주석, 렌더링에서 주석 부분을 제거한다

# 3. 타임리프 프로토타입 주석

<h1>3. 타임리프 프로토타입 주석</h1>
<!--/*/
<span th:text="${data}">html data</span>
/*/-->

타임리프 프로토타입은 약간 특이한데, HTML 주석에 약간의 구문을 더했다
HTML 파일을 웹 브라우저에서 그대로 열어보면 HTML 주석이기 때문에 렌더링하지 않고 주석처리 되지만, 타임리프 렌더링을 거치면 이 부분이 정상 렌더링 된다

블록 <th:block>

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

타임리프의 특성상 HTML 태그안에 속성으로 기능을 정의해서 사용하는데, 위 예처럼 이렇게 사용하기 애매한 경우에 사용하면 된다
<th:block>은 렌더링시 제거된다

자바스크립트 인라인

자바스크립트에서 타임리프를 편리하게 사용할 수 있는 자바스크립트 인라인 기능을 제공
<script th:inline="javascript">

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

# 텍스트 렌더링

  • var username = [[${user.username}]];
    • 인라인 사용 전 →var username = userA;
    • 인라인 사용 후 → var username = "userA";
  • 인라인 사용 후 렌더링 결과를 보면 문자 타입인 경우 " 를 포함한다
    추가로 자바스크립트에서 문제가 될수 있는 문자가 포함되어 있으면 이스케이프 처리도 해준다
    예) "\"

# 자바스크립트 내추럴 템플릿

타임리프는 HTML 파일을 직접 열어도 동작하는 내추럴 템플릿 기능을 제공

  • var username2 = /*[[${user.username}]]*/ "test username";

    • 인라인 사용 전 → var username2 = /*userA*/ "test username";
    • 인라인 사용 후 → var username2 = "userA";

    📌 인라인 사용 전 결과를 보면 정말 순수하게 그대로 해석, 내추럴 템플릿 기능이 동작X

# 객체

타임리프의 자바스크립트 인라인 기능을 사용하면 객체를 JSON으로 자동으로 변환

  • var user = [[${user}]];
    • 인라인 사용 전 → var user = BasicController.User(username=userA, age=10);
    • 인라인 사용 후 → var user = {"username":"userA","age":10};
  • 인라인 사용 전은 객체의 toString()이 호출된 값
  • 인라인 사용 후는 객체를 JSON으로 변환

# 자바스크립트 인라인 each

<script th:inline="javascript">
 [# th:each="user, stat : ${users}"]
 var user[[${stat.count}]] = [[${user}]];
 [/]
</script>
  • [/] : 자바; 세미콜론 처럼 타임리프가 끝난다는 뜻

템플릿 조각

웹 페이지를 개발할 때는 공통 영역이 많이 있다 예를 들어서 상단 영역이나 하단 영역, 좌측 카테고리 등등 여러 페이지에서 함께 사용하는 영역들이 있다
이런 부분을 코드를 복사해서 사용한다면 변경시 여러 페이지를 다 수정해야 하므로 상당히 비효율 적이다
그래서 타임리프는 이런 문제를 해결하기 위해 템플릿 조각과 레이아웃 기능을 지원한다

/resources/templates/template/fragment/footer.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>
  • th:fragment가 있는 태그는 다른곳에 포함되는 코드 조각으로 이해

/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>
  • template/fragment/footer :: copy : template/fragment/footer.html 템플릿에 있는 th:fragment="copy" 라는 부분을 템플릿 조각으로 가져와서 사용한다는 의미

# 부분 포함 insert

<div th:insert="~{template/fragment/footer :: copy}"></div>

  • th:insert를 사용하면 현재 태그(div) 내부에 추가

# 부분 포함 replace

<div th:replace="~{template/fragment/footer :: copy}"></div>

  • th:replace를 사용하면 현재 태그(div)를 대체

# 파라미터 사용

다음과 같이 파라미터를 전달해서 동적으로 조각을 렌더링 할 수 있음
<div th:replace="~{template/fragment/footer :: copyParam ('데이터1', '데이터2')}"></div>

<footer th:fragment="copyParam (param1, param2)">
 <p>파라미터 자리 입니다.</p>
 <p th:text="${param1}"></p>
 <p th:text="${param2}"></p>
</footer>

템플릿 레이아웃1

코드 조각을 레이아웃에 넘겨서 사용하는 방법
예를 들어서 <head>에 공통으로 사용하는 css , javascript 같은 정보들이 있는데, 이러한 공통 정보들을 한 곳에 모아두고, 공통으로 사용하지만, 각 페이지마다 필요한 정보를 더 추가해서 사용하고 싶다면 다음과 같이 사용하면 된다

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

/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>
  • common_header(~{::title},~{::link})이 부분이 핵심
    • ::title은 현재 페이지의 title 태그들을 전달
    • ::link는 현재 페이지의 link 태그들을 전달

템플릿 레이아웃2

# 템플릿 레이아웃 확장

앞서 이야기한 개념을 <head> 정도에만 적용하는게 아니라 <html> 전체에 적용 가능

/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>
  • layoutFile.html을 보면 기본 레이아웃을 가지고 있는데, <html>th:fragment 속성이 정의되어 있다
    • 이 레이아웃 파일을 기본으로 하고 여기에 필요한 내용을 전달해서 부분부분 변경하는 것 으로 이해하면 된다

/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>
  • layoutExtendMain.html는 현재 페이지인데, <html> 자체를 th:replace를 사용해서 변경하는 것을 확인할 수 있음
    • 결국 layoutFile.html필요한 내용을 전달하면서 <html> 자체를 layoutFile.html로 변경

# 생성 결과

<!DOCTYPE html>
<html>
<head>
<title>메인 페이지 타이틀</title>
</head>
<body>
<h1>레이아웃 H1</h1>

<section>
<p>메인 페이지 컨텐츠</p>
<div>메인 페이지 포함 내용</div>
</section>

<footer>
레이아웃 푸터
</footer>

</body>
</html>

섹션2. 타임리프 - 스프링 통합과 폼

타임리프 스프링 통합

# 스프링 통합으로 추가되는 기능들

  • 스프링의 SpringEL 문법 통합
  • ${@myBean.doSomething()} 처럼 스프링 빈 호출 지원
  • 편리한 폼 관리를 위한 추가 속성
    • th:object (기능 강화, 폼 커맨드 객체 선택)
    • th:field , th:errors , th:errorclass
  • 폼 컴포넌트 기능
    • checkbox, radio button, List 등을 편리하게 사용할 수 있는 기능 지원
  • 스프링의 메시지, 국제화 기능의 편리한 통합
  • 스프링의 검증, 오류 처리 통합
  • 스프링의 변환 서비스 통합(ConversionService)

# 설정 방법

입력 폼 처리

  • th:object : 커맨드 객체를 지정한다
  • *{...} : 선택 변수 식
    • th:object 에서 선택한 객체에 접근한다
  • th:field : HTML 태그의 id , name , value 속성을 자동으로 처리

요구사항 추가

체크 박스 - 단일1

<div>판매 여부</div>
<div>
 <div class="form-check">
   <input type="checkbox" id="open" name="open" class="form-check-input">
   <label for="open" class="form-check-label">판매 오픈</label>
 </div>
</div>

# 실행 로그

FormItemController : item.open=true //체크 박스를 선택하는 경우
FormItemController : item.open=null //체크 박스를 선택하지 않는 경우
  • 체크 박스를 체크하면 HTML Form에서 open=on 이라는 값이 넘어가고, 스프링은 on 이라는 문자를 true 타입으로 변환해준다
  • 체크 박스를 선택하지 않을 시 open 이라는 필드 자체가 서버로 전송되지 않는다
    • 수정의 경우에는 상황에 따라서 이 방식이 문제가 될 수 있다

# 체크 해제를 인식하기 위한 히든 필드

<input type="checkbox" id="open" name="open" class="form-check-input">
<input type="hidden" name="_open" value="on"/> <!-- 히든 필드 추가 -->

📌이런 문제를 해결하기 위해서 스프링 MVC는 히든 필드를 하나 만들어서,_open 처럼 기존 체크 박스 이름 앞에 언더스코어( _ )를 붙여서 전송하면 체크를 해제했다고 인식할 수 있다

  • 히든 필드는 항상 전송된다 따라서 체크를 해제한 경우 여기에서 open은 전송되지 않고, _open만 전송되는데, 이 경우 스프링 MVC는 체크를 해제했다고 판단한다

체크 박스 - 단일2(타임리프)

<div>판매 여부</div>
<div>
 <div class="form-check">
   <input type="checkbox" id="open" th:field="*{open}" class="form-check-input">
   <label for="open" class="form-check-label">판매 오픈</label>
 </div>
</div>
  • th:field를 사용하면 타임리프가 자동으로 히든필드를 생성해준다

체크 박스 - 멀티

# @ModelAttribute

@ModelAttribute("regions")
public Map<String, String> regions() {
   Map<String, String> regions = new LinkedHashMap<>();
   regions.put("SEOUL", "서울");
   regions.put("BUSAN", "부산");
   regions.put("JEJU", "제주");
   return regions;
}

# @ModelAttribute의 특별한 사용법

등록 폼, 상세화면, 수정 폼에서 모두 서울, 부산, 제주라는 체크 박스를 반복해서 보여주어야 하는데, 이를 위해 각각의 컨트롤러에서 model.addAttribute(...)을 사용해서 체크 박스를 구성하는 데이터를 반복해서 넣어주어야 한다

@ModelAttribute는 이렇게 컨트롤러에 있는 별도의 메서드에 적용할 수 있다
반환한 값이 자동으로 모델(model)에 담기게 된다
각각의 컨트롤러 메서드에서 모델에 직접 데이터를 담아서 처리하는 것도 가능

<div>등록 지역</div>
 <div th:each="region : ${regions}" class="form-check form-check-inline">
	 <input type="checkbox" th:field="*{regions}" th:value="${region.key}"
     class="form-check-input">
 	<label th:for="${#ids.prev('regions')}"
    th:text="${region.value}" class="form-check-label">서울</label>
 </div>
</div

th:for="${#ids.prev('regions')}"
멀티 체크박스는 같은 이름의 여러 체크박스를 만들 수 있다
그런데 문제는 이렇게 반복해서 HTML 태그를 생성할 때, 생성된 HTML 태그 속성에서name 은 같아도 되지만, id 는 모두 달라야 한다
따라서 타임리프는 체크박스를 each 루프 안에서 반복해서 만들 때 임의로 1 , 2 , 3 숫자를 뒤에 붙여준다

HTML의 id가 타임리프에 의해 동적으로 만들어지기 때문에 <label for="id 값">으로 label의 대상이 되는 id 값을 임의로 지정하는 것은 곤란하다
타임리프는 ids.prev(...) , ids.next(...) 을 제공해서 동적으로 생성되는 id값을 사용할 수 있도록 한다

라디오 버튼

  • 여러 선택지 중에 하나를 선택할 때 사용하는 버튼
  • @ModelAttribute 사용법 적용
@ModelAttribute("itemTypes")
public ItemType[] itemTypes() {
	return ItemType.values(); // ENUM의 모든 정보를 배열로 반환
}
<div>
 <div>상품 종류</div>
 <div th:each="type : ${itemTypes}" class="form-check form-check-inline">
 	 <input type="radio" th:field="*{itemType}" th:value="${type.name()}"
     class="form-check-input">
	 <label th:for="${#ids.prev('itemType')}" th:text="${type.description}"
     class="form-check-label">
 		BOOK
	 </label>
   </div>
</div>
  • 라디오 버튼은 이미 선택이 되어 있다면, 수정시에도 항상 하나를 선택하도록 되어 있으므로 체크 박스와 달리 별도의 히든 필드를 사용할 필요가 없다

셀렉트 박스

  • 셀렉트 박스는 여러 선택지 중에 하나를 선택할 때 사용
<div>
 <div>배송 방식</div>
 <select th:field="*{deliveryCode}" class="form-select">
   <option value="">==배송 방식 선택==</option>
   <option th:each="deliveryCode : ${deliveryCodes}" th:value="${deliveryCode.code}"
		   th:text="${deliveryCode.displayName}">FAST</option>
 </select>
</div>

# item.html

<div>
 <div>배송 방식</div>
 <select th:field="${item.deliveryCode}" class="form-select" disabled>
   <option value="">==배송 방식 선택==</option>
   <option th:each="deliveryCode : ${deliveryCodes}" th:value="${deliveryCode.code}"
		   th:text="${deliveryCode.displayName}">FAST</option>
 </select>
</div>
  • item.html 에는 th:object 를 사용하지 않았기 때문에 th:field 부분에 ${item.deliveryCode} 으로 적어주어야 한다
    • *{deliveryCode} = ${item.deliveryCode}
  • disabled 를 사용해서 상품 상세에서는 셀렉트 박스가 선택되지 않게 가능
<div>
 <DIV>배송 방식</DIV>
 <select class="form-select" id="deliveryCode" name="deliveryCode">
   <option value="">==배송 방식 선택==</option>
   <option value="FAST" selected="selected">빠른 배송</option>
   <option value="NORMAL">일반 배송</option>
   <option value="SLOW">느린 배송</option>
 </select>
</div>
  • 선택되는 경우, option 태그에 selected 속성유지
    • 빠른배송을 선택한 예시

섹션3. 메시지, 국제화

메시지, 국제화 소개

# 메시지

  • 다양한 메시지를 한 곳에서 관리하도록 하는 기능을 메시지 기능이라 한다
    • 예) messages.properties라는 메시지 관리용 파일을 만들고, 각 HTML들은 다음과 같이 해당 데이터를 key 값으로 불러서 사용하는 방식

# 국제화

  • 메시지에서 설명한 메시지 파일(messages.properties)을 각 나라별로 별도로 관리하면 서비스를 국제화 할 수 있다

    • 예) 2개의 파일 messages_en.properties, messages_ko.properties라는 파일을 만들어서 분류
    • 영어를 사용하는 사람이면 messages_en.properties를 사용, 한국어를 사용하는 사람이면 messages_ko.properties를 사용하게 개발
  • 한국인지 영어에서 접근한 것인지는 인식 방법은 HTTP accept-language해더 값을 사용, 또는 사용자가 직접 언어를 선택하도록 하고 쿠키 등을 사용해서 처리하면 된다

  • 스프링은 기본적인 메시지와 국제화 기능을 모두 제공 하며,타임리프도 스프링이 제공하는 메시지와 국제화 기능을 편리하게 통합해서 제공한다

스프링 메시지 소스 설정

메시지 관리 기능을 사용하려면 스프링이 제공하는 MessageSource를 스프링 빈으로 등록

# 직접 등록

@Bean
public MessageSource messageSource() {
 ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
 messageSource.setBasenames("messages", "errors");
 messageSource.setDefaultEncoding("utf-8");
 return messageSource;
}
  • basenames : 설정 파일의 이름을 지정
    • messages 로 지정하면 messages.properties 파일을 읽어서 사용
  • 추가로 국제화 기능을 적용하려면 messages_en.properties , messages_ko.properties 와 같이 파일명 마지막에 언어 정보를 주면 됨
    • 만약 찾을 수 있는 국제화 파일이 없으면 messages.properties(언어정보가 없는 파일명)를 기본으로 사용
  • 파일의 위치는 /resources/messages.properties 에 두면 되고,여러 파일을 한번에 지정 가능
  • defaultEncoding : 인코딩 정보를 지정

# 스프링 부트

  • 스프링 부트를 사용하면 스프링 부트가 MessageSource를 자동으로 스프링 빈으로 등록, 필요한 경우 application.properties에 별도의 메세지 소스를 설정한다
  • MessageSource가 setBasenames()에 지정된 이름의 메세지 파일들을 읽어서 가지고 있고, 스프링 빈으로 등록된다
  • 스프링 부트 메시지 소스 기본 값
    • spring.messages.basename=messages
  • MessageSource를 스프링 빈으로 등록하지 않고, 스프링 부트와 관련된 별도의 설정을 하지 않으면 messages라는 이름으로 기본 등록된다

스프링 메시지 소스 사용

# MessageSource 인터페이스

public interface MessageSource {
	String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
	String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;

# MessageSource 메세지 기능 테스트

messages.properties

hello=안녕
hello.name=안녕 {0}
@Test
 void helloMessage() {
   String result = ms.getMessage("hello", null, null);
   assertThat(result).isEqualTo("안녕");
 }
  • ms.getMessage("hello", null, null)
    • code: hello args: null locale: null
  • locale 정보가 없으면 basename에서 설정한 기본 이름 메시지 파일을 조회한다
    • basename으로 messages를 지정 했으므로 messages.properties 파일에서 데이터 조회

# MessageSourceTest 추가 - 메시지가 없는 경우, 기본 메시지

@Test
void notFoundMessageCode() {
	assertThatThrownBy(() -> ms.getMessage("no_code", null, null))
			 .isInstanceOf(NoSuchMessageException.class);
}

@Test
void notFoundMessageCodeDefaultMessage() {
 	String result = ms.getMessage("no_code", null, "기본 메시지", null);
	assertThat(result).isEqualTo("기본 메시지");
}
  • 메시지가 없는 경우에는 NoSuchMessageException 발생
  • 메시지가 없어도 기본 메시지(defaultMessage)를 사용하면 기본 메시지가 반환된다

# MessageSourceTest 추가 - 매개변수 사용

@Test
void argumentMessage() {
 String result = ms.getMessage("hello.name", new Object[]{"Spring"}, null);
 assertThat(result).isEqualTo("안녕 Spring");
}
  • 다음 메시지의 {0} 부분은 매개변수를 전달해서 치환할 수 있음
  • hello.name=안녕 {0} → Spring 단어를 매개변수로 전달 → 안녕 Spring

# 국제화 파일 선택

  • locale 정보를 기반으로 국제화 파일을 선택
  • Locale이 en_US의 경우 messages_en_US messages_en messages 순서로 찾는다
  • Locale 에 맞추어 구체적인 것이 있으면 구체적인 것을 찾고, 없으면 디폴트를 찾는다고 이해하면 됨

# MessageSource 국제화 기능 테스트

@Test
void defaultLang() {
  assertThat(ms.getMessage("hello", null, null)).isEqualTo("안녕");
  assertThat(ms.getMessage("hello", null, Locale.KOREA)).isEqualTo("안녕");
}
  • ms.getMessage("hello", null, null): locale 정보가 없으므로 messages를 사용
  • ms.getMessage("hello", null, Locale.KOREA): locale 정보가 있지만, message_ko 가없으므로 messages를 사용
@Test
void enLang() {
 assertThat(ms.getMessage("hello", null,
Locale.ENGLISH)).isEqualTo("hello");
}
  • ms.getMessage("hello", null, Locale.ENGLISH): locale 정보가 Locale.ENGLISH 이므로 messages_en을 찾아서 사용

📌 보충
Locale 정보가 없는 경우 Locale.getDefault() 을 호출해서 시스템의 기본 로케일을 사용
예) locale = null인 경우 시스템 기본 locale이 ko_KR 이므로 messages_ko.properties 조회시도,
조회 실패 messages.properties 조회

웹 애플리케이션에 메시지 적용하기

messages.properties
label.item=상품
  • 타임리프의 메시지 표현식 #{...}를 사용하면 스프링의 메시지를 편리하게 조회 가능
  • 메세지 표현식으로 작성된것들이 렌더링 후, 메세지 파일에 있는 내용으로 바뀌어 보여진다
    • 렌더링 전 <div th:text="#{label.item}"></h2>
    • 렌더링 후 <div>상품</h2>
  • 나중에 상품 등록을 다른 이름으로 변경이 필요한 경우, 메세지 파일의 내용만 변경하면 된다
  • 파라미터는 다음과 같이 사용가능
    • hello.name=안녕 {0}
    • <p th:text="#{hello.name(${item.itemName})}"></p>

웹 애플리케이션에 국제화 적용하기

  • 메시지 기능은 Locale 정보를 알아야 언어를 선택할 수 있다
  • 스프링은 언어 선택시 기본으로 Accept-Language 헤더의 값을 사용한다
  • Locale 선택 방식을 변경할 수 있도록 LocaleResolver라는 인터페이스를 제공하는데, 스프링 부트는 기본으로 Accept-Language를 활용하는 AcceptHeaderLocaleResolver를 사용한다

# LocaleResolver 인터페이스

public interface LocaleResolver {
  Locale resolveLocale(HttpServletRequest request);
  void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale);
}
  • Locale선택 방식을 변경하려면 LocaleResolver의 구현체를 변경해서 쿠키나 세션 기반 Locale 선택 기능을 사용할 수 있다
profile
오늘의 기록

0개의 댓글