RE-Heat 개발자 일지

[JPA 활용1] [5] 웹 계층 개발(하편) 본문

백엔드/JPA

[JPA 활용1] [5] 웹 계층 개발(하편)

RE-Heat 2023. 8. 27. 23:53

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-JPA-%ED%99%9C%EC%9A%A9-1/

 

실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 - 인프런 | 강의

실무에 가까운 예제로, 스프링 부트와 JPA를 활용해서 웹 애플리케이션을 설계하고 개발합니다. 이 과정을 통해 스프링 부트와 JPA를 실무에서 어떻게 활용해야 하는지 이해할 수 있습니다., 스프

www.inflearn.com

인프런 김영한 님의 강의를 듣고 작성한 글입니다.

 

[6] 상품 수정

ItemController

@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(@PathVariable Long itemId, @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() => itemService.findOne(itemId)을 호출해 수정할 상품 조회
  • 조회 결과를 모델 객체에 담아 뷰(items/updateItemFrom)로 전달

 

■ 상품 수정 실행

  • 상품 수정 폼에서 정보를 수정하고 submit 선택
  • /items/{itemId}/edit URL을 POST 방식으로 요청 => updateItem() 실행
  • 컨트롤러에 파라미터로 넘어온 item 엔티티 인스턴스는 준영속 상태다. 따라서 영속성 컨텍스트의 지원을 받지 못해 변경 감지 기능이 동작하지 않는다.

 

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>

 

실행화면

상품 목록에서 수정버튼 클릭 -> 수정 전 -> 수정 후 submit -> 수정 후 상품 목록

 

[7] 변경 감지와 병합(merge)

■ 준영속 엔티티란?

영속성 컨텍스트가 더는 관리하지 않는 엔티티를 말한다.

  • 여기서는 itemService.saveItem(book)에서 수정을 시도하는 Book 객체다. Book 객체는 이미 DB에 한번 저장되어서 식별자가 존재한다. 이렇게 임의로 만들어낸 엔티티도 기존 식별자를 가지고 있으면 준영속엔티티로 볼 수 있다.
  • itemService.saveItem(book)을 하지 않으면 Book객체가 바뀌었어도 업데이트 쿼리는 날아가지 않는다. 준영속 엔티티 상태라 변경감지가 불가능하기 때문.

 

■ 준영속 엔티티를 수정하는 2가지 방법

① 변경 감지 기능 사용

  • itemService에 updateItem() 메서드 생성
  • itemRepository.findOne()으로 호출했기 때문에 영속 상태
  • @Transactional 안에서 동작하므로 commit을 날릴 때 변경 감지 기능 동작. 바뀐 값있으면 update 쿼리를 날린다.

 

② 병합(merge) 사용

▶ 병합 동작 방식

1. merge()를 실행한다.
2.  파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회한다.
    2-1. 만약 1차 캐시에 엔티티가 없으면 데이터베이스에서 엔티티를 조회하고, 1차 캐시에 저장한다.
3. 조회한 영속 엔티티( mergeMember )에 member 엔티티의 값을 채워 넣는다.

(member 엔티티의 모든 값을 mergeMember에 밀어 넣는다. 이때 mergeMember의 “회원1”이라는 이름이 “회원명 변경”으로 바뀐다.)
4. 영속상태인 mergeMember를 반환한다.

 

return으로 Item값까지 반환하면 merge와 거의 같은 코드라고 봐도 된다.

 

※ 병합 주의점 ※

변경감지 기능을 쓰면 원하는 속성만 선택해 변경할 수 있지만, 병합을 쓰면 모든 속성이 변경된다. 이때 변경을 원하지 않았던 값에 null이 들어갈 위험이 있다.

 

예시] 

  • 책 가격의 setter를 막아놨다고 가정
  • 저대로 merge를 쓰면 book.price=null 상태로 들어가는 불상사가 발생한다.

 

■ 가장 좋은 해결 방법

결론 : 엔티티를 변경할 땐 항상 변경감지를 사용하자.

  • 컨트롤러에서 어설프게 엔티티를 생성하지 마세요.
  • 트랜잭션이 있는 서비스계층에 식별자( id )와 변경할 데이터를 명확하게 전달하세요.(파라미터 or dto) 
  • 트랜잭션이 있는 서비스 계층에서 영속상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경하세요. 
  • 트랜잭션 커밋 시점에 변경감지가 실행됩니다.

 

변경 전 - 어설프게 엔티티를 생성한 케이스

변경 후

클릭하면 더 크게 볼 수 있습니다.

 

파라미터 대신 dto 사용한 버전

파라미터에 값이 많을 땐 Dto를 따로 만들어서 넘겨주는 방법도 있다.

 

[8] 상품 주문

OrderController

@Controller
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;
    private final MemberService memberService;
    private final ItemService itemService;

    @GetMapping("/order")
    public String crateForm(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";
    }
}

■ 주문 폼 이동

  • 메인 화면에서 상품 주문을 선택하면 /order를 GET 방식으로 호출 
  • OrderController의 createForm()메서드
  • 주문 화면에는 주문할 고객정보와 상품 정보가 필요하므로 model 객체에 담아서 뷰에 넘겨줌

 

■ 주문 실행

  • 주문할 회원과 상품 그리고 수량을 선택해서 Submit 버튼을 누르면 /order URL을 POST 방식으로 호출
  • 컨트롤러의 order() 메서드를 실행
  • 이 메서드는 고객 식별자(memberId), 주문할 상품 식별자(itemId), 수량(count) 정보를 받아서 주문 서비스에 주문을 요청
  • 주문이 끝나면 상품 주문 내역이 있는 /orders URL로 리다이렉트

 

oderForm.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 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] 주문 목록 검색·취소

OrderController

@Controller
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;
    private final MemberService memberService;
    private final ItemService itemService;

    @GetMapping("/order")
    public String crateForm(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";
    }

    @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";
    }
}

① 특이사항

findOrders()처럼 단순히 리포지토리에서 불러오기만 하는 것이라면 Service단이 아닌 Controller단에서 처리해도 된다.

 

 

OrderForm.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 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>

 

실행화면

 

 

OrderList.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>
            <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>

① CANCEL 버튼이 눌렸을 때 <script></script> 안 코드가 /orders/{orderId}/cancel을 POST로 실행해 줌.

 

 

실행화면 - OrderList

CANCEL을 누르면 상태가 ORDER => CANCEL로 변경된 것을 확인할 수 있다.