plugins {
id 'org.springframework.boot' version '2.7.5'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
id 'java'
}
group = 'hello'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<ul>
<li>상품 관리
<ul>
<li><a href="/basic/items">상품 관리 - 기본</a></li>
</ul>
</li>
</ul>
</body>
</html>
package hello.itemservice.domain.item;
import lombok.Getter;
import lombok.Setter;
@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;
}
}
package hello.itemservice.domain.item;
import hello.itemservice.domain.dto.ItemUpdateParamDto;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Repository
public class ItemRepository {
private static final Map<Long, Item> store = new HashMap<>();
private static long sequence = 0L;
public Item save(Item item) {
item.setId(++sequence);
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, ItemUpdateParamDto updateParam) {
Item findItem = findById(itemId);
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getItemPrice());
findItem.setQuantity(updateParam.getItemQuantity());
}
public void clearStore() {
store.clear();
}
}
package hello.itemservice.domain.item;
import hello.itemservice.domain.dto.ItemUpdateParamDto;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
public class ItemRepositoryTest {
ItemRepository itemRepository = new ItemRepository();
@AfterEach
void afterEach(){
itemRepository.clearStore();
}
@Test
void save(){
//given
Item item = new Item("itemA",10000,10);
//when
Item savedItem = itemRepository.save(item);
//then
Item findItem = itemRepository.findById(item.getId());
assertThat(findItem).isEqualTo(savedItem);
}
@Test
void findAll(){
//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
void updateItem(){
//given
Item item = new Item("item1",10000,10);
Item savedItem = itemRepository.save(item);
Long itemId = savedItem.getId();
//when
ItemUpdateParamDto updateParam = new ItemUpdateParamDto("item2", 20000, 30);
itemRepository.update(itemId,updateParam);
//then
Item findItem = itemRepository.findById(itemId);
assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
assertThat(findItem.getPrice()).isEqualTo(updateParam.getItemPrice());
assertThat(findItem.getQuantity()).isEqualTo(updateParam.getItemQuantity());
}
}
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link 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'" 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>
<td><a href="item.html">1</a></td>
<td><a href="item.html">테스트 상품1</a></td>
<td>10000</td>
<td>10</td>
</tr>
<tr>
<td><a href="item.html">2</a></td>
<td><a href="item.html">테스트 상품2</a></td>
<td>20000</td>
<td>20</td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /container -->
</body>
</html>
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 등록 폼</h2>
</div>
<h4 class="mb-3">상품 입력</h4>
<form action="item.html" method="post">
<div><label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control" placeholder="이름을 입력하세요">
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
placeholder="가격을 입력하세요">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control"
placeholder="수량을 입력하세요">
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit">상품
등록
</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'" type="button">취소
</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 수정 폼</h2>
</div>
<form action="item.html" method="post">
<div>
<label for="id">상품 ID</label>
<input type="text" id="id" name="id" class="form-control" value="1"
readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control"
value="상품A">
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
value="10000">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control"
value="10">
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit">저장
</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='item.html'" type="button">취소
</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link href="../css/bootstrap.min.css" rel="stylesheet">
<style> .container {
max-width: 560px;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 상세</h2>
</div>
<div>
<label for="itemId">상품 ID</label>
<input type="text" id="itemId" name="itemId" class="form-control"
value="1" readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control"
value="상품A" readonly>
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
value="10000" readonly>
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control"
value="10" readonly>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" onclick="location.href='editForm.html'" type="button">상품 수정
</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'" type="button">목록으로
</button>
</div>
</div>
</div> <!-- /container -->
</body>
</html>
참고
/resources/static에 HTML을 넣어두면, 실제 서비스에서도 공개된다.
서비스를 운영한다면 지금처럼 공개할 필요없는 HTML을 두는 것은 주의
<html xmlns:th="[http://www.thymeleaf.org](http://www.thymeleaf.org/)">
📌 Thymeleaf 핵심
- th:xxx 가 붙은 부분은 서버사이드에서 렌더링 되고, 기존 값을 대체
- th:xxx 이 없으면 기존 html의 xxx 속성이 그대로 사용
th:href="@{/css/bootstrap.min.css}"
th:href="@{/css/bootstrap.min.css}"
th:onclick="|location.href='@{/basic/items/add}'|"
<span th:text="'Welcome to our application, ' + ${[user.name](http://user.name/)} + '!'">
<span th:text="|Welcome to our application, ${[user.name](http://user.name/)}!|">
<tr th:each="item : ${items}">
<td th:text="${item.price}">10000</td>
<td th:text="${item.price}">10000</td>
th:href="@{/basic/items/{itemId}(itemId=${[item.id](http://item.id/)})}"
ex) th:href="@{/basic/items/{itemId}(itemId=${[item.id](http://item.id/)}, query='test')}"
th:href="@{|/basic/items/${[item.id](http://item.id/)}|}"
참고
- 순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있는 Thymeleaf의 특징을 네츄럴 템플릿 (natural templates)이라 한다.
package hello.itemservice.web.basic;
import hello.itemservice.domain.dto.ItemUpdateParamDto;
import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.annotation.PostConstruct;
import java.util.List;
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
@Slf4j
public class BasicItemController {
private final ItemRepository itemRepository;
@GetMapping
public String items(Model model){
List<Item> items = itemRepository.findAll();
model.addAttribute("items",items);
return "basic/items";
}
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model){
Item item = itemRepository.findById(itemId);
model.addAttribute("item",item);
return "basic/item";
}
@GetMapping("/add")
public String addForm(){
return "basic/addForm";
}
//@PostMapping("/add")
public String addItemV1(@RequestParam String itemName,
@RequestParam Integer price,
@RequestParam Integer quantity,
Model model){
Item item = new Item(itemName,price,quantity);
itemRepository.save(item);
model.addAttribute("item",item);
return "basic/item";
}
/*
@ModelAttribute의 name 속성을 설정하면,
해당 이름으로 Model에 넣음
*/
//@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model){
itemRepository.save(item);
//model.addAttribute("item",item); // 자동으로 추가되므로, Model 객체 생략 가능
return "basic/item";
}
/*
@ModelAttribute의 name 속성을 생략 -> 클래스 이름의 첫 글자를 소문자로 치환해서 Model에 넣음
*/
//@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item){
itemRepository.save(item);
return "basic/item";
}
/*
@ModelAttribute 생략 -> 클래스 이름의 첫 글자를 소문자로 치환해서 Model에 넣음
*/
//@PostMapping("/add")
public String addItemV4(Item item){
itemRepository.save(item);
return "basic/item";
}
/*
PRG(Post, Redirect, Get) 패턴 적용
*/
//@PostMapping("/add")
public String addItemV5(Item item){
itemRepository.save(item);
return "redirect:/basic/items/" + item.getId(); // URL 인코딩 시 위험할 수 있음
}
@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}"; // URL 인코딩 시 위험할 수 있음
}
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable long itemId, Model model){
Item item = itemRepository.findById(itemId);
model.addAttribute("item",item);
return "basic/editForm";
}
@PostMapping("/{itemId}/edit")
public String editItem(@PathVariable Long itemId, ItemUpdateParamDto itemUpdateParamDto){
log.info("itemId = {}",itemId);
log.info("itemName = {}",itemUpdateParamDto.getItemName());
log.info("itemPrice = {}",itemUpdateParamDto.getItemPrice());
log.info("itemQuantity = {}",itemUpdateParamDto.getItemQuantity());
itemRepository.update(itemId,itemUpdateParamDto);
return "redirect:/basic/items/{itemId}";
}
/*
Test 데이터 추가
*/
@PostConstruct
public void init(){
itemRepository.save(new Item("itemA",10000,10));
itemRepository.save(new Item("itemB",20000,20));
}
}
@GetMapping
public String items(Model model){
List<Item> items = itemRepository.findAll();
model.addAttribute("items",items);
return "basic/items";
}
itemRepository
에서 모든 상품을 조회 → model
에 담음View Template
호출@RequiredArgsConstructor
- final 이 붙은 멤버변수만 사용해서 생성자를 자동 생성
@PostConstruct
해당 빈의 의존관계가 모두 주입되고 나면 초기화 용도로 호출된다.
public BasicItemController(ItemRepository itemRepository) {
this.itemRepository = itemRepository;
}
📌 생성자가 딱 1개만 있으면 스프링이 해당 생성자에 @Autowired 로 의존관계를 주입해준다. → final 키워드 필수 !!
<!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}">회원 id</a></td>
<td><a href="item.html" th:href="@{/basic/items/{itemId}(itemId=${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>
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model){
Item item = itemRepository.findById(itemId);
model.addAttribute("item",item);
return "basic/item";
}
<!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">
<style> .container {
max-width: 560px;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 상세</h2>
</div>
<h2 th:if="${param.status}" th:text="'저장 완료'"></h2>
<div>
<label for="itemId">상품 ID</label>
<input type="text" id="itemId" name="itemId" class="form-control"
value="1" th:value="${item.id}" readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control"
value="상품A" th:value="${item.itemName}" readonly>
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
value="10000" th:value="${item.price}" readonly>
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control"
value="10" th:value="${item.quantity}" readonly>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg"
onclick="location.href='editForm.html'"
th:onclick="|location.href='@{/basic/items/{itemId}/edit(itemId=${item.id})}'|"
type="button">상품 수정
</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/basic/items}'|"
type="button">목록으로
</button>
</div>
</div>
</div> <!-- /container -->
</body>
</html>
@GetMapping("/add")
public String addForm() {
return "basic/addForm";
}
addForm.html - with Thymeleaf
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<html>
<head>
<meta charset="utf-8">
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2>상품 등록 폼</h2>
</div>
<h4 class="mb-3">상품 입력</h4>
<form action="item.html" th:action method="post">
<div><label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control" placeholder="이름을 입력하세요">
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
placeholder="가격을 입력하세요">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control"
placeholder="수량을 입력하세요">
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit">상품
등록
</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/basic/items}'|"
type="button">취소
</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
상품 등록 Form은 단순히 View Template만 호출
속성 변경 - th:action
@RequestParam
또는 @ModelAttribute
를 사용해 요청 파라미터 데이터를 받는다.📌 중요
- 상품 상세에서 사용한 item.html View Template을 재활용한다.
@PostMapping("/add")
public String addItemV1(@RequestParam String itemName,
@RequestParam Integer price,
@RequestParam Integer quantity,
Model model){
Item item = new Item(itemName,price,quantity);
itemRepository.save(item);
model.addAttribute("item",item);
return "basic/item";
}
/*
@ModelAttribute의 name 속성을 설정하면,
해당 이름으로 Model에 넣음
*/
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model){
itemRepository.save(item);
//model.addAttribute("item",item); // 자동으로 추가되므로, Model 객체 생략 가능
return "basic/item";
}
/*
@ModelAttribute의 name 속성을 생략 -> 클래스 이름의 첫 글자를 소문자로 치환해서 Model에 넣음
*/
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item){
itemRepository.save(item);
return "basic/item";
}
/*
@ModelAttribute 생략 -> 클래스 이름의 첫 글자를 소문자로 치환해서 Model에 넣음
*/
@PostMapping("/add")
public String addItemV4(Item item){
itemRepository.save(item);
return "basic/item";
}
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable long itemId, Model model){
Item item = itemRepository.findById(itemId);
model.addAttribute("item",item);
return "basic/editForm";
}
@PostMapping("/{itemId}/edit")
public String editItem(@PathVariable Long itemId, ItemUpdateParamDto itemUpdateParamDto){
log.info("itemId = {}",itemId);
log.info("itemName = {}",itemUpdateParamDto.getItemName());
log.info("itemPrice = {}",itemUpdateParamDto.getItemPrice());
log.info("itemQuantity = {}",itemUpdateParamDto.getItemQuantity());
itemRepository.update(itemId,itemUpdateParamDto);
return "redirect:/basic/items/{itemId}";
}
📌 Spring은 redirect:
으로 redirect를 지원
/*
PRG(Post, Redirect, Get) 패턴 적용
*/
@PostMapping("/add")
public String addItemV5(Item item){
itemRepository.save(item);
return "redirect:/basic/items/" + item.getId(); // URL 인코딩 시 위험할 수 있음
}
"redirect:/basic/items/" + item.getId()
@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}";
}