이제 배운 것들을 토대로 간단한 게시판을 만들어보자.
상품을 관리할 수 있는 서비스를 만들어본다 가정하자.
상품에는 id, 이름, 가격, 수량이라는 속성이 존재한다.
이 상품의 목록, 상세, 등록, 수정할 수 있는 기능을 구현하려 한다.
@Data
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;
}
}
Integer로 한 이유는 price나 quantity가 0이 아니라 아예 값으로써 존재하지 않는 null 값으로 존재할 수 있기 때문이다.
예를 들어 사과가 0개라고 표현하기보다는 아예 없다는 null로 표현하는 것이 현실 의미로 좀더 부합하니까
@Repository
public class ItemRepository{
private static final Map<Long, Item> store = new HashMap<>();
private static long sequence = 0L; // 이 두 변수는 메모리 상에 남아있어야하므로 static
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();
}
}
상품 저장소가 정상적으로 작동하는지 test를 해보자.
class ItemRepositoryTest{
ItemRepository itemRepository = new ItemRepository();
// 매 번 테스트마다 남아있는 값 없애기 위해 시행
@AfterEach
void afterEach(){
itemRepository.clear();
}
@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
Item updateParam = new Item("item2", 20000, 30);
itemRepository.update(itemId, updateParam);
Item findItem = itemRepository.findById(itemId);
//then
assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
타임리프부분은 생략하구.. 핵심 컨트롤러 위주로 보겠다.
타임리프는 나중에 떠올릴 정도로 설명하자면,
th:xxx로 해서 웹 브라우저가 th부분을 사용해 기존 것을 대체하고 안되면 그냥 기존 것을 사용.
<!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/${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>
주요 문법이라고 한다면
1. @{...} = URL 링크를 표현할 때 사용
2. |...| = 리터럴 대체 문자
타임리프에서 문자와 표현식 등은 분리되어 있어서 문자열 연산처럼 더해서 사용해야한다.
<span th:text="'Welcome to our application, ' + ${user.name} + '!'">
<span th:text="|Welcome to our application, ${user.name}!|">
3. th:each
반목문이다.
<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>
4. 변수표현식
${item.price}
실제로는 item.getPrice()처럼 프로퍼티 접근법으로 작동한다.
@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));
}
localhost:8080/basic/items가 Get으로 호출되면 itemRepository에서 모든 상품을 조회한 다음,
모델에 담고 뷰 템플릿을 호출한다.
@RequiredArgsConstructor의 경우
final이 붙어있는 멤버변수에 한해서 생성자가 딱 한 개 있으면 @AutoWired로 의존관계를 주입해준다.
public BasicItemController(ItemRepository itemRepository){
this.itemRepository = itemRepository;
}
@PostConstruct의 경우 초기화 용도로 사용된다.
말 그대로 의존관계가 다 주입된 이후에 작동.
이제 상품 상세 컨트롤러도 봐보자.
@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";
}
PathVariable로 넘어온 상품 ID를 조회해서 모델에 넣어주고 있다.
이제는 상품 등록 폼을 보자.
@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";
}
상품 등록폼은 그냥 뷰 템플릿만 호출하고 있다.
그러면 뷰 템플릿에서 값들을 받고 Post 형식으로 데이터를 전달하겠지.
이 요청 파라미터 형식을 처리해야하므로, @RequestParam을 사용해보자.
@PostMapping("/add")
public String addItemV1(@RequestParam String itemName,
@RequestParam int price,
@RequestParam Integer 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";
}
RequestParam으로 값들을 받고 객체를 만들어서 넣어줬다.
하지만 변수들이 많아진다면 RequestParam의 수가 많아져 불편하다.
이번엔 @ModelAttribute를 사용해서 한번에 처리해보자.
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model){
itemRepository.save(item);
//model.addAttribute("item", item); // ModelAttribute("이름")을 통해 자동 생성
return "basic/item";
}
@ModelAttribute는 Item객체를 생성하고 요청 파라미터의 값들을 setXXX 으로 입력해준다.
또 한가지 중요한 기능은, Model에 지정한 객체를 자동으로 넣어준다.
즉, model.addAttribute를 자동으로 해준다.
모델에 데이터를 담을 때는 이름이 필요하기에, 이름은 @ModelAttribute에서 지정한 속성을 사용한다.
예를 들어
@ModelAttribute("hello") Item item -> 이름을 hello로 지정
model.addAttribute("hello", item); -> 모델에 hello 이름으로 저장.
@ModelAttribute의 이름을 생략할 수 있을까?
ㅇㅇ 그렇다.
생략을 하면, model에 저장되는 이름은 클래스명의 첫 문자를 소문자로 해서 등록한다.
/**
* @ModelAttribute name 생략 가능
* model.addAttribute(item); 자동 추가, 생략 가능
* 생략시 model에 저장되는 name은 클래스명 첫글자만 소문자로 등록 Item -> item
*/
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item) {
itemRepository.save(item);
return "basic/item";
}
또 아예 ModelAttribute 전체를 생략할 수 있다.
/**
* @ModelAttribute 자체 생략 가능
* model.addAttribute(item) 자동 추가
*/
@PostMapping("/add")
public String addItemV4(Item item) {
itemRepository.save(item);
return "basic/item";
}
하지만 이렇게 되면 해당 인자가 ModelAttribute인지 명시가 되지 않기에 가급적이면 @ModelAttribute를 쓰는 걸 권장한다.
이제 상품 수정 컨트롤러를 만들어보자.
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model){
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/editForm";
}
이제 또 뷰 템플릿을 호출하면 사용자가 값을 입력하고 Post요청을 보내겠지? 이 Post요청을 받아야한다.
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item){
itemRepository.update(itemId, item);
return "redirect:/basic/items/{itemId}";
}
지금 상품 수정 부분을 보면, 마지막 부분에 상품 상세 화면으로 가도록 리다이렉트를 호출했다.
* PRG(Post/Redirect/Get)
우리가 지금까지 만든 부분 중 상품 등록 처리 컨트롤러(addItemV1 ~ addItemV4)에서 심각한 문제가 있다.
다시 코드를 보자.
@GetMapping("/add")
public String addForm(){
return "basic/addForm";
}
//@PostMapping("/add")
public String addItemV1(@RequestParam String itemName,
@RequestParam int price,
@RequestParam Integer 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";
}
//@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model){
itemRepository.save(item);
//model.addAttribute("item", item); // ModelAttribute("이름")을 통해 자동 생성
return "basic/item";
}
//@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item){
itemRepository.save(item);
return "basic/item";
}
//@PostMapping("/add")
public String addItemV4(Item item){
// Item -> item 으로 이름 지정
itemRepository.save(item);
return "basic/item";
}
웹 브라우저의 새로고침은 마지막에 서버에 전송한 데이터를 다시 전송한다.
등록 폼에서 데이터를 입력하고 저장 버튼을 누르면, POST + 상품데이터가 서버로 전송된다.
이 상태에서 또 새로고침을 한다면, 마지막에 전송한 POST + 상품데이터가 또 서버로 전송되기 때문에,
Post 매칭 함수에서 구현된 save가 계속 작동해진다.
그럼 어떻게 하면 될까?
마지막에 Post가 아닌 다른 부분으로 가게 하면 되지 않을까.
상품 저장후에 뷰 템플릿으로 이동하는 것이 아니라, 상품 상세 화면으로 리다이렉트를 호출해주면 된다.
웹 브라우저는 라디이렉트 때문에 상품 저장후에 상품 상세화면으로 이동한다.
그렇기에 마지막 호출한 내용이 상세 화면인 GET /items/{id}이므로 새로고침해도 문제가 생기지 않는다.
@PostMapping("/add")
public String addItemV5(Item item){
// 리다이렉트
itemRepository.save(item);
return "redirect:/basic/items/" + item.getId();
}
상품 등록 처리 이후에 뷰 템플릿이 아닌, 상품 상세화면으로 리다이렉트 하는 방식을
PRG Post/Redirect/Get 이라 한다.
현재 return부분에서 item.getId()처럼 URL에 변수를 더해서 사용하는 것은 인코딩 문제 때문에 위험성이 있다.
그래서 RedirectAttributes를 사용한다.
지금까지 상품을 저장하고, 상품 상세 화면까지 리다이렉트하는 것은 좋았다.
하지만 고객입장에서 잘 된 것인지 확인하게 하기 위해 상품 상세 화면에서 저장되었다라는 문구를 보여주게 하고 싶다.
// RedirectAttributes
@PostMapping("/add")
public String addItemV6(Item item, RedirectAttributes redirectAttributes){
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
// return에 없는 것은 쿼리파라미터로 넘어감.
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/items/${itemId}";
}
RedirectAttributes를 사용하면 URL 인코딩도 해주고, PathVariable, 쿼리파라미터까지 처리해준다.
현재 return부분에서 itemId를 pathVariable로 넣어주고
사용하지 않은 status는 쿼리 파라미터로 처리해준다.
그렇기에 리다이렉트 결과는
localhost:8080/basic/items/itemID?status=true이런식으로 나온다.
'스프링 강의 필기 > 스프링 MVC 1편' 카테고리의 다른 글
6) 스프링 mvc 기본 기능 (0) | 2022.08.03 |
---|---|
5) 스프링 MVC - 구조 이해 (0) | 2022.07.30 |
4) MVC 프레임워크 만들기 (0) | 2022.07.30 |
3) 서블릿, JSP, MVC 패턴 (0) | 2022.07.27 |
1) 웹 애플리케이션 이해 (0) | 2022.07.22 |