본문 바로가기

Programming/스프링

[김영한 스프링 MVC 1] 스프링 MVC - 웹 페이지 만들기

요구사항 분석

 

  • 상품 도메인 모델

상품 ID
상품명
가격
수량

 

 

 

  • 상품 관리 기능

상품 목록
상품 상세
상품 등록
상품 수정

 

 

  • 서비스 제공 흐름

 

백엔드 개발자: 디자이너, 웹 퍼블리셔를 통해서 HTML 화면이 나오기 전까지 시스템을 설계하고, 핵심
비즈니스 모델 개발

이후 HTML이 나오면 뷰 템플릿으로 동적 변환, 웹 화면 흐름 제어

 

웹 프론트엔드 개발자가 별도로 있으면, 웹 프론트엔드 개발자가 웹 퍼블리셔 역할까지 포함해서 하는 경우도 있음

웹 프론트엔드 개발자 => HTML을 동적으로 만들고 웹 화면의 흐름을 담당

백엔드 개발자 => HTTP API를 통해 웹 클라이언트가 필요로 하는 데이터와 기능을 제공

 

 

 

상품 도메인 개발

 

  • Item
package hello.itemservice.domain.item;

import lombok.Data;

//@Getter @Setter
@Data //Getter, Setter, RequiredArgsConstructor, ToString, EqualsAndHashCode, Value
public class Item {

    private Long id;
    private String itemName;
    private Integer price; //null 허용 위해 Integer 사용
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

 

  • ItemRepository
package hello.itemservice.domain.item;

import org.springframework.stereotype.Repository;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Repository
public class ItemRepository {

    private static final Map<Long, Item> store = new HashMap<>(); //static
    // 멀티스레드에서는 ConcurrentHashMap
    private static long sequence = 0L; //static
    // 멀티스레드에서는 AtomicLong

    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 itemFound = findById(itemId);
        itemFound.setItemName(updateParam.getItemName());
        itemFound.setPrice(updateParam.getPrice());
        itemFound.setQuantity(updateParam.getQuantity());
    }

    public void clearStore(){
        store.clear();
    }

}

 

 

  • ItemRepositoryTest
package hello.itemservice.domain.item;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

class ItemRepositoryTest {

    ItemRepository itemRepository = new ItemRepository();

    @AfterEach
    void afterEach(){
        itemRepository.clearStore();
    }

    @Test
    void save() {
        //given
        Item item = new Item("itemA", 10000, 10);

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

        //then
        Item itemFound = itemRepository.findById(savedItem.getId());
        assertThat(itemFound).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 update() {
        //given
        Item item = new Item("item", 20000, 20);
        Item savedItem = itemRepository.save(item);
        Long id = savedItem.getId();

        //when
        Item updateParam = new Item("item2", 23000, 10);
        itemRepository.update(id, updateParam);

        //then
        Item foundItem = itemRepository.findById(id);
        assertThat(foundItem.getItemName()).isEqualTo(updateParam.getItemName());
        assertThat(foundItem.getPrice()).isEqualTo(updateParam.getPrice());
        assertThat(foundItem.getQuantity()).isEqualTo(updateParam.getQuantity());
    }

}

 

 

 

상품 목록 (타임리프)

 

부트 스트랩 활용 ( https://getbootstrap.com/docs/5.0/getting-started/download/ )
resources/static/css/bootstrap.min.css

※ /resources/static => 전체 공개되는 정적 리소스 (HTML)
※ /resources/templates => 뷰 템플릿 (타임리프)

 

 

 

  • 타임리프

th:xxx 가 붙은 부분은 서버사이드에서 렌더링되어 기존 HTML 속성 대체

HTML을 파일로 직접 열었을 때 th:xxx 있어도 웹 브라우저는 th: 속성 알지 못해 무시함

∴ 순수 HTML을 유지하면서 뷰 템플릿도 사용 가능 => 내추럴 템플릿

 

 

  • BasicItemController
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor // final 붙은 변수를 사용해 생성자 자동 생성
public class BasicItemController {

    private final ItemRepository itemRepository;

    /* @RequiredArgsConstructor로 생략
    @Autowired // 필드 하나면 생략 가능
    public BasicItemController(ItemRepository itemRepository) {
        this.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));
    }

}

 

 

  • 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"> <!--뷰 템플릿 거치면 th:href 값이 href로 대체되어 동적 변경-->
</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> <!--url 링크-->
          <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>

 

 

 

상품 상세

 

  • BasicItemController
    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model){
        Item item = itemRepository.findById(itemId);

        model.addAttribute("item", item);

        return "basic/item";
    }

 

 

  • 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> <!-- /container -->
</body>
</html>

 

 

 

상품 등록 폼

 

  • BasicItemController
    @GetMapping("/add")
    public String addForm(){
        return "basic/addForm";
    }

 

 

  • addForm.html
<!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">
  <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"><!--th:action 값 없으면 현재 URL에 데이터 전송--->
    <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>

 

 

 

상품 등록 처리 - @ModelAttribute

 

  • POST - HTML Form

content-type: application/x-www-form-urlencoded

메시지 바디에 쿼리 파리미터 형식으로 전달 ex) itemName=itemA&price=10000&quantity=10

 

 

  • BasicItemController
    //@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){ //item이라는 key 값으로 model에 자동 추가
        itemRepository.save(item);

        //model.addAttribute("item", item);

        return "basic/item";
    }

    //@PostMapping("/add")
    public String addItemV3(@ModelAttribute Item item){ //괄호 생략 시 클래스명 첫글자만 소문자로 바꿔 model 저장
        itemRepository.save(item);

        return "basic/item";
    }

    //@PostMapping("/add")
    public String addItemV4(Item item){ // String, int 외 타입은 @ModelAttribute로 인식
        itemRepository.save(item);

        return "basic/item"; //폼 새로고침 시 계속 POST 호출 -> 계속 등록됨 => PRG(post redirect get) 필요
    }

 

 

 

상품 수정

 

  • BasicItemController

 

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

        return "redirect:/basic/items/{itemId}"; //Pathvariable 값 활용
    }

 

 

※HTML form 전송은 PUT, PATCH 지원 X

 

 

 

Post / Redirect / Get

 

  • BasicItemController
    //@PostMapping("/add")
    public String addItemV4(Item item){ // String, int 외 타입은 @ModelAttribute로 인식
        itemRepository.save(item);

        return "basic/item"; //등록 후 새로고침 시 계속 POST 호출 -> 계속 등록됨 => PRG(post redirect get) 필요
    }

    //@PostMapping("/add")
    public String addItemV5(Item item){
        itemRepository.save(item);

        return "redirect:/basic/items/" + item.getId(); //상품 상세 페이지로 redirect //getId() => 인코딩 안돼 위험
    }

 

 

 

RedirectAttributes

 

: URL 인코딩, Pathvariable 쿼리 파라미터 처리

 

  • BasicItemController
    @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}"; //getId
    }