
상품 도메인 모델
상품 목록 조회
서비스 제고 흐름

Item - 상품 객체
@Getter
@Setter
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
ItemRepository - 상품 저장소
@Repository
public class ItemRepository {
private static final Map<Long, Item> store = new HashMap<>();
private static long sequance = 0L;
//아이템 저장
public Item save(Item item) {
item.setId(++sequance);
store.put(item.getId(), item);
return item;
}
public Item findById(Long id) {
return store.get(id);
}
public List<Item> findAll() {
return new ArrayList<>(store.values());
}
public void update(Long itemId, Item updateParam) {
Item findItem = findById(itemId);
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
}
//clearStore
public void clearStore(){
store.clear();
}
}
ItemRepositoryTest - 상품 저장소 테스트
class ItemRepositoryTest {
private ItemRepository itemRepository = new ItemRepository();
@AfterEach
void AfterEach(){
itemRepository.clearStore();
}
@Test
@DisplayName("상품 저장")
void save(){
//given
Item item = new Item("itemA", 10000, 10);
//when
Item savedItem = itemRepository.save(item);
//then
Item findItem = itemRepository.findById(savedItem.getId());
assertThat(findItem).isEqualTo(savedItem);
}
@Test
@DisplayName("전체 상품 조회 테스트")
void findByAll(){
//given
Item item1 = new Item("item1", 10000, 10);
Item item2 = new Item("item2", 20000, 20);
itemRepository.save(item1);
itemRepository.save(item2);
//when
List<Item> result = itemRepository.findAll();
//then
assertThat(result.size()).isEqualTo(2);
assertThat(result).contains(item1,item2);
}
@Test
@DisplayName("상품 수정")
void updateItem(){
//given
Item item = new Item("item1", 10000, 10);
Item savedItem = itemRepository.save(item);
Long itemId = savedItem.getId();
//when
Item updateItem = new Item("item2", 20000, 30);
itemRepository.update(itemId,updateItem);
//then
Item findItem = itemRepository.findById(itemId);
assertThat(findItem.getItemName()).isEqualTo(updateItem.getItemName());
assertThat(findItem.getPrice()).isEqualTo(updateItem.getPrice());
assertThat(findItem.getQuantity()).isEqualTo(updateItem.getQuantity());
}
}
실행 결과

부트 스트랩
HTML을 편리하게 사용하기 위해 부트스트랩을 사용하자
부트스트랩(Bootstrap)은 웹사이트를 쉽게 만들 수 있게 도와주는 HTML, CSS, JS 프레임워크이다.
부트스트랩 CSS 파일을 적용한 후, 각각의 HTML을 적용해보자
/resources/static/css/bootstrap.min.css: 부트스트랩 CSS를 적용하였다.
상품 목록 HTML: /resources/static/html/items.html
상품 상세 HTML: /resources/static/html/item.html
상품 등록 폼 HTML: /resources/static/html/addForm.html
상품 수정 폼 HTML: /resources/static/html/editForm.html
/resources/static에 넣어두었기 떄문에, 스프링 부트가 정적 리소스를 제공한다.
본격적으로 컨트롤러와 뷰 템플릿으로 개발해보자
BasicItemController
@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/items")
public class BasicItemController {
private ItemRepository itemRepository;
@GetMapping
public String items(Model model){
List<Item> items = itemRepository.findAll();
model.addAttribute("items", items);
return "basic/items";
}
//데스트용 데이터 추가
@PostConstruct
public void init(){
itemRepository.save(new Item("itemA", 10000, 10));
itemRepository.save(new Item("itemB", 20000, 20));
}
}
final이 붙은 멤버 변수만 사용해서 생성자를 자동으로 만들어준다. 테스트용 데이터 추가
items.html
resources/templates 디렉토리 내부에 basic 디렉토리를 생성하고 복사해서 붙여넣는다.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"> <!-- 타임리프 선언 -->
<head>
<meta charset="utf-8">
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2>상품 목록</h2>
</div>
<div class="row">
<div class="col">
<button class="btn btn-primary float-end"
onclick="location.href='addForm.html'"
th:onclick="|location.href='@{/basic/items/add}'|"
type="button">상품 등록</button>
</div>
</div>
<hr class="my-4">
<div>
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>상품명</th>
<th>가격</th>
<th>수량</th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${items}">
<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">회원아이디</a></td>
<td><a href="item.html" th:href="@{|/basic/items/${item.id}|}" th:text="${item.itemName}">상품명</a></td>
<td th:text="${item.price}">10000</td>
<td th:text="${item.quantity}">10</td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /container -->
</body>
</html>
타임리프 간단하게 알아보자
타임리프 사용 선언 : <html xmlns:th="http://www.thmeleaf.org">
속성 변경 : th:href
th:href="@{/css/bootstrap.min.css}"
--> href 속성을 동적으로 생성하여 클라이언트가 클릭할 때 해당 링크로 이동할 수 있게 한다.
타임리프의 핵심
핵심은 th:xxx가 붙은 부분은 서버 사이드에서 렌더링되고, 기존 것을 대체한다.
Thymeleaf 표현식이 없으면 해당 부분은 일반적인 정적 HTML로 처리되어 클라이언트에게 그대로 전송된다.
URL 링크 표현식 - @{}
th:href="@{/css/bootstrap.min.css}"
@{}:타임리프는 URL을 사용할 시, 이렇게 사용한다.
상품 등록 폼으로 이동
속성 변경 - th:onclick
onclick="location.href='addForm.html'" th:onclick="|location.href='@{/basic/items/add}'|"
여기서는 리터럴 대체 문법이 사용되었다
리터럴 대체 문법
|...| 이렇게 사용한다. <span th:text= "'Welcome to our application,'+ ${user.name}+'!'"><span th:text="|Welcome to our application, ${user.name}!|>참고
결과를 다음과 같이 만들어야 한다 : location.href=/basic/items/add'
리터럴 대체 문법 사용 --> th:onclick="|location.href='@{/basic/items/add}'|" 과 같이 편리하게 사용할 수 있다.
반복 출력 - th:each
<th:each = "item : ${items}">
반복은 th:each를 사용한다. 모델에 포함된 items컬렉션 데이터가 item 변수에 하나씩 포함되고, 반복문 안에 item 변수를 사용할 수 있다.
컬렉션 수 만큼 <tr>..<tr>이 하위 태그로 포함해서 생성한다.
변수 표현식 - ${...}
<td th:text="${item.price}">10000</td>
모델에 포함된 값이나, 타임리프 변수로 선언한 값을 조회할 수 있다.
프로퍼티 접근법을 사용한다(item.getPrice())
내용 변경 - th:text
<td th:text = "${item.price}">1000</td>
내용의 값을 th:text로 변경한다.
위 예시에서는 1000을 ${item.price}의 값으로 변경한다.
URL 링크 표현식2 - @{...}
th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
상품 ID를 선택하는 링크를 확인해보면, URL 링크 표현식을 사용하면 경로를 템플릿처럼 편리하게 사용할 수 있다.
URL 링크 간단히
th:href="@{|/basic/itemss/${item.id}|}"
--> 리터럴 대체 문법을 활용해서 간단히 사용할 수 있다.
순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있는 타임리프의 특징을 네츄럴 템플릿 (natural templates)이라 한다.
상품 상세 컨트롤러와 뷰를 만들어보자
BasicItemController
@GetMapping("/{itemId}")
public String item(@PathVariable(name = "itemId") long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/item";
}
@PathVariable : 로 넘어온 상품 ID로 상품을 조회하고, 모델에 담아둔다. 그리고 뷰 템플릿을 호출한다.item.html

속셩 변경 : th:value

상품수정 링크

th:onclick="location.href='@{/basic/items/{itemId}/edit(itemId=${item.id})}'|"목록으로 이동

th:onclick="|location.href='@{/basic/items}'|"BasicItemController
@PostMapping("/add")
public String save(){
return "basic/addForm";
}
addForm.html

속성 변경 - action

취소

th:onclick="|location.href='@{/basic/items}'|"상품 등록 폼에서 전달된 데이터로 실제 상품을 등록 처리해 볼 것이다
POST - HTML Form
itemName=itemA&price=10000&quantity=10요청 파라미터 형식으로 처리해야 하므로, 먼저 @RequestParam으로 사용해보자
addItemV1 - @RequestParam
@PostMapping("/add")
public String addItemV1(@RequestParam String itemName,
@RequestParam int price,
@RequestParam int quantity, Model model){
Item item = new Item();
item.setItemName(itemName);
item.setPrice(price);
item.setQuantity(quantity);
itemRepository.save(item);
model.addAttribute("item", item);
return "basic/item";
}
상품 등록 처리 @ModelAttrbute를 사용해보자
addItemV2-@ModelAttrbute를 사용
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model ){
itemRepository.save(item);
model.addAttribute("item", item);
return "basic/item";
}
@ModelAttribute
기능 1 : 요청 파라미터 처리
기능 2 : Model에 추가
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item){
itemRepository.save(item);
//model.addAttribute("item", item);
return "basic/item";
}
이렇게 코드를 작성해도 잘 동작하는 것을 볼 수 있다.
addItemV3 - @ModelAttribute 이름 생략
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item){
itemRepository.save(item);
return "basic/item";
}
@ModelAttribute 클래스명 -> 모델에 자동 추가되는 이름
@ModelAttribute Item exam1 -> item
addItemV4 - @ModelAttribute 전체 생략
@PostMapping("/add")
public String addItemV4(Item item){
itemRepository.save(item);
return "basic/item";
}
editForm 추가
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable(name="itemId") long itemId, Model model){
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/editForm";
}
editForm.html

상품 수정 컨트롤러 - edit추가
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable(name = "itemId") long itemId, @ModelAttribute Item item){
itemRepository.update(itemId, item);
return "redirect: /basic/items/{itemId}";
}
상품 수정은 상품 등록과 전체 프로세스가 유사하다
/items/{itemId}/edit : 상품 수정 폼/items/{itemId}/edit : 상품 수정 처리리다이렉트
상품 수정은 마지막에 뷰 템플릿을 호출하는 대신에, 상품 상세 화면으로 이동하도록 리다이렉트를 호출한다
참고
상품 등록을 완료하고 웹 브라우저의 새로고침 버튼을 클릭해보자. 상품이 계속해서 중복 등록되는 것을 확인할 수 있다.
상품 등록 후 상세 보기 페이지에서 새로고침을 하면, 계속 상품이 등록되는 것을 확인할 수 있다.
POST 등록 후 새로 고침

문제를 어떻게 해결할까??
POST, Redirect GET

GET/items/{itemId}가 되는 것이다. addItemV5 추가
@PostMapping("/add")
public String addItemV5(Item item){
itemRepository.save(item);
return "redirect:/basic/items/" + item.getId();
}
PRG Post/Redirect/Get이라고 한다. 고객에게 저장이 잘 되었으면 상품 상세 화면에 "저장되었습니다."라는 메시지를 보여주자
addItemV6
@PostMapping("/add")
public String addItemV6(Item item, RedirectAttributes redirectAttributes) {
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/items/{itemId}";
}
http://localhost:8080/basic/items/3?status=true가 출력된다.**RedirectAttributes
redirect:/basic/items/{itemId}뷰 템플릿 메시지 추가
item.html 추가
<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>
th:if: 해당 조건이 참이면 실행${param.status} : 타임리프에서 쿼리 파라미터를 편리하게 조회하는 기능따라서, 파라미터 status가 존재하고 true인 경우에만 <h2> 태그 내부에 "저장 완료!"라는 메시지가 표시된다.