Item Management Application Practice

Libary Dependencies

  • Spring Web
  • Thymeleaf
  • Lombok

Business Requirements

  1. Item Domain
    • 상품 ID
    • 상품명
    • 가격
    • 수량
  2. Item Functions
    • 상품 목록
    • 상품 상세
    • 상품 등록
    • 상품 수정

controller_form_structure

위의 요구사항과 controller 구조를 바탕으로 예제를 만들어 보자

Implementation

Domain

@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;
    }
}

Repository

@Repository
public class ItemRepository {
    private Map<Long, Item> items=new HashMap<>();
    private static Long sequence=0L;

    public Item save(Item item){
        item.setId(++sequence);
        items.put(item.getId(),item);
        return item;
    }
    public Item findById(Long id){
        return items.get(id);
    }
    public List<Item> findAll(){
        return new ArrayList<>(items.values());
    }
    public void updateItem(Long itemId, Item updatedItem){
        Item item = findById(itemId);
        item.setItemName(updatedItem.getItemName());
        item.setPrice(updatedItem.getPrice());
        item.setQuantity(updatedItem.getQuantity());
    }
    public void deleteItem(Long itemId){
        items.remove(itemId);
    }
    public void clearStore(){
        items.clear();
    }
}

Repository Test

ItemRepository itemRepository=new ItemRepository();
@AfterEach
public void afterEach(){
    itemRepository.clearStore();
}

@Test
public void save(){
    //given
    Item item=new Item("item1",1000,1);

    //when
    Item savedItem = itemRepository.save(item);
    Item findItem = itemRepository.findById(savedItem.getId());

    //then
    Assertions.assertThat(savedItem).isEqualTo(findItem);
}

@Test
public void findAll(){
    //given
    Item item=new Item("item1",1000,1);
    Item item2=new Item("item2",3000,1);

    //when
    Item savedItem=itemRepository.save(item);
    Item savedItem2=itemRepository.save(item2);
    List<Item> items=itemRepository.findAll();

    //then
    Assertions.assertThat(items.size()).isEqualTo(2);
}
@Test
public void updateItem(){
    //given
    Item item=new Item("item1",1000,1);
    Item savedItem=itemRepository.save(item);
    Long itemId=savedItem.getId();

    //when
    Item updateParam=new Item("item2",2000,2);
    itemRepository.updateItem(itemId,updateParam);
    Item updatedItem=itemRepository.findById(itemId);

    //then
    Assertions.assertThat(updateParam.getItemName()).isEqualTo(updatedItem.getItemName());
    Assertions.assertThat(updateParam.getPrice()).isEqualTo(updatedItem.getPrice());
    Assertions.assertThat(updateParam.getQuantity()).isEqualTo(updatedItem.getQuantity());
}
@Test
public void deleteItem(){
    //given
    Item item=new Item("item1",1000,1);
    Item savedItem=itemRepository.save(item);
    Long itemId=savedItem.getId();

    //when
    itemRepository.deleteItem(itemId);
    Item findItem = itemRepository.findById(itemId);

    //then
    Assertions.assertThat(findItem).isNull();
}

Thymeleaf Syntax

사용 선언

<html xmlns:th="http://www.thymeleaf.org">

html 속성 변경

<link href="../css/bootstrap.min.css" th:href="@{/css/bootstrap.min.css}" rel="stylesheet">

th:xxx 을 이용해서 html의 xxx속성 값을 대체 할 수 있다. 위의 경우 href 속성을 대체하는 것이다.

html만 실행했을 때는 href가 그대로 유지 되는데, 뷰 템플릿을 거치고 나면(서버사이드 랜더링을 통해) th:href의 값으로 교체된다. –> html을 그대로 사용해도 되는 natural template 성질을 가지고 있음

링크 표현식

<link th:href="@{/css/bootstrap.min.css}"/>

링크를 표현할때는 @{} 구조로 나타내야하는데, 이 구조를 URL 링크 표현식이라고 한다.

리터럴 교체

<button class="btn btn-primary float-end" th:onclick="|location.href='@{/basic/items/add}'|" type="button">상품 등록</button>

리터럴을 사용하지 않았을 때는 아래와 같이 복잡하게 만들어야 했는데 th:onclick="'location.href=' + '\'' + @{/basic/items/add} + '\''"

리터럴 사용하게되면 해당 표현식을 아래와 같이 간단하게 표현할 수 있게 된다.


> 반복문

```html
<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}">상품 가격</td>
    <td th:text="${item.quantity}">상품 수량</td>
</tr>

th:each 태그를 이용하게 되면 특정 html 태그를 반복해서 생성할 수 있다. 위의 경우 model로 전달된 items 내에 있는 item에 대해서 테이블 행(tr태그)를 반복적으로 만들어낸다.

변수 접근

<td th:text="${item.price}">10000</td>

${}을 이용해서 모델로 전달된 값이나, 타임리프 변수값을 이용할 수 있다.

html 태그 내용 수정

<td th:text="${item.price}">10000</td>

th:text을 이용해서 해당 태그의 내용을 수정할 수 있다.

타임리프 url 생성법

<a href="item.html" th:href="@{/basic/items/{itemId} (itemId=${item.id})}" th:text="${item.id}">상품 id</a>

위의 경우, itemId라는 path variable 생성하고 옆에서 해당 값을 담아주는 것을 확인할 수 있다.

th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}"

또한, 위와 같이 quert=’test’를 추가로 설정하게 되면 쿼리 파라미터도 아래와 같이 추가되는 것을 확인할 수 있다.


### Functions

#### 상품 목록

##### Controller

```java
private final ItemRepository itemRepository;
@GetMapping
public String items(Model model){
    List<Item> items=itemRepository.findAll();
    model.addAttribute("items",items);
    return "basic/items";
}

repository로 모든 아이템 리스트를 찾아서 모델을 이용해서 View로 전달

View
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link href="../css/bootstrap.min.css" th.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>
    <h2 th:if="${param.deleteStatus}" th:text="'삭제 완료'"></h2>
    <div class="row">
        <div class="col">
            <button class="btn btn-primary float-end"
                    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}">상품 가격</td>
                <td th:text="${item.quantity}">상품 수량</td>
            </tr>

            </tbody>
        </table>
    </div>
</div> <!-- /container -->
</body>
</html>

상품 상세

Controller
@GetMapping("/{itemId}")
public String getItem(@PathVariable Long itemId, Model model){
    Item item=itemRepository.findById(itemId);
    model.addAttribute("item",item);
    return "basic/item";
}
View
<!DOCTYPE HTML>
<html xmlns:th="www.thymeleaf.org">
<head>
  <meta charset="utf-8">
  <link href="../css/bootstrap.min.css" th: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.addStatus}" th:text="'저장 완료'"></h2>
  <h2 th:if="${param.updateStatus}" 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-primary btn-lg"
              onclick="location.href='editForm.html'" th:onclick="|location.href='@{/basic/items/{itemId}/delete(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>

상품 등록

Controller
//상품 등록 폼
@GetMapping("/add")
public String addForm(){
    return "basic/addForm";
}

@PostMapping("/add")
public String addItem(Item item,Model model){
    itemRepository.save(item);
    return "basic/item";
}

다음과 같이 form의 action에 부분에 응답 url을 지정하지 않으면 요청이 온 것으로 다시 form이 전송된다. 따라서 같은 url 매핑에 대해 http method 만 달리해서 처리하는 것이 가능하다.

위의 경우, Form으로 전달된 상품 정보를 @ModelAttribute이 생략된 Item이 받는다.

또한, @ModelAttribute가 객체 매핑을 할때 사용되지만, 추가로 Model에 자동으로 @ModelAttribute를 지정한 객체를 자동으로 넣어준다.

@ModelAttribute("item") Item item
//model.addAttribute("item",item)

위와 같이 @ModelAttribute에 name을 명시하게 되면 해당 name으로 model에 attribute가 추가된다.

만약 name이 지정되지 않은 경우 @ModelAttribute를 지정한 객체의 클래스의 첫번째 글자를 소문자를 name으로 한 attribute이 등록된다.

위의 경우 name이 생략되어 Item -> item으로 인식된다.

View
<!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="formcontrol"
             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="formcontrol"
             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>

상품 수정

Controller
@GetMapping("/{itemId}/edit")
public String editItemForm(@PathVariable Long itemId,Model model){
    Item item=itemRepository.findById(itemId);
    model.addAttribute("item",item);
    return "basic/updateForm";
}

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item){
    itemRepository.updateItem(itemId,item);
    return "basic/items/{itemId}";
}

상품 생성과 마찬가지로 하나의 url 매핑으로 처리한다.

View
<!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
@GetMapping("/{itemId}/delete")
public String deleteForm(@PathVariable Long itemId,Model model){
    Item findItem = itemRepository.findById(itemId);
    model.addAttribute("item",findItem);
    return "basic/deleteForm";
}

@PostMapping("/{itemId}/delete")
public String delete(@PathVariable Long itemId,@ModelAttribute Item item){
    itemRepository.deleteItem(itemId);
    return "basic/items";
}

상품 생성과 마찬가지로 하나의 url 매핑으로 처리한다.

View
<!DOCTYPE HTML>
<html xmlns:th="www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link href="../css/bootstrap.min.css" th: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}" 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" 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 방식으로 서버로 해당 기능을 요청하는 데, 이때 작업을 마치고 나서 refresh(새로고침)을 하게 되면 맨 마지막에 실행된 요청인 post가 재실행되어 작업이 중복되는 문제가 발생한다.

post_problem

그래서 post가 완료되면 아래와 같이 get으로 redirect해주는 작업이 필요하다. redirect으로 하게 되면 forward와 달리 client가 redirect된 url로 요청을 하는 거니, 맨 마지막에 실행된 요청이 get으로 되어 새로고침해도 post가 재실행되는 문제는 발생하지 않는다.

redirect_to_get

redirect을 적용하면 아래와 같이 된다.

@PostMapping("/add")
public String addItem(Item item) {
    itemRepository.save(item);
    return "redirect:/basic/items/" + item.getId();
}

위의 redirect 예제에는 문제가 한가지 존재하는데, item.getId()는 url 인코딩 되어 있지 않아서 직접적으로 더하는 것은 좋지 않다. 그래서 이를 지원해주기 위해 RedirectAttributes 라는 클래스가 제공된다.

RedirectAttributes

RedirectAttributes을 이용해서 url 인코딩을 수행할 수있고, query parameter도 등록할 수 있다.

@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}";
}

위의 controller는 아래의 url로 redirect 된다


addAttribute에 대해 첫번째 addAttribute은 path variable로 설정하고 나머지는 query parameter로 붙인다.

여기서 status=true라는 query paramter을 붙이는데, 이렇게 status 변수를 붙여서 상품 저장이 정상적으로 됬다라는 것을 알리기 위해 변수를 전달하는 것이다.

>item.html

```html
<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>

그러면 view에서는 전달된 status 변수의 값을 보고 위의 h2 태그를 실행하게 된다.

th:if는 특정 조건이 참일때만 태그가 보이도록 한다. 위의 경우 상품이 생성이 완료되어 ?status=true가 전달되면 view에서 저장완료 태그가 보이도록 된다.

타임리프에서 query parameter 값을 확인하기 위해 param 객체를 지원한다.

@PostMapping Controller에 redirect 추가

상품 등록

@PostMapping("/add")
public String addItemV6(Item item, RedirectAttributes redirectAttributes){
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("addStatus", true);
    return "redirect:/basic/items/{itemId}";
}

상품 수정

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item,RedirectAttributes redirectAttributes){
    itemRepository.updateItem(itemId,item);
    redirectAttributes.addAttribute("updateStatus", true);
    return "redirect:/basic/items/{itemId}";
}

상품 제거

@PostMapping("/{itemId}/delete")
public String delete(@PathVariable Long itemId,@ModelAttribute Item item,RedirectAttributes redirectAttributes){
    itemRepository.deleteItem(itemId);
    redirectAttributes.addAttribute("deleteStatus", true);
    return "redirect:/basic/items";
}

References

link: inflearn

link:springmvc

댓글남기기