본 포스팅은 인프런 - 스프링 MVC 1편을 강의를 바탕으로 공부하고 정리한 글입니다.
📢 본 포스팅에 앞서 예제를 위한 프로젝트를 생성하자.
📌 스프링 프로젝트 생성
스프링 부트 스타터를 이용해서 스프링 프로젝트를 생성해주도록 하자.
- 프로젝트 선택
- Project: Gradle
- Project Language: Java
- Spring Boot: 2.6.x (정식버전 중 가장 최신 버전 선택)
- Project Metadata
- Group: hello
- Artifact: item-service
- Name: item-service
- Package name: hello.item-service
- Packaging: Jar
- Java: 11
- Dependencies : Spring Web, Thymeleaf, Lombok
📌 실행 확인
인텔리제이로 프로젝트를 Open하고, 동작하는지 확인한다.
- 기본 메인 클래스 실행
- http://localhost:8080 호출해서 Whitelabel Error Page가 나오면 정상 동작
📌 자바로 실행하기
최근 인텔리제이 버전은 Gradle을 통해 실행하는 것이 기본값인데, 이렇게 하면 실행속도가 느리기 때문에 자바로 바로 실행하도록 설정을 변경해준다.
- 윈도우 사용자 : File → Setting → Gradle 검색
- 맥 사용자 : Preferences → Gradle 검색
- Build and run using : Gradle → IntelliJ IDEA 변경
- Run tests using : Gradle → IntelliJ IDEA
📌 롬복 적용
- File → Setting(맥:Preferences) → plugin → lombok 검색 설치 (재시작)
- File → Setting(맥:Preferences) → Annotation Processors 검색 → Enable annotation processing 체크 (재시작)
- 임의의 테스트 클래스를 만들고 @Getter, @Setter 확인
📌 웰컴 페이지
학습할 내용을 편하게 참고하기 위해 웰컴 페이지를 만들어 주도록 하겠다.
스프링 부트의 Jar를 사용할 경우 /resource/static/index.html 파일을 웰컴 페이지로 인식한다.
📁 index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<ul>
<li>상품 관리
<ul>
<li><a href="/basic/items">상품 관리 - 기본</a></li>
</ul>
</li>
</ul>
</body>
</html>
요구사항
상품을 관리할 수 있는 서비스를 만들어보도록 하자.
- 상품 도메인 모델
- 상품 ID
- 상품명
- 가격
- 수량
- 상품 관리 기능
- 상품 목록
- 상품 상세
- 상품 등록
- 상품 수정
✅ 서비스 화면 예시
✅ 서비스 제공 흐름
요구사항이 정리되면 디자이너, 웹 퍼블리셔, 백엔드 개발자가 업무를 나누어 진행한다.
- 디자이너 : 요구사항에 맞도록 디자인하고, 디자인 결과물을 웹 퍼블리셔에게 넘겨준다.
- 웹 퍼블리셔 : 디자이너에서 받은 디자인을 기반으로 HTML, CSS를 만들어 개발자에게 제공한다.
- 백엔드 개발자 : 디자이너, 웹 퍼블리셔를 통해 HTML이 나오기 전까지 시스템을 설계하고, 핵심 비즈니스 모델을 개발한다. 이후 HTML이 나오면 이 HTML을 뷰 템플릿으로 변환해서 동적으로 웹 화면의 흐름을 제어한다.
React, Vue.js 같은 웹 클라이언트 기술을 사용하고, 웹 프론트엔드 개발자가 별도로 있으면, 웹 프론트엔드 개발자가 웹 퍼블리셔 역할까지 포함해서 하는 경우도 있다.
웹 클라이언트 기술을 사용하면, 웹 프론트엔드 개발자가 HTML을 동적으로 만드는 역할과 웹 화면의 흐름을 담당한다.
이 경우 백엔드 개발자는 HTML 뷰 템플릿을 직접 만지는 대신에, HTTP API를 통해 웹 클라이언트가 필요로 하는 데이터와 기능을 제공하면 된다.
상품 도메인 개발
📁 domain/Item (상품 객체)
@Data //주요 도메인에 쓰기엔 위험(단순 예제니까 사용)
//@Getter @Setter //이렇게 분리해서 사용하는 것을 권장
public class Item {
private Long id; //상품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;
}
}
📁 domain/ItemRepository (상품 저장소)
@Repository
public class ItemRepository {
private static 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();
}
}
- Item을 저장할 리포지토리를 만들어줘야 한다.
- 상품 저장, 조회, 목록 조회, 수정 기능을 추가
- 개발환경(테스트)에서 리포지토리를 초기화 해주기 위해 clearStore 추가
- id는 전역변수로 선언된 sequance를 사용해 저장되는 시점에 할당해준다.
📁 test/domain/ItemRepositoryTest (상품 저장소 테스트)
class ItemRepositoryTest {
ItemRepository itemRepository = new ItemRepository();
@AfterEach
void tearDown() {
itemRepository.clearStore(); //테스트 끝날때마다 저장소 초기화
}
@Test
void save() {
//given
Item item = new Item("itemA", 10000, 10);
//when
Item savedItem = itemRepository.save(item);
//then
Item findItem = itemRepository.findById(item.getId());
assertThat(findItem).isEqualTo(savedItem);
}
@Test
void findById() {
//given
Item item = new Item("itemA", 10000, 10);
Item savedItem = itemRepository.save(item);
//when
Item findItem = itemRepository.findById(item.getId());
//then
assertThat(findItem).isEqualTo(savedItem);
}
@Test
void findAll() {
//given
Item itemA = new Item("itemA", 10000, 10);
Item itemB = new Item("itemB", 20000, 20);
itemRepository.save(itemA);
itemRepository.save(itemB);
//when
List<Item> result = itemRepository.findAll();
//then
assertThat(result.size()).isEqualTo(2);
assertThat(result).contains(itemA, itemB);
}
@Test
void update() {
//given
Item item = new Item("itemA", 10000, 10);
Item savedItem = itemRepository.save(item);
Long itemId = savedItem.getId();
//when
Item updateParam = new Item("itemB", 20000, 20);
itemRepository.update(itemId, updateParam);
Item findItem = itemRepository.findById(itemId);
//then
assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
}
}
상품 서비스 HTML
💡 부트스트랩
이번 프로젝트에서는 HTML을 편리하게 개발하기 위해 CSS는 부트스트랩을 사용했다.
우선 필요한 부트스트랩 파일을 설치해주도록 하자.
- 부트스트랩 공식 사이트
- 부트스트랩 사용하기
- 설치 : https://getbootstrap.com/docs/5.0/getting-started/download/
- Compiled CSS and JS 항목을 다운로드
- 추가 : 압축을 풀어 bootstrap.min.css를 복사해 다음 폴더에 추가
- 📂 resources/static/css/bootstram.min.css
- 확인 : http://localhost:8080/css/bootstrap.min.css
- 설치 : https://getbootstrap.com/docs/5.0/getting-started/download/
bootstrap.min.css를 복사해왔을 때 인식을 못하는 경우가 종종 있다.
서버를 돌리고 http://localhost:8080/css/bootstrap.min.css에 접속했을 때 제대로 나오지 않는다면, out 폴더를 삭제해준 뒤 다시 서버를 돌리면 정상 동작한다.
HTML, CSS 파일
- /resources/static/css/bootstrap.min.css → 부트스트랩 다운로드
- /resources/static/html/items.html → 상품 목록
- /resources/static/html/item.html → 상품 상세
- /resources/static/html/addForm.html → 상품 등록
- /resources/static/html/editForm.html → 상품 수정
/resources/static에 넣어두었기 때문에 스프링 부트가 정적 리소스를 제공한다.
따라서 해당 파일을 직접 열어도 동작한다.
👉🏻 상품 목록 HTML
📂 resources/static/html/items.html
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link 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'" 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>
<td><a href="item.html">1</a></td>
<td><a href="item.html">테스트 상품1</a></td>
<td>10000</td>
<td>10</td>
</tr>
<tr>
<td><a href="item.html">2</a></td>
<td><a href="item.html">테스트 상품2</a></td>
<td>20000</td>
<td>20</td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /container -->
</body>
</html>
👉🏻 상품 상세 HTML
📂 resources/static/html/item.html
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link 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>
<div>
<label for="itemId">상품 ID</label>
<input type="text" id="itemId" name="itemId" class="form-control"
value="1" readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control"
value="상품A" readonly>
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
value="10000" readonly>
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control"
value="10" 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'" type="button">상품 수정
</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'" type="button">목록으로
</button>
</div>
</div>
</div> <!-- /container -->
</body>
</html>
👉🏻 상품 등록 폼 HTML
📂 resources/static/html/addForm.html
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link 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" 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='items.html'" type="button">취소
</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
👉🏻 상품 수정 폼 HTML
📂 resources/static/html/editForm.html
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link 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" method="post">
<div>
<label for="id">상품 ID</label>
<input type="text" id="id" name="id" class="form-control" value="1"
readonly>
</div>
<div>
<label for="itemName">상품명</label>
<input type="text" id="itemName" name="itemName" class="formcontrol" value="상품A">
</div>
<div>
<label for="price">가격</label>
<input type="text" id="price" name="price" class="form-control"
value="10000">
</div>
<div>
<label for="quantity">수량</label>
<input type="text" id="quantity" name="quantity" class="formcontrol" value="10">
</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'" type="button">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
컨트롤러, 뷰 템플릿 개발
상품 목록
우선 상품목록 페이지의 컨트롤러와 뷰 템플릿을 구현해보자.
👉🏻 컨트롤러 (상품 목록)
📁 web/basic/BaseItemController
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor //final이 붙은 필드의 생성자를 생성
public class BasicItemController {
private final ItemRepository itemRepository;
//@Autowired
//public BasicItemController(ItemRepository itemRepository) {
// this.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));
}
}
- 컨트롤러 로직
- itemRepository에서 모든 상품을 조회한 다음 모델에 담는다.
- 그리고 뷰 논리이름을 반환하며 뷰 템플릿을 호출한다.
- @RequiredArgsConstructor
- final이 붙은 멤버변수만 사용해서 생성자를 자동으로 만들어준다.
- 생상자가 딱 1개만 있으면 스프링이 해당 생성자에 @Autowired로 의존관계를 주입해준다.
- 따라서 final 키워드를 빼면 안된다 ! (없을 경우 ItemRepository 의존관계 주입이 안됨)
- @PostConstruct
- 해당 빈의 의존관계가 모두 주입되고 나면 초기화 용도로 호출된다.
- 여기서는 테스트용 데이터가 없으면 회원 목록 기능이 정상 동작하는지 확인하기 어렵기 때문에 사용했다.
👉🏻 뷰 템플릿 (상품 목록)
📂 /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}">10000</td>
<td th:text="${item.quantity}">10</td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /container -->
</body>
</html>
타임리프 알아보기
상품 상세
👉🏻 컨트롤러 (상품 상세 추가)
📁 web/basic/BaseItemController
@GetMapping("{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/item";
}
- @PathVariable로 넘어온 itemId로 상품을 조회하고, 모델에 담아둔다.
- 그리고 뷰 논리이름을 반환해 뷰 템플릿을 호출한다.
👉🏻 뷰 템플릿 (상품 상세)
📂 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>
<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"
th:onclick="|location.href='@{/basic/items/{itemId}/edit (itemId=${item.id})}'|"
onclick="location.href='editForm.html'" 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>
- th:value - 속성 변경
- 모델에 있는 item 정보를 가져와 프로퍼티 접근법으로 출력한다. ➡ item.getId()
- 상품수정 링크
- th:onclick="|location.href='@{/basic/items/{itemId}/edit(itemId=${item.id})}'|"
- 목록으로 링크
- th:onclick="|location.href='@{/basic/items}'|"
상품 등록 폼
👉🏻 컨트롤러 (상품 등록 폼 추가)
📁 web/basic/BaseItemController
@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"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/basic/items}'|"
type="button">취소
</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
- th:action - 속성 변경
- HTML form에서 action에 값이 없으면 현재 URL에 데이터를 전송한다.
- 상품 등록 폼의 URL과 실제 상품 등록을 처리하는 URL을 동일하게 하고 HTTP 메소드로 두 기능을 구분한다.
- 상품 등록 폼 : GET /basic/items/add
- 상품 등록 처리 : POST /baisc/items/add
- 이렇게 하면 하나의 URL로 등록 폼과, 등록 처리를 깔끔하게 할 수 있다.
- 취소시 상품 목록으로 이동
- th:onclick="|location.href='@{/basic/items}'|"
상품 등록 처리
상품 등록 폼에서 작성한 폼 데이터를 전달해 실제 상품을 등록 처리해보도록 하자.
여기서는 HTML Form 방식으로 데이터를 전송한다.
- POST HTML Form
- Content-Type : application/x-www-form-urlencoded
- 메시지 바디에 쿼리 파라미터 형식으로 전달
- 예) itemName=name&price=10000&quantity=10
BasicItemController에 상품 등록 처리 코드를 추가해준다.
이때 요청 파라미터 형식을 조회, 처리하는 방법을 버전 별로 학습해보자.
👉🏻 addItemV1 - @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으로 요청 파라미터 데이터를 변수로 받는다.
- Item 객체를 생성해 전달받은 파라미터 값으로 세팅한 뒤 저장한다.
- 저장된 item을 모델에 담아 뷰에 전달한다.
👉🏻 addItemV2 - @ModelArribute
@PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item) {
itemRepository.save(item);
//model.addAttribute("item", item); //자동추가, 생략 가능
return "basic/item";
}
앞서 봤던 @RequestParam으로 변수를 하나하나 받아 Item을 생성하는 과정은 번거롭다.
@ModelAttribute를 사용하면 요청 파라미터를 한번에 객체로 매핑시켜 받을 수 있다.
- @ModelAttribute
- Item 객체를 생성하고, 요청 파라미터의 값을 받아 프로퍼티 접근법(setXxx)으로 입력해준다.
- 또한 모델에 지정한 객체를 자동으로 담아준다.
- model.addAttribute("item", item); // 생략 가능
- 모델에 데이터를 담을 때는 이름이 필요한데, 이름은 @ModelAttribute에 지정한 속성 ("item")을 사용한다.
👉🏻 addItemV3 - @ModelAttriute 이름 생략
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item) {
itemRepository.save(item);
return "basic/item";
}
- @ModelAttribute의 name 속성을 생략할 수 있다.
- 생략하면 모델에 저장될 때 클래스명(Item)에서 첫글자를 소문자로 변경(item)해 등록한다.
- 예) @ModelAttirbute Apple apple → model.addAttribute("apple", apple) 저장
👉🏻 addItemV4 - @ModelAttriute 생략
@PostMapping("/add")
public String addItemV4(Item item) {
itemRepository.save(item);
return "basic/item";
}
- 심지어 @ModelAttribute 자체도 생략 가능하다. 대상 객체는 모델에 자동 등록된다.
- 객체가 아니라 기본타입(String, int ...)일 경우 @RequestParam이 동작한다.
상품 수정 폼
이제 등록한 상품을 수정해보자.
👉🏻 컨트롤러 (상품 수정 폼 추가)
📁 web/basic/BaseItemController
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/editForm";
}
- 특정 상품을 수정하려면 해당 상품에 대한 정보(itemId)를 받아야 하기 때문에 @PathVariable을 사용해 전달해준다.
- 수정에 필요한 정보를 조회하고, 수정용 폼 뷰를 호출한다.
👉🏻 뷰 템플릿 (상품 수정 폼)
📂 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"
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>
상품 수정 처리
👉🏻 컨트롤러 (상품 수정 기능)
📁 web/basic/BaseItemController
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
itemRepository.update(itemId, item);
return "redirect:/basic/items/{itemId}";
}
- 상품 등록 폼의 URL과 실제 상품 등록을 처리하는 URL을 동일하게 하고 HTTP 메소드로 두 기능을 구분한다.
- 상품 수정 폼 : GET /items/{itemId}/edit
- 상품 수정 처리 : POST /items/{itemId}/edit
- 리다이렉트
- 상품 수정은 마지막에 뷰 템플릿 호출이 아닌 상품 상세 화면으로 이동하도록 리다이렉트를 호출한다.
- 스프링에서는 redirect:/... 를 사용해 편리하게 리다이렉트를 지원한다.
(만약 스프링이 아니라면 응답 상태코드를 3xx로 설정해 동작시켜야 한다.) - 컨트롤러에 매핑된 @PathVariable의 값인 itemId가 그대로 매핑되어 리다이렉트를 호출한다.
새로고침 문제 해결
PRG - Post/Redirect/Get
사실 지금까지 진행한 상품 등록 처리 컨트롤러는 심각한 문제가 있다. (addItemV1 ~ addItemV4)
상품 등록을 완료하고 웹 브라우저의 새로고침 버튼을 클릭하면 상품이 계속해서 중복 등록된다.
그 이유는 다음 그림을 통해 확인 할 수 있다.
- 웹 브라우저의 새로 고침은 마지막에 서버에 전송한 데이터를 다시 전송한다.
- 상품 등록 폼에서 데이터를 입력하고 저장을 클릭하면 POST /add 요청으로 폼 데이터를 서버로 전송하고, 상품 상세 뷰 템플릿을 호출한다.
- 이 상태에서 새로고침을 하면 마지막에 전송한 POST /add 요청의 폼 데이터를 서버로 다시 전송하게 된다.
- 따라서 내용은 같고, ID만 다른 상품 데이터가 계속 등록되게 된다.
이 문제는 PRG를 통해 해결할 수 있다.
- 새로고침 문제를 해결하려면 상품 저장 후에 뷰 템플릿으로 이동하는 것이 아니라, 상품 상세 화면으로 리다이렉트를 호출해주면 된다.
- 웹 브라우저는 리다이렉트 영향으로 상품 저장 후에 실제 상품 상세 화면으로 다시 이동한다.
- 따라서 새로고침 시 마지막에 호출한 내용으로 상품 상세 화면인 GET /items/{itemId} 요청을 전송하게 된다.
- 이후 새로고침을 해도 상품 상세 화면으로 이동하게 되므로 새로 고침 문제를 해결할 수 있다.
이제 PRG를 통해 중복 등록 문제가 있는 상품 등록 처리 컨트롤러를 수정해주도록 하자.
👉🏻 컨트롤러 추가 (addItemV5)
📁 web/basic/BaseItemController
@PostMapping("/add")
public String addItemV5(Item item) {
itemRepository.save(item);
return "redirect:/basic/items/" + item.getId();
}
- "redirect:/basic/items/" + item.getId()
- URL에 변수를 더해서 사용하는 것은 URL 인코딩이 안되기 때문에 위험하다.
- 따라서 바로 아래에서 설명하는 RedirectAttributes를 사용해 해결하자.
RedirectAttributes
리다이렉트를 통해 페이지를 이동하는 것은 좋은데, 이 경우 내가 수행한 로직(상품 등록, 상품 수정 등)이 정상적으로 완료되었는지를 알 수 없다.
리다이렉트로 된 페이지에 어떠한 결과를 노출하고 싶을 경우 RedirectAttributes를 사용할 수 있다.
👉🏻 RedirectAttributes 적용
📁 web/basic/BaseItemController
@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를 추가하면, 뷰 템플릿에서 th:if로 결과를 받아 결과 메시지를 출력할 수 있다.
- RedirectAttribute
- URL 인코딩 뿐만 아니라 PathVariable, 쿼리 파라미터까지 처리해준다.
- 실행결과 http://localhost:8080/basic/items/3?status=true가 리다이렉트 된다.
- 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 : 해당 조건이 참이면 실행
- ${param.status} : 타임리프에서 쿼리 파라미터를 편리하게 조회하는 기능이다.
- 원래는 컨트롤러에서 모델에 직접 담고 값을 꺼내야 하지만, 쿼리 파라미터는 자주 사용해 타임리프에서 직접 지원한다.
'🌱 Spring > Web MVC' 카테고리의 다른 글
로그인 (쿠키, 세션) (0) | 2022.05.02 |
---|---|
Bean Validation (0) | 2022.04.30 |
Validation (0) | 2022.04.26 |
타임리프(Thymeleaf) (1) | 2022.03.23 |
로깅 (0) | 2022.03.18 |
스프링 MVC 기본 기능 (0) | 2022.03.18 |
스프링 MVC 구조 이해하기 (0) | 2022.03.17 |
MVC 프레임워크 만들기 (0) | 2022.03.16 |