Search
Duplicate

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

목차

프로젝트 생성

스프링 부트 프로젝트 설정
Project: Gradle Project
Language: Java
Spring Boot: 2.4.X
Project Metadata
Group: hello
Artifact: item-service
Name: item-service
Package name: hello.itemservice
packaging: Jar
Java: 11 +
Dependencies
Spring Web
Thymeleaf
Lombok

참고: Git Repository

요구사항 분석

상품 도메인 모델

상품 아이디
상품 명
가격
수량

상품 관리 기능

상품 목록
상품 상세
상품 등록
상품 수정

서비스 화면

해당 서비스 화면 HTML페이지는 기본적으로 제공이 되며, 포스팅에도 첨부하지만, 상단 git 리포지토리에서 가져와서 사용하는것을 추천한다.

서비스 제공 흐름

이렇게 요구사항과 도메인, 화면이 어느정도 정리되면 웹 퍼블리셔, 백엔드 개발자가 업무를 나눠 진행해야 한다.
디자이너: 요구사항에 맞도록 디자인 후 디자인 결과물을 웹 퍼블리셔에게 전달.
웹 퍼블리셔: 디자이너에게 받은 디자인을 기반으로 HTML, CSS를 만들어 개발자에게 전달
백엔드 개발자: HTML화면을 받기전까지 시스템 설계및 핵심 비즈니스 모델을 개발한다. 이후 HTML을 전달받으면 뷰 템플릿으로 변환 후 화면을 그리고 제어한다.
참고 React, Vue.js와 같은 웹 클라이언트 기술을 사용하거나 웹 프론트엔드 개발자가 따로 있으면 프론트 개발자가 웹 퍼블리셔 역할까지 포함해 하는경우도 있다. 이 경우 프론트엔드 개발자가 HTML을 동적으로 생성하고 웹 화면의 흐름도 담당하기에 백엔드 개발자는 뷰 템플릿을 만지는 대신 HTTP API를 통해 웹 클라이언트가 필요로 하는 데이터와 기능을 제공하면 된다.

상품 도메인 개발

상품 도메인

상품 도메인은 다음과 같은 필드가 필요하다.
상품 아이디
상품 명
가격
수량
이를 코드로 구현하면 다음과 같다.
package hello.itemservice.domain.item; import lombok.Data; @Data public class Item { private Long id; private String itemName; private Integer price; private Integer quantity; public Item(String itemName, Integer price, Integer quantity) { this.itemName = itemName; this.price = price; this.quantity = quantity; } }
Java
복사
Lombok의 @Data 애노테이션을 활용해 getter,setter, 기본생성자 등을 쉽게 구현한다.

상품 저장소

상품 객체Item을 저장할 리포지토리를 만들어줘야 한다.
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 { private static final Map<Long, Item> store = new HashMap<>(); private static long sequence = 0L; 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()); } 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(); sequence = 0L; } }
Java
복사
기본적인 상품 저장, 조회, 목록조회, 수정 기능을 추가했다.
개발환경에서 리포지토리내에 store 콜렉션을 초기화 해주기 위해 clearStore를 구현한다.
아이디는 전역변수로 선언된 sequance를 활용해 할당해준다.

상품저장소 - 테스트 코드 작성

상품을 저장하는 책임을 가진 상품저장소(ItemRepository)에 만든 각 메서드들을 테스트할 필요가 있다.
package hello.itemservice.domain.item; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; @DisplayName("ItemRepository 관련 기능 테스트") class ItemRepositoryTest { private ItemRepository itemRepository = new ItemRepository(); @AfterEach void afterLogic() { itemRepository.clearStore(); } @Test void saveTest() { //given Item item = new Item("itemA", 10000, 10); //when Item savedItem = itemRepository.save(item); //then Item foundItem = itemRepository.findById(item.getId()); assertThat(foundItem).isEqualTo(savedItem); } @Test void findAllTest() { //given Item itemA = new Item("itemA", 10000, 10); Item itemB = new Item("itemB", 20000, 20); itemRepository.save(itemA); itemRepository.save(itemB); //when List<Item> items = itemRepository.findAll(); //then assertThat(items).hasSize(2); assertThat(items).contains(itemA, itemB); } @Test void updateTest() { //given Item itemA = new Item("itemA", 10000, 10); itemRepository.save(itemA); //when Item updateParam = new Item("itemB", 20000, 20); itemRepository.update(itemA.getId(), updateParam); //then Item foundItem = itemRepository.findById(itemA.getId()); assertThat(foundItem.getItemName()).isEqualTo(updateParam.getItemName()); assertThat(foundItem.getQuantity()).isEqualTo(updateParam.getQuantity()); assertThat(foundItem.getPrice()).isEqualTo(updateParam.getPrice()); } }
Java
복사
수행결과 정상적으로 모두 초록불이 나오면 기능은 제대로 구현되었다고 할 수 있다.

상품 서비스 HTML

상단에 첨부한 링크 에 있는 Git 프로젝트에서 src/main/resources/static 경로 안에 있는 css와 html 폴더를 가지고와서 연습중인 프로젝트 동일 위치에 붙혀넣도록 하자.
해당 학습에서 CSS는 부트스트랩을 사용했다.
부트 스트랩은 HTML을 편리하게 개발하기위해 사용했다. 부트 스트랩은 웹사이트를 쉽게 만들 수 있도록 도와주는 HTML, CSS, JS 프레임워크
부트스트랩 공식 사이트: https://getbootstrap.com
복사가 완료되었으면 해당 HTML 리소스들은 /resources/static 정적 경로에 넣어놨기에 스프링 부트에서 정적 리소스를 제공한다. 그래서 다음 링크로 접속을 시도하면 해당 HTML을 볼 수 있다.
http://localhost:8080/html/item.html
정적 리소스 경로(/resources/static)에 html을 넣어두면 실제 서비스에서도 공개되는데, 실제로 서비스를 운영할때는 공개할 필요가 없는 HTML같은 리소스를 이곳에 두는건 주의해야한다.
HTML 소스

상품 목록 - 타임리프

우선 상품목록을 노출하는 상품목록 페이지를 컨트롤러와 뷰 템플릿을 구현해보자.

상품관리 컨트롤러 - BaseItemController

package hello.itemservice.web.basic; import hello.itemservice.domain.item.Item; import hello.itemservice.domain.item.ItemRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import javax.annotation.PostConstruct; import java.util.List; @Controller @RequestMapping("/basic/items") @RequiredArgsConstructor public class BasicItemController { private final 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)); } }
Java
복사
@PostConstructor
⇒ 컨트롤러만 구현하고 테스트를 하면 노출할 상품이 없기 때문에 프로젝트 로드시 해당 빈의 의존관계가 모두 주입된 후 초기화 용도로 호출된다. 첨부된 메소드 init()을 수행해 2개의 아이템을 미리 추가한다.
@RequiredArgsConstructor
⇒ 롬복(Lombok)에서 제공하는 애노테이션으로 final이 붙은 멤버 변수만 사용해 생성자를 자동으로 만들어준다. 그럼 생성자를 통해 해당 멤버변수를 자동 주입해준다.

뷰 템플릿 - items.html

기존에 작성한 items.html을 뷰템플릿 영역으로 복사후 수정해서 타임리프 내츄럴 뷰 템플릿으로 만들어준다.
/resources/templates/basic/items.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"> </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}">회원 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}">상품 가격</td> <td th:text="${item.quantity}">수량</td> </tr> </tbody> </table> </div> </div> <!-- /container --> </body> </html>
HTML
복사

타임리프 사용법 간단히 알아보기

타임리프 뷰 템플릿을 사용하기위해 간단히 알아보자.

타임리프 사용선언

타임리프를 HTML 페이지에서 사용하기 위해선 다음과같이 html 태그에 작성해줘야 한다.
<html xmlns:th="http://www.thymeleaf.org">
HTML
복사

속성 변경 - th:href

th:href="@{/css/bootstrap.min.css}"
HTML
복사
기존 href="value1"th:href="value2"로 변경해준다.
타임리프 뷰 템플릿을 거치면 원래 값을 th:xxx 으로 변경한다. 만약 값이 없다면 새로 생성.
HTML을 그대로 볼 때는 href 속성이 그대로 사용되고 뷰 템플릿을 거치면 th:href의 값이 href로 대치된다.

타임리프 핵심

th:xxx부분은 서버사이드에서 렌더링되고 기존 속성을 대치한다. 만약 th:xxx이 없으면 기존 htmlxxx속성이 그대로 사용된다.
⇒ 그래서 HTML파일을 그냥 탐색기로 열어도 th:xxx 속성을 웹 브라우저에서는 읽지 못하기에 무시하고 기본 xxx속성을 읽어서 웹페이지는 깨지지않고 렌더링된다.

URL링크 표현식 - @{...}

URL 링크를 사용하는 경우 @{...}를 사용하는데 이를 URL링크 표현식이라 한다.
URL 링크 표현식을 사용하면 서블릿 컨텍스트를 자동으로 포함한다.

리터럴 대체 문법 - |...|

th:onclick="|location.href='@{/basic/items/add}'|"
HTML
복사
타임리프에서 문자와 표현식등을 합쳐서 쓰고자 할 때 사용한다.
기존에는 자바의 문자열 결합처럼 +연산자와 escape 기호를 사용해 하나하나 작성해야했지만, 리터럴 대체 문법을 사용하면 자바스크립트의 백틱(``)처럼 편리하게 사용 가능하다.

th:each 반복 출력

<tag th:each="item: ${items}">
HTML
복사
반복은 th:each를 사용하는데 모델에 포함된 컬렉션 데이터를 순회하며 사용한다.

변수 표현식 - ${...}

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

URL 링크 표현식2 - @{...}

th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
HTML
복사
경로를 템플릿처럼 사용할 수 있다.
경로변수({itemId}) 뿐 아니라 쿼리 파라미터도 생성할 수 있다.
th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}"
http://localhost:8080/basic/items/1?query=test

URL 링크 축약

th:href="@{|/basic/items/${item.id}|}"
HTML
복사
리터럴 대체 문법을 사용해 간단하게 사용할 수도 있다.

상품 상세

BasicItemController - 상품 상세 추가

특정 상품의 상세정보를 조회하는 API를 구현해보자
@GetMapping("/{itemId}") public String item(Model model, @PathVariable Long itemId) { Item item = itemRepository.findById(itemId); model.addAttribute("item", item); return "/basic/item"; }
Java
복사
BasicItemController.item(...)
PathVariable로 넘어온 아이템 아이디로 상품을 조회 후 모델에 추가해 뷰 템플릿을 호출한다.

item.html - 상품 상세 뷰

html 안의 파일중 item.html 파일을 복사해서 /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" onclick="location.href='items.html'" th:onclick="|location.href='@{/basic/items}'|" type="button">목록으로 </button> </div> </div> </div> <!-- /container --> </body> </html>
HTML
복사
th:value="${item.XXX}"
타임리프 문법으로 모델에 있는 item정보를 가져와 프로퍼티 접근법으로 출력한다.
item.getId()

상품 등록 폼

BasicItemController - 상품 등록 폼 추가

이제 상품 등록을 위해 상품등록페이지로 이동하는 API를 구현하자
@GetMapping("/add") public String addForm() { return "basic/addForm"; }
Java
복사
해당 API에서는 단순하게 뷰 템플릿만 호출해서 상품 등록페이지로 이동만 담당한다.

addForm.html - 상품 등록 폼 뷰

이전과동일하게 addForm.html 파일을 복사해 /resources/templates/basic/경로에 위치하자.
/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="formcontrol" 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="formcontrol" 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" onclick="location.href=item.html" th:onclick="|location.href='@{/basic/items}'|" type="button">취소 </button> </div> </div> </form> </div> <!-- /container --> </body> </html>
HTML
복사
th:action
⇒ HTML Form에서 action에 값이 설정되어있지 않으면 현재 URL을 그대로 사용한다.
상품 등록 폼의 URL과 실제 상품 등록을 처리하는 URL은 동일하되 HTTP 메서드로 구분한다.

상품 등록 처리 - @ModelAttribute

상품등록 폼에서 작성한 폼 데이터를 전달해서 실제 상품을 등록처리해본다.
여기서는 HTML Form 방식을 사용해 데이터를 전송한다.
POST - HTML Form
Content-Type: application/x-www-form-urlencoded
메세지 바디에 쿼리 파라미터 형식으로 전달된다.
itemName=name&price=10000&quantity=10
요청 파라미터 형식을 처리하는 방법을 버전별로 알아서 복잡한 방법부터 간결한 방법까지 사용해본다.
addItemV1
@PostMapping("/add") public String saveLegacy(@RequestParam String itemName, @RequestParam int price, @RequestParam Integer quantity, Model model) { Item item = new Item(); item.setItemName(itemName); item.setPrice(price); item.setQuantity(quantity); Item save = itemRepository.save(item); model.addAttribute("item", save); return "basic/item"; }
Java
복사
RequestParam으로 요청 파라미터 데이터를 해당 변수에 받는다.
Item 객체를 생성해 전달받은 파라미터로 값을 세팅한 뒤 저장한다.
저장된 아이템을 Model 객체에 담아 뷰에 전달한다.
위 방법은 간단하게 아이템을 저장하는 로직임에도 불구하고 코드가 너무 길다. 그리고 전달받는 3개의 요청 파라미터도 결국 하나의 객체를 만들기 위한 파라미터들이기에 이를 한번에 객체로 매핑시켜 받을수도 있다. @ModelAttribute애노테이션을 사용해보자.
addItemV2
@PostMapping("/add") public String saveV2(@ModelAttribute("item") Item item, Model model) { Item save = itemRepository.save(item); //model.addAttribute("item", save); //생략 가능 return "basic/item"; }
Java
복사
@ModelAttribute 애노테이션을 활용해 요청 파라미터를 처리해준다.
⇒ Item 객체를 생성 후, 요청 파라미터의 값을 프로퍼티 접근법(setXxx)으로 입력해준다.
@ModelAttribute - Model 자동 추가
⇒ 위 코드를 보면 Model 객체에 저장된 item을 추가해주는 로직을 주석처리했다. 이는 @ModelAttribute 애노테이션의 기능 덕분인데, 이 기능으로 바로 Model에 @ModelAttribute로 지정한 객체를 자동으로 넣어준다.
모델에 데이터를 담을때는 이름이 필요한데 이름은 애노테이션에 지정한 속성("item")을 사용한다.
addItemV3
@PostMapping("/add") public String saveV3(@ModelAttribute Item item, Model model) { itemRepository.save(item); //model.addAttribute("item", save); return "basic/item"; }
Java
복사
@ModelAttribute 애노테이션에서 name 속성을 생략할수도 있다.
⇒ 생략하면 모델에 저장될 때 클래스명에서 첫 글자를 소문자로 변경해 등록한다.
⇒ @ModelAttribute Apple apple이면 Model.addAttribute("apple", apple) 와 같다.
addItemV4
@PostMapping("/add") public String saveV4(Item item) { itemRepository.save(item); return "basic/item"; }
Java
복사
심지어 @ModelAttribute 애노테이션도 생략이 가능하다. 대상 객체가 모델에 자동등록되는 기능도 정상동작한다.
객체가 아니라 기본타입이면 @RequestParam이 동작한다.

상품 수정

이제 등록한 상품을 수정해보자. 여기서는 특정 상품을 수정해야하기에 해당 상품에 대한 정보를 어더와야해서 특정 상품의 아이디를 PathVariable로 전달해줘야 한다.

BasicController - 상품 수정 폼 컨트롤러 추가

@GetMapping("/{itemId}/edit") public String editForm(@PathVariable Long itemId, Model model) { Item item = itemRepository.findById(itemId); model.addAttribute("item", item); return "basic/editForm"; }
Java
복사
수정에 필요한 정보를 조회후 수정용 폼 뷰를 호출한다.

상품 수정 폼 뷰

정적리소스 경로에서 editForm.html 을 복사해 templates/basic 경로 아래 위치하자.
/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="formcontrol" 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="formcontrol" 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" onclick="location.href='item.html'" th:onclick="|location.href='@{/basic/items/{itemId}(itemId=${item.id})}'|" type="button">취소 </button> </div> </div> </form> </div> <!-- /container --> </body> </html>
HTML
복사

BasicItemController - 상품 수정 개발 처리 기능 추가

@PostMapping("/{itemId}/edit") public String edit(@PathVariable Long itemId, @ModelAttribute Item item) { itemRepository.update(itemId, item); return "redirect:/basic/items/{itemId}"; }
Java
복사
return "redirect:/basic/items/{itemId}"
⇒ 상품 수정은 마지막에 뷰 템플릿 호출이 아닌 상품 상세 화면으로 이동하도록 리다이렉트를 호출한다.
⇒ 스프링에서는 redirect:/... 를 사용해 편리하게 리다이렉트를 지원한다.
(만약 스프링이 아니라면 응답 상태코드를 3xx로 설정해서 동작시켜야 한다.)
⇒ 컨트롤러에 매핑된 @PathVariable의 값인 itemId가 그대로 사용되어 매핑된다.

PRG Post/Redirect/Get

여기까지 개발을 했다면 이런 의문이 생길 수 있다.
상품등록페이지 및 수정페이지에서 등록이 완료된상태에서 새로고침을 하면 어떻게 되지?
새로고침을 하게되면 마지막으로 요청했던 경로로 재요청을 하게되는데 마지막에 Post 방식으로 상품등록을 했다면 해당 상품등록 요청이 재전송되어 중복등록되는 치명적인 문제가 생길 수 있다.
문제가되는 흐름
위에서 언급했듯이 웹 브라우저는 새로 고침시 마지막에 서버에 전송한 데이터를 다시 전송하는데, 상품 등록 폼에서 데이터를 입력후 저장을 선택하면 POST /add + 상품 데이터를 서버로 전송하게되는데, 이 상태에서 다시 새로고침을 선택하면 마지막에 전송한 POST /add + 상품 데이터 를 서버로 재전송하게되어 중복등록이 되는 것이다.
PRG: Post Redirect Get 을 이용하여 해결
중요한 점은 Post요청으로 상품 저장을 한 뒤 (2)번 항목처럼 상품 상세화면으로 리다이렉트를 호출해주는 것이다.
웹 브라우저는 리다이렉트의 영향으로 상품 저장후 상품 상세 화면으로 이동하게 되는데, 따라서 이 이후 새로고침을 아무리해도 GET 요청은 멱등성을 보장하기에 새로고침 문제는 해결된다.

PRG 적용 코드

@PostMapping("/add") public String saveV5(Item item) { itemRepository.save(item); return "redirect:/basic/items/" + item.getId(); }
Java
복사
return "redirect:/basic/items/" + item.getId();
⇒ URL에 변수를 더해 사용하는 것은 URL 인코딩이 안되기에 위험하다.
⇒ 바로 아래에서 다룰 RedirectAttributes를 사용해 해결하자.

RedirectAttributes

리다이렉트를 통해 페이지를 이동하는 것은 좋은데, 이 경우 내가 수행한 로직(상품 등록, 상품 수정 등) 이 정상적으로 완료되었는지를 알 수 없다. 그래서 리다이렉트 된 페이지에 이런 결과를 노출하고싶을 때 RedirectAttributes를 이용하면 된다.

BasicItemController - RedirectAttributes적용

@PostMapping("/add") public String saveV6(Item item, RedirectAttributes redirectAttributes) { Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true); return "redirect:/basic/items/{itemId}"; }
Java
복사
리다이렉트할 때 간단히 status를 추가해 뷰 템플릿에서 th:if 로 결과를 표시할 수 있다.
⇒ 실행결과 http://localhost:8080/basic/items/3?status=true 가 리다이렉트된다.
RedirectAttributes
⇒ URL 인코딩 뿐아니라 PathVariable, 쿼리파라미터 처리까지 해준다.

이전 챕터로