
상품 도메인 개발
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 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, Item updateParam) {
Item findItem = findById(itemId);
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
}
public void clearStore() {
store.clear();
}
}
타임리프 알아보기
Thymeleaf는 서버 사이드에서 HTML 템플릿을 렌더링하는 엔진으로, HTML 파일을 그대로 두고 동적으로 렌더링할 수 있는 것이 큰 특징이다. JSP와 달리, 타임리프는 템플릿 기능을 지원하면서도 순수 HTML 파일을 그대로 사용할 수 있어서 편리하다.
- 타임리프 사용 선언
- HTML 파일에서 xmlns:th="http://www.thymeleaf.org"를 선언하여 타임리프 템플릿을 사용 - 속성 변경 - th:xxx
- 타임리프에서 th:xxx 속성은 서버에서 렌더링되어 HTML의 기존 속성을 동적으로 대체
- 예: th:href="@{/css/bootstrap.min.css}" → 서버에서 href="/css/bootstrap.min.css"로 동적으로 변환 - URL 링크 표현식 - @{...}
- 타임리프에서 URL을 표현할 때 @{...}를 사용하여 서블릿 컨텍스트를 자동으로 포함하여 URL을 생성
- 예: th:href="@{/css/bootstrap.min.css}" → 경로가 서블릿 컨텍스트를 포함하여 생성 - 리터럴 대체 - |...|
- |...| 문법은 문자열과 표현식을 쉽게 결합할 수 있게 해줌
- 예: th:onclick="|location.href='@{/basic/items/add}'|" → 리터럴과 표현식을 결합하여 동적으로 URL을 처리 - 반복 출력 - th:each
- th:each를 사용하여 컬렉션을 반복 출력
- 예: <tr th:each="item : ${items}"> → items 컬렉션의 각 항목에 대해 반복 - 변수 표현식 - ${...}
- ${...}를 사용하여 모델에서 가져온 변수 값을 출력
- 예: <td th:text="${item.price}">10000</td> → item.price 값을 동적으로 렌더링 - URL 링크 표현식 (변수 포함)
- URL 링크에서 변수 값을 동적으로 삽입 가능
- 예: th:href="@{/basic/items/{itemId}(itemId=${item.id})} - 타임리프의 동적 템플릿 처리
- 타임리프는 순수 HTML을 그대로 사용할 수 있으면서도 서버 사이드 렌더링을 통해 동적 결과를 생성할 수 있다. JSP와 달리 HTML 파일을 브라우저에서 직접 열어도 정상적으로 보여지며, 서버에서 렌더링된 결과를 확인할 수 있다.
상품 목록 - 타임리프
@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";
}
@GetMapping("/{itemId}")
public String item(@PathVariable("itemId") 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("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);
// ModelAttribute가 자동으로 모델에 추가해줌. 매핑된 이름을 통해
// model.addAttribute("item", item);
return "basic/item";
}
// 뒤에오는 Item에서 첫 글자만 소문자로 바꿔서 매핑함. @ModelAttribute("item)이 됨
// @PostMapping("/add")
public String addItemV3(@ModelAttribute Item item) {
itemRepository.save(item);
return "basic/item";
}
// @ModelAttribute 또한 생략 가능함 -> 굳이..? 근데 알고는 있어야 하니
// 상품 추가 후에, 새로고침 하면 POST 요청이 계속 들어가게 되는 치명적 오류가 발생
// 따라서 뷰네임을 단순 반환하는 것이 아니라, 리다이렉트를 해주어야 한다.
// @PostMapping("/add")
public String addItemV4(Item item) {
itemRepository.save(item);
return "basic/item";
}
// @PostMapping("/add")
public String addItemV5(Item item) {
itemRepository.save(item);
// + 연산은 URL 인코딩이 안돼서 위험함. 그래서 RedirectAttributes를 사용해야 함
return "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);
// {itemId}가 RedirectAttributes의 "itemId" 값으로 치환됨.
// status의 나머지는 URL의 쿼리 파라미터로 들어감
// 원래 모델에 값을 담고, 뷰에서 꺼내야 하는데 status는 타임리프에서 직접 지원해서 뷰에서 -바로 꺼낼 수 있음.
return "redirect:/basic/items/{itemId}";
}
@GetMapping("{itemId}/edit")
public String editForm(@PathVariable("itemId") Long itemId, Model model) {
Item findItem = itemRepository.findById(itemId);
model.addAttribute("item", findItem);
return "basic/editForm";
}
@PostMapping("{itemId}/edit")
public String edit(@PathVariable("itemId") Long itemId, @ModelAttribute Item item) {
itemRepository.update(itemId, item);
return "redirect:/basic/items/{itemId}";
}
// 테스트용 데이터 추가
@PostConstruct
public void init() {
itemRepository.save(new Item("itemA", 10000, 10));
itemRepository.save(new Item("itemB", 20000, 20));
}
}
뷰 템플릿
items.html
<!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'"
type="button"
th:onclick="|location.href='@{/basic/items/add}'|">상품 등록
</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>
</body>
</html>
item.html
<!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>
</body>
</html>
addForm.html
<!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 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>
</body>
</html>
editForm.html
<!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 action="item.html" th:action method="post">
<div>
<label for="id">상품 ID</label>
<input type="text" id="id" name="id" 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}">
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control" value="10000" th:value="${item.price}">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control" value="10" th:value="${item.quantity}">
</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'"
th:onclick="|location.href='@{/basic/items/{itemId}(itemId=${item.id})}'|"
type="button">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
PRG 패턴(Post / Redirect / Get)
- 문제: 상품 등록 후 새로 고침 시 상품이 중복 등록되는 문제.
-> POST 요청 후 새로 고침하면 동일한 POST 요청이 다시 전송되기 때문. - 해결 방법: Post/Redirect/Get(PRG) 패턴 사용.
- Post: 데이터를 서버로 전송.
- Redirect: 서버가 클라이언트에게 리다이렉트 응답을 보내 GET 요청을 유도.
- Get: 클라이언트는 상품 상세 화면을 요청.
- 리다이렉트 시 문제: URL에 변수 포함 시 URL 인코딩 문제 발생.
-> 해결 방법: RedirectAttributes 사용.- RedirectAttributes를 사용하면 URL에 안전하게 변수 추가 가능.
- 쿼리 파라미터로 itemId와 status 추가하여 "저장되었습니다" 메시지 전달.


'공부 > Spring' 카테고리의 다른 글
| [인프런 김영한] 실전! 스프링 부트와 JPA 활용1 - 2 (0) | 2025.11.07 |
|---|---|
| [인프런 김영한] 실전! 스프링 부트와 JPA 활용1 - 1 (6) | 2025.08.12 |
| [스프링 MVC 6 / 인프런 김영한] 스프링 MVC - 기본 기능 (0) | 2025.03.30 |
| [스프링 MVC 5 / 인프런 김영한] 스프링 MVC - 구조 이해 (0) | 2025.03.30 |
| [스프링 MVC 4 / 인프런 김영한] MVC 프레임워크 만들기 (0) | 2025.03.29 |
