(스프링 mvc1) - 웹 페이지 만들기

짜스의 하루 ·2024년 3월 7일

프로젝트 생성

  • Welcome 페이지 추가: 편리하게 사용할 수 있도록 Welcome 페이지를 추가하자. ( resources/static/index.html )

요구 사항 분석

상품 도메인 모델

  • 상품 ID
  • 상품명
  • 가격
  • 수량
  • 상품 관리 기능

상품 목록 조회

  • 상품 상세 조회
  • 상품 등록
  • 상품 수정
  • 서비스 제공 화면

서비스 제고 흐름

  • 클라이언트가 상품 목록 페이지를 본다(항상 컨트롤러를 통해서 뷰가 호출된다)
  • 상품 목록 페이지에서는 상품 등록 폼 페이지로 이동할 수 있다.
    --> 상품 등록 폼에서 양식을 입력 후 "등록" 버튼을 누르면, 상품 저장 컨트롤러에 요청된다. 상품 저장 컨트롤러에서는 저장 후, 상품 상세 뷰를 내부적으로 호출한다.
  • 상품 목록 페이지에서는 상품 상세 페이지로 이동할 수 있다
  • 상품 상세 페이지에서는 상품 수정 폼 페이지로 이동할 수 있다.
    --> 상품 수정 폼 페이지에서 값을 수정한 후, "저장"을 누르면 상품 수정 컨트롤러에 요청된다. 상품 수정 컨트롤러는 수정 후--> 상품 상세로 redirect한다.

상품 도메인 개발

Item - 상품 객체

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

}

ItemRepository - 상품 저장소

@Repository
public class ItemRepository {
    private static final Map<Long, Item> store = new HashMap<>();
    private static long sequance = 0L;

    //아이템 저장
    public Item save(Item item) {
        item.setId(++sequance);
        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());
    }
    //clearStore
    public void clearStore(){
        store.clear();
    }

}

ItemRepositoryTest - 상품 저장소 테스트

  • 단축키 : Ctrl + shift + T)
class ItemRepositoryTest {
    private ItemRepository itemRepository = new ItemRepository();

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

    @Test
    @DisplayName("상품 저장")
    void save(){
        //given
        Item item = new Item("itemA", 10000, 10);

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

        //then
        Item findItem = itemRepository.findById(savedItem.getId());
        assertThat(findItem).isEqualTo(savedItem);
    }

    @Test
    @DisplayName("전체 상품 조회 테스트")
    void findByAll(){
        //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
    @DisplayName("상품 수정")
    void updateItem(){
        //given
        Item item = new Item("item1", 10000, 10);
        Item savedItem = itemRepository.save(item);
        Long itemId = savedItem.getId();

        //when
        Item updateItem = new Item("item2", 20000, 30);
        itemRepository.update(itemId,updateItem);

        //then
        Item findItem = itemRepository.findById(itemId);
        assertThat(findItem.getItemName()).isEqualTo(updateItem.getItemName());
        assertThat(findItem.getPrice()).isEqualTo(updateItem.getPrice());
        assertThat(findItem.getQuantity()).isEqualTo(updateItem.getQuantity());

    }

}

실행 결과


상품 서비스 HTML

부트 스트랩
HTML을 편리하게 사용하기 위해 부트스트랩을 사용하자

  • 부트스트랩(Bootstrap)은 웹사이트를 쉽게 만들 수 있게 도와주는 HTML, CSS, JS 프레임워크이다.

  • 부트스트랩 CSS 파일을 적용한 후, 각각의 HTML을 적용해보자

    /resources/static/css/bootstrap.min.css: 부트스트랩 CSS를 적용하였다.
    상품 목록 HTML: /resources/static/html/items.html
    상품 상세 HTML: /resources/static/html/item.html
    상품 등록 폼 HTML: /resources/static/html/addForm.html
    상품 수정 폼 HTML: /resources/static/html/editForm.html

  • /resources/static에 넣어두었기 떄문에, 스프링 부트가 정적 리소스를 제공한다.


상품 목록 - 타임리프

본격적으로 컨트롤러와 뷰 템플릿으로 개발해보자

BasicItemController

@Controller
@RequiredArgsConstructor
@RequestMapping("/basic/items")
public class BasicItemController {
    private 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));
        
    }
}

  • @RequiredArgsConstructor: Lombok 어노테이션으로, 클래스의 필드에 대한 생성자를 자동으로 생성한다. --> itemRepository 필드에 대한 생성자가 생성됩니다.
  • final이 붙은 멤버 변수만 사용해서 생성자를 자동으로 만들어준다.

테스트용 데이터 추가

  • @PostConstruct: 초기화 메서드로, 빈(Bean)이 생성된 후 자동으로 호출된다. 여기에서는 테스트용으로 두 개의 상품을 생성하여 itemRepository에 저장하고 있다.

items.html
resources/templates 디렉토리 내부에 basic 디렉토리를 생성하고 복사해서 붙여넣는다.

<!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}">회원아이디</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>

타임리프 간단하게 알아보자

  • 타임리프 사용 선언 : <html xmlns:th="http://www.thmeleaf.org">

  • 속성 변경 : th:href
    th:href="@{/css/bootstrap.min.css}"
    --> href 속성을 동적으로 생성하여 클라이언트가 클릭할 때 해당 링크로 이동할 수 있게 한다.

  • 타임리프의 핵심
    핵심은 th:xxx가 붙은 부분은 서버 사이드에서 렌더링되고, 기존 것을 대체한다.
    Thymeleaf 표현식이 없으면 해당 부분은 일반적인 정적 HTML로 처리되어 클라이언트에게 그대로 전송된다.

  • URL 링크 표현식 - @{}
    th:href="@{/css/bootstrap.min.css}"
    @{}:타임리프는 URL을 사용할 시, 이렇게 사용한다.

  • 상품 등록 폼으로 이동
    속성 변경 - th:onclick
    onclick="location.href='addForm.html'" th:onclick="|location.href='@{/basic/items/add}'|"
    여기서는 리터럴 대체 문법이 사용되었다

리터럴 대체 문법

  • |...| 이렇게 사용한다.
  • 타임리프에서 문자와 표현식 등은 분리되어 있기 때문에, 더해서 사용해야 한다.
    <span th:text= "'Welcome to our application,'+ ${user.name}+'!'">
  • 다음과 같이 리터럴 대체 문법을 사용하면, 더하기 없이 편리하게 사용할 수 있다.
    <span th:text="|Welcome to our application, ${user.name}!|>

참고
결과를 다음과 같이 만들어야 한다 : location.href=/basic/items/add'
리터럴 대체 문법 사용 --> th:onclick="|location.href='@{/basic/items/add}'|" 과 같이 편리하게 사용할 수 있다.

  • 반복 출력 - th:each
    <th:each = "item : ${items}">
    반복은 th:each를 사용한다. 모델에 포함된 items컬렉션 데이터가 item 변수에 하나씩 포함되고, 반복문 안에 item 변수를 사용할 수 있다.
    컬렉션 수 만큼 <tr>..<tr>이 하위 태그로 포함해서 생성한다.

  • 변수 표현식 - ${...}
    <td th:text="${item.price}">10000</td>
    모델에 포함된 값이나, 타임리프 변수로 선언한 값을 조회할 수 있다.
    프로퍼티 접근법을 사용한다(item.getPrice())

  • 내용 변경 - th:text
    <td th:text = "${item.price}">1000</td>
    내용의 값을 th:text로 변경한다.
    위 예시에서는 1000을 ${item.price}의 값으로 변경한다.

  • URL 링크 표현식2 - @{...}
    th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
    상품 ID를 선택하는 링크를 확인해보면, URL 링크 표현식을 사용하면 경로를 템플릿처럼 편리하게 사용할 수 있다.

  • URL 링크 간단히
    th:href="@{|/basic/itemss/${item.id}|}"
    --> 리터럴 대체 문법을 활용해서 간단히 사용할 수 있다.

순수 HTML을 그대로 유지하면서 뷰 템플릿도 사용할 수 있는 타임리프의 특징을 네츄럴 템플릿 (natural templates)이라 한다.


상품 상세

상품 상세 컨트롤러와 뷰를 만들어보자

BasicItemController

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

item.html

속셩 변경 : th:value

  • th:value="${item.id}"
  • 모델에 있는 item 정보를 획득하고 프로퍼티 접근법으로 출력한다.
  • value의 속성을 th:value 속성으로 변경한다

상품수정 링크

  • th:onclick="location.href='@{/basic/items/{itemId}/edit(itemId=${item.id})}'|"

목록으로 이동

  • th:onclick="|location.href='@{/basic/items}'|"

상품 등록 폼

BasicItemController

@PostMapping("/add")
    public String save(){
        return "basic/addForm";
    }
  • 단순하게 뷰 템플릿만 호출하도록 코드를 작성해보았다.

addForm.html

속성 변경 - action

  • th:action
  • HTML form에서 action에 값이 없으면 현재 URL에 데이터를 전송한다.
  • 상품 등록 폼의 URL과 실제 상품 등록을 처리하는 URL을 똑같이 맞추고, HTTP 메서드로 두 기능을 구분한다
    --> 상품 등록 폼: GET basic/items/add
    --> 상품 등록 처리: POST basic/items/add
  • 이렇게 하면 하나의 URL로 등록 폼과 등록 처리를 깔끔하게 할 수 있다.

취소

  • 취소 버튼을 누를 시, 상품 목록으로 이동한다.
  • th:onclick="|location.href='@{/basic/items}'|"

상품 등록 처리 - @ModelAttribute 애노테이션 사용

상품 등록 폼에서 전달된 데이터로 실제 상품을 등록 처리해 볼 것이다

POST - HTML Form

  • content- type: application/x-www-form-urlencoded
  • 메시지 바디에 쿼리 파라미터 형식으로 전달
    itemName=itemA&price=10000&quantity=10
  • 예) 회원 가입, 상품 주문, HTML FORM 사용

요청 파라미터 형식으로 처리해야 하므로, 먼저 @RequestParam으로 사용해보자

addItemV1 - @RequestParam

 @PostMapping("/add")
    public String addItemV1(@RequestParam String itemName,
                            @RequestParam int price,
                            @RequestParam int 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 String itemName : itemName 요청 파라미터 데이터를 해당 변수에 받는다
  • Item 객체를 생성하고 itemRepository를 통해서 저장한다.
  • 저장된 item을 모델에 담아서 뷰에 전달한다.
  • (참고) 여기서는 상품 상세에서 사용한 item.html 뷰 템플릿을 그대로 재활용한다.

상품 등록 처리 @ModelAttrbute를 사용해보자

addItemV2-@ModelAttrbute를 사용

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

@ModelAttribute
기능 1 : 요청 파라미터 처리

  • @ModelAttribute는 Item 객체를 생성하고, 요청 파라미터의 값을 프로퍼티 접근법으로 입력해준다.

기능 2 : Model에 추가

  • @ModelAttributesms는 모델에 @ModelAttribute로 지정한 객체를 자동으로 넣어준다.
@PostMapping("/add")
    public String addItemV2(@ModelAttribute("item") Item item){
        itemRepository.save(item);
        //model.addAttribute("item", item);

        return "basic/item";
    }

이렇게 코드를 작성해도 잘 동작하는 것을 볼 수 있다.

  • 모델에 데이터를 담을 때는 이름이 필요하다. 이름은 @ModelAttribute에 지정한 name(value) 속성을 사용한다.

addItemV3 - @ModelAttribute 이름 생략

  @PostMapping("/add")
    public String addItemV3(@ModelAttribute Item item){
        itemRepository.save(item);
        
        return "basic/item";
    }
  • @ModelAttribute의 이름을 생략하면, 모델에 저장될 때 클래스 명을 사용한다.
    이때 클래스의 첫 글자만 소문자로 변경해서 등록한다

    @ModelAttribute 클래스명 -> 모델에 자동 추가되는 이름
    @ModelAttribute Item exam1 -> item

addItemV4 - @ModelAttribute 전체 생략

 @PostMapping("/add")
    public String addItemV4(Item item){
        itemRepository.save(item);
        
        return "basic/item";
    }

상품 수정 폼 컨트롤러 & 화면

editForm 추가

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

editForm.html

상품 수정 컨트롤러 - edit추가

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

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

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

리다이렉트
상품 수정은 마지막에 뷰 템플릿을 호출하는 대신에, 상품 상세 화면으로 이동하도록 리다이렉트를 호출한다

  • 스프링은 redirect:/로 편리하게 리다이렉트를 지원한다
  • redirect:/basic/items/{itemId}
    --> 컨트롤러에 매핑된 @PathVariable의 값은 redirect에도 사용할 수 있다.
    --> redirect:/basic/items/{itemId}의 {itemId}는 @PathVariable Long itemId의 값을 그대로 사용한다.

참고

  • HTML Form 전송은 GET, POST만 사용가능
  • PUT, PATCH는 HTTP API 전송시에 사용

PRG Post/Redirect/Get

상품 등록을 완료하고 웹 브라우저의 새로고침 버튼을 클릭해보자. 상품이 계속해서 중복 등록되는 것을 확인할 수 있다.

상품 등록 후 상세 보기 페이지에서 새로고침을 하면, 계속 상품이 등록되는 것을 확인할 수 있다.

POST 등록 후 새로 고침

  • 웹 브라우저의 새로 고침은 마지막에 서버에 전송한 데이터를 다시 전송한다.(마지막에 했던 행위를 다시 하는 것)
  • 새로고침을 하게 되면, 마지막에 전송한 POST/add에 상품 데이터를 서버에 재전송하게 된다.
  • 내용은 같고 ID만 다른 상품 데이터가 계속 쌓이게 되는 것이다.(웹 브라우저 입장에서는 POST /add 를 요청한 것이 마지막 요청이 되므로, 새로 고침시 다시 POST /add 를 요청하게 된다.)

문제를 어떻게 해결할까??
POST, Redirect GET

  • 웹 브라우저의 새로 고침은 마지막에 서버에 전송한 데이터를 다시 전송한다.
  • 새로 고침 문제를 해결하려면, 상품 저장 후 뷰 템플릿으로 이동하는 것이 아니라, 상품 상세 화면으로 리다이렉트를 호출해주면 된다.
  • 웹 브라우저는 리다이렉트의 영향으로 상품 저장 후에 실제 상품 상세 화면으로 다시 이동한다.
    --> 따라서 마지막에 호출한 내용이 상품 상세 화면인 GET/items/{itemId}가 되는 것이다.
  • 이후 새로고침을 해도 상품 상세 화면으로 이동하게 되므로 새로 고침 문제를 해결할 수 있다.

addItemV5 추가

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

        return "redirect:/basic/items/" + item.getId();
    }
  • 상품 등록 처리 후, 뷰 템플릿이 아니라 상품 상세 화면으로 리다이렉트 하도록 코드를 작성해보자--> 이러한 방식을 PRG Post/Redirect/Get이라고 한다.

RedirectAttributes

고객에게 저장이 잘 되었으면 상품 상세 화면에 "저장되었습니다."라는 메시지를 보여주자

addItemV6

 @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}";
    }
  • 리다이렉트할 때 간단히 status=true를 추가해보자
  • 실행해보면, http://localhost:8080/basic/items/3?status=true가 출력된다.

**RedirectAttributes

  • RedirectAttributes를 사용하면, URL 인코딩도 해주고, pathVariable,쿼리 파라미터까지 처리해준다.
  • redirect:/basic/items/{itemId}
    --> pathVariable 바인딩 : {itemId}
    --> 나머지는 쿼리 파라미터로 처리해준다 : ?status=true

뷰 템플릿 메시지 추가
item.html 추가
<h2 th:if="${param.status}" th:text="'저장 완료!'"></h2>

  • th:if: 해당 조건이 참이면 실행
  • ${param.status} : 타임리프에서 쿼리 파라미터를 편리하게 조회하는 기능
    -->${param.status}는 모델에 있는 status라는 이름의 파라미터를 참조한다. 이 파라미터가 존재하고 값이 true인 경우에만 이 조건이 참이 됩니다.

따라서, 파라미터 status가 존재하고 true인 경우에만 <h2> 태그 내부에 "저장 완료!"라는 메시지가 표시된다.

profile
2024. 01. 02 ~ 백앤드 공부 시작, 2024. 04.01 ~ 프론트 공부 시작

0개의 댓글