실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발
스프링 핵심 원리 - 기본편 - 인프런
스프링 입문자가 예제를 만들어가면서 스프링의 핵심 원리를 이해하고, 스프링 기본기를 확실히 다질 수 있습니다. 초급 프레임워크 및 라이브러리 웹 개발 서버 개발 Back-End Spring 객체지향 온
www.inflearn.com
강의를 들으며 생각 정리 + "자바 ORM 표준 JPA 프로그래밍" 책 참고
홈 화면과 레이아웃
이제 앞서 제작한 도메인들과 비즈니스 로직들에 대해 웹 계층을 개발해보자.
우선 홈 화면의 기본 툴을 만든다.
-> 지금은 로직들을 웹으로 표현해 보는 것이 목적이기 때문에 앞으로 html 파일들의 세부적인 내용들에 대해서는 다루지 않는다.
<controller/HomeController.java>
@Controller
@Slf4j
public class HomeController {
@RequestMapping("/")
public String home() {
log.info("home controller"); // 처음 웹 계층을 보는 것이기 때문에 로그를 통해 잘 실행되나 확인
return "home";
}
}
기본 localhost 주소에 대해 우선적으로 컨트롤러에 "/" request가 있나 확인한다.(무조건 static/index.html로 빠지는게 아니다)
위처럼 기본 주소에 대한 컨트롤러를 통해 home.html를 화면에 출력한다.
+) "home" 처럼 뷰의 이름을 리턴했을 때 타임리프가 자동으로 "home.html"로 매핑해준다.
<home.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>
<div th:replace="fragments/footer :: footer" />
</div> <!-- /container -->
</body>
</html>
home.html을 요약한 코드다. 예제에서는 뷰 템플릿을 최대한 간단하게 설명하려고, header, footer와 같은 템플릿 파일을 반복해서 포함한다.
<결과 화면>
+) 참고 : 뷰 템플릿 변경사항을 서버 재시작 없이 즉시 반영하기
- spring-boot-devtools 추가
- 프로그램 실행 중 html 파일 수정 시 build->Recompile
gksdudrb922.tistory.com/67?category=956531
devtools
스프링 웹 환경에서 html 파일을 수정할 때마다 서버를 중지했다 다시 재시작 해줘야 하는 번거로움이 있었는데, spring-boot-devtools 라이브러리를 추가하면, html 파일을 컴파일만 해주면 서버 재시
gksdudrb922.tistory.com
이제 깔끔한 레이아웃을 위해 부트스트랩을 사용하겠다. (https://getbootstrap.com/)
부트스트랩 사이트에서 Download -> CSS and JS를 다운 받고 resources/static 하위에 css, js를 추가한다.
또한 resources/static/css/jumbotron-narrow.css 역시 추가한다.
+) 2021.05.15 최신 버전으로 하면 아래 결과 화면처럼 되지 않기 때문에 CDN으로 4.3.1버전을 불러온다.
또한 bootstrap.min.css만 사용하기 때문에 static에는 css/jumbotron-narrow.css만 추가하면 된다.
<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, shrinkto-fit=no">
<!-- Custom styles for this template -->
<link href="/css/jumbotron-narrow.css" rel="stylesheet">
<!-- Bootstrap CDN -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
<title>Hello, world!</title>
</head>
<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;
}
}
<결과 화면>
이처럼 깔끔하게 레이아웃을 설정했다.
회원 등록
이제 도메인을 화면에 적용시켜야 한다. 여기서 중요한 점은 엔티티을 그대로 모델링해서 뷰 템플릿에 적용시키는 것이 아닌 따로 화면 전용 폼 객체를 사용한다는 것이다.
그냥 엔티티를 사용하면 안되나? -> 대부분 실제 엔티티 필드들과 화면 상에서 원하는 폼이 다르고 원하는 validation 등을 붙여주려면 폼 객체를 따로 설정하는 것이 편하다. 또한 엔티티를 폼으로 사용하면 엔티티가 화면 종속이 되면서 복잡해진다.
-> 실무에서 엔티티는 핵심 비즈니스 로직만 가지고 있고, 화면을 위한 로직은 없어야 한다.
<controller/MemberForm.java>
@Getter @Setter
public class MemberForm {
@NotEmpty(message = "회원 이름은 필수 입니다")
private String name;
private String city;
private String street;
private String zipcode;
}
@NotEmpty 처럼 화면 전용 validation을 추가할 수 있다.
<controller/MemberController.java>
@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:/";
}
}
회원 등록시(/members/new) Member 엔티티를 모델링하는 것이 아닌 MemberForm을 모델링하는 것을 볼 수 있다. 이 후 createMemberForm.html에서 작업이 이루어 지고(세부적인 html은 다루지 않는다) 회원을 '등록' 했을 때 PostMapping 부분을 보자.
PostMapping의 인자를 보면 MemberForm에 @Valid가 붙어있는 것을 볼 수 있다. @Valid를 통해 해당 객체의 검증이 이루어 진다. MemberForm에서 validation 부분은 @NotEmpty로 만약 MemberForm의 name 필드 값이 비어 있다면 @Valid가 에러를 발생시키는 것이다. 이러한 에러 결과 값은 두 번째 인자인 BindingResult에 전달되고(스프링이 같이 보내준다) 이 BindingResult 값을 통해 에러를 탐지할 수 있다.
회원 목록 조회
@GetMapping("members")
public String list(Model model) {
List<Member> members = memberService.findMembers();
model.addAttribute("members", members);
return "members/memberList";
}
컨트롤러에 코드를 추가한다. 회원 전체 목록을 조회하는 것이기 때문에 멤버 전체를 모델링한다.
memberList.html을 추가한다.
상품 등록
상품 역시 회원 등록과 비슷하게 Bookform을 따로 명시하고 컨트롤러와 뷰 템플릿을 추가하면 된다.
<controller/BookForm.java>
@Getter @Setter
public class BookForm {
private Long id;
private String name;
private int price;
private int stockQuantity;
private String author;
private String isbn;
}
<controller/ItemController>
@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";
}
}
회원 등록과 차이점은 상품 등록의 경우 저장이 끝나면 홈 화면이 아닌 상품 목록 화면(redirect:/items)으로 리다이렉트 하도록 설정했다.
상품 목록
@GetMapping("/items")
public String list(Model model) {
List<Item> items = itemService.findItem();
model.addAttribute("items", items);
return "items/itemList";
}
컨트롤러에 코드를 추가한다.
itemList.html을 추가한다.
상품 수정
@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(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";
}
컨트롤러에 코드를 추가한다.
상품 수정 폼 이동
- 수정 버튼을 선택하면 /items/{itemId}/edit URL을 GET 방식으로 요청
- 그 결과로 updateItemForm() 메서드를 실행하는데 이 메서드는 findOne(itemId)를 호출해서 수정할 상품을 조회
- 조회한 상품의 정보를 BookForm에 담아 뷰에 전달
상품 수정 실행
- 상품 수정 폼에서 정보를 수정하고 제출하면 /items/{itemId}/edit URL을 POST 방식으로 요청
- updateItem() 메서드를 실행 -> 이 때 컨트롤러에서 파라미터로 넘어온 item 엔티티 인스턴스는 현재 준영속 상태다. 따라서 영속성 컨텍스트의 지원을 받을 수 없고 데이터를 수정해도 변경 감지 기능은 동작하지 않는다.
위 예시처럼 한다면 수정은 되겠지만(병합 기능) 변경 감지 기능이 사용되지 않아 몇 가지 제한사항이 있을 것이다. 우선 병합에 대해 알아보자.
변경 감지와 병합(merge)
준영속 엔티티? -> 영속성 컨텍스트가 더는 관리하지 않는 엔티티를 말한다. 예시에서는 itemService.saveItem(book)에서 수정을 시도하는 Book 객체다. 이렇게 임의로 만들어낸 엔티티도 기존 DB에 저장된 식별자를 가지고 있으면 준영속 엔티티로 본다.
준영속 엔티티를 수정하는 방법
- 변경 감지 기능 사용
- 병합(merge) 사용
변경 감지 기능
영속성 컨텍스트에서 엔티티를 다시 조회한 후에 데이터를 수정하는 방법이다. 엔티티를 조회할 때 해당 엔티티는 영속성 컨텍스트에 있게 되게 때문에 데이터 수정 시 커밋 시점에 변경 감지가 이루어진다.
ex)
@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
Item findItem = em.find(Item.class, itemParam.getId()); //같은 엔티티를 조회한
다.
findItem.setPrice(itemParam.getPrice()); //데이터를 수정한다.
}
병합 사용
병합은 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용하는 기능이다.
병합 동작 방식
- merge()를 실행한다.
- 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회한다.
- 만약 1차 캐시에 엔티티가 없으면 데이터베이스에서 엔티티를 조회하고, 1차 캐시에 저장한다
- 조회한 영속 엔티티(mergeMember)에 member 엔티티의 값을 채워 넣는다. (member 엔티티의 모든 값을 mergeMember에 밀어 넣는다.)
- 영속 상태인 mergeMember를 반환한다.
+) 주의할 점으로 변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만, 병합을 사용하면 모든 속성이 변경된다. 즉, 병합시 값이 없으면 null로 업데이트 할 위험도 있다.(병합은 모든 필드를 교체한다.)
또한 merge는 준영속->영속이 되는 것이 아닌 수정된 영속 상태인 엔티티를 계속 사용하는 것이다.
상품 리포지토리 저장 메서드 분석
<ItemRepository>
public void save(Item item) {
if (item.getId() == null) {
em.persist(item);
} else {
em.merge(item);
}
}
save() 메서드는 식별자 값이 없으면 새로운 엔티티로 판단해서 영속화(persist)하고 식별자가 있으면 병합(merge)를 수행한다. 상품 등록시 updateItem() 메서드의 예시처럼 준영속 상태인 상품 엔티티를 수정할 때는 id 값이 있으므로 병합을 수행하게 된다.
참고로 병합은 모든 필드를 변경해버리고, 데이터가 없으면 null로 업데이트 해버리기 때문에 병합을 사용하는 것이 오히려 번거로울 때가 있다.
좋은 해결 방법 -> 엔티티를 변경할 때는 항상 변경 감지를 사용하자.
- 컨트롤러에서 어설프게 엔티티를 생성하지 말자.
- 트랜잭션이 있는 서비스 계층에서 식별자와 변경할 데이터를 명확하게 전달하자.
- 트랜잭션이 있는 서비스 계층에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경하자.
- 튼랜잭션 커밋 시점에 변경 감지가 실행된다.
<controller/ItemController.java 상품 수정 부분 수정>
@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";
}
<ItemService에 updateItem 메서드 추가>
@Transactional
public void updateItem(Long itemId, String name, int price, int stockQuantity) {
Item findItem = itemRepository.findOne(itemId);
findItem.setName(name);
findItem.setPrice(price);
findItem.setStockQuantity(stockQuantity);
}
상품 주문
<controller/OrderController.java>
@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.findItem();
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";
}
}
회원과 상품의 전체 리스트를 뷰 템플릿에 보낸다.(GET)
주문하고자 하는 회원과 상품의 id 값과 수량을 파라미터로 받아 order() 메서드를 통해 주문하면 된다.(POST)
주문 목록 검색, 취소
주문 목록 검색
@GetMapping("/orders")
public String orderList(@ModelAttribute("orderSearch") OrderSearch orderSearch, Model model) {
List<Order> orders = orderService.findOrders(orderSearch);
model.addAttribute("orders", orders);
return "order/orderList";
}
컨트롤러에 코드를 추가한다.
앞서 주문 도메인에서 개발한 findOrders을 통해 OrderSearch 정보에 따라 주문 목록 검색을 한다.
주문 취소
@PostMapping("/orders/{orderId}/cancel")
public String cancelOrder(@PathVariable("orderId") Long orderId) {
orderService.cancelOrder(orderId);
return "redirect:/orders";
}
컨트롤러에 코드를 추가한다.
이 역시 앞서 주문 도메인에서 개발한 cancelOrder를 통해 주문 취소를 한다.
-> 컨트롤러는 최대한 서비스 로직을 호출하는 정도로 사용하는 것이 좋다. 서비스 로직의 트랜잭션 안에서 영속성 컨텍스트 등의 JPA 기능을 사용하는 것이 좋다.
'java > jpa' 카테고리의 다른 글
[JPA] API 개발 고급 - 준비 (0) | 2021.05.18 |
---|---|
[JPA] API 개발 기본 (0) | 2021.05.17 |
[JPA] 주문 도메인 개발 (0) | 2021.02.14 |
[JPA] 상품 도메인 개발 (0) | 2021.02.13 |
[JPA] 회원 도메인 개발 (0) | 2021.02.12 |