개발자로 후회없는 삶 살기
spring PART.타임리프 기본 기능, 스프링 통합 본문
서론
※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.
https://www.inflearn.com/roadmaps/373
본론
- 텍스트 text, utext
th:text해서 넣어주면 태그 사이에 content에 값이 들어갑니다.
@GetMapping("/text-basic")
public String textBasic(Model model) {
model.addAttribute("data", " <b>Hello Spring!</b>");
return "basic/text-unescaped";
}
모델에 data를 키로 뷰에 전달합니다. 타임리프는 무조건 컨트롤러가 호출할 것이니 모델에 값을 넣고 타임리프로 뷰에 동적으로 렌더링합니다.
<li>th:text 사용<span th:text="${data}"></span></li>
<li>태그 없이 직접 사용 = [[${data}]]</li>
템플릿을 만들고 컨트롤러 뷰 호출을 받습니다. 타임리프 xmlns 선언을 꼭 해줘야합니다. span 태그에 th:text="${data}"하면 span 태그 안에 값이 들어갑니다.
이건 무조건 태그가 있어야하는데 태그 없이 그냥 바로 출력하는 방법은 [[${data}]]를 하면 됩니다. 실행결과는 다음과 같습니다.
-> Escape
처음 웹을 다루는 사람은 진짜 이것을 조심해야합니다. 특수문자 escape입니다. 의도가 뭐냐면 <b> 태그를 넣어서 글자가 진하게 나오길 기대했습니다.
하지만 실행해보면 안됩니다. 이런 특수문자를 HTML 문법으로 보는 것을 escape라고 합니다.
<ul>
<li>th:text = <span th:text="${data}"></span></li>
<li>th:utext = <span th:utext="${data}"></span></li>
</ul>
<h1><span th:inline="none">[[...]] vs [(...)]</span></h1>
<ul>
<li><span th:inline="none">[[...]] = </span>[[${data}]]</li>
<li><span th:inline="none">[(...)] = </span>[(${data})]</li>
</ul>
> 하지만 우리가 원하는 것은 언 이스케이프입니다. 타임리프는 언이스케이프로 utext와 [( )]를 지원합니다.
실행해보면 언이스케이프 된게 보입니다.
- 변수
타임리프에서 변수를 사용할 때는 ${}를 씁니다. 그리고 변수 표현식에는 스프링 EL이라는 스프링이 제공하는 변수 표현식을 사용할 수 있습니다.
@GetMapping("/variable")
public String variable(Model model) {
User userA = new User("userA", 10);
User userB = new User("userB", 20);
List<User> list = new ArrayList<>();
list.add(userA);
list.add(userB);
Map<String, User> map = new HashMap<>();
map.put("userA", userA);
map.put("userB", userB);
model.addAttribute("user", userA);
model.addAttribute("users", list);
model.addAttribute("userMap", map);
return "basic/variable";
}
내부 클래스로 user 클래스를 만들고 생성하고 User를 담은 List, Map도 만듭니다. 모델에 유저 하나, List, Map를 담고 이를 타임리프에서 활용해보겠습니다.
<ul>Object
<li>${user.username} = <span th:text="${user.username}"></span></li>
<li>${user['username']} = <span th:text="${user['username']}"></span></li>
<li>${user.getUsername()} = <span th:text="${user.getUsername()}"></span></li>
</ul>
<ul>List
<li>${users[0].username} = <span th:text="${users[0].username}"></span></li>
<li>${users[0]['username']} = <span th:text="${users[0]['username']}"></span></li>
<li>${users[0].getUsername()} = <span th:text="${users[0].getUsername()}"></span></li>
</ul>
<ul>Map
<li>${userMap['userA'].username} = <span th:text="${userMap['userA'].username}"></span></li>
<li>${userMap['userA']['username']} = <span th:text="${userMap['userA']['username']}"></span></li>
<li>${userMap['userA'].getUsername()} = <span th:text="${userMap['userA'].getUsername()}"></span></li>
</ul>
스프링에서 객체에 접근하는 EL 표현법이 있는게 타임리프에서 그것을 그대로 가져다 씁니다. .username, ['username'], .get 등 다양한 방법이 있습니다. 객체 하나를 다룰 때는 .username을 가장 많이 쓰고 리스트와 맵은 요소 하나에 접근하고 .username을 쓰면 됩니다.
실행 결과는 다음과 같습니다.
-> 지역변수 선언
<div th:with="first=${users[0]}">
<p>처음 사람의 이름은 <span th:text="${first.username}"></span></p>
</div>
타임 리프 안에서 지역변수 선언이 가능합니다. first=${users[0]}하면 users 0번 요소가 first가 되어서 first.username이 됩니다. 지역변수라서 그 scope에서만 사용가능합니다.
- 기본 객체들
// 컨트롤러
session.setAttribute("sessionData", "Hello Session");
//타임리프
<ul>
<li>request = <span th:text="${#request}"></span></li>
<li>response = <span th:text="${#response}"></span></li>
<li>session = <span th:text="${#session}"></span></li>
<li>servletContext = <span th:text="${#servletContext}"></span></li>
<li>locale = <span th:text="${#locale}"></span></li>
</ul>
타임리프는 기본 객체를 제공합니다. session객체, request 객체 등에 쉽게 접근할 수 있습니다.
편의 객체도 있는데 request param를 편리하게 타임리프가 바로 쓸 수 있게 해주는 등 가능합니다. 컨트롤러 호출에 paramData라는 쿼리 파라미터를 넣어서 뷰를 호출했습니다.
<ul>
<li>Request Parameter = <span th:text="${param.paramData}"></span></li>
<li>session = <span th:text="${session.sessionData}"></span></li>
<li>spring bean = <span th:text="${@helloBean.hello('Spring!')}"></span></li>
</ul>
원래라면 컨트롤러에서 모델에 담아서 렌더링해야하는데 타임리프가 그냥 직접 불러 쓸 수 있게 편의 객체를 제공합니다. 진짜 엄청난 편리함입니다. 요청을 컨트롤러로 하고 뷰를 return한 것 뿐인데 파라미터가 같이 가는 것 같습니다, 그러니 타임리프가 받아서 쓰겠습니다. > 파라미터는 'param.'으로 접근하고 session은 session.setAttribute("sessionData", "Hello Session"); 이라고 담아놨는데 'session.키'로 접근할 수 있습니다.
@Component("helloBean")
static class HelloBean {
public String hello(String data) {
return "Hello " + data;
}
}
그리고 스프링 빈도 직접 접근이 가능합니다. 만든 스프링 빈을 컴포넌트 스캔으로 빈을 등록해놨는데 스프링 빈 이름인 '@helloBean.'으로 접근이 가능합니다.
- 유틸리티 객체와 날짜
//컨트롤러
model.addAttribute("localDateTime", LocalDateTime.now());
//html
<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>
타임리프는 다양한 유틸리티 객체를 제공합니다. 날짜 유틸리티를 사용하는 법을 보자 model에 localDateTime을 담아서 보냈습니다. .format으로 포맷팅할 수 있고 원하는 값도 다 뽑을 수 있습니다.
- URL 링크
<ul>
<li><a th:href="@{/hello}">basic url</a></li>
<li><a th:href="@{/hello(param1=${param1}, param2=${param2})}">hello query param</a></li>
<li><a th:href="@{/hello/{param1}/{param2}(param1=${param1}, param2=${param2})}">path variable</a></li>
<li><a th:href="@{/hello/{param1}(param1=${param1}, param2=${param2})}">path variable + query parameter</a></li>
</ul>
1) hello로 가는 가장 기본입니다. 그러면 content에 url이 담깁니다.
2) 쿼리 파라미터를 넣고 싶은 경우입니다. 이 href를 눌렀을 때 컨트롤러를 쿼리 파라미터를 주면서 호출할 수 있습니다.
3) path variable도 있습니다. '/' 안하고 hello에 바로 '()'를 붙이면 쿼리 파라미터이고 '/'하고 붙이면 pathvariable입니다.
4) path variable과 쿼리 파라미터를 둘 다 주는 것입니다. ()에 값이 두개인데 앞에/에 {}가 하나면 남는애는 자동으로 쿼리 파라미터로 붙게 됩니다.
- 리터럴
int a = 10이 리터럴인데 타임리프는 다음과 같은 리터럴이 있습니다.
타임리프에서 항상 문자 리터럴은 " "가 있어도' '로 또 감싸야합니다.
근데 이 문자를 항상 감싸는 게 너무 귀찮습니다. 그래서 공백 없이 쭉 이어지는 건 리터럴로 인정해줍니다. "hello"는 다 이어져있으니 리터럴로 봐줍니다. 하지만 hello world는 무조건 ' '로 감싸야합니다.
리터럴과 변수를 더하는 것도 되고 리터럴 대체 문법이라는게 있는데 원래대로라면 '+'로 더하는 것을 | |로 대체해줍니다. 변수와 문자를 더할 때 리터럴 대체 문법을 씁니다.
버튼 onclick에도 씁니다.
- 연산
@GetMapping("/operation")
public String operation(Model model) {
model.addAttribute("nullData", null);
model.addAttribute("data", "Spring!");
return "basic/operation";
}
null 데이터는 어떻게 하나 보기 위해 null도 하나 모델에 넣었습니다.
자바 연산과 다른지 않습니다.
<ul>
<li>${data}?: '데이터가 없습니다.' = <span th:text="${data}?: '데이터가 없습니다.'"></span></li>
<li>${nullData}?: '데이터가 없습니다.' = <span th:text="${nullData}?: '데이터가 없습니다.'"></span></li>
</ul>
> 엘비스 연산자라는 데이터가 없는 경우 쓰는 연산자가 있는데 데이터가 있으면 ?앞을 쓰고 없으면 뒤가 나옵니다. 뒤에 '데이터가 없다'가 문자열 리터럴인데 띄어쓰기가 되어있어서 ' '를 붙입니다.
- 속성 값 설정
타임리프는 html 태그에 th: 에 속성을 지정합니다. html에 name이 있는데 th:name이 있으면 렌더링할 때 기존 속성을 th:name으로 대체합니다. 타임리프는 네츄럴 템플릿으로 기존 html을 바꾸지 않고 살짝 추가해서 바꿔치기 합니다.
> 실행해보면 소스보기를 하면 name이 mook에서 userA로 바뀌었습니다.
<h1>속성 추가</h1>
- th:attrappend = <input type="text" class="text" th:attrappend="class='large'" /><br/>
- th:attrprepend = <input type="text" class="text" th:attrprepend="class='large'" /><br/>
- th:classappend = <input type="text" class="text" th:classappend="large" /><br/>
> 속성을 치환말고 추가하는 방법도 있다. th:attrappend하면 클래스 속성 뒤에 클래스의 속성값인 text에 large가 붙는다.
id=large라고 하면 id="alarge"가 됩니다. class외에 어떤 속성에도 append 할 수 있습니다.
classappend하면 자동으로 띄어쓰기 하고 뒤에 붙어서 text large가 됩니다. 다른 건 몰라도 classappend는 종종 사용합니다.
<h1>checked 처리</h1>
- checked o <input type="checkbox" name="active" th:checked="true" /><br/>
- checked x <input type="checkbox" name="active" th:checked="false" /><br/>
- checked=false <input type="checkbox" name="active" checked="false" /><br/>
> html에서는 checked 값이 있으면 t든 f든 체크처리가 되어버립니다. 타임리프의 체크드 처리는 false이면 체크를 해제해줍니다. 이게 없으면 개발자가 지저분하게 코드를 넣어줘야하는데 타임리프가 편리하게 처리해줍니다.
- 반복문
여기서 알아야하는 중요한 내용이 있는데 ${}에 들어가는 값은 대부분 컨트롤러에서 모델에 담아서 보내는 것입니다. ${}에는 모델의 키를 쓴다고 생각해야 합니다.
@GetMapping("/each")
public String each(Model model) {
addUsers(model);
return "basic/each";
}
private void addUsers(Model model) {
List<User> list = new ArrayList<>();
list.add(new User("userA", 10));
list.add(new User("userB", 20));
list.add(new User("userC", 30));
model.addAttribute("users", list);
}
타임리프의 반복은 List 요소에 접근할 때 씁니다. 몇 번째 반복인지 상태 값도 주게 할 수 있습니다. 모델에 User를 저장한 List를 담습니다. > 모델에 list를 users를 키로 담았습니다. users에서 값을 하나 꺼내서 user에 담고 변수로 접근하게 됩니다.
<tr th:each="user : ${users}">
<td th:text="${user.username}">username</td>
<td th:text="${user.age}">0</td>
</tr>
users에서 값을 하나 꺼내서 user에 담고 변수로 접근하게 됩니다.
이거 3개가 통째로 렌더링이 됩니다.
-> 반복 상태 유지
반복에서 재밌는 기능을 제공합니다. 현재 반복이 어떻게 되고 있는지 상태를 알려줍니다. th:each="user : ${users}"에서 , userStat을 넣어두면 현재 루프의 상태를 알려줍니다. ex) 현재 짝수인지 홀수인지를 볼 수 있어서 짝수이면 색을 칠한다던가 할 수 있습니다.
> 실행 결과를 보면 count는 1부터 시작하는데 1부터 시작해서 1, 2, 3으로 나오고 etc에 쭉 상태 코드 값이 나옵니다. 보통 테이블 넣을 때 첫번째 일때 어떤 게 쓰기고 마지막일 때 어떤게 쓰이고 이런게 있는데 그럴 때 편하게 쓸 수 있습니다. userStat를 생략해도 user+Stat을 명시적으로 만들어줍니다.
- 조건부 평가
if, unless(if의 반대)를 제공합니다. 만약 user.age가 더 작으면 미성년자라고 th:text의 값이 들어갑니다. > 재밌는 게 이 조건을 충족해야 태그가 나오고 만족하지 않으면 해당 태그 자체가 싹 날라갑니다. for문을 돌면서 if문을 체크하는데 10살은 다 나오는데 20살은 큰 것 하나만 나오고 작거나 큰 것은 태그 자체가 날라가 버립니다.
<tr th:each="user : ${users}">
<td>
<span th:text="미성년자" th:if="${userStat.odd}"></span>
</td>
</tr>
홀수인 경우에만 T가 되게 하면
1, 3인 경우만 찍히고 2인 경우 span 태그가 사라집니다.
switch - case도 있습니다. userA는 10살, userB는 20살로 모델에 넣은 list대로 출력이 됩니다.
- 주석
1. html 주석
<!-- -->은 html이 사용하는 주석입니다. 타임리프는 html 주석을 렌더링을 하지 안고 그대로 남겨 둡니다. 그래서 남습니다. 하지만 브라우저에서도 주석이 될 것이라서 결국 다 사라질 것입니다.
2. 타임리프 파서 주석
타임리프가 인정하는 주석으로 아예 없어집니다.
3. 타임리프 프로토타입 주석
브라우저에서는 html에는 지워지는데 렌터링하면 주석처리 안하고 렌더링합니다.
- 블록
타임리프 자체 태그입니다. 타임리프은 보통 속성으로 동작하는데 특별한 경우를 위해 유일한 자체 태그를 제공합니다. div를 2개씩 돌아가면서 출력하고 싶을 때 사용합니다. div 하나씩 돌릴 때는 th:each로 반복 돌리면 됩니다. 근데 div를 두개씩 돌리려면 block 태그를 씁니다. block 태그는 영역을 잡는 태그로 if 등 다른 태그들과도 쓸 수 있습니다.
- 템플릿 조각
웹 페이지를 개발할 때는 공통영역이 많습니다. 헤더, 바디, 왼쪽 등등이 영역 자체를 재활용할 때가 되게 많습니다. 타임리프도 페이지를 조각조각내서 불러다가 재활용할 수 있는 기능을 제공합니다. 예를 들어서 페이지가 10개인데 항상 하단의 copyright가 있으면 불러다가 쓰는 것입니다.
@Controller
@RequestMapping("/template")
public class TemplateController {
@GetMapping("/fragment")
public String template() {
return "template/fragment/fragmentMain";
}
}
새로운 컨트롤러를 만듭니다.
뷰도 템플릿을 따로 만들고 fragment라는 조각을 불러다 쓰는 폴더도 만듭니다.
<!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>
뷰 폴더 구조도 동일하게 만듭니다. 그리고 하단 영역 먼저 만들어보자 footer.html을 만듭니다. 여러 페이지에서 이 푸터를 가져다 쓸 것입니다. th:fragment="이름" 이라고 해서 마치 메서드의 이름을 주듯이 이름을 줄 수 있습니다. 다른 데서 이 이름으로 태그를 긁어 씁니다.
Main 뷰에서 footer를 불러다 쓰는 것을 보겠습니다. th:insert라고 하면 footer의 경로쓰고 :: 조각 이름을 넣으면 조각을 불러옵니다.
실행해보면 메인 html에는 푸터에 관련된 코드가 전혀없습니다. 근데 부분 포함 insert 다음에 div를 넣었는데 이 div 안에 가져온 copy 이름의 footer가 insert 됩니다. replace는 div를 copy 조각으로 대체해버립니다. div가 사라집니다. 이렇게 해서 원하는 조각들을 불러다가 만들 수가 있습니다. fragment를 만들고 사용하는 측에서 fragment 코드를 불러다 포함시킵니다.
<footer th:fragment="copyParam (param1, param2)">
<p>파라미터 자리 입니다.</p>
<p th:text="${param1}"></p>
<p th:text="${param2}"></p>
</footer>
> 조각에 파라미터를 사용할 수 있습니다. 푸터를 작성할 때()에 파라미터를 넣었습니다. copyParam의 조각을 만들고 (param1, 2)라고 하고 푸터 내부에서 $에 파라미터를 넣어서 조각을 만들어 두면
<div th:replace="~{template/fragment/footer :: copyParam ('데이터1', '데이터2')}"></div>
가져다 쓰는 곳에서 :: copyParam('데이터1', '데이터2') 이렇게 해서 파라미터를 넣어서 조각에 있는 $를 그대로 가져와서 값을 넣어 사용할 수 있습니다.
- 템플릿 레이아웃1
이전에는 코드 조각을 가져와서 썼는데 이번에는 확장해서 어떤 레이아웃이 있고 레이아웃에 조각을 넘겨서 전체가 완성이 되게 해보겠습니다.
ex) 예를 들어서 head 태그를 전체 코드에서 공통으로 쓰고 싶습니다. head에 css, js가 정의가 되어있어서 공통으로 쓰고 싶은 것입니다. 그래서 이 html이 큰 모양이 정해져있고 내 코드를 그 모양에 맞춰서 넣고 싶습니다.
-> 코드로 보자
새로운 폴더 구조를 만듭니다. base.html이 우리 사이트에서 공통으로 가져다 쓸 head입니다. 메인 html을 만들고 여기서 저 base를 가져다 쓸 것입니다.
메인에서 replace로 base에 있는 common_header 코드를 가져다 쓸 것입니다. 근데 그냥 가져오지 않습니다. 공통 레이아웃이 있어도 각 사이트마다 조금씩 다르게 하고 싶은 게 있습니다. ex) 여기서는 타이틀을 다르게 하고 싶고, 추가로 링크 관련된 css 정보를 다르게 넣고 싶은 것입니다. 레이아웃은 정해져있는데 거기에 좀 다르게 짚어 넣고 싶습니다.
이 문법을 쓰면 title 태그를 여기에 넣어버립니다. 태그 자체를 넣어버립니다. common_header에 title태그가 넘어가서 base 조각이 main에 주입될 텐데 그때 base의 ${title}에 main의 넣은 <title>이 들어갑니다.
-> 실행
<--공통-->, <--추가-->는 base의 태그였는데 main의 title인 메인 타이틀과 link가 있습니다. 다시 말하지만 main에서 base를 가져다 쓸 것이기 때문에 main에서 base에 파라미터로 넘기고 넘긴 값이 적용되고 base를 가져다 쓴 것입니다. base가 헤더라는 큰 틀이고 모든 페이지에서 base를 불러다가 쓸 것입니다. 근데 그 안에 부분을 살짝 바꾸고 싶을 때 쓰는 것입니다.
- 템플릿 레이아웃2
html 전체를 레이아웃으로 만들고(base가 레이아웃입니다.) 그 안에 살짝 바꾸는 것을 해보자 그러면 html 태그가 fragment인 것입니다.
레이아웃 파일을 만듭니다. layoutFile.html입니다. 이게 껍데기입니다. 우리 사이트가 100 페이지가 되면 항상 모양은 똑같고 푸터에는 '레이아웃 푸터'라고 나와야하고 h1은 '레아아웃 H1'이라고 나와야하고 div 부분만 바뀌어야합니다. 그리고 타이틀도 페이지마다 달라야합니다. 헤더, 푸터, 사이드는 다 똑같고 컨텐츠만 다른 전형적인 레이아웃입니다.
-> 메인 페이지
우리 사이트에 오면 메인이 먼저 나옵니다. replace를 하는데 레이아웃 file로 html 전체를 교체해버립니다. 다르게 표현되어질 것들은 레이아웃에 매개로 전달합니다. 그렇게 해서 모든 페이지에 레이아웃을 맞추고 일부만 바꿉니다.
-> 실행
전체 틀은 레이아웃 틀인데 타이틀과 section이 레이아웃의 div 부분과 바뀌어있습니다. 페이지가 적으면 조각만 쓰고 페이지가 많으면 레이아웃을 씁니다.
- 스프링 통합과 폼
타임리프가 스프링과 통합되어 제공하는 기능이 많습니다. 특히 그 중에서 입력 폼에 대해 자세히 알아보겠습니다. 회원가입과 주문할 때 입력을 하는 데 이런게 스프링과 통합되어서 편리하게 지원을 많이 합니다. 쇼핑몰 프로젝트를 다듬어서 조금 기능을 얹을 것입니다. 이전 프로젝트에 form 기능을 붙일 것입니다.
- 타임리프와 스프링 통합
타임리프는 순수 메뉴얼도 있는데 그건 앞에서 배운 것이고 스프링과 함께 사용할 때 필요한 메뉴얼도 있습니다. 타임리프는 스프링 없이도 할 수 있는데 스프링 관련된 기능을 통합할 수도 있습니다.
-> 제공하는 기능들
스프링 EL 문법, 편리한 폼 관리를 위한 추가 속성, 국제화 등 많습니다.
- 입력 폼 처리
기존 상품 관리 프로젝트를 타임리프가 지원하는 기능을 사용해서 효율적으로 개선해보겠습니다.
- 등록 폼
등록할 때 addForm에서 등록 버튼을 눌러서 등록했었습니다. 지금은 거의 타임리프을 사용하지 않았습니다.
@GetMapping("/add")
public String addForm() {
return "form/addForm";
}
// 후
@GetMapping("/add")
public String addForm(Model model) {
model.addAttribute("item", new Item());
return "form/addForm";
}
먼저 addForm 메서드에서 모델로 빈 것이라도 item을 하나 넘겨야합니다.
<form action="item.html" th:action th:object="${item}" method="post">
<div>
<label for="itemName">상품명</label>
<input type="text" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
</div>
<div>
<label for="price">가격</label>
<input type="text"th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
</div>
<div>
<label for="quantity">수량</label>
<input type="text"th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
</div>
그리고 html에서 th:object로 방금 받은 빈 item을 넣어줍니다. 빈 아이템 객체가 넘어옵니다. 이 item 객체를 가지고 코드들이 돌아갈 것입니다. > id와 name이 중복으로 보이는데 th:field를 사용하면 id, name, value을 자동으로 만들어줍니다. *이 있으면 item.itemName으로 item에 소속된 것으로 봅니다. 자동으로 *{}에 있는 것으로 item.itemName을 만들어줍니다.
실행해보면 id, name, value가 생성이 됩니다. value는 model로 넘어온 item 객체의 필드값이 자동으로 들어가는데 지금 빈 객체라서 비어있습니다. 이런 중복을 타임리프가 간단하게 해결해줍니다.
-> 정리
타임리프를 쓰면 form을 쓸 때 field만 알면 됩니다. obj는 form에 객체를 연결하기 위해 넣는 것으로 *{}를 obj에 연결하여 소속된 것으로 만들기 위해서 model로 넣어준 것입니다. field를 쓰면 소속시킬 객체의 필드와 input 태그의 name이 연결이 되고 value에도 값이 들어갑니다. value는 버튼 클릭 시 필드에 들어갈 입력값일 뿐이고 실제 객체의 필드와 연결은 name 속성과 객체 필드명만 같으면 됩니다.
> 또한 결과적으로 POST에서 등록할 때 modelattribute로 item을 받는데 이 item을 받을 수도 있습니다. 또한 html에 name 속성을 쓰면 오타가 발생해도 안 잡아주는데 타임리프 field를 쓰면 오타를 잡아줍니다. 이를 위해서 add 컨트롤러에서 빈 객체 하나 만드는 비용은 거의 들지 않습니다.
- 수정 폼
수정도 같은 방식으로 하면 됩니다. edit form에 GetMapping은 이미 item을 모델에 넘겨줬습니다. 수정 폼에 가보면 th:value로 값을 뿌리고 있었습니다.
<form action="item.html" th:action th:object="${item}" method="post">
<div>
<label for="id">상품 ID</label>
<input type="text" class="form-control" th:field="*{id}" readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" class="form-control" th:field="*{itemName}">
</div>
<div>
<label for="price">가격</label>
<input type="text" class="form-control" th:field="*{price}">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" class="form-control" th:field="*{quantity}">
</div>
이것을 Obj와 field로 한 번에 처리가 됩니다. th:value를 지우고 field를 넣는다. editForm으로 모델로 넘어오는 item은 값이 빈 객체가 아니고 값이 들어가 있으니 value=에 값이 담겨져 나옵니다.
실행해보면 id, name, value를 다 지웠는데 field가 다 넣어줍니다. 사실 이것의 진짜 위력은 검증에서 발휘됩니다.
- 쇼핑몰 요구사항 추가
기존 상품 서비스에 요구사항이 추가되었습니다. 상품을 등록할 때 이름, 가격, 양만 넣었는데(아이템의 필드가 이름, 가격, 양이었고 id는 save할 때 들어갔었습니다.) 판매여부, 등록 지역, 상품 종류 ,배송방식 등을 추가해야합니다. 이 값들은 단순히 str이나 int로 들어가면 안 되는 값들입니다. (ModelAttribute로 추가된 필드의 값들이 들어온다는 것입니다.) 단일, 다중, 라디오버튼, 셀렉트 박스 등을 사용해보겠습니다.
-> 클래스 추가
1. 상품 종류
package hello.itemservice.domain.item;
public enum ItemType {
BOOK("도서"), FOOD("식품"), ETC("기타");
private final String description;
ItemType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
상품 종류는 enum으로 만들 것입니다. BOOK("도서")라고 하면 enum에 필드가 있는 것인데 desc로 상품 종류에 대한 설명("도서")을 줄 것입니다. 생성자로 설명을 초기화합니다.
2. 배송 방식
@Data
@AllArgsConstructor
public class DeliveryCode {
private String code;
private String displayName;
}
배송과 관련된 것은 자바 코드로 만들 것입니다. Fast는 빠른 배송, normal은 일반 배송입니다. code 필드는 'FAST' 같은 시스템에게 전달하는 값이고 displayName은 '빠른 배송' 같은 고객에게 보여주는 값입니다.
3. 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;
item 객체에다가 이것들을 사용할 수 있게 해야합니다.
1) 판매여부는 T, F로 할 것입니다.
2) 등록지역은 문자로 여러개 받을 것이고
3) 상품 종류, 배송 방식은 위에서 만든 것으로 할 것입니다.
enum, 클래스, String 같은 다양한 상황을 준비했습니다. 실제 실무에서는 각각의 상황에 맞게 여러가지를 섞어서 쓰니 각각의 폼의 데이터를 어떻게 받을 수 있을지 알아볼 것입니다. input으로부터 '값을 하나만 받는지 여러개 받는지'를 고려해야하고 타입은 '무엇으로 오는지'도 고민해야 합니다. 모두 다 Item에 기본 타입으로 멤버로 넣을 수 있는데 클래스, enum으로 하는 것이 클래스 분리 OOP입니다.
- 체크박스 단일
<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>
판매여부를 체크하는 것을 만들 것입니다. addForm html에 체크박스를 넣습니다. 전송 방식은 똑같습니다. 지금 item에 4가지 필드를 추가했는데 이전에는 form에서 전송 버튼을 누르면 form의 name 속성이 컨트롤러로 넘어왔습니다.
> 넘어올 때 @ModelAttribute Item item으로 왔는데 그러면 name 속성과 일치하는 Item의 필드에 value값이 들어가는 것이었습니다. 이번에도 똑같습니다. name이 open이라서 Item의 Boolean open 필드에 값이 넘어올 것입니다.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {
log.info("open = {}", item.getOpen());
> log로 open 값이 잘 왔나 보겠습니다.
실행을 하고 체크하고 등록을 누르면 true라고 남습니다. 이것을 봤을 때 ckeckbox의 value값은 t, f라는 것을 알 수 있습니다.
> 개발자 모드로 보면 넘어오는 open 값이 on입니다. on이 넘어오는데 스프링의 타입 컨버터가 true 타입으로 바꿔줍니다.
> 근데 체크를 안하면 open 값이 안 넘어갑니다. null이라고 뜹니다. F도 아니라 null입니다.
-> 주의
html 체크박스는 선택이 안 되면 아예 값을 넘기지를 않습니다. 이게 등록을 할 때는 문제가 아닐 수 있는데 수정할 때 상황에 따라 문제가 될 수 도 있습니다.
사용자가 의도적으로 체크되어 있던 값을 체크를 풀고 넘겼는데 배송 여부가 변경되지 않을 수도 있습니다. 물론 서버에서 값이 null이면 코드로 처리할 수 있는데 코드가 지저분해집니다. 그래서 스프링에서 약간의 트릭을 사용합니다.
<div class="form-check">
<input type="checkbox" id="open" name="open" class="form-check-input">
<input type="hidden" name="_open" value="on">
<label for="open" class="form-check-label">판매 오픈</label>
</div>
> 히든 필드를 하나 만듭니다. _open이라고 하나 넣으면 type="hidden"으로 name을 _open으로 on을 보내면
원래 open은 역시나 안 넘어오는데 _open이 넘어온다. 근데 콘솔에는 null이 아니라 false라고 뜹니다. 이는 스프링에서 제공하는 것으로 '_같은 이름'을 주면 모든 체크박스 T, F를 편리하게 사용할 수 있습니다.
- 체크박스 단일 강화
근데 개발할 때 히든 필드를 넣는게 귀찮습니다. 그래서 이것을 타임리프가 자동화 해줍니다. addForm을 타임리프로 바꿀 것입니다. 채크박스에서 name과 히든 필드를 지우고 th:field *{open}이라고 하면 ${item.open}과 같은 효과입니다.
이렇게 하면 히든을 지웠는데 name, id, value는 field가 만들어주니 알겠는데 체크박스의 경우에는 hidden 속성도 자동으로 만들어줍니다. 타임리프를 쓰면 체크박스의 히든을 만드는 고민을 하지 않아도 되는 것입니다.
-> 상품 상세
체크 박스를 상품 상세에도 넣을 것입니다. 조심해야할 것이 상세에는 th obj를 안써서 *을 못하고 ${item.}으로 해야합니다. > 상품 상세에도 체크 박스를 만드는데 상세에서는 체크가 되면 안 될 것입니다. 그래서 input 태그 뒤에 disable을 넣어줍니다.
그런데 상품 상세를 보면 자동으로 체크가 되어있습니다.
체크 박스는 checked라는 속성이 있으면 체크가 됩니다. 타임리프가 checked도 넣어줍니다. 이게 상품을 등록할 때 체크를 하고 상품상세로 넘어가서 그렇습니다.
체크를 안하고 등록하면 체크가 안된 상태로 상품 상세로 갑니다. 이걸 원래는 개발자가 다 코드로 해야하는데 th:field가 checked 속성을 다 처리를 해준 것입니다.
-> 상품 수정
수정에도 체크 박스를 넣을 것입니다. 상품 상세는 체크박스가 눌리면 안 되는게 맞는데 상품 수정은 체크를 바꿀 수 있어야합니다. 근데 수정해도 상세로 리다이렉트하면 체크가 안 되어있습니다.
public void update(Long itemId, Item updateParam) {
Item findItem = findById(itemId);
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
findItem.setOpen(updateParam.getOpen());
}
업데이트 비즈니스 로직에 처리를 안 했기 때문이니 레포지토리 코드를 수정하면 잘 수정됩니다. 수정할 updateParam의 필드에 수정할 값들을 넣었으니 이전처럼 set하면 됩니다. 타임리프 없이 체크박스를 하려면 정말 지저분한 코드가 많이 들어가는데 타임리프가 자동화해줍니다.
- 체크박스 멀티
등록 지역은 체크박스를 제주, 서울, 부산 중 다중으로 체크할 수 있습니다.
-> 등록 폼
// 등록 폼으로 가는 컨트롤러
@GetMapping("/add")
public String addForm(Model model) {
model.addAttribute("item", new Item());
Map<String, String> regions = new LinkedHashMap<>();
regions.put("SEOUL", "서울");
regions.put("PUSAN", "부산");
regions.put("JEJU", "제주");
model.addAttribute("regions", regions);
return "form/addForm";
}
// 수정 폼으로 가는 컨트롤러
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
Map<String, String> regions = new LinkedHashMap<>();
regions.put("SEOUL", "서울");
regions.put("PUSAN", "부산");
regions.put("JEJU", "제주");
model.addAttribute("regions", regions);
return "form/editForm";
}
먼저 판매여부처럼 등록 폼에 체크박스가 나와줘야합니다. LinkedHashMap을 쓰는데 해쉬맵은 순서가 보장이 되지 않는데 이건 순서가 보장이 됩니다. put에 키(SEOUL)는 시스템에서 전달될 값이고 벨류는 사용자에게 보여줄 값입니다. Map에 체크박스에 보여질 값을 put하고 모델에 담아서 뷰에 줄 것입니다. (처음에 뷰에 만들어 놓으면 되지 않나 싶었지만 유지보수 차원에서 컨트롤러에 두는게 좋습니다.)
@ModelAttribute("regions")
public Map<String, String> regions() {
Map<String, String> regions = new LinkedHashMap<>();
regions.put("SEOUL", "서울");
regions.put("PUSAN", "부산");
regions.put("JEJU", "제주");
return regions;
}
이게 상세, 수정에도 나와야합니다. {item}, {item}/edit에도 Map 코드를 넣습니다. 근데 이것을 한 방에 해결하는 방법이 있습니다. 이런 중복때문에 스프링에서 굉장히 특별한 기능을 제공해줍니다. 컨트롤러 메서드 밖에 ModelAttribute를 쓰고 메서드를 하나 만드는데 Map 타입을 반환형으로 하고 함수 이름을 Map 변수 명을 함수명으로 해서 만들면 이 컨트롤러를 호출할 때 항상 @ModelAttribute("region")의 이름인 "region"으로 return 값이 Model.addAttribute해서 모델에 무조건 담깁니다.
컨트롤러를 호출할 때마다 model.addAttribute()가 되는 것입니다. > 이 컨트롤러의 어떤 맵핑 메서드가 호출이되든 자동으로 모델에 담깁니다. 그래서 중복되는 model.addattribute를 지울 수 있습니다.
※ 이렇게 한 이유
등록 폼, 상세 , 수정 폼에서 모두 서울, 부산, 제주라는 체크 박스를 반복해서 보여줘야하는데 그러려면 각각의 컨트롤러에서 modelattribute를 해야합니다. 그 반복을 없애려고 한 것입니다.
-> 등록 폼
<!-- 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>
모델에게 받은 서울, 부산, 제주를 html에 보여야합니다. th:each로 Map도 반복을 할 수 있다고 했습니다. Map에 3개의 값이 있으니 3번 돌면서 체크박스와 3개의 지역을 그립니다.
-> 코드 설명
*는 그냥 id, name 채우는 용으로 한 것인데 원래는 id랑 name이 똑같이 "regions"로 들어가야하는데 html이 중복 id는 안돼서 1, 2, 3을 타임리프가 자동으로 붙입니다. 지금 헷갈리는게 지금까지는 하나의 input 태그의 value 속성값이 item의 필드로 들어갔었습니다.
근데 input 태그가 3개라서 어색합니다. 근데 보니 3개의 name 속성 모두 "regions"라는 이름으로 되어있습니다. 그 이유는 field를 item의 regions로 했기 때문입니다. 이럴 경우 전송 버튼을 누르면 3개의 체크박스에서 체크 되어 있는 1~3개의 value들이 하나의 item 객체의 regions 필드에 들어갑니다.
-> 정리
타임리프는 체크박스를 each로 반복해서 만들때 field를 쓰면 임의로 1, 2, 3 숫자를 붙여줍니다. html id가 타임리프로 동적으로 만들어지기 때문에 label은 for의 ids.prev로 동적으로 생성되는 id값을 사용할 수 있게 타임리프가 지원해줍니다. label의 for 속성과 input 태그의 id 속성이 같아야 라벨을 클릭해도 체크가 되기 때문에 이것을 사용합니다.
★ 이렇게 해서 객체 필드에 단순 타입 말고 List를 담는 것을 확인해봤습니다. input 태그가 여러개 있을 때 같은 name이 여러개면 그 name과 같은 이름의 필드에 값이 여러개 들어오는 것이었고 들어오는 실제 값은 input 태그의 value였습니다.
-> 결과
결과를 보면 하나만 체크하면 item.regions에 []하나만 넘어가고
여러개 체크하면 각각의 값이 같은 name이니 item의 같은 이름의 필드에 들어갑니다. 상세 페이지와 수정 페이지도 똑같이 넣고 상세는 체크 안 되게 disabled를 하면 됩니다.
- 라디오 버튼
public enum ItemType {
BOOK("도서"), FOOD("식품"), ETC("기타");
private final String description;
ItemType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
상품 타입의 여러 선택지 중에 하나를 선택할 때 라디오 버튼을 사용합니다. 이번에는 enum을 사용해서 개발할 것입니다. 예전에는 단순한 문자를 map에 넣었었습니다. > 상품 종류는 도서, 식품, 기타이고 라디오 버튼은 하나만 선택할 수 있는 특성이 있습니다.
-> ModelAttribute 중복 제거
@ModelAttribute("itemTypes")
public ItemType[] itemTpyes() {
return ItemType.values();
}
얘도 ModelAttribute로 모든 컨트롤러마다 반복적으로 될 것이라서 반복을 빼줄 것입니다. 반환형은 enum 배열로 넘겨줄 것입니다. enum이 values라고 하면 enum 안에 있는 필드(BOOK("도서"), FOOD("식품"), ETC("기타"))를 배열로 넘겨줍니다. 이렇게 코드를 작성하면 itemTypes라는 이름으로 모델에 배열이 담길 것입니다. 이제 객체에 enum 타입의 필드에 값을 어떻게 담나 보겠습니다.
-> 등록 폼
<!-- 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>
html은 이전에 한 것과 비슷합니다. each의 $는 넘어온 enum 타입의 배열이고 *은 item.itemType으로 이렇게 하면 id, name이 itemType으로 생기고 id는 자동으로 1, 2, 3이 붙을 것입니다. value는 type.name()이라고 하면 enum의 이름을(BOOK("도서")면 BOOK이 enum 이름) 가져올 수 있고 for는 똑같습니다. text는 라벨에 들어갈 글로 type(itemType)의 description이 들어갈 것입니다.
※ 이전에 itemType에 게터을 써놨는데 이유는 ModelAttribute나 타임리프의 ${item.username}이나 프로퍼티 접근법으로 게터 세터가 필요하기 때문입니다.
> 돌려서 소스보기하면 radio 버튼으로 value가 ${type.name}이 BOOK으로 enum의 이름이 그대로 문자로 들어갑니다. 라벨의 text는 도서, 식품, 기타가 출력됩니다.
이렇게 enum도 객체의 필드로 두고 item 클래스에 배열이 아닌 ItemType itemType 이렇게 하나만 들어오게 하면 하나만 잘 들어옵니다. 라디오 버튼은 값이 하나만 선택되니 배열을 사용하지 않은 것입니다. enum도 여러개 받으려면 필드에 enum 타입 배열을 넣으면 됩니다.
> 체크를 안 하면 null이 들어옵니다. 근데 얘는 null이 들어와도 됩니다. 얘는 히든 필드를 만들지 않습니다. 그런 건 체크박스에서 만드는 것이고 라디오버튼은 한가지는 무조건 선택이 되고 선택되면 비울 수 없어서 히든이 필요 없습니다.
-> 상품 상세
똑같이 html을 복사해서 넣으면 됩니다.
-> 수정 폼
똑같이 html을 복사해서 넣으면 됩니다. 수정을 하려고 하면 이미 수정 폼에는 등록 때 등록된 Item의 라디오박스가 선택이 되어서 넘어올 것인데 이미 선택된 라디오 박스는 무조건 하나는 선택되게 되어서 히든 필드가 필요가 없습니다. 하지만 등록에서는 선택하지 않아서 null이 될 수는 있습니다.
- 셀렉트 박스
이번에도 시스템에 주는 코드와 사용자에게 주는 코드가 나뉘어져있습니다. 여러 선택지 중에서 하나를 선택할 수 있는데 자바 객체를 이용해서 사용해보겠습니다.
@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;
}
모델 어트리부트에 반환형은 List로 deliveryCodes 자바 객체 타입을 잡고 ArrayList에 deliveryCodes 를 담아서 넣습니다. deliveryCodes는 롬복 생성자로 두개의 필드를 생성자에서 초기화하게 했으니 code와 displayName을 넣습니다.
-> 등록 폼
<!-- SELECT -->
<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>
<hr class="my-4">
html은 똑같습니다. select 태그의 값은 역시나 value값으로 셀렉트 박스도 여러가지 중에 하나를 선택하는데 deliveryCode에 초기화된 값이 String 타입이라서 Item 객체의 필드도 select 태그의 name과 이름을 맞추고 타입은 deliveryCode에 있는 String 타입으로 했습니다.
> field는 item의 deliveryCode로 해서 id, name을 맞추고 name은 item 객체의 필드명과 같아집니다. option이 셀렉트 박스의 목록인데 each로 loop를 도는데 모델에 넣은 deliveryCodes로 item에 보내는 value값은 code로 FAST, 사용자에게 보이는 text는 "빠른 배송"으로 하게 합니다.
'[백엔드] > [spring | 학습기록]' 카테고리의 다른 글
spring PART.검증 (0) | 2023.04.20 |
---|---|
spring PART.메시지, 국제화 (0) | 2023.04.19 |
spring PART.스프링 기본 기능 2, 웹 쇼핑몰 개발 (0) | 2023.04.15 |
spring PART.스프링 MVC 기본 기능, API 설계 실전 (0) | 2023.04.13 |
spring PART.스프링 MVC 시작 (0) | 2023.04.13 |