RE-Heat 개발자 일지

스프링 MVC 1편 - [7] 웹 페이지 만들기 [하편] 본문

백엔드/스프링

스프링 MVC 1편 - [7] 웹 페이지 만들기 [하편]

RE-Heat 2023. 7. 6. 18:17

출처 : https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 - 인프런 | 강의

웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., -

www.inflearn.com

인프런 김영한님의 스프링 MVC 1편 강의를 듣고 정리한 내용입니다.

 

[6] 상품 상세

상품 상세 컨트롤러와 뷰 개발.

BasicItemController - item()

 

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

 

참고] @RequestParam vs @PathVariable

https://velog.io/@dmchoi224/Rest-API-RequestParam-%EA%B3%BC-PathVariable

 

Rest API , @RequestParam 과 @PathVariable

REST API REST API 란 REST API 에서 REST는 Representational State Transfer 의 약자로 소프트웨어 프로그램 아키텍처의 한 형식. 즉, 자원을 이름 (자원의 표현) 으로 구분하여 해당 자원의 상태 (정보)를 주고 받

velog.io

http://restapi.com?userId=test&memo=테스트 [쿼리 스트링 방식]
http://restapi.com/test/테스트 [RESTful 방식]

@RequestParam은 쿼리 스트링에서 사용한다.

@PathVariable은 값을 하나만 받아올 수 있으므로 Restful방식에서 활용

 

/resources/templates/basic/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"
                    th:onclick="|location.href='@{/basic/items}'|"
                    onclick="location.href='items.html'"
                    type="button">목록으로</button>
        </div>
    </div>

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

=>items.html의 상품ID 혹은 상품명을 클릭하면 localhost:8081/basic/items/1로 넘어감


[7] 상품 등록 폼

BasicItemController : addForm 추가

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

/resources/templates/basic/addForm.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>

    <h4 class="mb-3">상품 입력</h4>

    <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>
        <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"
                        th:onclick="|location.href='@{/basic/items}'|"
                        onclick="location.href='items.html'"
                        type="button">취소
                </button>
            </div>
        </div>

    </form>

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

① th:action : HTML form에서 action에 값이 없으면 현재 URL을 데이터에 전송한다.

② 취소 : 취소 시 상품 목록으로 이동 th:onclick="|location.href='@{/basic/items}'|"

 

[8] 상품 등록 처리 - @ModelAttribute

상품 등록 폼에 전달된 데이터를 바탕으로 실제 상품을 등록 처리하자!

 

BasicItemController : 상품등록

//@PostMapping("/add")
public String addItemV1(@RequestParam String itemName,
                        @RequestParam Integer 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) {

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

    return "basic/item";
}

//@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item) {
    //@ModelAttribute에 이름을 넣지 않으면 클래스의 첫번째 대문자를 소문자로 바꿔서 이름을 넣어 줌.
    //ex) Item -> item ==> model.addAttribute("item", item);
    //ex) HelloData -> helloData => model.addAttribute("helloData", helloData)


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

    return "basic/item";
}

//@PostMapping("/add")
public String addItemV4(Item item) {
    //@ModelAttribute 생략도 가능. but 비추천

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

    return "basic/item";
}

■ 상품등록 V1

① 요청파라미터 형식이므로 @RequestParam를 사용.

② Item 객체를 생성하고 ItemRepository를 통해 저장

③ 저장된 item을 모델에 담은 후 뷰에 전달

 

■ 상품등록 V2

① @ModelAttribute로 변수를 한 번에 받음. 

   @ModelAttribute는 Item객체를 생성하고 모델에 데이터를 담는 역할까지 해줌.

 

■ 상품등록 V3

① @ModelAttribute의 이름("item")을 생략하면 클래스명의 첫 글자만 소문자로 변경해 등록한다.

      ex) Item -> item ==> model.addAttribute("item", item);
           HelloData -> helloData => model.addAttribute("helloData", helloData)

 

■ 상품등록 V4

① @ModelAttribute 자체도 생략 가능.

다만 @ModelAttribute까지 생략하면 가독성에 좋지 않을 수 있어 영한님은 비추천하심.


[9] 상품 수정

등록한 상품을 수정해 보자!

 

상품 수정은 상품 등록과 전체 프로세스가 유사하다.

  • GET /items/{itemId}/edit: 상품수정폼
  • POST /items/{itemId}/edit : 상품 수정 처리

 

BasicItemController : 상품수정폼

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

 

/resources/templates/basic/editForm.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>

    <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}">
        </div>
        <div>
            <label for="price">가격</label>
            <input type="text" id="price" name="price" class="form-control" value="10000" th:value="${item.price}">
        </div>
        <div>
            <label for="quantity">수량</label>
            <input type="text" id="quantity" name="quantity" class="form-control" value="10" th:value="${item.quantity}">
        </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"
                        th:onclick="|location.href='@{/basic/items/{itemId}(itemId=${item.id})}'|"
                        onclick="location.href='item.html'" type="button">취소</button>
            </div>
        </div>

    </form>

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

 

BasicItemController : 상품수정처리

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

① 리다이렉트

상품수정처리에선 뷰 템플릿을 호출하는 대신 상품 상세 화면으로 이동하도록 리다이렉트를 호출한다.

redirect:/basic/items/{itemId}에서 {itemId}는 @PathVatriable Long itemId 값을 그대로 사용함.

 

참고] HTML Form 전송은 GET, POST만 사용할 수 있음. PUT, PATCH는 지원하지 않는다.


[10] PRG : Post/Redirect/Get

상품 등록에선 Redirect를 안 썼는데, 상품 수정에선 redirect를 쓴 이유는 무엇일까?

 

상품 등록 페이지에서 등록 버튼을 누르고 새로고침을 하면 계속 등록되는 문제점이 발생한다.

웹브라우저에 새로 고침은 마지막 행동을 재반복(마지막 서버에 전송한 데이터 다시 전송)하는 것이다. 그러면 POST/add가 다시 호출돼 내용은 같고 ID만 다른 상품 데이터가 쌓이게 된다. 이런 문제에 관한 해법은 바로 Redirect다.

 

Redirect는 웹 브라우저에게 리다이렉트하라는 명령을 하는 것이고 그러면 웹 브라우저는 GET 방식으로 상품 상세 페이지를 연다. 마지막에 한 행위가 GET방식으로 상품 상세페이지를 여는 것이었으므로 새로고침을 눌러도 상품 상세 화면으로 이동한다. 

 

이런 패턴을 POST -> Redirect -> GET의 앞 글자를 따 PRG 패턴이라고 부른다.

 

BasicItemController : 상품등록 V5 (PRG 패턴 적용)

//@PostMapping("/add")
public String addItemV5(Item item) {
    itemRepository.save(item);
    //새로고침을 해도 재등록되지 않도록 redirect처리.
    return "redirect:/basic/items/" + item.getId();
}

 

문제점 : item.getId()를 쓰면 'URL 인코딩'이 되지 않아 위험. 이를 RedirectAttributes를 사용해 해결할 수 있다.

 


[11] RedirectAttributes

저장 후 바로 상세화면으로 가면 클라이언트 입장에선 제대로 저장이 된 것인지 제대로 판단이 서지 않는다. 이럴 때 RedirectAttributes를 쓰면 편리하게 해결할 수 있다. 또 URL에 한글이나 띄어쓰기 등이 있어 '인코딩'이 필요할 때도 유용하다.

 

 

BasicItemController : 상품등록 V6 (RedirectAttributes 적용)

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

① redirectAttributes 객체에 itemId를 추가하면서 인코딩도 동시에 해결

② 쿼리 파라미터에 status=true를 추가하면 뷰 템플릿에 조건문을 활용해 (th:if로) '저장되었습니다'를 띄울 수 있음.

 

실행하면 다음과 같은 리다이렉트 결과가 나온다.

http://localhost:8080/basic/items/3?status=true

 

■ RedirectAttributes

redirect:/basic/items/{itemId}

URL 인코딩 처리 + PathVariable + 쿼리 파라미터까지 추가

 

redirect:/basic/items/{itemId}
 - pathVariable 바인딩: {itemId}
 - 나머지는 쿼리 파라미터로 처리: ?status=true

 

resources/templates/basic/item.html

<div class="container">

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

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

 - th:if 해당 조건이 참이면 실행. 여기선 status=true가 쿼리파라미터로 넘어오면 실행

 - ${param.status} 타임리프에서 쿼리 파라미터를 편리하게 조회하는 기능.

 

실행 결과