스프링 MVC - 웹 페이지 만들기
스프링 MVC를 사용해 상품을 관리할 수 있는 기본적인 서비스를 만든다.
다음과 같은 Depndencies를 추가한다.
Spring Web, Thymeleaf, Lombok
1. 요구사항 분석
상품을 관리할 수 있는 서비스를 만든다.
■ 상품 도메인 모델
상품 도메인 모델은 다음과 같은 정보를 갖는다.
· 상품 ID
· 상품명
· 가격
· 수량
■ 상품 관리 기능
상품 관리에 대해 다음과 같은 서비스를 제공한다.
· 상품 목록
· 상품 상세
· 상품 등록
· 상품 수정
■ 서비스 화면
제공되는 서비스 화면은 다음과 같다.
[상품 목록]
상품들의 정보를 나열하고 상품 등록을 할 수 있다.
[상품 상세]
상품 모델의 정보를 나타낸다.
상품을 수정하거나 목록으로 이동할 수 있다.
[상품 등록]
새로운 상품을 등록한다.
상품 모델에 따라 상품명, 가격, 수량을 기입해야 한다.
[상품 수정]
기존의 상품을 수정한다.
상품 ID는 고정된채, 상품 모델에 따라 상품명, 가격, 수량을 수정할 수 있다.
■ 서비스 제공 흐름
[서비스 제공 흐름]
2. 상품 도메인 & 상품 저장소
[상품 도메인]
@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;
}
}
상품에 대한 객체를 구현한다.
Item 객체는 상품의 id, 이름, 가격, 개수를 멤버로 갖는다.
각 멤버 변수에대한 getter, setter는 @Getter, @Setter로 생략되었다.
[상품 저장소]
@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();
}
}
ItemRepository는 상품을 저장하는 객체이다.
상품이 저장될 store는 HashMap으로 (상품 Id, 상품 객체) 형식을 따른다.
· save(item) : 상품 Id를 부여하고 상품을 store에 저장한다.
· findById(id) : id로 상품을 조회한다.
· findAll() : 상품을 전부 조회한다.
· update(itemId, updateParam) : 새로운 Item 인스턴스 정보로 특정 id를 가진 상품의 정보를 변경한다.
3. 상품 서비스 HTML
■ 부트스트랩
부트스트랩은 웹사이트를 쉽게 만들 수 있게 도와주는 HTML, CSS, JS 프레임워크이다.
다음의 사이트에서 Compiled CSS and JS 항목을 다운로드해서 압축을 푼 뒤, bootstrap.min.css를 복사해서 resources/static/css/bootstrap.min.css 와 같이 추가하면 된다.
다운 사이트 : https://getbootstrap.com/docs/5.0/getting-started/download/
■ 상품 목록
[상품 목록 컨트롤러]
@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에서 모든 상품을 조회한 다음 모델에 담는다.
그리고 논리 뷰 이름("basic/items")에 따라 뷰 템플릿을 호출한다.
@PostConstruct로 테스트용 데이터를 두개 넣어주었다.
[상품 뷰 목록 - 타임리프]
<!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>
상품 목록이 나타나는 화면을 구성한다.
· <html xmlns: th="http://www.thymeleaf.org>
- 타임리프 사용을 선언하는 문구이다.
· th : 속성 변경
-타임리프 뷰 템플릿을 거치게 되면 html내의 원래 값을 th:xxx 값으로 변경한다. 만약 값이 없다면 새로 생성한다.
ex) href="value1" th:href="value2" => 타임리프가 적용되면 th:href="value2"가 사용된다.
대부분의 HTML 속성을 th:xxx 로 변경할 수 있다.
· URL 링크 표현식
- 타임리프는 URL 링크를 사용하는 경우 @{...} 의 형식을 사용한다.
- 경로 변수와 쿼리 파라미터도 생성할 수 있다.
ex) th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}"
=> http://localhost:8080/basic/items/1?query=test
- 리터럴 대체 문법을 써서 간단하게 변수를 넣을 수 있다.
ex) th:href="@{/basic/items/${item.id}|}",
· 리터럴 대체
- 타임리프에서 문자와 표현식 등은 분리되어 있기 때문에 더해서 사용해야 한다.
ex) <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}"> 처럼 사용하면 items 컬렉션 데이터가 item 변수에 하나씩 포함된다.
· 변수 표현식
- ${...} 의 형식을 따른다.
- 모델에 포함된 값이나, 타임리프 변수로 선언한 값을 조회할 수 있다.
- 프로퍼티 접근법을 사용한다.
ex) item.id
■ 상품 상세
상품 상세를 조회하는 컨트롤러와 뷰를 구현한다.
[상품 상세 컨트롤러]
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {
private final ItemRepository itemRepository;
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/item";
}
}
PathVariable로 넘어온 상품ID로 상품을 조회하고 모델에 담아둔다. 그리고 뷰 템플릿을 호출한다.
[상품 상세 뷰 - 타임 리프]
<!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>
상품 상세화면을 구성한다.
상품이 새로 추가되면 상품 상세 뷰로 간게 되는데 이 때 status값이 true이면 "저장 완료!" 메시지를 출력해준다.
각 value를 th:value="${item.*}"로 대체해서 상품 정보를 나타낼 수 있게 했다.
상품수정 링크를 다음과 같이 재 정의 하였다.
th:onclick="|location.href='@{/basic/items/{itemId}/edit(itemId=${item.id})}'|"
목록으로 가는 링크를 다음과 같이 재 정의하였다.
th:onclick="|location.href='@{/basic/items}'|"
■ 상품 등록
상품등록 컨트롤러와 뷰를 구현한다.
[상품 등록 폼 컨트롤러]
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
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 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 action="item.html" th:action method="post">
- HTML form에서 action에 값이 없으면 현재 URL에 데이터를 전송한다.
- 위 방식대로 하면 현재 URL이 POST 메소드로 요청된다.
- 이런 방식으로 URL은 동일하지만 HTTP 메서드로 두 기능을 구분할 수 있다.
취소시 상품 목록으로 이동한다.
[상품 등록 처리 컨트롤러]
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {
private final ItemRepository itemRepository;
@PostMapping("/add")
public String save(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/items/{itemId}";
}
}
추가된 상품을 저장소에 저장하고 저장된 상품의 상품 상세 컨트롤러로 리다이렉트 한다.
redirectAttributes에 itemID와 status를 넣어 리다이렉트할 URL을 반환한다.
status가 true이면 상품 상세 뷰에서 "저장 완료!"라는 메시지가 추가로 출력된다(상품 상세 뷰 예제 참고).
위의 예제와 같이 반환하면 결과적으로 다음과 같은 URL로 재요청하게 된다.
http://localhost:8080/basic/items/3?status=true
■PRG
상품을 등록할 때 리다이렉트 하지 않고 바로 논리 뷰 이름을 반환해서 뷰 템플릿을 호출한다고 하자.
이 경우 상품 등록(POST)을 한 후 새로고침을 하게되면 어떻게 될까?
[POST 새로고침]
새로고침은 마지막에 서버에 전송한 데이터를 다시 전송한다.
상품 등록 폼에서 데이터를 입력하고 저장을 선택하면 POST /add + 상품 데이터를 서버로 전송한다.
이 상태에서 새로 고침을 하면 마지막에 전송한 POST /add + 상품 데이터를 서버로 다시 전송하게 된다.
그래서 내용은 같고 ID만 다른 상품 데이터가 계속 쌓이게 된다.
이러한 문제 때문에 POST 메서드로 새로 리소스를 등록할 경우 바로 뷰 템플릿을 호출하는 것이 아니라 GET 메서드의 컨트롤러를 호출하도록 리다이렉트를 해줄 필요가 있다.
[PRG 방식의 구조]
위와 같은 구성에서는 상품 저장(POST /add)을 하면 이제 상품 상세 폼을 호출하는 컨트롤러(GET /items/{id}로 리다이렉트된다.
때문에 새로고침을 해도 상품 상세 폼이 호출될뿐 더이상 중복해서 상품이 등록되지 않는다.
이런 문제 해결 방식을 PRG(Post/Redirect/Get) 라 한다.
■ RedirectAttributes
컨트롤러에서 특정 URL로 리다이렉트를 지정하고 싶으면 다음과 같은 형식으로 URL을 반환하면된다.
return "redirect:URL"
이 리다이렉트할 URL을 좀 더 처리하기 쉽게 해주는 객체가 redirectAttributes이다.
만약 리다이렉트할 URL에 변수나 쿼리 파라미터를 넣고싶다면 redirectAttributes.addAttribute()를 사용하면 된다.
[redirectAttribute 사용 예]
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/items/{itemId}";
앞의 상품 등록 컨틀롤러의 일부분이다.
redirectAttribute.addAttribute()에 Key, Value 형식으로 값을 넣을 수 있다.
저장된 값은 리다이렉트 되는 URL에 Key값을 이용해 변수로 사용할 수 있으며, 변수로 사용되지 않으면 쿼리문으로 넘겨진다.
■ 상품 수정
상품 수정 폼과 상품 수정 컨트롤러를 만들고 상품 수정 폼 뷰를 구현한다.
[상품 수정 폼 컨트롤러]
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {
private final ItemRepository itemRepository;
@GetMapping("/{itemId}/edit")
public String editFrom(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/editForm";
}
}
수정에 필요한 정보를 조회하고, 수정용 폼 뷰를 호출한다.
[상품 수정 폼 뷰 - 타임리프]
<!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="formcontrol"
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="formcontrol"
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>
상품 등록과 유사하고 특별히 크게 바뀐 부분은 없다.
[상품 수정 컨트롤러]
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {
private final ItemRepository itemRepository;
@PostMapping("{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
itemRepository.update(itemId, item);
return "redirect:/basic/items/{itemId}";
}
}
itemRepository의 update 메서드를 사용해서 상품을 수정하고 POST의 중복을 막기위해 상품 상세 컨트롤러로 리다이렉트 한다.
■ 전체 컨트롤러 모습
[전체 컨트롤러]
@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 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 save(@ModelAttribute("item") Item item,
RedirectAttributes redirectAttributes) {
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/items/{itemId}";
//return "redirect:/basic/items/";
}
@GetMapping("/{itemId}/edit")
public String editFrom(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/editForm";
}
@PostMapping("{itemId}/edit")
public String edit(@PathVariable 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));
}
}
'Spring > 스프링 MVC 기본' 카테고리의 다른 글
#6 스프링 MVC 기본 기능 (0) | 2021.07.07 |
---|---|
#5 스프링 MVC 구조 이해 (0) | 2021.06.30 |
#4 MVC 프레임워크 (0) | 2021.06.29 |
#3 JSP & MVC 패턴 적용 (0) | 2021.06.28 |
#2 서블릿 (0) | 2021.06.23 |
댓글