Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
Tags
- jpa - 객체지향 쿼리 언어
- 자바의 정석 기초편 ch4
- 스프링 db2 - 데이터 접근 기술
- 스프링 mvc2 - 로그인 처리
- 자바 기본편 - 다형성
- 코드로 시작하는 자바 첫걸음
- 자바의 정석 기초편 ch7
- 스프링 db1 - 스프링과 문제 해결
- 2024 정보처리기사 수제비 실기
- 자바의 정석 기초편 ch3
- 자바의 정석 기초편 ch13
- jpa 활용2 - api 개발 고급
- 자바의 정석 기초편 ch6
- 스프링 mvc2 - 검증
- 게시글 목록 api
- @Aspect
- 스프링 mvc1 - 서블릿
- 자바의 정석 기초편 ch2
- 2024 정보처리기사 시나공 필기
- 자바의 정석 기초편 ch1
- 자바의 정석 기초편 ch11
- 자바의 정석 기초편 ch14
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch9
- 자바의 정석 기초편 ch8
- 자바의 정석 기초편 ch5
- 스프링 입문(무료)
- 스프링 mvc1 - 스프링 mvc
- 자바의 정석 기초편 ch12
- 스프링 mvc2 - 타임리프
Archives
- Today
- Total
나구리의 개발공부기록
웹 계층 개발, 홈 화면과 레이아웃, 회원 등록, 회원 목록 조회, 상품 등록, 상품 목록, 상품 수정, 변경 감지와 병합(merge), 상품 주문, 주문 목록 검색 및 취소 본문
인프런 - 스프링부트와 JPA실무 로드맵/실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발
웹 계층 개발, 홈 화면과 레이아웃, 회원 등록, 회원 목록 조회, 상품 등록, 상품 목록, 상품 수정, 변경 감지와 병합(merge), 상품 주문, 주문 목록 검색 및 취소
소소한나구리 2024. 10. 1. 13:33출처 : 인프런 - 실전! 스프링 부트와 JPA활용1 - 웹 애플리케이션 개발(유료) / 김영한님
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용
1. 홈 화면과 레이아웃
1) HomeController
@Slf4j
@Controller
public class HomeController {
@RequestMapping("/")
public String home() {
log.info("home controller");
return "home";
}
}
2) 타임리프 템플릿 등록
- 타임리프 레이아웃을 활용하여 header, footer, bodyheader를 home.html에 삽입하여 출력
- 간단하게 적용하기 위하여 Include Style로 적용하였지만 실무에서 적용할 때는 반복적인 코드를 제거하는 Hierarchical-style layout을 사용함
- https://www.thymeleaf.org/doc/articles/layouts.html - 타임리프 템플릿 스타일 적용 가이드
- https://nagul2.tistory.com/228 - 타임리프 템플릿 조각, 템플릿 레이아웃 관련 설명
(1) 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>
(2) 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>
(3) footer.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div class="footer" th:fragment="footer">
<p>© Hello Shop V2</p>
</div>
(4) 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>
3) view 리소스 등록
- 부트스트랩 v4.3.1 버전을 다운받아서 css,js 디렉토리를 통채로 복사하여 resources/static 하위에 복사
- css와 js 복사 후 바로 적용이 안될 수 있어 프로젝트의 빌드를 재빌드하고 서버를 재시작 하면 정상적으로 적용이 됨
- resources/static/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;
}
}
4) 전체 적용 후 실행 모습
2. 회원 등록
- 폼 객체를 사용해서 화면 계층과 서비스 계층을 명확하게 분리
- 스프링 MVC와 타임리프에 대한 문법은 스프링 완전정복 로드맵의 강의가 우선 선행되어야 이해할 수 있음
- 스프링 MVC1편 강의 모음
- 스프링 MVC2편 강의 모음
1) MemberForm - 폼 객체(DTO)
@Getter @Setter
public class MemberForm {
@NotEmpty(message = "회원 이름은 필수 입니다")
private String name;
private String city;
private String street;
private String zipcode;
}
2) MemberController
- createForm(): GetMapping으로 단순 회원 등록을 위한 Form을 띄워줌
- create(): PostMapping 으로 실제 회원 가입 데이터를 저장
- @Valid를 사용하여 spring 파라미터로 넘어온 form에 대해서 검증하도록설정, @Validated를 사용하면 groups 기능을 사용할 수 있음
- BindingResult로 에러 발생 시 members/createMemberform으로 이동하도록 설정하면 타임리프와 스프링이 통합이 매우 잘되어있어서 어떤 에러가 있는지를 화면에서 표시해줄 수 있음
- 파라미터에 엔터티를 직접 안쓰고 MemberForm이라는 전송용 (dto역할) 폼 객체를 사용하는 이유는 화면에서 넘어올때와 실제 도메인이 원하는 검증이 차이가 발생하면 억지로 맞춰야하는데 점점 코드가 지저분해짐
- 애플리케이션이 정말 심플하면 엔터티를 바로 받아도 되지만 조금만 복잡해져도 실무에서는 단순한 폼 화면이 없기 때문에 필요한 데이터만 채워서 화면에 넘기는 것이 권장됨
- 생성한 MemberForm에대한 정보를 model에 담아서 전달
package jpabook.jpashop.controller;
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@GetMapping("/members/new")
public String createForm(Model model) {
model.addAttribute("memberForm", new MemberForm());
return "members/createMemberForm";
}
@PostMapping("/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:/"; // 회원가입이 되면 홈으로 이동
}
}
3) createMemberForm.html
- ~resources/templates에 members 디렉토를 만들고 createMemberForm.html을 생성
- 이름 라벨에 보면 에러가 발생 되면 에러 메세지와 함께 에러가 발생했을 때 적용할 css가 적용되도록 작성되어있음
<!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-control fieldError' : '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>
4) 결과 화면
(1) localhost:8080/members/new
- 회원가입화면
- 필수 입력 필드인 이름을 비워두고 회원가입을 하면 다시 회원가입 폼을 띄워주면서 css로 오류메세지를 고객에게 띄워줌
3. 회원 목록 조회
1) MemberController - list() 추가
- 여기서는 폼객체를 안쓰고 엔터티를 직접 반환했는데 지금과 같은 예제에서는 단순하니까 가능하지만 실무에서는 더 복잡해질 것이기 때문에 DTO로 변환해서 화면에 필요한 내용만 출력하는 것을 권장함
- 그리고 웹에 데이터를 출력할 때는 엔터티를 출력해도 괜찮지만 API를 만들 때는 이유를 불문하고 절대 엔터티를 웹으로 반환하면 안됨
- 만약 엔터티에 로직이 추가가되면 필드 내용이 그대로 노출되고, 로직 변경하는 것만으로 API 스펙이 변경되어버려 불안정한 API스펙이 되어버림
- 회원리스트를 model에 담아서 전달
@GetMapping("/members")
public String list(Model model) {
List<Member> members = memberService.findMembers();
model.addAttribute("members", members);
return "members/memberList";
}
2) memberList.html 추가
- ~templates/members위치에 생성
- 타임리프는 list에 담긴 값을 th:each 문법으로 반복하여 쉽게 꺼낼 수 있음
- 타임리프에서 ?를 사용하면 null을 무시함
<!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>
** 폼 객체 vs 엔터티 직접사용 정리
- 요구사항이 정말 단순할 때는 폼 객체(MemberForm)없이 엔터티(Member)를 직접 등록과 수정화면해서 사용해도됨
- 하지만 화면 요구사항이 복잡해지기 시작하면 화면을 처리하기 위한 기능이 점점 증가하고 결과적으로 엔터티는 점점 화면에 종속적으로 변하게 되며 화면 기능때문에 지저분해진 엔터티는 결국 유지보수하기 어려워짐
- 엔터티는 핵심 비즈니스 로직만 가지고 있고 화면을 위한 로직은 없어야함
- JPA 쓸때는 최대한 엔터티를 순수하게 유지하고 핵심 비즈니스 로직에만 의존성이 있도록 설계해야함
- 화면이나 API에 맞는 폼 객체나 DTO를 사용하여 엔터티를 최대한 순수하게 유지하도록 해야함
4. 상품 등록
1) BookForm - 폼객체(DTO)
package jpabook.jpashop.controller;
@Getter @Setter
public class BookForm {
// Item 공통 속성
private Long id;
private String name;
private int price;
private int stockQuantity;
// Book 속성
private String author;
private String isbn;
}
2) ItemController
- createForm() : GetMapping으로 상품 등록 폼을 화면에 보여주고 생성한 BookForm을 model에 담아서 화면에 전달
- create() : PostMapping으로 실제 상품 등록 데이터를 전송 후 itmes화면으로 리다이렉트
package jpabook.jpashop.controller;
@Controller
@RequiredArgsConstructor
public class ItemController {
private final ItemService itemService;
@GetMapping("/items/new")
public String createForm(Model model) {
model.addAttribute("form", new BookForm());
return "items/createItemForm";
}
@PostMapping("/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"; // 저장된 책 목록으로 바로 리다이렉트
}
}
3) createItemForm.html
- ~/resources/templates/items에 생성
<!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>
5. 상품 목록
1) ItemController - list() 추가
- 전체 아이템 정보를 model에 담아서 전달
@GetMapping("/items") // 아이템 목록
public String list(Model model) {
List<Item> items = itemService.findItems();
model.addAttribute("items", items);
return "items/listItems";
}
2) listItems.html
- ~/resources/templates/items에 생성
<!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>
6. 상품 수정
1) ItemController 추가
- GET 방식으로 수정을 하기위한 updateItemForm을 띄우고 Post로 수정 후 저장시 상품 목록으로 redirect
- 지금은 보안 관련되는 로직을 작성하지 않았지만, id는 조작하기가 쉬워서 해당 아이템에 대해 유저가 권한이 있는지 없는지를 체크하는 로직이 서버에 꼭 있어야 함
- 화면에 필요한 정보를 model에 담아서 전달
@GetMapping("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("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";
}
2) updateItemForm.html
- ~/resources/templates/items에 생성
<!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>
7. 변경 감지와 병합 - 매우중요(완벽 이해 해야함)
1) 준영속 엔터티
- 영속성 컨텍스트가 더는 관리하지 않는 엔터티 - JPA가 관리하지 않음
- itemService.saveItem(book) 에서 수정을 시도하는 Book 객체는 이미 DB에 한번 저장되어서 식별자가 존재함
- 이렇게 임의로 만들어낸 엔터티도 기존 식별자를 가지고 있으면 준영속 엔터티로 볼 수 있음
2) 준영속 엔터티를 수정하는 2가지 방법
(1) 변경 감지 기능 사용 - 실무에서는 무조건 해당 방식으로 사용하는 것을 권장
- 영속성 컨텍스트에서 엔터티를 다시 조회한 후에 데이터를 수정하는 방법
- 트랜잭션 안에서 엔터티를 다시 조회, 변경할 값 선택 -> 트랜잭션 커밋 시점에 변경 감지(Dirty Checking)이 동작해서 데이터베이스에 UPDATE SQL을 실행
@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
Item findItem = em.find(Item.class, itemParam.getId()); //같은 엔티티를 조회한다.
findItem.setPrice(itemParam.getPrice()); //데이터를 수정한다.
}
(2) 병합 사용
@Transactional
void update(Member member) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
Member mergeMember = em.merge(member);
}
- merge()를 실행하면 파라미터로 넘어온 준영속 엔터티의 식별자 값으로 1차 캐시에서 엔터티를 조회
- 만약 1차 캐시에 엔터티가 없으면 데이터베이스에서 엔터티를 조회하고 1차 캐시에 저장
- 조회한 영속 엔터티(mergeItem)에 member 엔터티의 값을 채워 넣고 영속 상태인 mergeMember를 반환
- member엔터티의 모든 값을 mergeMember에 밀어넣기 때문에 "회원1" 이라는 이름이 "회원명 변경"으로 바뀜
(3) 병합 동작 방식 정리
- 준영속 엔터티의 식별자 값으로 영속 엔터티를 조회
- 영속 엔터티의 값을 준영속 엔터티의 값으로 모두 교체(병합)
- 트랜잭션 커밋 시점에 변경 감지 기능이 동작해서 데이터베이스에 UPDATE SQL을 실행
** 주의!
- 변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만 병합을 사용하면 모든 속성이 변경되어버림
- 병합은 모든 필드를 교체하므로 병합시 값이 없으면 기존의 값이 null로 업데이트 되는 상황이 발생할 수 있음 -> 실무에서 변경 감지 기능을 사용하는 이유
2) ItemRepository - save() 분석
public void save(Item item) {
if (item.getId() == null) {
em.persist(item); // id값이 없으면 저장
} else {
em.merge(item); // update와 비슷하며 DB에 저장된 엔터티를 수정한다고 이해하면됨 -> 자세한 내용은 이후에 설명
}
}
- save() 메서드는 식별자 값이 없으면 새로운 엔터티로 판단해서 영속화(persist)하고 식별자가 있으면 이미 한번 영속화 되었던 엔터티로 판단해서 병합(merge)함
- 지금처럼 준영속 상태인 상품 엔터티를 수정할 때는 id값이 있으므로 병합을 수행
(1) 새로운 엔터티 저장과 준영속 엔터티 병합을 한번에 처리
- save()메서드 하나로 저장(영속)과 수정(병합)을 처리함으로써 해당 메서드를 사용하는 클라이언트틑 저장과 수정을 구분하지 않아도 되기 때문에 클라이언트의 로직이 단순해짐
- 영속 상태의 엔터티는 변경감지기능이 동작해서 트랜잭션을 커밋할 때 자동으로 수정되므로 별도의 수정 메서드를 호출하지 않아도 됨
- 예제처럼 매우 간단한 상황이기에 merge()를 사용할 수 있었음
** 참고
- save()메서드는 식별자를 자동 생성해야 정상 동작하는데, Item 엔터티의 식별자는 자동으로 생성되도록 @generatedValue를 선언했으므로 식별자 없이 save()를 호출하면 persist()가 호출되면서 식별자 값이 자동으로 할당됨
- 만약 식별자를 직접 할당하도록 @Id만 선언했다면 식별자가 없는 상태로 persist()를 호출하여 식별자자 없다는 예외가 발생함
- 실무에서는 보통 업데이트 기능이 매우 제한적인데, merge(병합)은 모든 필드를 변경해버리고 데이터가 없으면 null로 병합을 해버림
- 병합을 사용하면서 이 문제를 해결하려면 변경 폼 화면에서 모든 데이터를 상항 유지해야하는데 실무에서는 보통 변경 가능한 데이터만 노출하기 때문에 병합을 사용하는것이 오히려 번거로움
3) 엔티티 변경은 항상 변경 감지를 사용
- 컨트롤러에서 어설프게 엔터티를 생성하지 말것
- 트랜잭션이 있는 서비스 계층에 식별자(id)와 변경할 데이터를 명확하게 전달할 할 것(파라미터 or dto)
- 트랜잭션이 있는 서비스 계층에서 영속 상태의 엔터티를 조회하고 엔터티의 데이터를 직접 변경 할것
- 트랜잭션 커밋 시점에 변경 감지가 실행 됨
(1) ItemService - updateItem() 추가
- 변경 감지를 이용, 영속성 컨텍스트가 자동으로 변경하기 때문에 저장하는 메서드가 없이 자동으로 저장됨
- 파라미터의 수가 너무 많아지면 별도의 DTO 객체를 생성하여 파라미터로 받아서 값을 수정
- 지금은 예제이기 때문에 setter를 이용했지만 유지보수를 위해 실무에서는 별도의 메서드를 만들어서 값을 변경해야 함
- setter(세터)를 풀어놓으면 추적하기가 매우 어려워짐
/**
* 영속성 컨텍스트가 자동으로 변경
* 파라미터가 너무 많아지면 DTO객체를 생성해서 해당 객체의 값을 불러와서 입력하는 것을 권장
* 계속 강조하는 것처럼 setter를 이용하지 말고 별도의 메서드를 만들어서 값을 변경하는 것을 권장 - 유지보수 용이성 확보
*/
@Transactional
public void updateItem(Long itemId, String name, int price, int stockQuantity) {
Item item = itemRepository.findOne(itemId);
item.setName(name);
item.setPrice(price);
item.setStockQuantity(stockQuantity);
}
(2) ItemController - updateItem() 수정
- Book을 어설프게 생성하지 않고 itemService의 updateItem을 호출해서 값을 변경
/**
* 상품 수정, 권장 코드 - 컨트롤러의 코드가 매우 깔끔해짐
*/
@PostMapping("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";
}
8. 상품 주문
1) OrderController
- createForm(): GET 방식으로 상품 주문을 위한 폼을 보여주고 화면에 필요한 정보를 model 객체에 담아서 전달
- order(): POST 방식으로 각 식별자와 주문 수량에 대한 정보를 받아서 order()를 호출하고 주문이 끝나면 /orders로 리다이렉트
@Controller
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
private final MemberService memberService;
private final ItemService itemService;
@GetMapping("/order")
public String createForm(Model model) {
List<Member> members = memberService.findMembers();
List<Item> items = itemService.findItems();
model.addAttribute("members", members);
model.addAttribute("items", items);
return "order/orderForm";
}
@PostMapping("/order")
public String order(@RequestParam("memberId") Long memberId,
@RequestParam("itemId") Long itemId,
@RequestParam("count") int count) {
orderService.order(memberId, itemId, count);
return "redirect:/orders";
}
}
2)orderForm.html
- ~/resources/order의 경로에 생성
<!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>
9. 주문 목록 검색, 취소
1) OrderController 추가
(1) orderList()
- OrderSearch의 조건을 가지고 주문 목록의 데이터를 model에 담아서 반환
(2) cancelOrder()
- 주문취소는 상태를 변화하는 것이기 때문에 포스트로 만드는 것을 권장
- orderId의 값으로 cencelOrder()를 호출 후 orders로 리다이렉트
@GetMapping("/orders")
public String orderList(@ModelAttribute("orderSearch") OrderSearch orderSearch, Model model) {
List<Order> orders = orderService.findOrders(orderSearch);
model.addAttribute("orders", orders);
return "order/orderList";
}
// 주문 취소
@PostMapping("/orders/{orderId}/cancel")
public String cancelOrder(@PathVariable("orderId") Long orderId) {
orderService.cancelOrder(orderId);
return "redirect:/orders";
}
2) orderList.html
- ~/resources/order의 경로에 생성
<!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>