9. 스프링 MVC - 웹 페이지 만들기

김현준·2023년 7월 30일

Gaounuri_Spring_Study

목록 보기
10/10

Project Code File

📌 목차

  • 상품 도메인 개발
  • 상품 서비스 HTML
  • 상품 목록 - 타임리프
  • 상품 등록 처리 - @ModelAttribute
  • PRG - Post/Redirect/Get
  • RedirectAttributes

📌 개요

상품을 관리할 수 있는 간단한 웹 서비스를 개발해보자.

상품 도메인 모델에는 총 4가지 특성이 존재한다.

  • 상품 ID , 상품명 , 가격 , 수량

상품관리기능에도 총 4가지 기능이 존재한다.

  • 상품 목록

image

  • 상품 상세

image

  • 상품 수정

image

  • 상품 등록

image

  • 서비스 제공 흐름

image

서비스 제공 흐름은 위 그림과 같다. 스프링 MVC 패턴을 사용하고 목록 , 등록 , 상태 , 수정 , 저장 에 대한 컨트롤러를 개발해서 뷰를 호출한다. HTMLThymeleaf 를 통해 동적으로 데이터를 제공할 수 있도록 한다.

데이터 베이스는 따로 사용하지 않는다.

📌 상품 도메인 개발

package hello.itemservice.domain.item;

@Data
public class Item {
    private Long id;
    private String itemName;
    private Integer price; // 가격이 없을 수 있기 때문에 Integer , nullable
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

상품 객체 코드는 위와 같다.
@Data 에노테이션은 lombok 에 있는걸 사용하였다.
id , price , quantity 는 없을 수 있기 때문에 Long , Integer 로 선언해주었다.
기본생성자와 값을넣을수 있는 생성자 2개를 만들었다.

package hello.itemservice.domain.item;

@Repository
public class ItemRepository { // ctrl+shift+T -> test code 생성

    private static final Map<Long, Item> store = new HashMap<>(); // 멀티쓰레스 환경에선  ConcurrentHashMap<>() 사용
    private static long sequence = 0L; // 멀티 쓰레드 환경에선 AtomicLong 사용

    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());
        // 한번 감싸서 반환하면 ArrayList 에 값을 추가해도 실제 store에는 변경 X
    }

    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 용
    }
}

상품 저장소의 코드는 위와같다.
Map<Long , Item> store 로 상품의 ID 와 객체를 저장할 수 있도록 한다.
그리고 ID 를 저장하기 위해 sequence 변수를 만들었다.

멀티쓰레드 환경에서는 각각 ConcurrentHashMap , AtomicLong 을 사용한다는것을 알아두자.

메서드는 상품 저장 , ID 로 찾기 , 전체 찾기 , 업데이트가 있다.

package hello.itemservice.domain.item;

class ItemRepositoryTest {

    ItemRepository itemRepository = new ItemRepository();

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

    @Test
    void save() {
        //given
        Item item = new Item("itemA", 10000, 10);

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

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

    @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);


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

테스트 코드는 위와같다. @AfterEach 를 통해 매번 테스트마다 저장소를 초기화 해준다.
저장 , 전체찾기 , 업데이트에 대해 테스트를 진행해주었다.

📌 상품 서비스 HTML

HTML 을 편리하게 관리하기 위해서 부트스트랩을 사용하였다.

다운로드 링크 에 들어가서 Compiled CSS and JS 항목에 다운로드 해주고 bootstrap.min.css 파일을 resources/static/css/bootstrap.min.css 경로에 추가해주었다.

html 코드 양이 매우 많기 때문에 글에서는 코드를 넣지 않고 여기 에 코드를 첨부하겠다.

📌 상품 목록 - 타임리프

컨트롤러와 뷰 템플릿을 개발해보자.

먼저 타임리프의 문법을 몇가지 살표보자.

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

타임리프를 사용하기 위해서는 위 코드를 명시해줘야한다.

th:href="@{/css/bootstrap.min.css}"

속성을 변경할려면 th:href 를 사용할 수 있다. 이 속성은 아까 부투스트랩에서 다운한 css 파일의 내용이 담겨있다.

타임리프의 핵심은 th: 가 붙은 부분은 서버사이드에서 렌더링되고 기존것을 대체한다는 점이다.
th: 가 없으면 기존 html 의 속성이 적용된다.

th:href="@{/css/bootstrap.min.css}"

타임리프에서 URL 링크를 사용하는 경우 @{...} 을 사용한다.

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

onclick 은 버튼 클릭을 의미한다. 이때 리터럴 문법을 사용하였다.

리터럴 문법은 |...| 이렇게 사용한다.

<span th:text="'Welcome to our application, ' + ${user.name} + '!'">

문자열과 데이터를 같이쓸려면 원래 위와같이 사용해야한다.

<span th:text="|Welcome to our application, ${user.name}!|">

하지만 리터럴 문법을 사용하면 깔끔하게 처리할 수 있다

<tr th:each="item : ${items}">

반복 출력은 위 문법을 사용한다.

<td th:text="${item.price}">10000</td>

변수를 표현할때는 ${...} 처럼 사용한다.
이때 item.price 는 자바의 프로퍼티 접근법에 의해 item.getPrice() 메서드를 호출해서 값을 가져온다.

<td th:text="${item.price}">10000</td>

텍스트의 내용을 변경할때는 th:text 의 문법을 사용한다.
서버사이드 렌더링에서 값을 가져올 수 있으면 그 값을 10000 대신 대체해준다.

th:href="@{/basic/items/{itemId}(itemId=${item.id})}"

URL 링크를 표현할때 위처럼 PathVariable 형식으로도 사용할 수 있다.
, 를 통해서 query='test' 같이 쿼리파라미터도 추가할 수 있다.

타임리프의 장점은 JSP 와는 달리 순수 HTML 을 유지하면서 뷰 템플릿도 사용할 수 있다. 이러한 특징을 네츄럴 템플릿 natural templates 이라고 한다.

@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor // itemRepository DI
public class BasicItemController {

    private final ItemRepository itemRepository;

기본적인 코드는 위와같다. @RequiredArgsConstructor 에노테이션을 통해 의존성 주입을 해주었다.

📌 상품 등록 처리 - @ModelAttribute

상품등록 컨트롤러를 만들어 보자. 요청파라미터 형식이므로 @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 으로 데이터를 불러온 후에 model 에다가 Item 객체를 저장해준다.

@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item) {
    itemRepository.save(item);
	model.addAttribute("item", item); // 재동 추가가 되기 떄문에 생략 가능
    return "basic/item";
}

@ModelAttribute 에노테이션을 사용했다. 이때 @ModelAttribute 로 인해 Item 객체를 생성하고 프로퍼티접근법 setXxx 를 통해 데이터 값을 알아서 넣어준다.

만약 @ModelAttirubte 에 문자열로 객체 이름을 지정하지 않으면 클래스 이름에서 첫번째 글자를 소문자로 바꾸고 모델에 저장해준다.

@PostMapping("/add")
public String addItemV4(Item item) { // 객체일때는 ModelAttribute 생략 가능.
	itemRepository.save(item);
	return "basic/item";
}

또한 스프링에서는 객체를 파라미터에 넣을시 자동으로 @ModelAttribute 가 등록된다.
다만 명시성이 조금 떨어진다.

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

상품 수정 컨트롤러는 위와같다. @PathVariable 에노테이션을 사용하였다.
Get 방식으로 사용했다. 그 이유는 직접 상품을 수정하느 코드가 아니라 상품수정 HTML 이 있는 화면으로 이동시켜 주기 때문이다.

@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

지금까지 사용한 컨트롤러로 실제로 상품을 추가하면 심각한 오류가 발생한다.
상품 등록을 완료한 상태에서 새로고침을 하면 계속해서 등록이 된다.

image

그 이유는 상품등록폼에서 데이터를 입력하고 저장을 하면 POST /add + 상품데이터를 서버로 전송하게 되는데 이 상태에서 새로고침시 또 POST 를 해버리게 된다.

image

이를 해결하기 위해서 상품 저장후에 뷰 템플릿으로 이동하는게 아니라 상품 상세 화면링크로 리다이렉트를 해주면 된다.

//    @PostMapping("/add")
public String addItemV5(Item item) {
    itemRepository.save(item);
    return "redirect:/basic/items/" + item.getId();
}

따라서 기존에 return basic/item 을 위처럼 바꿔주면 된다.

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

이전에 return 에서 item.getId 를 더해주었는데 잘못하면 특수문자나 공백으로 인해 경로를 인식하지 못할 수 있다. 이를 해결하기 위해 RedirectAttributes 을 사용한다.
RedirectAttributes 는 자동으로 URL 인코딩을 대신 해준다.
또한 제대로 저장된것을 확인하기 위해서 status 정보를 true 로 저장해준다.
그리고 반환값은 리다이렉트로 해당 상품의 번호를 URL 경로에 넣어준다.

이제 resources/templates/basic/item.html 경로에 있는 HTML 코드에

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

위와같은 문구를 추가해주면

image

글자가 제대로 나오는것을 확인할 수 있다.

profile
울산대학교 IT융합학부

1개의 댓글

comment-user-thumbnail
2023년 7월 30일

공감하며 읽었습니다. 좋은 글 감사드립니다.

답글 달기