스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 정리(1)

Jim·2023년 7월 16일
0

스프링

목록 보기
3/10
post-thumbnail

1. 타임리프 - 기본 기능

1. 타임리프 특징

2. 기본 표현식

참고

  • 간단한 표현:
    • 변수 표현식: ${...}
    • 선택 변수 표현식: *{...}
    • 메시지 표현식: #{...}
    • 링크 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: _

3. 텍스트 - text, utext

  • HTML 태그의 속성에 기능을 정의
    <span th:text="${data}">
  • HTML 콘텐츠 영역안에서 직접 데이터를 출력
    [[${data}]]
  • 웹 브라우저는 <를 HTML 태그의 시작으로 인식한다. 태그의 시작이 아니라 문자로 표현하는 방법을 HTML 엔티티라 한다. 이렇게 HTML에서 사용하는 특수 문자를 HTML 엔티티로 변경하는 것을 이스케이프(escape)라 한다. 타임리프가 제공하는 th:text, [[...]]기본적으로 이스케이프(escape)를 제공한다.
  • Unescape
    • th:text -> th:utext
    • [[...]] -> [(...)]
  • th:inline="none" : 타임리프는 콘텐츠 영역안의 [[...]]를 해석하기 때문에, 이 태그 안에서는 타임리프가 해석하지 말라는 옵션.
  • escape를 기본으로 하고, 꼭 필요할 때만 unescape를 사용하자!

4. 변수 - SpringEL

  • ${...} : 이 변수 표현식에는 스프링 EL이라는 스프링이 제공하는 표현식을 사용할 수 있다.
  • 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
<!-- 지역 변수는 선언한 태그 안에서만 사용할 수 있다.-->
<div th:with="first=${users[0]}">
  <p>처음 사람의 이름은 <span th:text="${first.username}"></span></p>
</div>

5. 기본 객체들

  • ${#request} - 스프링 부트 3.0부터 제공 X
  • ${#response} - 스프링 부트 3.0부터 제공 X
  • ${#session} - 스프링 부트 3.0부터 제공 X
  • ${#servletContext} - 스프링 부트 3.0부터 제공 X
  • ${#locale}
  • 스프링 부트 3.0부터 제공하지 않는 객체들은 직접 model에 해당 객체를 추가해서 사용해야 한다.
  • param : HTTP 요청 파라미터 접근 편의 객체
    • 예) ${param.paramData}
  • session : HTTP 세션 접근 편의 객체
    • 예) ${session.sessionData} <- session.setAttribute("sessionData", "Hello Session");
  • @ : 스프링 빈 접근
    • 예) ${@helloBean.hello('Spring!')} <- HelloBean 객체의 hello 메서드 사용

6. 유틸리티 객체와 날짜

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

  • 타임리프에서 자바8 날짜LocalDate, LocalDateTime, Instant를 사용하려면 추가 라이브러리 필요. 스프링 부트 타임리프를 사용하면 해당 라이브러리(thymeleaf-extras-java8time)가 자동으로 추가되고 통합된다.
  • #temporals 사용 예시
@GetMapping("/date")
public String date(Model model) {
	model.addAttribute("localDateTime", LocalDateTime.now());
    return "basic/date";
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
  
<h1>LocalDateTime</h1>
<ul>
    <li>default = <span th:text="${localDateTime}"></span></li>
    <li>yyyy-MM-dd HH:mm:ss = 
      <span th:text="${#temporals.format(localDateTime, 'yyyy-MM-dd HH:mm:ss')}">
      </span>
  	</li>
</ul>
  
<h1>LocalDateTime - Utils</h1>
<ul>
    <li>${#temporals.day(localDateTime)} = 
      <span th:text="${#temporals.day(localDateTime)}"></span>
    </li>
    <li>${#temporals.month(localDateTime)} = 
      <span th:text="${#temporals.month(localDateTime)}"></span>
    </li>
    <li>${#temporals.monthName(localDateTime)} = 
      <span th:text="${#temporals.monthName(localDateTime)}"></span>
    </li>
    <li>${#temporals.monthNameShort(localDateTime)} = 
      <span th:text="${#temporals.monthNameShort(localDateTime)}"></span>
  	</li>
    <li>${#temporals.year(localDateTime)} = 
      <span th:text="${#temporals.year(localDateTime)}"></span>
  	</li>
    <li>${#temporals.dayOfWeek(localDateTime)} = 
      <span th:text="${#temporals.dayOfWeek(localDateTime)}"></span>
  	</li>
    <li>${#temporals.dayOfWeekName(localDateTime)} = 
      <span th:text="${#temporals.dayOfWeekName(localDateTime)}"></span>
  	</li>
    <li>${#temporals.dayOfWeekNameShort(localDateTime)} = 
      <span th:text="${#temporals.dayOfWeekNameShort(localDateTime)}"></span>
  	</li>
    <li>${#temporals.hour(localDateTime)} = 
      <span th:text="${#temporals.hour(localDateTime)}"></span>
  	</li>
    <li>${#temporals.minute(localDateTime)} = 
      <span th:text="${#temporals.minute(localDateTime)}"></span>
  	</li>
    <li>${#temporals.second(localDateTime)} = 
      <span th:text="${#temporals.second(localDateTime)}"></span>
  	</li>
    <li>${#temporals.nanosecond(localDateTime)} = 
      <span th:text="${#temporals.nanosecond(localDateTime)}"></span>
  	</li>
</ul>
  
</body>
</html>

7. URL 링크

  • 단순한 URL
    • @{/hello} -> /hello
  • 쿼리 파라미터
    • @{/hello(param1=${param1}, param2=${param2})}
      -> /hello?param1=data1&param2=data2
  • 경로 변수
    • @{/hello/{param1}/{param2}(param1=${param1}, param2=${param2})}
      -> /hello/data1/data2
  • 경로 변수 + 쿼리 파라미터
    • @{/hello/{param1}(param1=${param1}, param2=${param2})}
      -> /hello/data1?param2=data2
  • 상대경로, 절대경로, 프로토콜 기준을 표현할 수 도 있다.
    • /hello : 절대 경로
    • hello : 상대 경로
    • 참고

8. 리터럴

리터럴은 소스 코드상에 고정된 값, 타임리프는 다음과 같은 리터럴이 있다.

  • 문자: 'hello'
  • 숫자: 10
  • 불린 true, false
  • null: null
  • 타임리프에서 문자 리터럴은 항상 '(작은 따옴표)로 감싸야 한다. 공백 없이 쭉 이어진다면 하나의 의미있는 토큰으로 인지해서 작은 따옴표를 생략할 수 있다.
    • <span th:text="hello world!"></span> -> 오류
    • <span th:text="'hello world!'"></span> -> 정상 동작
    • <span th:text="'hello' + ' world!'"></span> -> 정상 동작
    • <span th:text="'hello ' + ${data}"></span> -> 정상 동작
  • 리터럴 대체(Literal substitutions)
    -> <span th:text="|hello ${data}|"></span> -> 정상 동작

9. 연산

  • 산술 연산
    • <li>10 + 2 = <span th:text="10 + 2"></span></li>
      -> 10 + 2 = 12
    • <li>10 % 2 == 0 = <span th:text="10 % 2 == 0"></span></li>
      -> 10 % 2 == 0 = true
  • 비교 연산
    • HTML 엔티티를 사용해야 하는 부분을 주의.
      > (gt), < (lt), >= (ge), <= (le), ! (not), == (eq), != (neq, ne)
  • 조건식
    • <li>(10 % 2 == 0)? '짝수':'홀수' = <span th:text="(10 % 2 == 0)? '짝수':'홀수'"></span></li>
  • Elvis 연산자
    • <li>${data}?: '데이터가 없습니다.' = <span th:text="${data}?: '데이터가없습니다.'"></span></li>
      -> ${data}?: '데이터가 없습니다.' = Spring!
    • <li>${nullData}?: '데이터가 없습니다.' = <span th:text="${nullData}?: '데이터가 없습니다.'"></span></li>
      -> ${nullData}?: '데이터가 없습니다.' = 데이터가 없습니다.
  • No-Operation
    • <li>${data}?: _ = <span th:text="${data}?: _">데이터가 없습니다.</span></li>
      -> ${data}?: _ = Spring!
    • <li>${nullData}?: _ = <span th:text="${nullData}?: _">데이터가없습니다.</span></li>
      -> ${nullData}?: _ = 데이터가없습니다. (마치 타임리프가 실행되지 않는 것 처럼 동작)

10. 속성 값 설정

  • 속성 설정
    • th:* 속성을 지정하면 타임리프는 기존 속성을 th:*로 지정한 속성으로 대체한다. 기존 속성이 없다면 새로 만든다.
    • <input type="text" name="mock" th:name="userA" />
      -> 타임리프 렌더링 후 <input type="text" name="userA" />
  • 속성 추가
    • <input type="text" class="text" th:attrappend="class=' large'" />
    • <input type="text" class="text" th:attrprepend="class='large '" />
    • <input type="text" class="text" th:classappend="large" />
  • checked 처리
    • HTML에서는 <input type="checkbox" name="active" checked="false" /> 이 경우에도 checked 속성이 있기 때문에 checked 처리 되어버린다.
    • 타임리프의 th:checked는 값이 false인 경우 checked속성 자체를 제거한다.

11. 반복

  • 반복 기능
    • <tr th:each="user : ${users}"> -> 컬렉션 ${users}의 값을 하나씩 꺼내서 왼쪽 변수(user)에 담아서 태그를 반복 실행.
    • th:eachList뿐만 아니라 배열, java.util.Iterable, java.util.Enumeration을 구현한 모든 객체를 반복에 사용할 수 있다. Map 사용시 변수에 담기는 값은 Map.Entry이다.
  • 반복 상태 유지
    • <tr th:each="user, userStat : ${users}"> -> 반복의 두번째 파라미터를 설정해서 반복의 상태를 확인 할 수 있다. 두번째 파라미터 생략시, 지정한 변수명(user) + Stat.
  • 반복 상태 유지 기능
    • index: 0부터 시작
    • count: 1부터 시작
    • size: 전체 사이즈
    • even, odd: 홀수, 짝수 여부(boolean)
    • first, last: 처음, 마지막 여부(boolean)
    • current: 현재 객체

12. 조건부 평가

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

13. 주석

HTML

<h1>예시</h1>
<span th:text="${data}">html data</span>

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

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

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

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

결과

<h1>예시</h1>
<span>Spring!</span>

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

<h1>2. 타임리프 파서 주석</h1>




<h1>3. 타임리프 프로토타입 주석</h1>

<span>Spring!</span>
  • 표준 HTML 주석
    -> 자바스크립트의 표준 HTML 주석은 타임리프가 렌더링 하지 않고, 그대로 남겨 둔다.
  • 타임리프 파서 주석
    -> 렌더링에서 주석 부분을 제거
  • 타임리프 프로토타입 주석
    -> HTML 파일을 웹 브라우저에서 그대로 열어보면 HTML 주석이기 때문에 이 부분이 웹 브라우저가 렌더링 하지 않는다. 타임리프 렌더링을 거치면 이 부분이 정상 렌더링 된다.

14. 블록

<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>
  • <th:block>은 HTML 태그가 아닌 타임리프의 유일한 자체 태그.
  • 타임리프의 특성상 HTML 태그안에 속성으로 기능을 정의해서 사용하는데, 위 예처럼 이렇게 사용하기 애매한 경우에 사용. <th:block>은 렌더링시 제거 된다.

15. 자바스크립트 인라인

HTML

<!-- 자바스크립트 인라인 사용 전 -->
<script>
    var username = [[${user.username}]];
    var age = [[${user.age}]];

    //자바스크립트 내추럴 템플릿
    var username2 = /*[[${user.username}]]*/ "test username";

    //객체
    var user = [[${user}]];
</script>

<!-- 자바스크립트 인라인 사용 후 -->
<script th:inline="javascript">
    var username = [[${user.username}]];
    var age = [[${user.age}]];

    //자바스크립트 내추럴 템플릿
    var username2 = /*[[${user.username}]]*/ "test username";

    //객체
    var user = [[${user}]];
</script>

자바스크립트 인라인 사용 전 - 결과

<!-- 자바스크립트 인라인 사용 전 -->
<script>
    var username = UserA;
    var age = 10;

    //자바스크립트 내추럴 템플릿
    var username2 = /*UserA*/ "test username";

    //객체
    var user = BasicController.User(username=UserA, age=10);
</script>

자바스크립트 인라인 사용 후- 결과

<!-- 자바스크립트 인라인 사용 후 -->
<script>
    var username = "UserA";
    var age = 10;

    //자바스크립트 내추럴 템플릿
    var username2 = "UserA";

    //객체
    var user = {"username":"UserA","age":10};
</script>
  • 텍스트 렌더링
    • 인라인 사용 후 렌더링 결과를 보면 문자 타입인 경우 "를 포함 해준다. 추가로 자바스크립트에서 문제가 될 수 있는 문자가 포함되어 있으면 이스케이프 처리도 해준다. 예)"->\"
  • 자바스크립트 내추럴 템플릿
    • 타임리프는 HTML 파일을 직접 열어도 동작하는 내추럴 템플릿 기능을 제공한다. 자바스크립트 인라인 기능을 사용하면 주석을 활요해서 이 기능을 사용할 수 있다.
    • 인라인 사용 전 결과를 보면 내추럴 템플릿 기능이 동작하지 않고, 심지어 렌더링 내용이 주석처리 되어 버린다.
    • 인라인 사용 후 결과를 보면 주석 부분이 제거되고, 기대한 "UserA"가 정확하게 적용된다.
  • 객체
    • 인라인 사용 전은 객체의 toString()이 호출된 값.
    • 인라인 사용 후는 객체를 JSON으로 변환.

16. 자바스크립트 인라인 each

HTML

<!-- 자바스크립트 인라인 each -->
<script th:inline="javascript">

    [# th:each="user, stat : ${users}"]
    var user[[${stat.count}]] = [[${user}]];
    [/]

</script>

결과

<!-- 자바스크립트 인라인 each -->
<script>

    
    var user1 = {"username":"UserA","age":10};
    var user2 = {"username":"UserB","age":20};
    var user3 = {"username":"UserC","age":30};
    

</script>

17. 템플릿 조각

<!-- /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>
<!-- /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>
  • th:fragment 가 있는 태그는 다른곳에 포함되는 코드 조각으로 이해하면 된다.
  • template/fragment/footer :: copy -> template/fragment/footer.html 템플릿에 있는 th:fragment="copy" 라는 부분을 템플릿 조각으로 가져와서 사용한다는 의미.
  • th:insert를 사용하면 현재 태그(div) 내부에 추가.
  • th:replace를 사용하면 현재 태그(div)를 대체.
  • ~{...}를 사용하는 것이 원칙이지만 템플릿 조각을 사용하는 코드가 단순하면 이 부분을 생략할 수 있다.
  • 파라미터를 전달해서 동적으로 조각을 렌더링 할 수도 있다.

18. 템플릿 레이아웃1

<!-- /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 태그들을 전달.
  • 타이틀이 전달한 부분으로 교체되고, 공통 부분은 그대로 유지, 추가 부분에 전달한 <link> 들이 포함된다.
  • 레이아웃 개념을 두고, 그 레이아웃에 필요한 코드 조각을 전달해서 완성.

19. 템플릿 레이아웃2

<!-- /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>
<!-- /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>
  • layoutFile.html을 보면 기본 레이아웃을 가지고 있는데 <html>th:fragment속성이 정의되어 있다. 이 레이아웃 파일을 기본으로 하고 여기에 필요한 내용을 전달해서 부분부분 변경하는 것이다.
  • layoutExtendMain.html은 현재 페이지인데, <html>자체를 th:replace를 사용해서 변경하는 것을 확인할 수 있다. 결국 layoutFile.html에 필요한 내용을 전달하면서 <html>자체를 layoutFile.html로 변경한다.

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

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

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

2. 설정 방법

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
  • 타임리프 관련 설정을 변경하고 싶으면 링크를 참고(thymeleaf 검색 필요)해서 application.properties에 추가하면 된다.

3. 입력 폼 처리

타임리프가 제공하는 입력 폼 기능 적용

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

등록 폼

  • th:object를 적용하려면 먼저 해당 오브젝트 정보를 넘겨주어야 한다. 등록 폼이기 때문에 데이터가 비어있는 빈 오브젝트를 만들어서 뷰에 전달하자.
<!-- form/addForm.html 변경 코드 부분 -->
<form action="item.html" th:action th:object="${item}" method="post">
    <div>
        <label for="itemName">상품명</label>
        <input type="text" id="itemName" th:field="*{itemName}" 
               class="form-control" placeholder="이름을 입력하세요">
    </div>
    <div>
        <label for="price">가격</label>
        <input type="text" id="price" th:field="*{price}" 
               class="form-control" placeholder="가격을 입력하세요">
    </div>
    <div>
        <label for="quantity">수량</label>
        <input type="text" id="quantity" th:field="*{quantity}" 
               class="form-control" placeholder="수량을 입력하세요">
    </div>
  • th:object="${item} : <form>에서 사용할 객체를 지정. 선택 변수 식(*{...})을 적용할 수 있다.
  • th:field="*{itemName}"
    • *{itemName}은 선택 변수 식을 사용했는데, ${item.itemName}과 같다. 앞서 th:objectitem을 선택했기 때문에 선택 변수 식을 적용할 수 있다.
    • th:fieldid, name, value 속성을 모두 자동으로 만들어준다.
      • id: th:field에서 지정한 변수 이름과 같다. id="itemName"
      • name: th:field에서 지정한 변수 이름과 같다. name="itemName"
      • value: th:field에서 지정한 변수의 값을 사용한다. value=""

수정 폼

<form action="item.html" th:action th:object="${item}" method="post">
    <div>
        <label for="id">상품 ID</label>
        <input type="text" id="id" class="form-control" 
               th:field="*{id}" readonly>
    </div>
    <div>
        <label for="itemName">상품명</label>
        <input type="text" id="itemName" class="form-control" 
               th:field="*{itemName}" >
    </div>
    <div>
        <label for="price">가격</label>
        <input type="text" id="price" class="form-control" 
               th:field="*{price}">
    </div>
    <div>
        <label for="quantity">수량</label>
        <input type="text" id="quantity" class="form-control" 
               th:field="*{quantity}">
    </div>

4. 요구 사항 추가

  • 판매 여부
    • 판매 오픈 여부
    • 체크 박스로 선택할 수 있다.
  • 등록 지역
    • 서울, 부산, 제주
    • 체크 박스로 다중 선택할 수 있다.
  • 상품 종류
    • 도서, 식품, 기타
    • 라디오 버튼으로 하나만 선택할 수 있다.
  • 배송 방식
    • 빠른 배송
    • 일반 배송
    • 느린 배송
    • 셀렉트 박스로 하나만 선택할 수 있다.

ItemType - 상품 종류

public enum ItemType {

    BOOK("도서"), FOOD("음식"), ETC("기타");

    private final String description;

    ItemType(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }
}

DeliveryCode - 배송 방식

/**
 * FAST: 빠른 배송
 * NORMAL: 일반 배송
 * SLOW: 느린 배송
 */

@Data
@AllArgsConstructor
public class DeliveryCode {

    private String code;
    private String displayName;

}

Item - 상품

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    private Boolean open; //판매 여부
    private List<String> regions; //등록 지역
    private ItemType itemType; //상품 종류
    private String deliveryCode; // 배송 방식

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

체크 박스 - 단일1

<!-- resources/templates/form/addForm.html 추가 -->
<hr class="my-4">

<!-- single checkbox -->
<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이라는 필드 자체가 서버로 전송되지 않는다.
    • HTTP 메시지 바디를 보면 open의 이름도 전송이 되지 않는 것을 확인할 수 있다. -> itemName=itemA&price=10000&quantity=10
    • 서버에서 Boolean 타입을 찍어보면 결과가 null인 것을 확인할 수 있다. -> log.info("item.open={}", item.getOpen());
    • 수정의 경우에는 상황에 따라서 이 방식이 문제가 될 수 있다. 사용자가 의도적으로 체크되어 있던 값을 체크를 해제해도 저장시 아무 값도 넘어가지 않기 때문에, 서버 구현에 따라서 값이 오지 않은 것으로 판단해서 값을 변경하지 않을 수도 있다.
  • 위의 문제를 해결하기 위해서 스프링 MVC는 약간의 트릭을 사용하는데, 히든 필드를 하나 만들어서, _open처럼 기존 체크 박스 이름 앞에 언더스코어(_)를 붙여서 전송하면 체크를 해제했다고 인식할 수 있다. 체크를 해제한 경우 open은 전송되지 않고, _open만 전송되는데, 이 경우 스프링 MVC는 체크를 해제했다고 판단한다.
    • <input type="hidden" name="_open" value="on"/>
    • 실행 로그
      • FormItemController : item.open=true //체크 박스를 선택하는 경우
      • FormItemController : item.open=false //체크 박스를 선택하지 않는 경우
    • 체크 박스 체크
      • open=on&_open=on
      • 체크 박스를 체크하면 스프링 MVC가 open에 값이 있는 것을 확인하고 사용한다. 이때 _open은 무시.
    • 체크 박스 미체크
      • _open=on
      • 체크 박스를 체크하지 않으면 스프링 MVC가 _open만 있는 것을 확인하고, open의 값이 체크되지 않았다고 인식한다.

체크 박스 - 단일2

<!-- addForm.html -->
<!-- single checkbox -->
<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>
  • 타임리프가 제공하는 체크 박스 코드로 변경해, HTML 생성 결과를 보면 히든 필드 부분이 자동으로 생성되어 있다.
  • item.html에는 th:object를 사용하지 않았기 때문에 th:field부분에 ${item.open}으로 적어준다. disabled를 사용해서 상품 상세에서는 체크 박스가 선택되지 않도록 한다.
  • 체크 박스에서 판매 여부를 선택해서 저장하면, 조회시에 checked 속성이 추가된 것을 확인할 수 있다. 타임리프의 th:field를 사용하면, 값이 true인 경우 체크를 자동으로 처리해준다.

체크 박스 - 멀티

@ModelAttribute("regions")
public Map<String, String> regions() {
	Map<String, String> regions = new LinkedHashMap<>();
    regions.put("SEOUL", "서울");
    regions.put("BUSAN", "부산");
    regions.put("JEJU", "제주");
    return regions;
}
<!-- addForm.html 추가 -->
<!-- multi checkbox -->
<div>
    <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>
  • @ModelAttribute는 이렇게 컨트롤러에 있는 별도의 메서드에 적용할 수 있다. 이렇게 하면 해당 컨트롤러를 요청할 때 regions에서 반환한 값이 자동으로 모델(model)에 담기게 된다.
  • th:for="${#ids.prev('regions')}"
    • 반복해서 HTML 태그를 생성할 때, 생성된 HTML 태그 속성에서 name은 같아도 되지만, id는 모두 달라야 한다. 타임리프는 체크박스를 each루프 안에서 반복해서 만들 때 임으로 1, 2, 3 숫자를 뒤에 붙여준다.
    • 타임리프는 ids.prev(...), ids.next(...)를 제공해서 동적으로 생성되는 id값을 사용할 수 있도록 한다.
  • 서울, 부산 선택
    • regions=SEOUL&_regions=on&regions=BUSAN&_regions=on&_regions=on
    • 로그: item.regions=[SEOUL, BUSAN]
  • 지역 선택X
    • _regions=on&_regions=on&_regions=on
    • 로그: item.regions=[]
  • item.html에는 th:object를 사용하지 않았기 때문에 th:field부분에 ${item.regions}로 적어준다. disabled를 사용해서 상품 상세에서는 체크 박스가 선택되지 않도록 한다.
  • 멀티 체크 박스에서 등록지역을 선택해서 저장하면, 조회시에 checked속성이 추가된 것을 확인할 수 있다. 타임리프는 th:field에 지정한 값과 th:value의 값을 비교해서 체크를 자동으로 처리해준다.

라디오 버튼

@ModelAttribute("itemTypes")
public ItemType[] itemTypes() {
	return ItemType.values();
}
<!-- addForm.html 추가 -->
<!-- radio button -->
<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>
  • ItemType.values()를 사용하면 해당 ENUM의 모든 정보를 배열로 반환한다. 예) [BOOK, FOOD, ETC]
  • 실행 로그
    • item.itemType=FOOD: 값이 있을 때
    • item.itemType=null: 값이 없을 때
  • 라디오 버튼은 이미 선택이 되어 있다면, 수정시에도 항상 하나를 선택하도록 되어 있으므로 체크박스와 달리 히든 필드를 사용할 필요가 없다.
  • item.html에는 th:object를 사용하지 않았기 때문에 th:field부분에 ${item.itemType}으로 적어준다. disabled를 사용해서 상품 상세에서는 라디오 버튼이 선택되지 않도록 한다.
  • 선택 후 조회시 checked="checked" 적용.
  • <div th:each="type : ${T(hello.itemservice.domain.item.ItemType).values()}">
    • ${T(hello.itemservice.domain.item.ItemType).values()} 스프링EL 문법으로 ENUM을 직접 사용할 수 있다.
    • 이렇게 사용하면 ENUM의 패키지 위치가 변경되거나 할때 자바 컴파일러가 타임리프까지 컴파일 오류를 잡을 수 없으므로 추천하지 않는다.

셀렉트 박스

@ModelAttribute("deliveryCodes")
public List<DeliveryCode> deliveryCodes() {
	List<DeliveryCode> deliveryCodes = new ArrayList<>();
    deliveryCodes.add(new DeliveryCode("FAST", "빠른 배송"));
    deliveryCodes.add(new DeliveryCode("NORMAL", "일반 배송"));
    deliveryCodes.add(new DeliveryCode("SLOW", "느린 배송"));
    return deliveryCodes;
}
<!-- addForm.html 추가 -->
<!-- SELECT -->
<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>
  • @ModelAttribute가 있는 deliveryCodes()메서드는 컨트롤러가 호출 될 때 마다 사용되므로 deliveryCodes객체도 계속 생성된다. 미리 생성해두고 재사용하는 것이 더 효율적.
  • item.html에는 th:object를 사용하지 않았기 때문에 th:field부분에 ${item.deliveryCode}로 적어준다. disabled를 사용해서 상품 상세에서는 셀렉트 박스가 선택되지 않도록 한다.
  • 선택 후 조회시 selected="selected" 적용.

3. 메시지, 국제화

1. 스프링 메시지 소스 설정

직접 등록

// MessageSource는 인터페이스.
// 구현체인 ResourceBundleMessageSource를 스프링 빈으로 등록.
@Bean
public MessageSource messageSource() {
	ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
 	messageSource.setBasenames("messages", "errors");
 	messageSource.setDefaultEncoding("utf-8");
	return messageSource;
}
  • basename: 설정 파일의 이름을 지정.
    • messages로 지정하면 messages.properties파일을 읽어서 사용.
    • 추가로 국제화 기능을 적용하려면 messages_en.proerties, messages_ko.properties와 같이 지정. 만약 찾을 수 있는 국제화 파일이 없으면 messages.properties를 기본으로 사용.
    • 파일의 위치는 /resources/messages.properties
    • 여러 파일을 한번에 지정할 수 있다.
  • defaultEncoding: 인코딩 정보를 지정.

스프링 부트

  • 스프링 부트를 사용하면 스프링 부트가 MessageSource를 자동으로 빈으로 등록.
  • 스프링 부트 사용시 application.properties에 다음과 같이 메시지 소스를 설정 할 수 있다.
    • spring.messages.basename=messages,config.i18n.messages
    • 기본 값: spring.messages.basename=messages
    • MessageSource를 스프링 빈으로 등록하지 않고, 스프링 부트와 관련된 별도의 설정을 하지 않으면 messages라는 이름으로 기본 등록된다. 따라서 messages_en.properties, messages_ko.properties, messages.properties 파일만 등록하면 자동으로 인식.

2. 스프링 메시지 소스 사용

메시지 파일 만들기

  • messages.properties
hello=안녕
hello.name=안녕 {0}
  • messages_en.properties
hello=hello
hello.name=hello {0}

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) throwsNoSuchMessageException;

테스트 코드

@SpringBootTest
public class MessageSourceTest {
	
    @Autowired
 	MessageSource ms;
 
 	@Test
 	void helloMessage() {
 		String result = ms.getMessage("hello", null, null);
 		assertThat(result).isEqualTo("안녕");
	}
}
  • 메시지 코드로 hello를 입력하고 나머지는 null 입력.
  • locale정보가 없으면 Locale.getDefault()를 호출해서 시스템의 기본 로케일을 사용한다.
    • 예) locale = null인 경우 -> 시스템 기본 localeko_KR이므로 messages_ko.properties 조회 시도 -> 조회 실패 -> messages.properties 조회
@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)를 사용하면 기본 메시지가 반환.
@Test
void argumentMessage() {
	String message = ms.getMessage("hello.name", new Object[]{"Spring"}, null);
    assertThat(message).isEqualTo("안녕 Spring");
}
  • hello.name=안녕 {0} -> Spring 단어를 매개변수로 전달 -> 안녕 Spring
@Test
void defaultLang() {
	assertThat(ms.getMessage("hello", null, null)).isEqualTo("안녕");
    assertThat(ms.getMessage("hello", null, Locale.KOREA)).isEqualTo("안녕");
}
  • Locale이 en_US의 경우 messages_en_US -> messages_en -> messages 순서로 찾는다.
  • Locale에 맞추어 구체적인 것이 있으면 구체적인 것을 찾고, 없으면 디폴트를 찾는다.
  • ms.getMessage("hello", null, Locale.KOREA): locale 정보가 있지만, messages_ko가 없으므로 messages 사용
@Test
void enLang() {
	assertThat(ms.getMessage("hello", null, Locale.ENGLISH)).isEqualTo("hello");
}
  • messages_en 찾아서 사용

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

messages.properties - 메시지 추가 등록

label.item=상품
label.item.id=상품 ID
label.item.itemName=상품명
label.item.price=가격
label.item.quantity=수량

page.items=상품 목록
page.item=상품 상세
page.addItem=상품 등록
page.updateItem=상품 수정

button.save=저장
button.cancel=취소

타임리프 메시지 적용

  • 타임리프의 메시지 표현식 ${...}을 사용하면 스프링의 메시지를 편리하게 조회할 수 있다.
    예) <div th:text="#{label.item}"></h2> -> <div>상품</h2>
  • 파라미터는 다음과 같이 사용할 수 있다.
    hello.name=안녕 {0}
    <p th:text="#{hello.name(${item.itemName})}"></p>

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

messages_en.properties - 메시지 추가 등록

label.item=Item
label.item.id=Item ID
label.item.itemName=Item Name
label.item.price=price
label.item.quantity=quantity

page.items=Item List
page.item=Item Detail
page.addItem=Item Add
page.updateItem=Item Update

button.save=Save
button.cancel=Cancel

웹으로 확인하기

  • 웹 브라우저의 언어 설정 값 변경 : 크롬 브라우저 -> 설정 -> 언어를 검색하고, 우선 순위를 변경
  • 웹 브라우저의 언어 설정 값을 변경하면 요청 시 Accept-Language의 값이 변경.

스프링의 국제화 메시지 선택

  • 앞서 MessageSource테스트에서 보았듯이 메시지 기능은 Locale정보를 알아야 언어를 선택할 수 있다.
  • 스프링은 언어 선택시 기본으로 Accept-Language 헤더의 값을 사용한다.

LocaleResolver

public interface LocaleResolver {

	Locale resolveLocale(HttpServletRequest request);
	void setLocale(HttpServletRequest request, 
    		@Nullable HttpServletResponse response, @Nullable Locale locale);
            
}
  • 스프링은 Locale 선택 방식을 변경할 수 있도록 LocaleResolver라는 인터페이스를 제공하는데, 스프링 부트는 기본으로 Accept-Language를 활용하는 AcceptHeaderLocaleResolver를 사용.
  • 만약 Locale 선택 방식을 변경하려면 LocaleResolver의 구현체를 변경해서 쿠키나 세션 기반의 Locale 선택 기능을 사용할 수 있다. 예를 들어서 고객이 직접 Locale을 선택하도록 하는 것이다. 관련해서 LocaleResolver를 검색 해보자.

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

profile
You never fail until you stop trying.

0개의 댓글