관리 메뉴

나구리의 개발공부기록

스프링 MVC - 웹 페이지 만들기, 상품 상세, 상품 등록 폼, 상품 등록 처리 - @ModelAttribute, 상품수정, PRG Post/Redirect/Get, RedirectAttributes 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술

스프링 MVC - 웹 페이지 만들기, 상품 상세, 상품 등록 폼, 상품 등록 처리 - @ModelAttribute, 상품수정, PRG Post/Redirect/Get, RedirectAttributes

소소한나구리 2024. 3. 9. 23:03

  출처 : 인프런 - 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 (유료) / 김영한님  
  유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  

https://inf.run/Gmptq


1. 상품 상세

1) 상품 상세 구현

(1) BasicItemController - 상품 상세 추가

  • PathVariable로 넘어온 상품 ID로 상품을 조회 하고 모델에 담아둔 뒤 뷰 템플릿을 호출
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
    Item item = itemRepository.findById(itemId);
    model.addAttribute("item", item);
    return "basic/item";
}

 

(2) 상품 상세 뷰 - 타임리프 적용

  • /resources/static/html 경로의 item.html 복사 후 수정
  • 속성 변경 th:value="${item.id}": 모델에 있는 item정보를 획득하고 프로퍼티 접근법으로 출력(item.getId())하고, value속성을 th:value속성으로 변경
  • 상품 수정 링크: th:onclick="|location.href='@{/basic/items/{itemId}edit(itemId=${item.id})'|"
  • 상품 목록 링크: th:onclick="|location.href='@{/basic/items}'|"
<!DOCTYPE HTML>
<!-- 타임리프 선언 -->
<html xmlns:th="http://www.thymeleaf.org">

... 동일 코드 생략

    <!-- CSS 적용 -->
    <link href="../css/bootstrap.min.css"
          th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
            
... 동일 코드 생략

        <label for="itemId">상품 ID</label>
        <input type="text" id="itemId" name="itemId" class="form-control" value="1" th:value="${item.id}" readonly>
    </div>
    <!-- 동일하게 itemName,itemPrice, itemQuantity의 value값을 th:value="${item.id}"의 값으로 치환 -->
  
   ... 동일 코드 생략


        <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>
                    
    ... 동일 코드 생략
            
            <!-- 상품 목록 링크 변경 -->
            <button class="w-100 btn btn-secondary btn-lg"
                    onclick="location.href='items.html'"
                    th:onclick="|location.href='@{/basic/items}'|"
                    type="button">목록으로</button>
                    
    ... 동일 코드 생략
    
</html>

2. 상품 등록 폼

1) 상품 등록 폼 구현

(1) BasicItemController - 상품 등록 폼 추가

  • 상품 등록 폼과 상품 저장이 같은 URL이지만 HTTP 메서드로 구분됨
@GetMapping("/add")
public String addForm() {
    return "basic/addForm";
}
// 같은 URL인데 HTTP 메서드로 기능을 구분
@PostMapping("/add")
public String save() {
    return "basic/addForm";
}

 

(2) 상품 등록 뷰 - 타임리프적용

  • /resources/static/html 경로의 addForm.html 복사 후 수정
  • 속성 변경 th:action : HTML form에서 action에 값이 없으면 현재 URL에 데이터를 전송함
  • 하나의 URL로 HTTP메서드를 다르게 적용하여 등록 폼과 등록 처리 기능을 구분했기 때문에 깔끔하게 처리할 수 있음
  • th:onclick="|location.href='@{/basic/items}'|": 취소 시 상품 목록으로 이동
<!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">
          
    ... 동일 코드 생략
    
    <!--th:action에 아무런 값이 없으면 현재 URL에 값을 넘김 -->
    <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 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>
                        
    ... 동일 코드 생략
    
</html>

3. 상품 등록 처리 - @ModelAttribute

1) 상품 등록 처리

(1) BasicItemController - addItemV1(@RequestParam)

  • save()메서드를 수정하여 상품 등록을 처리
  • @RequestParam으로 변수를 하나씩 받아서 Item객체를 생성해고 itemRepository를 통해 저장 후 모델에 담아 뷰로 반환
  • 중요!: 상품상세에서 사용한 item.html 뷰템플릿을 그대로 재활용, return "basic/item";
  • setter로 값을 입력했지만 Item객체를 생성시 생성자를 통해 값을 입력해도 됨
@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";
}

 

(2) BasicItemController - addItemV2(@ModelAttribute)

  • @ModelAttribute - 요청 파라미터 처리: @ModelAttribute는 Item객체를 생성하고 요청 파라미터의 값을 프로퍼티 접근법(setXxx)로 입력해줌
  • @ModelAttribute - Model 추가: @ModelAttribute는 모델(Model)에 @ModelAttribute로 지정한 객체를 자동으로 넣어주기 때문에 model.addAttribute()코드를 생략해도 됨
  • 모델에 담을 때는 이름이 필요한데 @ModelAttribute에 ("item") 처럼 지정한 이름을 사용하고 만약 아래처럼 아래처럼 이름을 다르게 지정하면 다른 이름이 모델에 포함됨
    - @ModelAttribute("hello") Item item -> @ModelAttribute 이름을 hello으로 지정
    - model.addAttribute("hello", item); -> item 객체가 Model에 hello이름으로 저장 됨
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item, Model model) {

    itemRepository.save(item);

    // @ModelAttribute("item")을 하면 model.addAttribute("item", item) 코드가 자동으로 추가가 됨ㅁ
//    model.addAttribute("item", item);

    return "basic/item";
}

// 즉, 파라미터의 Model model도 생략이 가능하여 아래처럼 작성해도 됨
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item) {
    itemRepository.save(item)
    return "basic/item";
}

 

(3) BasicItemController - addItemV3(@ModelAttribute의 이름 생략)

  • @ModelAttribute의 이름을 생략하면 모델에 저장될 때 클래스명을 사용하며 클래스의 첫글자만 소문자로 변경해서 @ModelAttribute의 이름으로 등록됨(Item -> item)
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item) {
    // @ModelAttribute("item") 괄호도 생략이 가능함 -> V2 버전과 동일하게 동작함
    // @ModelAttribute 객체 매개변수에서 객체의 첫 대문자가 소문자로 변환되어서 @ModelAttribute 이름으로 등록 됨
    itemRepository.save(item);
    return "basic/item";
}

 

(4) BasicItemController - addItemV4(@ModelAttribute자체를 생략)

  • @ModelAttribute 자체를 생략하도 동작하며 V3와 동작되는 방식은 동일함
  • 파라미터에 단순타입이오면 @RequestParam으로 동작하고 그 외의 타입이오면 @ModelAttribute 타입으로 동작됨
  • 그러나, 이렇게까지 생략을 해야할 필요성이 있는지는 고민하면서 사용할 것
@PostMapping("/add")
public String addItemV4(Item item) {
 // @ModelAttribute 자체도 생략이 가능, V3와 동작되는 방식은 동일
    itemRepository.save(item);
    return "basic/item";
}

4. 상품 수정

1) 상품 수정 적용

(1) BasicItemController -  상품 수정 폼 추가

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

 

(2) 상품 수정 폼 뷰 - 타임리프 적용

  • /resources/static/html 경로의 editForm.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">
          
    ... 동일 코드 생략
    
        <form action="item.html" th:action method="post">
            <label for="id">상품 ID</label>
            <input type="text" id="id" name="id" class="form-control" value="1" th:value="${item.id}" readonly>
            
        ... 동일 코드 생략
        
            <input type="text" id="itemName" name="itemName" class="form-control" value="상품A" th:value="${item.itemName}">
            
        ... 동일 코드 생략
        
            <input type="text" id="price" name="price" class="form-control" value="10000" th:value="${item.price}">
            
        ... 동일 코드 생략
        
            <input type="text" id="quantity" name="quantity" class="form-control" value="10" th:value="${item.quantity}">
            
        ... 동일 코드 생략
        
                <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>
                        
        ... 기존 코드 동일
</html>

 

(3) BasicItemController -  상품 수정 처리 추가

  • 상품 등록 프로세스와 유사하게 MappingURL은 동일하게 하면서 전송 방식만 GET에서 POST로 변경하여 처리
  • redirect: 스프링에서는 redirect:/... 으로 편하게 리다이렉트를 할 수 있도록 지원하며 컨트롤러에 매핑된 @PathVariable의 값을redirect에도 사용할 수 있음
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
    itemRepository.update(itemId,item);
    return "redirect:/basic/items/{itemId}"; // 리다이렉트
}

 

** 참고

  • 리다이렉트에 대한 자세한 내용은 HTTP 강의를 참고
  • HTML Form 전송은 GET, POST만 사용할 수 있으나 스프링에서는 히든 필드를 통해서 PUT, PATCH 매핑을 사용할 수 있음
  • 그러나 이것도 HTTP 요청상으로는 POST 요청임

5. PRG Post/Redirect/Get

1) Post, Redirect Get - 실무에서 자주 사용함

(1) 문제 상황

  • 지금까지의 상품 등록 처리 컨트롤러(addItemv1 ~ addItemv4)에는 상품을 등록완료하고 웹브라우저의 새로고침을 누르면 상품이 계속 중복 등록 됨
  • 그 이유는 웹 브라우저의 새로 고침은 마지막에 서버에 전송한 데이터를 다시 전송하는데, 상품 등록 폼에서 데이터를 입력하고 저장하여 서버로 전송한 데이터를 새로 고침을 하게되어 해당 데이터를 다시 서버로 전송하게 되기 때문임
  • 이 부분은 상품 수정에서 했던것 처럼 redirect로 반환하여 해결할 수 있음

(2) 기존 addItemv1 ~ addItemv4 상품 등록 컨트롤러의 저장 방식

  1. Get /add로 상품 등록 폼 컨트롤러에 요청하면 GET 으로 상품 등록 폼 뷰를 응답
  2. POST /add 로 상품저장 컨트롤러에 요청하면 POST로 상품 상세 뷰를 응답 (여기에서 POST /add가 최종 요청)
  3. 새로고침 (마지막의 요청을 재요청)을 하면 POST /add가 다시 요청되면서 상품이 저장이 되고 그 저장한 상품 상세 뷰가 반환

(3) Redirect GET을 이용하여 문제 해결

  1. 동일
  2. POST /add로 상품저장 컨트롤러에 요청하면 상품상세 컨트롤러로 Redirect /iitems/{id} 호출
  3. 클라이언트(웹브라우저)가 Redirect의 영향으로 GET /items/{id}로 상품상세 컨트롤러에 요청 후 상품 상세 뷰가 응답
  4. 여기에서 새로고침하면 Get /items{id} 가 호출되면서 상품상세 뷰만 계속 응답 됨

좌) POST 등록 후 새로고침 / 우) POST, Redirect GET 적용

 

(3) BasicItemController - addItemV5(새로고침하면 상품이 등록되는 문제를 해결, PRG 방식 적용)

  • 상품 등록 처리 이후에 뷰 템플릿이 아니라 상품 상세 화면으로 리다이렉트 

** 주의!

  • return redirect: 경로 + item.getId(); 처럼 URL 변수를 더해서 사용하는 것은 URL인코딩이 안되기 때문에 위험함
  • 다음에 설명하는 RedirectAttributes를 사용하여 문제 해결
// 새로고침하면 계속 상품이 등록되는 문제를 해결
@PostMapping("/add")
public String addItemV5(Item item) {
    itemRepository.save(item);
    return "redirect:/basic/items/" + item.getId();
}

6. RedirectAttributes

1) RedirectAttributes

(1) 요구사항 추가

  • 상품을 저장하고 상품 상세 화면으로 리다이렉트하여 새로 고침에 의해 상품 등록이 중복으로 되는 것을 막은 것은 해결되었음
  • 그러나 고객입장에서 저장이 잘 되었는지 확인이 불분명하여 저장이 잘 되었으면 상품상세 화면에 "저장되었습니다"라는 메시지가 출력되도록 요구사항이 추가

(2) BasicItemController - addItemV6(RedirectAttributes 사용)

  • 리다이렉트를 할 때 status=true를 추가하여 뷰 템플릿에서 이 값이 있으면 '저장되었습니다'라는 메시지를 출력
  • RedirectAttributes를 사용하면 URL인코딩, PathVariable, 쿼리 파라미터까지 처리해줌
    - redirect:/basic/items/{itemId}, PathVariable 바인딩으로 itemId 처리
    - 나머지는 쿼리 파라미터로 처리, ?status=true
// 저장이 잘 되었는지 확인하는 문구를 추가
@PostMapping("/add")
public String addItemV6(Item item, RedirectAttributes redirectAttributes) {
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    
    // getId() -> {itemId} 치환
    // 나머지는 쿼리 파라미터로 처리 (?status=true)
    return "redirect:/basic/items/{itemId}"; 

}

 

(3) item.html 수정 - 뷰 템플릿에 메시지 추가

  • th:if: 해당 조건이 참이면 실행
  • ${param.status}: 타임리프에서 쿼리 파라미터를 조회하는 기능
    원래는 컨트롤러에서 모델에 담아서 값을 꺼내야 하지만 쿼리 파라미터는 자주 사용하여 타임리프에서 직접 지원함
<html>

... 동일 코드 생략

    <div class="py-5 text-center">
        <h2>상품 상세</h2>
    </div>

    <!-- 저장완료 시 param.status가 ture면 저장완료라는 문구를 띄움 -->
    <h2 th:if="${param.status}" th:text="'저장 완료'"></h2>

... 동일 코드 생략

</html>

 

(4) 실행 결과

  • 실행해보면 최종적으로 상품을 등록하면 상품이 저장되었다는 문구가 뜨게되고, 저장하지 않고 상품 상세화면으로 돌아가면 해당 문구는 보이지 않음

** 쿼리파라미터에 정보를 남지 않게 하려면 아래처럼 작성

@PostMapping("/add")
public String addItemV6(Item item, RedirectAttributes redirectAttributes) {
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());

    // 아래처럼 하면 쿼리파라미터에 정보가 남지 않아 보안에 적합
    redirectAttributes.addFlashAttribute("status", "저장 완료");
    return "redirect:/basic/items/{itemId}";
}
  <!-- redirectAttributes.addFlashAttribute("status", "저장 완료"); 사용 시 -->
    <h2 th:if="${status}" th:text="${status}"></h2>