스프링 강의 필기/스프링 MVC 1편

7) 스프링 MVC - 웹 페이지 만들기

void_melody 2023. 1. 27. 17:03

이제 배운 것들을 토대로 간단한 게시판을 만들어보자.

 

상품을 관리할 수 있는 서비스를 만들어본다 가정하자.

 

상품에는 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이런식으로 나온다.