MVC 패턴과 Thymeleaf를 이용하여 간단한 웹사이트를 만들어본다.
1. 프로젝트 생성
프로젝트를 생성한다. 패키지명에 "-" 같은 표기가 들어가면 안되므로 유의하자
서버를 띄워 잘 실행되는지 확인하고, 아래의 index.html을 추가하여 welcome page가 정상적으로 보이는지 확인한다.
resources/static/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
<!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>
|
cs |
표현할 웹페이지의 기획 내용은 다음과 같다.
1. 상품 목록을 보여준다.
2. 상품 목록에서 상품 상세로 이동가능하다.
3. 상품 저장이 가능하며, 저장 완료 시 상품 상세 화면으로 이동한다.(내부적으로 view 호출)
4. 상품 수정이 가능하며, 수정 완료 시 상품 상세 화면으로 redirect 한다.
2. 상품 도메인 개발
Item.class 파일을 만든다. 기본 생성자와 itemName, price, quantity를 포함한 생성자를 작성해준다.
Item 클래스
java/hello/itemservice/domain/item/Item.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
@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;
}
}
|
cs |
ItemRepository 클래스
java/hello/itemservice/domain/item/ItemRepository.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
@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);
}
//store 값 보호를 위해 List로 한번 감쌈
public List<Item> findAll() {
return new ArrayList<>(store.values());
}
public void update(Long itemId, Item updateParam) {
Item findItem = findById(itemId);
//원래는 updateParam을 위한 별도 DTO가 필요. setId를 해버릴 수도 있음
findItem.setItemName(updateParam.getItemName());
findItem.setPrice(updateParam.getPrice());
findItem.setQuantity(updateParam.getQuantity());
}
public void clearStore() {
store.clear();
}
}
|
cs |
Repository 기능 테스트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
class ItemRepositoryTest {
ItemRepository itemRepository = new ItemRepository();
@AfterEach
void afterEach() {
itemRepository.clearStore();
}
@Test
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
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, 20);
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());
}
}
|
cs |
3. 페이지 작성 환경 구성하기
페이지는 HTML, CSS로 개발한다. 그러나 CSS 등 디자인적 요소는 여기서 주요하게 다룰 요소도 아니고, admin 페이지를 만드는 수준이기 때문에 간단하게 부트스트랩을 이용한다.
부트스트랩 다운로드 : Compiled CSS and JS 파일을 다운로드 받는다.
https://getbootstrap.com/docs/5.0/getting-started/download/
압축을 풀고, bootstrap.min.css 파일을 resources/static/css/bootstrap.min.css 경로에 넣어준다. 서버를 키고, http://localhost:8080/css/bootstrap.min.css 주소로 접속하여 css 파일이 정상작동 하는지 확인한다. 만약 정상적으로 보이지 않는다면, out 폴더를 삭제 후 서버를 재실행하면 out 폴더가 새롭게 생성되면서 css 파일이 다시 만들어진다.
html 파일 4개를 추가한다. 이 파일을 resources/static/html 경로에 넣어주도록 한다. resources/static 폴더에 있는 파일들은 외부에 공개되므로, 실제 서비스를 운영할 때는 이 경로에 html 파일을 넣어서 공개되도록 하면 안된다.
4. 상품 목록, 상품 상세, 상품 등록 페이지 만들기
상품 목록
컨트롤러 작성
우선 클라이언트의 요청을 받을 Controller를 작성한다. /basic/items로 @RequestMapping 처리를 한다. 그리고 basic/items을 반환하여 templates/basic/items.html View로 forward 되도록 해준다. 아래 @PostConstruct 부분은 BasicController 빈이 생성된 후 초기 데이터를 생성해줌으로써 테스트 시에 데이터가 들어있도록 해주는 부분이다.
java/hello/itemservice/web/basic/BasicItemController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
@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));
}
}
|
cs |
View 화면단 작성
기존의 items.html 파일에 타임리프를 적용한다. 타임리프는 MVC-2편에서 자세히 배우니, 중요부분 외에는 생략한다.
1. html 태그에 타임리프를 쓸 수 있도록 등록, th:href 문법을 활용하여 css 링크를 대체한다.
1
2
3
4
5
6
7
8
|
<!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>
|
cs |
2. 상품 등록 버튼을 눌렀을때 이벤트를 th:onclick으로 대체한다. 리터럴 대체 문법 | | 도 활용한다.
1
2
3
4
5
6
7
|
<div class="row">
<div class="col">
<button class="btn btn-primary float-end"
th:onclick="|location.href='@{/basic/items/add}'|"
onclick="location.href='addForm.html'" type="button">상품 등록</button>
</div>
</div>
|
cs |
3. items 데이터를 Controller에서 받아와서 반복문을 실행하는 th:each 문법을 적용한다. th:text 문법으로 각 html 컨텐츠를 대체한다.
1
2
3
4
5
6
7
8
|
<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/{itemId}(itemId=${item.id})}" th:text="${item.getItemName()}">상품명</a></td>
<td th:text="${item.getPrice()}">10000</td>
<td th:text="${item.getQuantity()}">10</td>
</tr>
</tbody>
|
cs |
상품 상세페이지 만들기
BasicController에 상품 상세 페이지를 위한 method를 추가한다. itemId를 PathVariable로 받아서 URL 경로에 itemId를 추가해 각 상품의 상세 페이지로 이동되도록 한다. model도 받아와서, repository에서 찾아온 item 객체를 model 객체에 넣어주도록 한다.
1
2
3
4
5
6
|
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "basic/item";
}
|
cs |
View 코드는 생략한다. input 박스에 items 객체 정보가 표현되어야 하므로, th:each, th:value 문법을 적용하고 상품 수정 및 목록으로 버튼에는 th:onclick을 적용한다.
상품 등록 페이지 만들기
컨트롤러에 /basic/addForm으로 이동하는 페이지, 같은 경로에서 save를 수행하는 PostMapping 메서드를 추가한다. 이렇게 하면 상품 등록 폼 페이지에서 등록 버튼을 눌렀을때, 같은 경로로 이동하여 페이지가 바뀌지 않고 Controller에 Post 요청만 보내는 방식이 된다. URL 경로는 같은데, Get과 Post 메서드만 바꿔서 작성하여 간결하고 명확한 구성이 된다.
1
2
3
4
5
6
7
8
9
|
@GetMapping("/add")
public String addForm() {
return "/basic/addForm";
}
@PostMapping("/add")
public String save() {
return "/basic/addForm";
}
|
cs |
View쪽 코드는 생략한다. 그러나 form 양식의 action 부분은 th:action으로 치환하되, th:action의 이동 경로값을 지정하지 않는다. 이렇게 하면 Controller에서 지정해준 것처럼 현재 페이지의 URL을 그대로 적용하게 된다. 다시 말해 페이지 이동이 일어나지 않고, post method만 적용하는 것이다.
1
2
3
4
5
6
|
<!-- th:action이 비어있으면 현재 URL에 값을 넘김-->
<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>
|
cs |
5. 상품 등록/수정 처리
상품 등록
Controller
상품 등록 처리를 위해 Controller의 save 메서드를 수정한다. addItemV1으로 일단 @RequestParam을 통해서 파라미터를 받아오도록 하는데, 각 파라미터의 이름은 addForm.html 에서의 <form> 태그 내부에서 <input> 태그의 name 속성값으로 지정해주면 된다!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
@PostMapping("/add")
public String save(@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";
}
|
cs |
addForm.html 파일의 <form> 태그 일부
1
2
3
4
5
6
7
8
9
10
11
12
13
|
<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>
|
cs |
이제 배웠던 방식인 @ModelAttribute를 적용하는 것을 순서대로 적용해본다. @ModelAttribute의 키 값이 되는 "item"은 객체의 이름과 같을때 생략이 가능하다. 그리고 최종적으로는 @ModelAttribute 자체도 생략이 가능하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// @PostMapping("/add")
public String addItemV2(@ModelAttribute("item") Item item) {
itemRepository.save(item);
// model.addAttribute("item", item);
return "/basic/item";
}
// @PostMapping("/add")
public String addItemV3(@ModelAttribute Item item) {
itemRepository.save(item);
return "/basic/item";
}
@PostMapping("/add")
public String addItemV4(Item item) {
itemRepository.save(item);
return "/basic/item";
}
|
cs |
상품 수정
상품 수정도 상품 등록과 마찬가지로 처리한다. Controller에서는 데이터 객체를 받아오기 위해서 @ModelAttribute를 사용한다. 그리고, 상품 수정이 완료되면 상품 상세 페이지로 이동하도록 redirect: 를 지정한다. redirect 부분에서는 PathVariable로 지정된 {itemId}를 그대로 사용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
|
@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}";
}
|
cs |
6. PRG : Post/Redirect/Get 패턴과 RedirectAttributes
PRG 패턴
상품 등록을 하는 부분은 새로고침시마다 Post 요청을 보내서 같은 내용을 중복 등록하게 한다는 문제가 발생한다. 웹 브라우저에서는 Post/ add 메서드를 요청하고, 이를 서버에서 처리하여 items/{itemId}의 View를 클라이언트에 전달해준 것이다. 이 상태에서 새로고침을 하면, 브라우저는 마지막에 했던 작업을 반복함으로써 Post를 반복적으로 보내게 된다.
이를 해결하기 위한 방법이 PRG 패턴이다. 브라우저에서 Post 요청을 보냈을 때, 서버에서 단순히 View를 던지는 것이 아니라 Redirect를 보낸다. Redirect를 받은 브라우저는 다시 Get 방식으로 Redirect에 담긴 주소에 요청을 하여 items/{itemId} URL로 이동하게 된다. 브라우저의 마지막 요청이 Get 방식이므로, 새로고침을 해도 Post가 반복적으로 일어나지 않게 된다.
add 메서드를 다음과 같이 수정한다.
1
2
3
4
5
6
|
@PostMapping("/add")
public String addItemV5(Item item) {
itemRepository.save(item);
return "redirect:/basic/items/" + item.getId();
}
|
cs |
주의사항(22.02.06)
일반적으로 return "view페이지 파일 경로"를 사용하지만, redirect를 사용할때는 return "redirect:/controller의 url path"를 적어줘야 한다.
RedirectAttributes
그런데, PRG 패턴에서의 return문 처럼 반환할 URL 주소에 item.getId()와 같은 숫자값을 넣는 것은 상관없지만, 만약 띄어쓰기가 들어간 문자열이나, 한글 등이 입력되면 인코딩 되지 않은 채로 전달되어 문제가 발생할 수 있다. 이때 사용하는 것이 RedirectAttributes 이다.
또한, 리다이렉트가 진행되는 add 메서드 성공 시 "저장되었습니다."라는 문자열을 표현하고 싶다면 URL 상에 값을 넣어줄 수 있는 RedirectAttributes를 이용할 수 있다. 아래와 같이 실습해보자.
우선 컨트롤러에서 RedirectAttributes를 파라미터로 받고, 여기에 itemId와 status값을 받는다. 그리고 pathvariable 형태로 itemId를 추가해준다. URL 상에 포함되지 않은 status 값의 경우 URL상 쿼리파라미터로 ?status=true와 같이 추가된다. 이를 이용하여 View에서 추가 정보를 표현할 수 있다.
1
2
3
4
5
6
7
8
|
@PostMapping("/add")
public String addItemV6(Item item, RedirectAttributes redirectAttributes) {
itemRepository.save(item);
redirectAttributes.addAttribute("itemId", item.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/items/{itemId}";
}
|
cs |
컨트롤러에서 이동하는 View단에 아래와 같이 추가한다. th:if 문을 통해 검사하고, param.status 구문으로 URL상의 쿼리파라미터값을 가져올 수 있다.
1
|
h2 th:if="${param.status}" th:text="'저장이 완료 되었습니다.'"></h2>
|
cs |
참조
1. 인프런_스프링 MVC 1편 - 백엔드 웹개발 핵심 기술_김영한 님 강의
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1
'Programming-[Backend] > Spring' 카테고리의 다른 글
[스프링 웹MVC-2] 2. 타임리프 기본 - 2 : 대표 문법 공부 계속 - 반복, 조건, 주석, 블록, 자바스크립트, 템플릿, 레이아웃 (0) | 2021.08.16 |
---|---|
[스프링 웹MVC-2] 1. 타임리프 기본 - 1 : 프로젝트 생성, 대표 문법 공부 - text, 변수, 기본 및 편의 객체, 날짜, 유틸리티 (0) | 2021.08.16 |
[스프링 웹MVC] 13. 스프링 MVC - HTTP 메시지 컨버터, 요청 매핑 핸들러 어댑터 구조 (0) | 2021.08.14 |
[스프링 웹MVC] 12. 스프링 MVC - 응답 정보 처리 기능 (0) | 2021.08.12 |
[스프링 웹MVC] 11. 스프링 MVC - 요청 정보 처리 기능 (0) | 2021.08.08 |