📌 홈 화면과 레이아웃
- 홈화면
- 회원 기능
- 회원 등록
- 회원 조회
- 상품 기능
- 상품 등록
- 상품 수정
- 상품조회
- 주문 기능
- 상품 주문
- 주문 내역 조회
- 주문 취소
⚡ 홈 컨트롤러 등록
🔶 main/.../jpashop/controller/HomeController
package jpabook.jpashop.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@Slf4j
public class HomeController {
@RequestMapping("/")
public String home(){
log.info("home controller");
return "home";
}
}
✔ @Controller
- 해당 클래스가 스프링 MVC의 컨트롤러 역할을 한다는 것 의미
- 클라이언트로부터의 요청을 받아 처리하고, 응답 반환
✔ @Slf4j
- Lombok 라이브러리에서 제공
- 간편하게 로깅 코드를 생성
log
변수를 사용하여 쉽게 로깅
✔ `@RequestMapping("/")
- 해당 메서드가 어떤 URL 경로에 매핑될 것인지 지정
- 여기서는 "/" 경로에 매핑되어 있으므로 애플리케이션의 루트 경로로 들어오는 요청을 이 메서드가 처리
✔ return home
- 해당 컨트롤러의
home()
메서드가 처리한 요청에 대한 응답 home
이라는 이름의 뷰를 찾아서 렌더링
⚡ 스프링 부트 타임리프 기본 설정
spring:
thymeleaf:
prefix: classpath:/templates/
suffix: .html
- 스프링 부트 타임리프 viewName 매핑
- resources:templates/ +{ViewName}+ .html
- resources:templates/home.html
➡ 반환한 문자home
과 스프링부트 설정 prefix
, suffix
정보를 사용해서 렌더링할 뷰html
를 찾는다.
➡ 참고링크
home..html
🔶 main/.../resources/templates/home.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header">
<title>Hello</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<div class="container">
<div th:replace="fragments/bodyHeader :: bodyHeader" />
<div class="jumbotron"> <h1>HELLO SHOP</h1>
<p class="lead">회원 기능</p> <p>
<a class="btn btn-lg btn-secondary" href="/members/new">회원 가입</a>
<a class="btn btn-lg btn-secondary" href="/members">회원 목록</a> </p>
<p class="lead">상품 기능</p> <p>
<a class="btn btn-lg btn-dark" href="/items/new">상품 등록</a>
<a class="btn btn-lg btn-dark" href="/items">상품 목록</a>
</p>
<p class="lead">주문 기능</p> <p>
<a class="btn btn-lg btn-info" href="/order">상품 주문</a>
<a class="btn btn-lg btn-info" href="/orders">주문 내역</a> </p>
</div>
<div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>
fragments/header.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="header">
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-
to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="/css/bootstrap.min.css" integrity="sha384-
ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
crossorigin="anonymous">
<!-- Custom styles for this template -->
<link href="/css/jumbotron-narrow.css" rel="stylesheet">
<title>Hello, world!</title>
</head>
fragments/bodyHeader.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div class="header" th:fragment="bodyHeader">
<ul class="nav nav-pills pull-right">
<li><a href="/">Home</a></li>
</ul>
<a href="/"><h3 class="text-muted">HELLO SHOP</h3></a>
</div>
fragments/footer.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div class="footer" th:fragment="footer">
<p>© Hello Shop V2</p>
</div>
localhost/8080 화면
💡 cf) Hierarchical-style layouts
- 예제에서는 뷰 템플릿을 최대한 간단하게 설명하려고, header , footer 같은 템플릿 파일을 반복해서 포함
- 다음 링크의 Hierarchical-style layouts을 참고하면 이런 부분도 중복을 제거할 수 있음
- 참고 링크
💡 cf2)뷰 템플릿 변경사항을 서버 재시작 없이 즉시 반영하기
- spring-boot-devtools 추가
- html 파일 build-> Recompile
⚡ view 리소스 등록
🔶 이쁜 디자인을 위해 부트스트랩을 사용하겠다. (v4.3.1) (https://getbootstrap.com/)
resources/static
하위에css
,js
추가resources/static/css/jumbotron-narrow.css
추가
jumbotron-narrow.css 파일
/* Space out content a bit */
body {
padding-top: 20px;
padding-bottom: 20px;
}
/* Everything but the jumbotron gets side spacing for mobile first views */
.header,
.marketing,
.footer {
padding-left: 15px;
padding-right: 15px;
}
/* Custom page header */
.header {
border-bottom: 1px solid #e5e5e5;
}
/* Make the masthead heading the same height as the navigation */
.header h3 {
margin-top: 0;
margin-bottom: 0;
line-height: 40px;
padding-bottom: 19px;
}
/* Custom page footer */
.footer {
padding-top: 19px;
color: #777;
border-top: 1px solid #e5e5e5;
}
/* Customize container */
@media (min-width: 768px) {
.container {
max-width: 730px;
}
}
.container-narrow > hr {
margin: 30px 0;
}
/* Main marketing message and sign up button */
.jumbotron {
text-align: center;
border-bottom: 1px solid #e5e5e5;
}
.jumbotron .btn {
font-size: 21px;
padding: 14px 24px;
}
/* Supporting marketing content */
.marketing {
margin: 40px 0;
}
.marketing p + h4 {
margin-top: 28px;
}
/* Responsive: Portrait tablets and up */
@media screen and (min-width: 768px) {
/* Remove the padding we set earlier */
.header,
.marketing,
.footer {
padding-left: 0;
padding-right: 0;
}
/* Space out the masthead */
.header {
margin-bottom: 30px;
}
/* Remove the bottom border on the jumbotron for visual effect */
.jumbotron {
border-bottom: 0;
}
}
📌 회원 등록
- 폼 객체를 사용해서 화면 계층과 서비스 계층을 명확하게 분리
⚡ 회원 등록 폼 객체
🔶 main/.../controller/MemberForm
package jpabook.jpashop.controller;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter@Setter
public class MemberForm {
@NotEmpty(message = "회원 이름은 필수입니다")
private String name;
private String city;
private String street;
private String zipcode;
}
✔ @NotEmpty(message = "회원 이름은 필수입니다")
- Bean Validation 기능의 일부
- 해당 필드가 비어있지 않아야 함을 검증
name
필드가 비어있으면 유효성 검사 실패로 간주되고 지정한 메시지 출력
⚡ 회원 등록 컨트롤러
🔶 main/.../controller/MemberController
package jpabook.jpashop.controller;
import jakarta.validation.Valid;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@GetMapping(value = "/members/new")
public String createForm(Model model){
model.addAttribute("memberForm", new MemberForm());
return "members/createMemberForm";
}
@PostMapping(value = "/members/new")
public String create(@Valid MemberForm form, BindingResult result){
if(result.hasErrors()){
return "members/createMemberForm"
}
Address address = new Address(form.getCity(), form.getStreet(), form.getZipcode());
Member member = new Member();
member.setName(form.getName());
member.setAddress(address);
memberService.join(member);
return "redirect:/";
}
}
✔ @Controller
- 해당 클래스를 웹 요청을 처리하는 컨트롤러로 지정
✔ @RequiredArgsConstructor
- Lombok의 기능 중 하나
- 클래스 final 필드에 대한 생성자를 자동으로 생성
✔ @GetMapping(value = "/members/new")
- HTTP GET 요청에 대응하도록 지정
✔ public String createForm(Model model){}
- Model 객체를 통해 데이터를 뷰로 전달
✔ model.addAttribute("memberForm", new MemberForm());
memberForm
이름으로 모델에 추가- 데이터를 뷰에서 사용 가능
✔ return "members/createMemberForm"
members/createMemberForm
뷰 템플릿을 반환- 웹 브라우저에 출력되는 것이 아니라 서버에서 해당 뷰를 렌더링하여 HTML 형태로 클라이언트에게 전달
✔ @PostMapping(value = "/members/new")
- HTTP POST 요청에 대응하는 역할을 한다는 것을 나타냄
✔ public String create(@Valid MemberForm form, BindingResult result){}
@Valid
- 입력 데이터의 유효성 검사
- 유효성 검사 : 입력 데이터의 형식, 범위, 규칙 등을 검사하여 올바른 데이터인지 확인하는 작업
MemberForm
에 있는@NotEmpty
BindingResult
- 객체를 매개변수로 받아 유효성 검사 결과를 저장
✔ if(result.hasErrors()){}
- 유효성 검사 결과에 오류가 있는지 확인
✔ return "members/createMemberForm"
- 오류가 있다면 다시 회원 등록 폼을 보여주기 위해 해당 뷰 반환
✔ return "redirect:/";
- spring MVC에서 사용되는 리다이렉트를 나타내는 코드
- 클라이언트의 요청을 다른 URL로 리다이렉트 시킴
- 일반적으로 웹 애플리케이션에서 어떤 동작을 처리한 후에, 사용자를 다른 페이지로 이동시키거나 리소스를 다운로드하도록 할 때 사용
- 특히 POST 요청을 처리한 후에 사용자에게 새로운 페이지를 보여주거나 다른 URL로 이동시키기 위해 사용
- 경로"/"로 리다이렉트 하라는 뜻
- 첫 페이지로 돌아가라는 뜻
❗ GET 메서드와 POST 메서드
- GET 메서드
- 서버로부터 데이터를 요청하는 용도로 사용
- 웹 브라우저에서 URL을 입력하거나 링크를 클릭하면 GET 요청이 생성
- 주로 데이터 조회나 뷰 요청할 때 사용
- URL에 파라미터를 붙여서 보내며, 브라우저 주소창에도 요청 내용이 노출됨
- 캐싱되기 때문에 같은 요청이 여러번 발생해도 서버에 동일한 요청이 가지 않을 수 있음
- POST 메서드
- 서버로 데이터를 전송하고자 할 때 사용
- 보안이나 데이터 길이의 제한 없이 데이터를 전송할 수 있음
- 주로 회원가입, 로그인 등에서 사용자가 입력한 데이터를 서버로 전달하는데 쓰임
- 요청 본문에 데이터를 담아 전송하므로 URL에는 보이지 않음
- 캐싱되지 않음
⚡ 회원 등록 폼 화면
🔶 templates/members/createMemberForm.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<style>
.fieldError {
border-color: #bd2130;
} </style>
<body>
<div class="container">
<div th:replace="fragments/bodyHeader :: bodyHeader"/>
<form role="form" action="/members/new" th:object="${memberForm}" method="post">
<div class="form-group">
<label th:for="name">이름</label>
<input type="text" th:field="*{name}" class="form-control" placeholder="이름을 입력하세요"
th:class="${#fields.hasErrors('name')}? 'form-controlfieldError' : 'form-control'">
<p th:if="${#fields.hasErrors('name')}"
th:errors="*{name}">Incorrect date</p>
</div>
<div class="form-group">
<label th:for="city">도시</label>
<input type="text" th:field="*{city}" class="form-control" placeholder="도시를 입력하세요"> </div>
<div class="form-group">
<label th:for="street">거리</label>
<input type="text" th:field="*{street}" class="form-control" placeholder="거리를 입력하세요">
</div>
<div class="form-group">
<label th:for="zipcode">우편번호</label>
<input type="text" th:field="*{zipcode}" class="form-control" placeholder="우편번호를 입력하세요"> </div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
<br/>
<div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>
📌 회원 목록 조회
⚡ 회원 목록 컨트롤러 추가
🔶 main/.../controller/MemberController
package jpabook.jpashop.web;
@Controller
@RequiredArgsConstructor
public class MemberController {
//추가
@GetMapping(value = "/members")
public String list(Model model){
List<Member> members = memberService.findMembers();
model.addAttribute("members",members);
return "members/memberList";
}
}
- 조회한 상품을 뷰에 전달하기 위해 스프링 MVC가 제공하는 모델(Model) 객체에 보관
- 실행할 뷰 이름을 반환
⚡ 회원 목록 뷰
🔶 templates/members/memberList.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
<div th:replace="fragments/bodyHeader :: bodyHeader" />
<div>
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>이름</th> <th>도시</th> <th>주소</th> <th>우편번호</th>
</tr>
</thead>
<tbody>
<tr th:each="member : ${members}">
<td th:text="${member.id}"></td>
<td th:text="${member.name}"></td>
<td th:text="${member.address?.city}"></td>
<td th:text="${member.address?.street}"></td>
<td th:text="${member.address?.zipcode}"></td>
</tr>
</tbody>
</table>
</div>
<div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>
💡 cf) 타임리프에서 ?를 사용하면 null 을 무시한다.
💡 cf) 폼 객체 vs 엔티티 직접 사용
💡 cf)
요구사항이 정말 단순할 때는 폼 객체( MemberForm ) 없이 엔티티( Member )를 직접 등록과 수정 화면에서 사용해도 됨
화면 요구사항이 복잡해지기 시작하면, 엔티티에 화면을 처리하기 위한 기능이 점점 증가
➡ 엔티티는 점점 화면에 종속적으로 변하고, 이렇게 화면 기능 때문에 지저분해진 엔티티는 결국 유지보수하기 어려워짐
실무에서 엔티티는 핵심 비즈니스 로직만 가지고 있고, 화면을 위한 로직은 없어야 함
화면이나 API에 맞 는 폼 객체나 DTO를 사용하자.
화면이나 API 요구사항을 이것들로 처리하고, 엔티티는 최대한 순수 하게 유지
API를 만들 때는 절대 엔티티를 외부로 반환하면 안됨!
📌 상품 등록
⚡ 상품 등록 폼
🔶 main/.../controller/BookForm
package jpabook.jpashop.controller;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class BookForm {
private Long id;
private String name;
private int price;
private int stockQuantity;
private String author;
private String isbn;
}
⚡ 상품 등록 컨트롤러
🔶 main/.../controller/ItemController
package jpabook.jpashop.controller;
import jpabook.jpashop.domain.item.Book;
import jpabook.jpashop.service.ItemService;
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.PostMapping;
@Controller
@RequiredArgsConstructor
public class ItemController {
private final ItemService itemService;
@GetMapping(value = "/items/new")
public String createForm(Model model){
model.addAttribute("form", new BookForm());
return "items/createItemForm";
}
@PostMapping(value = "/items/new")
public String create(BookForm form){
Book book = new Book();
book.setName(form.getName());
book.setPrice(form.getPrice());
book.setStockQuantity(form.getStockQuantity());
book.setAuthor(form.getAuthor());
book.setIsbn(form.getIsbn());
itemService.saveItem(book);
return "redirect:/items";
}
}
⚡ 상품 등록 뷰(items/createItemForm.html
)
🔶 items/createItemForm.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
<div th:replace="fragments/bodyHeader :: bodyHeader"/>
<form th:action="@{/items/new}" th:object="${form}" method="post">
<div class="form-group">
<label th:for="name">상품명</label>
<input type="text" th:field="*{name}" class="form-control"
placeholder="이름을 입력하세요"> </div>
<div class="form-group">
<label th:for="price">가격</label>
<input type="number" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
</div>
<div class="form-group">
<label th:for="stockQuantity">수량</label>
<input type="number" th:field="*{stockQuantity}" class="form-
control" placeholder="수량을 입력하세요"> </div>
<div class="form-group">
<label th:for="author">저자</label>
<input type="text" th:field="*{author}" class="form-control"
placeholder="저자를 입력하세요"> </div>
<div class="form-group">
<label th:for="isbn">ISBN</label>
<input type="text" th:field="*{isbn}" class="form-control"
placeholder="ISBN을 입력하세요"> </div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
<br/>
<div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>
상품 등록
- 상품 등록 폼에서 데이터를 입력하고 Submit 버튼을 클릭하면
/items/new
를 POST 방식으로 요청 - 상품 저장이 끝나면 상품 목록 화면
redirect:/items
으로 리다이렉트
📌 상품 목록
⚡ 상품 목록 컨트롤러
🔶 main/.../controller/ItemController
@Controller
@RequiredArgsConstructor
public class ItemController {
private final ItemService itemService;
/**
* 상품 목록
*/
@GetMapping(value = "/items")
public String list (Model model){
List<Item> items = itemService.findItems();
model.addAttribute("items", items);
return "items/itemList";
}
}
⚡ 상품 목록 뷰
🔶 items/itemList.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
<div th:replace="fragments/bodyHeader :: bodyHeader"/>
<div>
<table class="table table-striped">
<thead> <tr>
<th>#</th> <th>상품명</th> <th>가격</th> <th>재고수량</th> <th></th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${items}">
<td th:text="${item.id}"></td>
<td th:text="${item.name}"></td>
<td th:text="${item.price}"></td>
<td th:text="${item.stockQuantity}"></td>
<td>
<a href="#" th:href="@{/items/{id}/edit (id=${item.id})}" class="btn btn-primary" role="button">수정</a>
</td> </tr>
</tbody>
</table>
</div>
<div th:replace="fragments/footer :: footer"/>
</div> <!-- /container -->
</body>
</html>
- model에 담아둔 상품 목록인
items
를 꺼내서 상품 정보를 출력
📌 상품 수정
⚡ 상품 수정과 관련된 컨트롤러 코드
🔶 main/.../controller/ItemController
@Controller
@RequiredArgsConstructor
public class ItemController {
/**
* 상품 수정 폼
*/
@GetMapping(value = "/items/{itemId}/edit")
public String updateItemForm(@PathVariable("itemId") Long itemId, Model model){
Book item = (Book) itemService.findOne(itemId);
BookForm form = new BookForm();
form.setId(item.getId());
form.setName(item.getName());
form.setPrice(item.getPrice());
form.setStockQuantity(item.getStockQuantity());
form.setAuthor(item.getAuthor());
form.setIsbn(item.getIsbn());
model.addAttribute("form", form);
return "items/updateItemForm";
}
/**
* 상품 수정
*/
@PostMapping(value = "/items/{itemId}/edit")
public String updateItem(@ModelAttribute("form") BookForm form){
Book book = new Book();
book.setId(form.getId());
book.setName(form.getName());
book.setPrice(form.getPrice());
book.setStockQuantity(form.getStockQuantity());
book.setAuthor(form.getAuthor());
book.setIsbn(form.getIsbn());
itemService.saveItem(book);
return "redirect:/items";
}
}
✔ @GetMapping(value = "/items/{itemId}/edit")
- URL 패턴을 지정하여 HTTP GET요청을 처리
/items/{itemId}/edit
로 들어오는 GET 요청을 저리하는 핸들러메서드를 지정- {itemId}는 경로 변수로, 실제 어떤 값이 들어오더라도 해당 변수를 추출하여 메서드 파라미터로 활용 가능
✔ @PathVariable("itemId") Long itemId, Model model)
- 경로 변수를 메서드 파라미터로 받아올 때 사용
- {itemId} 경로 변수 값을 Long 타입의 itemId 파라미터로 받아오게 됨
✔ Book item = (Book) itemService.findOne(itemId);
- itemService를 사용하여
itemId
에 해당하는 상품을 조회함
✔ @ModelAttribute("form") BookForm form
- 요청 파라미터나 모델 속성 값을 메서드 파라미터로 받아오거나 설정할 때 사용
⚡ 상품 수정 폼 화면
🔶 items/updateItemForm.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
<div th:replace="fragments/bodyHeader :: bodyHeader"/>
<form th:object="${form}" method="post">
<!-- id -->
<input type="hidden" th:field="*{id}" />
<div class="form-group">
<label th:for="name">상품명</label>
<input type="text" th:field="*{name}" class="form-control"
placeholder="이름을 입력하세요" /> </div>
<div class="form-group">
<label th:for="price">가격</label>
<input type="number" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요" />
</div>
<div class="form-group">
<label th:for="stockQuantity">수량</label>
<input type="number" th:field="*{stockQuantity}" class="form-control" placeholder="수량을 입력하세요" /> </div>
<div class="form-group">
<label th:for="author">저자</label>
<input type="text" th:field="*{author}" class="form-control" placeholder="저자를 입력하세요" />
</div>
<div class="form-group">
<label th:for="isbn">ISBN</label>
<input type="text" th:field="*{isbn}" class="form-control" placeholder="ISBN을 입력하세요" />
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
<div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>
상품 수정 폼 이동
- 수정 버튼을 선택하면
/items/{itemId}/edit
URL을 GET 방식으로 요청 - 그 결과로
updateItemForm()
메서드를 실행하는데 이 메서드는itemService.findOne(itemId)
를 호출해서 수정할 상품 조회 - 조회 결과를 모델 객체에 담아서 뷰
items/updateItemForm
에 전달
상품 수정 실행
- 상품 수정 폼 HTML에는 상품의 id(hidden), 상품명, 가격, 수량 정보 있음
- 상품 수정 폼에서 정보를 수정하고 Submit 버튼을 선택
items/{itemId}/edit
URL을 POST 방식으로 요청하고updateItem()
메서드를 실행- 이때 컨트롤러에 파라미터로 넘어온
item
엔티티 인스턴스는 현재 준영속 상태임
➡ 영속성 컨텍스트의 지원을 받을 수 없고 데이터를 수정해도 변경 감지 기능은 동작하지 않음
📌 변경 감지와 병합(merge)
❗ 중요!!
⚡ 준영속 엔티티?
- 영속성 컨텍스트가 더는 관리하지 않는 엔티티
- 여기서는
itemService.saveItem(book)
에서 수정을 시도하는Book
객체 Book
객체는 이미 DB에 한 번 저장되어서 식별자가 존재함
- 여기서는
- 이렇게 임의로 만들어낸 엔티티도 기존 식별자를 가지고 있으면 준영속 엔티티로 볼 수 있음
⚡ 준영속 엔티티를 수정하는 2가지 방법
- 변경 감지 기능 사용
- 병합(merge)사용
⚡ 변경 감지 기능 사용
@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
Item findItem = em.find(Item.class, itemParam.getId()); //같은 엔티티를 조회한 다.
findItem.setPrice(itemParam.getPrice()); //데이터를 수정한다. }
}
- 영속성 컨텍스트에서 엔티티를 다시 조회한 후에 데이터를 수정하는 방법
- 트랜젝션에서 엔티티를 다시 조회, 변경할 값 선택 -> 트랜잭션 커밋 시점에 변경 감지(Dirty Checking)이 동작해서 데이터베이스에 UPDATE SQL 실행
⚡ 병합 사용
- 병합은 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용하는 기능
@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티 Item mergeItem = em.merge(itemParam);
}
병합 : 기존에 있는 엔티티
✔ 병합 동작 방식
merge()
실행- 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티 조회
2-1. 만약 1차 캐시에 엔티티가 없으면 데이터베이스에서 엔티티를 조회하고, 1차 캐시에 저장 - 조회한 영속 엔티티(mergeMember)에 member 엔티티의 값을 채워넣음
(member 엔티티의 모든 값을 mergeMember에 밀어 넣음 -> 이때 mergeMember의 "회원1"이라는 이름이 "회원명변경"으로 바뀜 - 영속 상태인 mergeMember를 변환
✔ 병합시 동작 방식을 간단히 정리
- 준영속 엔티티의 식별자 값으로 영속 엔티티를 조회
- 영속 엔티티의 값을 준영속 엔티티의 값으로 모두 교체(병합)
- 트랜잭션 커밋 시점에 변경 감지 기능이 동작해서 데이터베이스에 UPDATE SQL 실행
❗ 주의 : 변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만, 병합을 사용하면 모든 속성이 변경됨
➡ 병합 시 값이 없으면 null
로 업데이트 할 위험도 있음(병합은 모든 필드를 교체함)
⚡ 상품 리포지토리의 저장 메서드 분석 itemRepository
package jpabook.jpashop.repository;
@Repository
public class ItemRepository {
@PersistenceContext
EntityManager em;
public void save(Item item) {
if (item.getId() == null) {
em.persist(item);
} else {
em.merge(item);
}
}
//...
}
save()
메서드는 식별자 값이 없으면(null) 새로운 엔티티로 판단해서 영속화(persist)하고 식별자가 있으면 병합(merge)- 지금처럼 준영속 상태인 상품 엔티티를 수정할 떄는 id값이 있으므로 병합 수행
⚡ 새로운 엔티티 저장과 준영속 엔티티 병합을 편리하게 한 번에 처리
- 상품 리포지토리에선
save()
메서드를 유심히 봐야 함 - 이 메서드 하나로 저장과 수정(병합)을 다 처리함
- 식별자 값이 없으면 새로운 엔티티로 판단해서
persist()
로 영속화하고, 만약 식별자 값이 있으면 이미 한 번 영속화 되었던 엔티티로 판단해서merge()
로 수정(병합)함- 저장
save
의 의미는 신규 데이터를 저장하는 것뿐만 아니라 변경된 데이터의 저장이라는 의미도 포함 - 이 메서드를 사용하는 클라이언트는 저장과 수정을 구분하지 않아도 되므로 클라이언트의 로직이 단순해짐
- 저장
💡 cf)
- save() 메서드는 식별자를 자동 생성해야 정상 동작함
- 여기서 사용한 Item 엔티티의 식별자는 자동으로 생성되도록 @GeneratedValue 를 선언함
- 식별자 없이 save() 메서드를 호출하면 persist() 가 호출되면서 식별자 값이 자동으로 할당됨
- 반면에 식별자를 직접 할당하도록 @Id 만 선언 했다고 가정하면 식별자를 직접 할당하지 않고, save() 메서드를 호출하면 식별자가 없는 상태로 persist() 를 호출함
- 식별자가 없다는 예외 발생
💡 cf)
- 실무에서는 보통 업데이트 기능이 매우 제한적
- 병합은 모든 필드를 변경해버리고, 데이터 가 없으면 null 로 업데이트 해버림
- 병합을 사용하면서 이 문제를 해결하려면, 변경 폼 화면에서 모든 데이터를 항상 유지해야 햄
- 실무에서는 보통 변경가능한 데이터만 노출하기 때문에, 병합을 사용하는 것이 오히려 번거로움
⚡ 가장 좋은 해결 방법
엔티티를 변경할 때는 항상 변경 감지를 사용해라
- 컨트롤러에서 어설프게 엔티티 생성 금지
- 트랜잭션이 있는 서비스 계층에 식별자(id)와 변경할 데이터를 명확하게 전달해야 함(파라미터 or dto)
- 트랜잭션이 있는 서비스 계층에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경하라
- 트랜잭션 커밋 시점에 변경 감지가 실행됨
@Controller
@RequiredArgsConstructor
public class ItemController {
private final ItemService itemService;
/**
*상품 수정,권장 코드
*/
@PostMapping(value = "/items/{itemId}/edit")
public String updateItem(@PathVariable Long itemId, @ModelAttribute("form") BookForm form) {
itemService.updateItem(itemId, form.getName(), form.getPrice(), form.getStockQuantity());
return "redirect:/items";
}
}
package jpabook.jpashop.service;
@Service
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
/**
* 영속성 컨텍스트가 자동 변경
*/
@Transactional
public void updateItem(Long id, String name, int price, int stockQuantity)
{
Item item = itemRepository.findOne(id);
item.setName(name);
item.setPrice(price);
item.setStockQuantity(stockQuantity);
}
}
📌 상품 주문
⚡ 상품 주문 컨트롤러
🔶 main/.../controller/OrderController
package jpabook.jpashop.controller;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.item.Item;
import jpabook.jpashop.service.ItemService;
import jpabook.jpashop.service.MemberService;
import jpabook.jpashop.service.OrderService;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
private final MemberService memberService;
private final ItemService itemService;
@GetMapping(value = "/order")
public String createFrom(Model model){
List<Member> members = memberService.findMembers();
List<Item> items = itemService.findItems();
model.addAttribute("members", members);
model.addAttribute("items", items);
return "order/orderForm";
}
@PostMapping(value = "/order")
public String order(@RequestParam("memberId") Long memberId, @RequestParam("itemId") Long itemId, @RequestParam("count") int count){
orderService.order(memberId, itemId, count);
return "redirect:/orders";
}
}
✔ @RequestParam()
- 스프링 프레임워크에서 웹 요청의 파라미터 값을 가져올 때 사용되는 애노테이션
- 웹 요청을 처리하는 컨트롤러의 메서드 매개변수에 "@RequestParam"을 붙이면 해당 매개변수는 요청에 따라 파라미터 값을 받아올 수 있음
주문 폼 이동
- 메인 화면에서 상품 주문을 선택하면
/order
를 GET 방식으로 호출 OrderController
의createrForm()
메서드- 주문 화면에는 주문할 고객 정보와 상품 정보가 필요하므로
model
객체에 담아서 뷰에 넘겨줌
주문 실행
- 주문할 회원과 상품, 수량을 선택해서 Submit버튼을 누르면
/order
URL을 POST방식을 호출 - 컨트롤러의
order()
메서드를 실행 - 이 메서드는 고객 식별자(memberId), 주문할 상품 식별자(itemId), 수량(count) 정보를 받아서 주문 서비스에 주문 요청
- 주문이 끝나면 상품 주문 내역이 있는
/orders
URL로 리다이렉트
⚡ 상품 주문 폼
🔶 order/orderForm
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header" />
<body>
<div class="container">
<div th:replace="fragments/bodyHeader :: bodyHeader"/>
<form role="form" action="/order" method="post">
<div class="form-group">
<label for="member">주문회원</label>
<select name="memberId" id="member" class="form-control">
<option value="">회원선택</option>
<option th:each="member : ${members}"
th:value="${member.id}"
th:text="${member.name}" />
</select>
</div>
<div class="form-group">
<label for="item">상품명</label>
<select name="itemId" id="item" class="form-control">
<option value="">상품선택</option>
<option th:each="item : ${items}"
th:value="${item.id}"
th:text="${item.name}" />
</select>
</div>
<div class="form-group">
<label for="count">주문수량</label>
<input type="number" name="count" class="form-control" id="count" placeholder="주문 수량을 입력하세요">
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
<br/>
<div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>
📌 주문 목록 검색, 취소
⚡ 주문 목록 검색 컨트롤러
🔶 main/.../OrderController
@Controller
@RequiredArgsConstructor
public class OrderController {
@GetMapping(value = "/orders")
public String orderList(@ModelAttribute("orderSearch")OrderSearch orderSearch, Model model){
List<Order>orders = orderService.findOrders(orderSearch);
model.addAttribute("orders",orders);
return "order/orderList";
}
}
⚡ 주문 목록 검색 화면
🔶 order/orderList
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header"/>
<body>
<div class="container">
<div th:replace="fragments/bodyHeader :: bodyHeader"/>
<div>
<div>
<form th:object="${orderSearch}" class="form-inline">
<div class="form-group mb-2">
<input type="text" th:field="*{memberName}" class="form- control" placeholder="회원명"/>
</div>
<div class="form-group mx-sm-1 mb-2">
<select th:field="*{orderStatus}" class="form-control">
<option value="">주문상태</option>
<option th:each="status : ${T(jpabook.jpashop.domain.OrderStatus).values()}"
th:value="${status}"
th:text="${status}">option
</option>
</select>
</div>
<button type="submit" class="btn btn-primary mb-2">검색</button>
</form>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>회원명</th>
<th>대표상품 이름</th>
<th>대표상품 주문가격</th>
<th>대표상품 주문수량</th>
<th>상태</th>
<th>일시</th>
<th></th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${orders}">
<td th:text="${item.id}"></td>
<td th:text="${item.member.name}"></td>
<td th:text="${item.orderItems[0].item.name}"></td>
<td th:text="${item.orderItems[0].orderPrice}"></td>
<td th:text="${item.orderItems[0].count}"></td>
<td th:text="${item.status}"></td>
<td th:text="${item.orderDate}"></td>
<td>
<a th:if="${item.status.name() == 'ORDER'}" href="#" th:href="'javascript:cancel('+${item.id}+')'" class="btn btn-danger">CANCEL</a>
</td>
</tr>
</tbody>
</table>
</div>
<div th:replace="fragments/footer :: footer"/>
</div> <!-- /container -->
</body>
<script>
function cancel(id) {
var form = document.createElement("form");
form.setAttribute("method", "post");
form.setAttribute("action", "/orders/" + id + "/cancel");
document.body.appendChild(form);
form.submit();
}
</script>
</html>
⚡ 주문 취소
🔶 main/.../OrderControlle
@Controller
@RequiredArgsConstructor
public class OrderController {
@PostMapping(value="/order/{orderId}/cancel")
public String cancelOrder(@PathVariable("orderId")Long orderId){
orderService.cancelOrder(orderId);
return "redirect:/orders";
}
}
✔ @PathVariable
- Spring MVC 프레임워크에서 사용되는 애노테이션
- URL 경로에서 변수 값을 추출하여 메서드 파라미터에 바인딩 하는 역할
- RESTful 웹 애플리케이션에서 동적인 URL을 처리하거나, 경로에 포함된 데이터 컨트롤러 메서드로 전달 가능
<실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발_김영한>을 수강하고 작성한 글입니다

PREV
[스프링 부트와 JPA 활용 1] 5. 주문 도메인 개발
📌 구현 기능 및 순서 ✔ 구현 기능 상품 주문 주문 내역 조회 주문 취소 ✔ 순서 주문 엔티티 , 주문 상품 엔티티 개발 주문 리포지토리 개발 주문 서비스 개발 주문 검색 기능 개발 주문 기능
nyeroni.tistory.com
'인프런 Spring 강의 정리 > 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발' 카테고리의 다른 글
[스프링 부트와 JPA 활용 1] 5. 주문 도메인 개발 (0) | 2024.01.24 |
---|---|
[스프링 부트와 JPA 활용 1] 4. 상품 도메인 개발 (0) | 2024.01.23 |
[스프링 부트와 JPA 활용 1] 3. 애플리케이션 구현 준비, 회원 도메인 개발 (0) | 2024.01.23 |
[스프링 부트와 JPA 활용1] 2. 도메인 분석 설계 (4) | 2024.01.23 |
[스프링 부트와 JPA 활용 1] 1. 프로젝트 환경 설정 (1) | 2024.01.23 |