- 요구사항 분석
상품을 관리할 수 있는 서비스를 만들어볼 것이다.
스프링 MVC와 타임리프를 사용해 만들어보자.
상품 도메인 모델
- 상품 ID
- 상품명
- 가격
- 수량
상품 관리 기능
- 상품 목록
- 상품 상세
- 상품 등록
- 상품 수정
서비스 제공 흐름
현재 상황은 웹 퍼블리셔가 HTML, CSS를 만들어 제공한 상황이이다.
핵심 비지니스 로직을 개발하고 뷰 템플릿을 통해 동적인 HTML을 제공하도록 해보자!
- 타임리프
타임리프의 핵심은 th:xxx 가 붙은 부분은 서버사이드에서 렌더링 되고, 기존것을 대체한다는 점이다.
th:xxx 이 없으면 기존 HTML의 xxx 속성이 그대로 사용된다.
HTML을 파일로 직접 열었을 때, JSP 같은 뷰 템플릿 엔진의 경우 자바 코드가 섞여있어 깨지는 상황이 많지만
타임리프에서 웹 브라우저는 th: 속성을 알지 못하므로 무시하고 기존의 속성이 사용된다.
따라서 HTML 파일 보기를 유지하면서 뷰 템플릿 기능도 할 수 있다.
타임리프의 핵심 사용법들을 알아보자.
내용 변경 - th:text
<td th:text="${item.price}">10000</td>
- '10000'이라고 써놓은 내용의 값을 th:text 의 값으로 변경한다.
- 여기서는 ${item.price}의 값으로 변경한다.
변수 표현식 - ${....}
<td th:text="${item.price}">10000</td>
- 위와 동일한 코드이다.
- 모델에 포함된 값이나, 타임리프 변수로 선언한 값을 조회할 수 있다.
- 프로퍼티 접근법을 사용한다. (item.price는 item.getPrice()랑 동일한 의미)
URL 링크 표현식 - @{....}
<link href="../css/bootstrap.min.css"
th:href="@{/css/bootstrap.min.css}"
rel="stylesheet">
- 타임리프는 URL링크를 사용하는 경우 @{...} 을 사용한다.
<td><a href="items.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
th:text="${item.id}">회원id</a></td>
- 다른 방식으로 URL 내에 타임리프 변수를 사용할 수 있다.
- {itemId}으로 변수를 설정하고 (itemId=${item.id}) 로 변수에 값을 넣을 수 있다.
- 추가로, th:href="@{/basic/items/{itemId}(itemId=${item.id}, query='test')}" 처럼 쿼리 파라미터를 넣어줄 수 있다.
반복 출력 - th:each
<tr th:each = "item : ${items}">
<td><a href="items.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
th:text="${item.id}">회원id</a></td>
<td><a href="items.html" th:href="@{|/basic/items/${item.id}|}"
th:text="${item.itemName}">상품명</a></td>
<td th:text="${item.price}">가격</td>
<td th:text="${item.quantity}">10</td>
</tr>
- 자바의 for-each문과 같은 동작을 한다.
속성 변경 - th:onclick
<button class="btn btn-primary float-end"
onclick="location.href='addForm.html'"
th:onclick="|location.href='@{/basic/items/add}'|"
type="button">상품 등록
</button>
- 버튼 클릭시 URL 경로를 옮기는 것이다.
리터럴 대체 - |....|
- 타임리프에서 문자와 표현식 등은 분리되어 있기 때문에 원래는 더해서 사용해야 한다.
th:onclick="'location.href=' + '\'' + @{/basic/items/add} + '\''"
- 리터럴 대체 문법을 사용하면 다음과 같이 편리하게 사용할 수 있다.
th:onclick="|location.href='@{/basic/items/add}'|"
- 상품 도메인 개발
Item.java
@Data
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;
}
}
- 상품 ID, 이름, 수량, 가격을 멤버 변수로 갖는다.
- 생성자로 상품ID를 제외한 나머지를 추가해놨다.
- 상품ID는 직접 만드는게 아닌, 상품등록시 자동으로 ++되면서 번호를 부여받을 것이다.
ItemRepository.java
@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 item = findById(itemId);
item.setItemName(updateParam.getItemName());
item.setPrice(updateParam.getPrice());
item.setQuantity(updateParam.getQuantity());
}
public void clearStore() {
store.clear();
}
}
- 상품 저장시 sequence가 증가하며 상품 ID를 부여받는다.
- 그 외 필요한 findById, findAll, update 등을 설계했다.
- 상품 목록 개발
서비스 화면
Controller
@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", 30000, 12));
}
}
- 현재 저장되어 있는 전체 상품 목록을 타임리프로 넘겨줘야 한다.
- 따라서 URL이 호출되면, 모델에 전체 상품을 리스트에 담아 반환한다.
- 이후 타임리프를 통해 모델에 있는 값을 th:each 문을 통해 하나씩 꺼내어 상품정보를 표시해주면 된다.
- 상품 ID나 상품명을 클릭하면 해당 상품의 상세 페이지를 열 수 있도록 링크를 달아놨다.
- 상품 상세 URL은 현재 URL인 /basic/items 밑에 상품번호를 붙이도록 했다.
<td><a href="items.html" th:href="@{/basic/items/{itemId}(itemId=${item.id})}"
th:text="${item.id}">회원id</a></td>
- 상품 등록 버튼은 나중에 처리하고 우선 상세 페이지로 이동해 마저 개발해보자.
- 상품 상세 개발
서비스 화면
Controller
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {
private final ItemRepository itemRepository;
@GetMapping("/{itemId}")
public String item(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item",item);
return "basic/item";
}
}
- 이전의 상품 목록 HTML에서 링크를 클릭할 시 basic/items/{itemId} 경로를 호출하도록 만들었다.
- 해당 경로의 컨트롤러가 호출되며, 경로로 들어온 상품ID를 기반으로 findById()를 통해 상품을 가져온다.
- 해당 상품정보를 모델에 담아 상품 상세 HTML 경로로 반환한다.
- 타임리프를 통해 상품 정보들이 표시된다.
- '목록으로' 버튼을 클릭하면 /basic/items 경로를 호출해 다시 돌아간다.
- '상품 수정' 버튼을 클릭하면 /basic/items/{itemId}/edit 경로를 호출한다.
- 상품 수정 개발
서비스 화면
Controller
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {
private final ItemRepository itemRepository;
@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 editSaveForm(@PathVariable Long itemId, @ModelAttribute Item item) {
itemRepository.update(itemId, item);
return "redirect:/basic/items/{itemId}";
}
}
- 이전의 상품 상세 페이지에서 '상품 수정' 버튼을 클릭하면 GET 요청으로 수정 Form이 담긴 HTML 경로를 반환해 렌더링 해준다.
- 이 후 폼에서 수정을 마치고 '저장' 버튼을 클릭하면 해당 폼의 데이터가 POST 방식으로 전달된다.
- 재밌는건, 두 가지의 기능을 모두 같은 경로에서 처리했다는 점이다!
- 다만 초기에 상품 수정 폼을 요청 받을 때는 GetMapping으로, 이 후 버튼을 눌러 저장할 때는 PostMapping으로 데이터를 받아와 처리한다. 실무에서도 이 방식을 많이 사용한다.
- 또 재밌는건, 수정 후 저장버튼을 누르면 아예 제품 상세 페이지 경로로 리다이렉트 하고있다.
- 리다이렉트 하는것과 상세 페이지 뷰 경로를 반환해주는 것과 무슨 차이가 있을까?
- 우선 상세 페이지 뷰 경로를 반환해주면, 실제 URL 경로는 그대로 ...../edit으로 남아있는 상태이다.
- 이때 만약 새로고침을 누른다면 가장 마지막 요청이 실행되므로 저장을 실행하는 POST 액션이 실행될 것이다.
- 만약 지금 이 상황이 상품 수정이 아니고 상품 등록이었다면, 새로고침을 할 때마다 같은 상품이 계속 등록되는것이다.
- 이런 상황을 방지하기 위해 리다이렉트를 통해 URL 경로 자체를 상세 페이지로 옮겨버리는 것이다.
- 이런 구조를 PRG(Post, Redirect, Get)이라고 부른다!
- Post로 저장하고 --> Redirect로 이동하고 --> Get으로 해당 페이지를 요청하는 사이클이다.
- 상품 등록 개발
서비스 화면
Controller
@Controller
@RequestMapping("/basic/items")
@RequiredArgsConstructor
public class BasicItemController {
private final ItemRepository itemRepository;
@GetMapping("/add")
public String addForm() {
return "basic/addForm";
}
// @PostMapping("/add")
public String saveFormV1(@RequestParam String itemName,
@RequestParam int price,
@RequestParam int quantity,
Model model) {
Item item = new Item(itemName, price, quantity);
itemRepository.save(item);
model.addAttribute("item", item);
return "basic/item";
}
// @PostMapping("/add")
public String saveFormV2(@ModelAttribute("item") Item item) { //모델 받아와서 직접 안넣어줘도 애노테이션 뒤에 이름 지정해주면
// 해당 이름으로 받아온 객체 저장해줌.
itemRepository.save(item);
// model.addAttribute("item", item); 자동 추가됨. 생략 가능
return "basic/item";
}
// @PostMapping("/add")
public String saveFormV3(@ModelAttribute Item item) { // 이름을 지정 안하면 클래스명의 맨 앞글자만 소문자로 바꿔서
// 모델 이름으로 저장
itemRepository.save(item);
// model.addAttribute("item", item); 자동 추가됨. 생략 가능
return "basic/item";
}
//@PostMapping("/add")
public String saveFormV4(Item item) { // @ModelAttribute는 생략가능!
// 이 경우에도 Item -> item 으로 바뀌어서 모델이름으로 담기는거 잊지말자.
itemRepository.save(item);
// model.addAttribute("item", item); 자동 추가됨. 생략 가능
return "basic/item";
}
// @PostMapping("/add")
public String saveFormV5(Item item) {
itemRepository.save(item);
return "redirect:/basic/items/" + item.getId();
}
@PostMapping("/add")
public String saveFormV6(Item item, RedirectAttributes redirectAttributes) {
Item saveditem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", saveditem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/basic/items/{itemId}";
}
}
- 상품 수정과 비슷한 과정이다.
- 최초 상품 목록 페이지에서 '상품 등록' 버튼을 클릭할 시 Get 요청으로 상품 등록 폼을 받게된다.
- 이후 상품 정보를 입력하고 '상품 등록' 버튼을 클릭할 시 Post 방식으로 동일한 URL로 요청을 보낸다.
- 컨트롤러 입장에서는 같은 URL로 요청이 들어왔지만 HTTP 메소드가 다르므로 구분할 수 있다.
- 이후 실제 상품을 등록하는 방법을 여러가지 버전으로 만들어봤다.
- 첫 번째는 @RequestParam을 통해 직접 요청 파라미터를 하나씩 꺼내서 상품을 저장하는 것이다.
- HTML 폼으로 데이터를 전송하면 요청 파라미터로 처리할 수 있기 때문에 가능한 것이다.
- 두 번째는 @ModelAttribute를 사용해 원하는 객체타입으로 데이터를 처음부터 받아온 것이다.
사실 이후에 model.addAttribute() 과정이 필요하지만, 해당 애노테이션이 자동으로 처리해준다. 편리하다!
모델에 저장 할 때 이름을 지정하고 싶으면 @ModelAttribute("item"), 이런 식으로 지정해주면 된다. - 세 번째도 동일한데 모델에 저장할 이름을 지정하지 않으면 객체 클래스 이름의 맨 앞글자만 소문자로 치환하고,
해당 이름을 모델에 저장할 이름으로 인식한다. - 네 번째는 @ModelAttribute 애노테이션까지 생략한 것이다. 파라미터에 애노테이션이 생략되어 있을 경우 String, int, Integer 등의 기본 타입은 @RequestParam이 생략되어 있다고 간주하고, 그 외는 @ModelAttribute 애노테이션이 생략되어있다고 간주한다.
- 마지막 두개는 리다이렉트를 적용한 모습이다.
- 상품 수정 때도 이야기했지만, 첫 번째 ~ 네 번째 방식처럼 단순히 상품 등록 후 해당 상품의 상세 페이지 뷰를 반환해준다고 URL 자체가 옮겨진게 아니다. 단순히 해당 HTML 뷰만 현재 경로에 반환된 것이다!
- 따라서 이 경우는 리다이렉트 해주지 않으면 새로고침 시 무한히 상품이 등록되는 불상사가 벌어질 것이다.
- 추가로 RedirectAttributes라는 객체를 사용해 인코딩 + 리다이렉트하면서 쿼리파라미터까지 붙여줄 수 있다.
- 해당 쿼리파라미터를 이용해 "저장 완료!" 등의 메세지를 동적으로 HTML에 보여줄 수 있겠다.
출처 : 인프런, 김영한의 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술
'Spring' 카테고리의 다른 글
[Spring] 메시지, 국제화 기능 (0) | 2023.07.19 |
---|---|
[Spring] 타임리프(Thymeleaf)와 스프링의 통합 (0) | 2023.07.19 |
[Spring] 스프링 MVC 기본 기능 - HTTP 요청, 응답 처리하기 (0) | 2023.07.14 |
[Spring] 스프링 MVC 구조 이해하기 (1) | 2023.07.11 |
[Spring] 프론트 컨트롤러를 도입한 MVC 프레임워크 만들기 (1) | 2023.07.09 |