Web/Spring

[Inflearn] 스프링 MVC (8) 웹 페이지 만들기

UL :) 2022. 3. 7. 18:18

김영한 강사님의 '스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술' 강의 정리

2022.02.21,03.7 진행

강의를 듣고 개인적으로 정리한 글입니다. 코드와 그림 출처는 김영한 강사님께 있습니다. 문제 있을 시 알려주세요.

드디어 이번 강의 마지막 파트! 졸업과 밴드 합주, 정보처리기사 필기시험 준비 등이 겹쳐 바쁜지라 그간 강의를 거의 듣지 않았다... 필기합격한거 같으니 다시 빠샤~! 다음 강의도 쭉쭉 들어야지 😋

상품 도메인

package hello.itemservice.domain.item;

import lombok.Data;
import lombok.Getter;
import lombok.Setter;

//@Data //다만들어줘서 위험하다..(숙지 필요)일반 왔다갔다하는 DTO 정도는 괜찮
@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;
    }
}
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 {

    //동시에 여러스레드에서 접근 시 HashMap,long 사용x
    private static final Map<Long, Item> store = new HashMap<>(); //static
    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()); //한번 감싸서 반환하면 안전하다(현재 변수에 변경이 x)
    }

    public void update(long itemId, Item updateParam){
        //프로젝트가 커지면 ItemParamDTO 클래스(id빼고 있음)를 따로 만들어 사용하는게 명확하다
        Item findItem = findById(itemId);
        findItem.setItemName(updateParam.getItemName());
        findItem.setPrice(updateParam.getPrice());
        findItem.setQuantity(updateParam.getQuantity());
    }

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

부트스트랩 적용

부트스트랩은 웹사이트를 쉽게 만들 수 있게 다양한 기능을 제공하는 HTML, CSS, JS 프레임워크이다. 하나의 CSS로 다양한 기기에서 작동한다.

  • 압축 풀고 bootstrap.min.css 를 복사해서 resources/static/css 폴더에 추가

이렇게 잘 나오면 성공인데, 인텔리제이 문제로 종종 안뜰 수 있다. 그러면 빌드파일이 저장되는 out 폴더에 복사해놓은 파일이 안보일 경우 out 폴더를 지워주고 서버를 다시 켠다.

정적 리소스가 공개되는 /resources/static 폴더에 HTML을 넣어두면 실제 서비스에서도 공개되므로 주의할 것

타임리프 사용 - 상품 목록,상세,등록 폼

상품 목록, 상세, 등록등의 폼은 강의에서 제공되는 html을 사용하였다.

URI 설계는 다음과 같다.

  • 상품목록 GET /baisc/items 상품버튼 → 상품상세
  • 상품상세 GET /baisc/items/{itemId}
  • 상품등록폼 GET /baisc/items/add 저장버튼→ 상품저장
  • 상품저장 POST /baisc/items/add → 상품상세(내부호출)

HTML Form 전송은 PUT, PATCH를 지원하지 않으므로 GET, POST만 사용할 수 있다.

페이지가 동적으로 돌아가게 하기위해 html을 타임리프로 고칠 것이다. <html> 태그에 다음 코드를 넣는다.

<html xmlns:th="http://www.thymeleaf.org">

부트스트랩의 css 파일을 불러오는 방식을 [상대경로]에서 [절대경로]로 바꿔주자.(나중에 폴더 바뀌어도 되도록)

타임리프는 th:를 붙이면 기존코드를 덮는 식으로 동작한다. 링크거는건 @ 붙이면 됨.

<link th:href="@{/css/bootstrap.min.css}"
        href="../css/bootstrap.min.css" rel="stylesheet">

위 코드들은 include 문법으로 공통처리 할 수 있다.

타임리프는 뷰 템플릿이 렌더링 될 때만 치환을 해준다. 그래서 순수 html 파일을 웹브라우저에서 열어도 그대로 쓸 수 있고 깨지지 않는다는 게 장점이다. (서버를 띄웠다 내렸다 하지 않고 테스트를 할 수 있다) 그래서 내추럴 템플릿이라고도 한다.

계속해서 html파일을 templates 폴더에 옮긴 다음, 위와 같이 불러오는 데이터나 버튼의 링크 부분을 타임리프로 바꿔서 적용시킨다. Controller의 함수와 매칭해서 작성한다.

하나의 URL로 꿩먹고 알먹고

HTTP 강의에서 배운 API방식대로, 같은 URL이지만 다른 HTTP 메서드를 사용해서 깔끔하게 처리한다!
GET /basic/items/addaddForm.html → POST /basic/items/add

  • 상품 등록 폼: GET, /basic/items/add
  • 상품 등록 처리: POST, /basic/items/add
<div class="container">
    <div class="py-5 text-center">
        <h2>상품 등록 폼</h2>
    </div>
    <h4 class="mb-3">상품 입력</h4>
    <form action="item.html" th:action="@{/basic/items/add}" method="post">
        <div>
        ...

th:action 이렇게 값을 비워도 자신의 URL에 POST 방식으로 보내므로 똑같이 동작한다.

ModelAttribute로 상품 등록 처리

@ModelAttribute는 요청파라미터를 처리하고 Model을 추가해주는 역할을 한다.

  • 모델 객체(Item)를 생성하고 요청 파라미터의 값을 프로퍼티 접근법(setXX)으로 저장해준다.
  • @ModelAttribute("item") Item item →이렇게 괄호에 지정한 객체를 자동으로 모델에 넣어준다.
    • 이름을 생략하면 클래스명의 첫글자만 소문자로 변경해서 모델로 등록한다.
  • @ModelAttribute자체도 생략가능하지만 권장하지 않는다고 하심.
    • 이 경우 대상 객체가 모델에 자동등록된다.
    @PostMapping("/add")
    public String addItemV3(@ModelAttribute Item item){

        itemRepository.save(item);
//        model.addAttribute("item",item); //자동 추가, 생략 가능

        return "basic/item";
    }

Redirect - 상품수정

상품 수정도 똑같이 /basic/items/edit 이 URL 하나로 GEt과 POST 메서드 두개를 쓰자.
상품 수정버튼을 눌렀을 때 /basic/items/{itemId}/edit 로 이동하고, 저장버튼을 누르면 /basic/items/{itemId}로 리다이렉트 한다.

  • 스프링은 redirect:/...으로 리다이렉트를 지원한다.
    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model){
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item",item);

        return "basic/editForm";
    }

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

강의에서는 입력 폼도 이쁘게 나오던데 난 왜 이모양일까... 🤷‍♀️
itemA의 상품명을 itemD로 변경하고 저장하면, HTTP 302코드가 나오며 Location헤더에 있는 url로 리다이렉트 한다.

PRG Post/Redirect/Get

사실 지금까지 작성한 컨트롤러는 심각한 문제가 있다...?! 🙄

    @PostMapping("/add")
    public String addItemV4(Item item){

        itemRepository.save(item);
        return "basic/item";
    }
  • 상품저장 POST /baisc/items/add → 상품상세(내부호출)

상품 등록을 위해 /basic/items/add 로 요청이 들어오면, 위의 GET addForm 메서드가 실행되어 basic/addForm.html 이 내보내지고, 저장버튼을 누르면 위의 POST addItemV4 메서드가 실행되어 상품을 저장한 후 baisc/item.html를 내부 호출했다.

문제는 새로고침을 할 경우 마지막에 전송한 POST /basic/items/add + 상품 데이터를 서버로 다시 전송하게 된다는 것이다. 따라서 등록한 상품이 계속 추가되는 문제가 발생한다...!!

이를 해결하기 위해서는 상품 저장 후에 뷰 템플릿으로 이동하지 않고, 리다이렉트를 호출하면 된다.
그러면 새로고침을 해도 마지막 호출인 GET/items/{itemId}가 되어 문제가 없어진다.

RedirectAttributes

//    @PostMapping("/add")
    public String addItemV5(Item item){

        itemRepository.save(item);
        return "redirect:/basic/items/" + item.getId();
    }

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

addItemV5 메서드와 같이 +item.getId() 같이 URL에 변수를 더해서 사용하면 URL 인코딩이 안되므로 위험하다(URL에 띄어쓰기나 한글이 들어가면 안된다). 따라서 RedirectAttributes를 사용하자.

또한 사용자가 저장을 했을 때 식별할 수 있도록 저장완료라는 글자도 띄워보자.

/baisc/item.html에 아래 코드를 추가한다.

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