요구사항 분석
상품 도메인 모델
- 상품 ID
- 상품명
- 가격
- 수량
상품 관리 기능
- 상품 목록
- 상품 상세
- 상품 등록
- 상품 수정
서비스 제공 흐름
![](https://blog.kakaocdn.net/dn/crlwwR/btskSoGWbrI/Js1BOTLwctHIUII4Hqfjg1/img.png)
- 디자이너: 요구사항에 맞도록 디자인하고, 디자인 결과물을 웹 퍼블레셔에게 넘겨준다.
- 웹 퍼블리셔: 디자이너에서 받은 디자인을 기반으로 HTML, CSS를 만들어 개발자에게 제공한다.
- 백엔드 개발자: 디자이너, 웹 퍼블리셔를 통해서 HTML 화면이 나오기 전까지 시스템을 설계하고, 핵심 비즈니스 모델을 개발한다. 이후 HTML이 나오면 이 HTML을 뷰 템플릿으로 변환해서 동적으로 화면과 웹 화면의 흐름을 제어한다.
상품 도메인 개발
@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;
}
}
@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 itemId) {
return store.get(itemId);
}
public List<Item> findAll() {
return new ArrayList<>(store.values());
}
public void update(Long itemId, Item updateParam) {
Item item = findById(itemId);
item.setItemName(updateParam.getItemName());
item.setPrice(updateParam.getPrice());
item.setQuantity(updateParam.getQuantity());
}
public void clearStore() {
store.clear();
}
}
상품 목록 - 타임 리프
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
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";
}
@PostConstruct
public void init() {
itemRepository.save(new Item("itemA", 10000, 10));
itemRepository.save(new Item("itemB", 20000, 20));
itemRepository.save(new Item("itemC", 30000, 30));
}
}
타임리프 간단히 알아보기
<!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 th:onclick="|location.href='@{/basic/items/add}'|"
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 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/${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.thymeleaf.org
">
속성 변경 - th:href
th:href="@{/css/bootstrap.min.css}"
href=”value1”
을th:href=”value2”
의 값으로 변경한다.
- 타임리프 뷰 템플릿을 거치면 원래의 값을
th:xxx
의 값으로, 거치지 않으면 기존의 값으로 렌더링한다.
- 따라서, 뷰 템플릿을 거치지 않더라도 기존의 HTML 파일을 볼 수 있다.
링크 표현식 - @{…}
th:href="@{/css/bootstrap.min.css}"
@{…}
: 타임리프는 URL링크를 사용하는 경우@{…}
를 사용한다. 이것을 URL 링크 표현식이라 한다.
- URL 링크 표현식을 사용하면 서빌릇 컨텍스트를 자동으로 포함한다(요즘은 잘 안 쓰니 나중에 궁금하면 찾아보자!)
리터럴 대체 - |…|
- 타임리프에서 문자와 표현식등은 분리되어 있기 때문에 더해서 사용해야한다.
<span th:text="'Welcome to our application, ' + ${
user.name
} + '!'">
- 다음과 같이 리터럴 대체 문법을 사용하면, 더하기 없이 편리하게 사용할 수 있다.
<span th:text="|Welcome to our application, ${
user.name
}!|">
반복 출력 - th:each
<tr th:each="item : ${items}">
- 반복은
th:each
를 사용한다. 이렇게 하면 모델에 포함된 items 컬렉션 데이터가 item 변수에 하나씩 포함되고, 반복문 안에서 item 변수를 사용할 수 있다.
- 컬렉션의 수 만큼
<tr>…</tr>
이 하위 태그를 포함해서 생성된다.
변수 표현식 - ${…}
<td th:text="${item.price}">10000</td>
- 모델에 포함된 값이나, 타임리프 변수로 선언한 값을 조회할 수 있다.
- 프로퍼티 접근법을 사용한다(
item.getPrice()
)
링크 표현식2 - @{…}
th:href="@{/basic/items/{itemId}(itemId=${
item.id
})}"
- 상품 ID를 선택하는 링크를 확인해보자ㅣ.
- URL 링크 표현식을 사용하면 경로를 템플릿처럼 편리하게 사용할 수 있다.
- 경로 변수({itemId})뿐만 아니라 쿼리 파라미터도 생성한다.
- 예)
th:href="@{/basic/items/{itemId}(itemId=${
item.id
}, query='test')}"
상품 등록 폼
public class BasicItemController {
@GetMapping("/add")
public String addForm() {
return "basic/addForm";
}
}
<!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>
<h4 class="mb-3">상품 입력</h4>
<form th:action="@{/basic/items/add}" 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" th:onclick="|location.href='@{/basic/items}'|" onclick="location.href='items.html'" type="button">취소
</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
상품 등록 처리 - @ModelAttribute
public class BasicItemController {
// @PostMapping("/add")
public String addItemV1(@RequestParam("itemName") String itemName,
@RequestParam("price") Integer price,
@RequestParam("quantity") Integer quantity,
Model model) {
Item item = new Item(itemName, price, quantity);
itemRepository.save(item);
model.addAttribute("item", item);
return "basic/item";
}
// @PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item) {
itemRepository.save(item);
return "basic/item";
}
// @PostMapping("/add")
public String addItemV3(@ModelAttribute Item item) {
itemRepository.save(item);
return "basic/item";
}
// @PostMapping("/add")
public String addItemV4(Item item) {
itemRepository.save(item);
return "basic/item";
}
}
- v1:
@RequestParam
으로 하나 하나 받아온다. 이후 생성한 객체를Model
에 넣어서 뷰 템플릿에 전달한다.
- v2:
@ModelAttribute
를 사용하여 form의 data를 간단하게 받아온다.- 뿐만 아니라..!
@ModelAttribute
를 사용하면Model
에 굳이 넣지 않더라도,@ModelAttribute
로 지정한 객체를 자동으로Model
에 넣어준다. attribute의 key는name(value)
속성을 사용한다. 변수의 이름을 key로 사용하는 것이 아니다!
- 뿐만 아니라..!
- v3:
@ModelAttribute
에서 name을 생략하면, Class의 이름에서 첫 글자를 소문자로 바꾼 값을 key로 사용한다. 변수의 이름을 key로 사용하는 것이 아니다!
- v4:
@ModelAttribute
자체도 생략가능하다!
상품 수정
public class BasicItemController {
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/editForm";
}
}
<!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>
<form th:action="@{/basic/items/{itemId}/edit(itemId=${item.id})}" action="item.html" method="post">
<div>
<label for="id">상품 ID</label>
<input type="text" id="id" name="id" class="form-control" th:value="${item.id}" value="1" readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control" th:value="${item.itemName}" value="상품A">
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control" th:value="${item.price}" value="10000">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control" th:value="${item.quantity}" 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" th:onclick="|location.href='@{/basic/items/{itemId}(itemId=${item.id})}'|" onclick="location.href='item.html'" type="button">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
상품 수정 처리
public class BasicItemController {
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
itemRepository.update(itemId, item);
return "redirect:/basic/items/{itemId}";
}
}
리다이렉트
- 스프링은
redirect:/...
으로 편리하게 리다이렉트를 지원한다.
redirect:/basic/items/{itemId}
- 컨트롤러에 매핑된
@PathVariable
값을 redirect에도 사용할 수 있다.
redirect:/basic/items/{itemId}
에서{itemId}
는@PathVariable Long itemId
의 값을 그대로 사용한다.
- 컨트롤러에 매핑된
PRG(POST-REDIRECT-GET)
![](https://blog.kakaocdn.net/dn/nYRJ7/btskReyehzt/PS5uyXIl7iBFB1TKBuaqZK/img.png)
- 지금까지 진행한 상품 등록 처리 컨트롤러는 심각한 문제가 있다(addItemV1 ~ V4) 상품 등록을 완료하고 웹 브라우저의 새로고침 버튼을 클릭하면 상품이 계속해서 중복 등록되는 것을 볼 수 있다.
- 왜냐하면.. 기존의 url을 그대로 갖고 있기 때문에 새로고침할 경우 계속 POST요청이 날라가서 등록이 된다.
public class BasicItemController {
@PostMapping("/add")
public String addItemV5(Item item) {
itemRepository.save(item);
return "redirect:/basic/items/" + item.getId();
}
}
- 이렇게 redirect해서 브라우저의 URL을 변경시켜 새로고침해도 중복등록되지 않도록 할 수 있다.
- 하지만,
return "redirect:/basic/items/" + item.getId();
처럼 String을 더하게 되면 URL인코딩이 되지 않기 때문에 위험하다(validation을 할 수가 없다).
RedirectAttributes
public class BasicItemController {
@PostMapping("/add")
public String addItemV6(Item item, RedirectAttributes redirectAttributes) {
itemRepository.save(item);
redirectAttributes.addAttribute("itemId", item.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/items/{itemId}";
}
}
- 단순하게 URL을 변경해주는(상품 상세 페이지로)것 뿐만 아니라 상세 페이지에서 상품 등록에 성공했다는 메시지를 띄워보자.
- 위와 같이
RedirectAttributes
에 attribute를 추가하면 http://localhost:8080/basic/items/3?status=true로 redirect하게 된다.
RedirectAttributes
의 attribute는 pathVariable에 바인딩되고, 바인딩되고 남은 부분은 쿼리 파라미터로 처리하게 된다.
<!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"
th:value="${item.id}"
value="1" readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control"
th:value="${item.itemName}"
value="상품A" readonly>
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
th:value="${item.price}"
value="10000" readonly>
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control"
th:value="${item.quantity}"
value="10" readonly>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg"
th:onclick="|location.href='@{/basic/items/{itemId}/edit(itemId=${item.id})}'|"
onclick="location.href='editForm.html'" type="button">상품 수정
</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
th:onclick="|location.href='/basic/items'|"
onclick="location.href='items.html'" type="button">목록으로
</button>
</div>
</div>
</div> <!-- /container -->
</body>
</html>
th:if
- 해당 조건이 참이면 실행한다.
${param.status}
- 타임리프에서 쿼리 파라미터를 편리하게 조회하는 기능이다.
- 원래는 컨트롤러에서 모델에 직접 담고 값을 꺼내야한다. 근데, 쿼리 파라미터는 자주 사용하기때문에 타임리프에서 직접 지원한다.
Uploaded by N2T
(23.06.21 22:15)에 작성된 글 입니다.