[JPA 활용 1편] 웹 계층 개발

HJ·2024년 2월 14일
0

JPA 활용 1편

목록 보기
4/4
post-custom-banner

김영한 님의 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 강의를 보고 작성한 내용입니다.


1. 회원 등록

1-1. 회원 등록 GET

[ MemberForm ]

@Getter
@Setter
public class MemberForm {
    @NotEmpty(message = "회원 이름은 필수입니다")
    private String name;
    private String city;
    private String street;
    private String zipcode;
}

회원을 등록할 때 필요한 정보들을 생성합니다. 이때 @NotEmpty 를 사용하여 이름은 필수로 입력 받을 수 있도록 합니다.

[ MemberController ]

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

회원을 등록할 때 memberForm 객체를 생성하고, model 에 memberForm 이라는 이름으로 전달합니다.

[ createMemberForm.html ]

<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>
    ...
</form>

th:object 로 전달 받은 memberForm 객체를 form 안에서 사용한다고 명시하고, *{...} 통해 memberForm 에 있는 값을 꺼낼 수 있습니다.

submit 을 누르면 form 태그에서 지정한 action 과 method 를 참고하여 요청을 보내게 됩니다.


1-2. 회원 등록 POST

@Controller
@RequiredArgsConstructor
public class MemberController {
    ...
    @PostMapping("/members/new")
    public String create(@Valid MemberForm form, BindingResult bindingResult) {
        if (bindingResult.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:/";
    }
}

@Valid 를 사용하면 @NotEmpty 와 같은 어노테이션을 기반으로 유효성 검사를 수행하고, 검사 결과를 BindingResult 에 저장합니다. @Valid 는 검증할 데이터 앞에, BindingResult 는 검증할 데이터 뒤에 위치합니다.

만약 BindingResult 에 error 가 존재한다면 다시 createMemberForm 을 보여주도록 합니다. 이때 스프링이 BindingResult 를 화면까지 끌고가기 때문에 createMemberForm 화면에서 BindingResult 에 담긴 정보들을 사용할 수 있게 됩니다.

<p th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Incorrect date</p>

전달받은 BindingResult 의 #fields 에 name 이라는 에러가 있으면 th:errors 를 통해 name 필드에 대해 에러 메세지를 뽑아서 출력해줍니다.

추가로 이름을 입력하지 않고 다른 값들을 입력했을 때 create()MemberForm 에 다른 데이터들은 정상적으로 들어오게 되고, 에러가 있어도 MemberForm 의 데이터도 다시 그대로 가져가기 때문에 기존에 입력한 정보는 유지됩니다.




2. 상품 수정

2-1. 상품 수정 GET

@Controller
@RequiredArgsConstructor
public class ItemController {
    ...
    @GetMapping("/items/{itemId}/edit")
    public String updateItemForm(@PathVariable 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";
    }
}

itemId 를 @Pathvariable 로 전달 받고, itemService 를 통해 Item Entity 를 가져옵니다. 이때 가져온 Item 을 Book 으로 캐스팅하여 사용하였습니다.

화면에 전달할 때는 Entity 가 아닌 DTO 를 사용하는 것이 좋기 때문에 BookForm 을 생성하고, Entity 의 값을 넣어주어 model 에 담아 화면에 전달합니다.


2-2. 상품 수정 POST

public class ItemController {
    ...
    @PostMapping("/items/{itemId}/edit")
    public String updateItem(@ModelAttribute 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";
    }
}

@ModelAttribute 를 이용하여 form 태그에 담긴 정보들을 BookForm 객체를 통해 전달 받고 Entity 를 생성해 정보들을 입력하고 itemService 의 saveItem() 을 호출합니다.

saveItem() 은 ItemRepository 의 save() 를 호출하는데 내부에서 전달받은 Item Entity 의 id 값이 있는지, 없는지에 따라 persist()merge() 를 호출하게 됩니다.




3. 변경 감지와 병합( merge )

3-1. 변경 감지

엔티티는 영속 상태로 관리되기 때문에 엔티티의 값만 변경하면 JPA 가 트랜잭션 커밋 시점에 변경된 내용을 알게 되어 DB 에 반영을 해줍니다.

@Test
void updateTest() {
    Book book = em.find(Book.class, 1L);
    // Transaction
    book.setName("aaaa");
    // Transcation Commit
}

즉, 위처럼 트랜잭션 안에서 Entity 의 내용을 변경하면, 트랜잭션이 커밋될 때 JPA 가 변경 부분에 대해서 찾은 다음 update 쿼리를 자동으로 생성해서 DB 에 반영을 해줍니다. 이러한 것을 Dirty Checking, 변경감지라고 하는데 flush 할 때 Dirty Checking 이 일어납니다.


3-2. 준영속 엔티티

2-2 을 보면 새로운 Book 을 생성하지만 id 값을 세팅합니다. 이 말은 JPA 에게 DB 에 한 번 들어갔다가 나온 Entity 라는 의미입니다. 즉, DB 에 한 번 갔다 온 상태로 식별자가 정확하게 DB 에 있으면 준영속 엔티티라고 합니다.

준영속 엔티티란 영속성 컨텍스트가 더는 관리하지 않는 엔티티를 말하는데 2-2 처럼 임의로 만들어낸 엔티티도 기존 식별자를 가지고 있으면 준영속 엔티티로 볼 수 있습니다.

JPA 가 관리하는 영속 상태 엔티티는 변경 감지가 일어나 트랜잭션 커밋 시점에 데이터가 변경됩니다. 하지만 준영속 엔티티는 JPA 가 관리하지 않기 때문에 값을 변경해도 DB 에 업데이트가 되지 않습니다.

준영속 엔티티를 수정하는 2가지 방법 중 첫 번째 방법은 준영속 엔티티이지만 변경 감지를 이용하는 방법이고, 두 번쨰는 병합( merge )를 이용하는 방법입니다.


3-3. 변경 감지를 이용한 준영속 엔티티 수정

public class ItemService {
    ...
    @Transactional
    public void updateItem(Long itemId, Book bookParam) {
        Item findItem = itemRepository.findOne(itemId); // 실제 DB 에 있는 영속 상태 Entity 를 가져옵니다.
        findItem.setPrice(bookParam.getPrice());
        findItem.setName(bookParam.getName());
        findItem.setStockQuantity(bookParam.getStockQuantity());
    }
}

itemRepository 를 통해 찾아온 Item 은 영속상태입니다.

트랜잭션이 커밋되면 JPA 는 flush 를 날리게 됩니다. flush 는 영속성 컨텍스트에 있는 엔티티 중에 변경된 엔티티가 무엇인지 다 찾고 변경된 부분에 대해 업데이트 쿼리를 날리게 됩니다.

즉, 찾아온 Item 이 영속상태이고, 변경 감지가 동작하기 때문에 save() 를 호출하지 않아도 DB 의 값이 자동으로 변경됩니다.


3-4. Merge 를 이용한 준영속 엔티티 수정

Merge 는 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용하는 기능입니다.

public class ItemRepository {
    ...
    public void save(Item item) {
        if (item.getId() == null) {
            em.persist(item);
        } else {
            em.merge(item);
        }
    }
}

위의 코드는 ItemRepository 의 save() 메서드인데 수정 시에 호출하면 ID 값이 존재하기 때문에 merge() 가 호출되게 됩니다.

수정 시에 파라미터로 넘어온 Item 은 변경된 값이 들어있는 객체입니다. merge() 가 호출되면 파라미터로 받은 Item 과 동일한 ID 를 가진 Item 을 찾아옵니다. 그리고 찾아온 Item 을 파라미터로 받은 Item 의 값으로 전부 변경합니다.

찾아온 Item 은 영속상태이고, 값이 전부 변경되었기 때문에 변경 감지가 일어나 트랜잭션이 커밋될 때 DB 에 값이 반영됩니다. 즉, 3-3 에서 한 작업을 merge() 를 호출함으로써 JPA 가 자동으로 처리하도록 하는 것입니다.

em.merge(item) 는 Item 을 반환하는데 반환된 Item 은 영속성 컨텍스트에서 관리되고, 파라미터로 넘어온 item 은 영속 상태로 변하진 않습니다.


3-5. Merge 동작 방식 정리

  1. merge() 를 실행

  2. 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회. 만약 1차 캐시에 엔티티가 없으면 데이터베이스에서 엔티티를 조회하고, 1차 캐시에 저장한다.

  3. 조회한 영속 엔티티에 준영속 엔티티의 값을 채워 넣음

  4. 영속 상태인 엔티티를 반환

  5. 트랜잭션 커밋 시점에 변경 감지 기능이 동작해서 데이터베이스에 UPDATE SQL이 실행

[ 주의 ]

변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만, 병합을 사용하면 모든 속성이 변경( 모든 필드를 교체 )됩니다. 그래서 병합 시 값이 없으면 null 로 업데이트 할 위험이 존재합니다.

그렇기 때문에 merge 를 사용하는 것보다 변경 감지를 이용하는 것이 더 좋은 방법입니다.


3-6. 가장 좋은 해결책

[ ItemController ]

public class ItemController {
    ...
    @PostMapping("/items/{itemId}/edit")
    public String updateItem(@PathVariable Long itemId, @ModelAttribute BookForm form) {
        itemService.updateItem(itemId, form.getName(), form.getPrice(), form.getStockQuantity());
        return "redirect:/items";
    }
}

[ ItemService ]

public class ItemService {
    ...
    @Transactional
    public void updateItem(Long itemId, String name, int price, int stockQuantity) {
        Item findItem = itemRepository.findOne(itemId); // 실제 DB 에 있는 영속 상태 Entity 를 가져옵니다.
        findItem.setName(name);
        findItem.setPrice(price);
        findItem.setStockQuantity(stockQuantity);
    }
}

2-2 와 3-3 에서 작성한 코드를 위처럼 변경하는 것이 가장 좋은 방법입니다. ( setXXX 를 다른 메서드로 만드는 것이 더 좋습니다 )

Controller 에서 Entity 를 생성하지 않고, Service 계층에 식별자( id )와 변경할 데이터를 파라미터나 DTO 를 생성하여 전달합니다.

그 후 Service 에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경합니다.
영속 상태의 엔티티가 변경되었기 때문에 트랜잭션 커밋 시점에 변경 감지가 실행됩니다.




4. 주문하기

4-1. 주문하기 GET

[ OrderController ]

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

예제를 보면 회원과 상품을 선택 후 수량을 입력해 주문을 하는 형식이기 때문에 DB 에 있는 모든 회원과 모든 상품을 조회해서 model 에 담아 화면으로 넘겨줍니다.

[ orderForm.html ]

<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>
    ...
</form>

model 에서 전달 받은 항목들을 화면에 뿌리기 위해 th:each 를 사용해서 members 라는 key 안에 들어있는 데이터들을 반복해서 option 태그들을 생성합니다.


4-2. 주문하기 POST

public class OrderController {
    ...
    @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";
    }
}

위의 html 을 보면 select 태그의 name 에 memberId 라고 되어 있는 것을 확인할 수 있습니다. 스프링으로 전달될 때 memberId 라는 이름으로 전달되기 때문에 @RequestParam 을 이용해서 값을 가져올 수 있습니다.




5. 주문 목록 검색, 취소

[ OrderController ]

@Controller
@RequiredArgsConstructor
public class OrderController {
    ...
    // 주문 목록 검색
    @GetMapping("/orders")
    public String orderList(@ModelAttribute OrderSearch orderSearch, Model model) {
        List<Order> orders = orderService.findOrders(orderSearch);
        model.addAttribute("orders", orders);
        return "order/orderList";
    }

    // 주문 취소

}

@ModelAttribute 는 model 에 담지 않아도 자동으로 화면에 전달됩니다.

검색 조건을 걸고 검색을 눌렀을 때 @ModelAttribute 가 OrderSearch 객체로 받아, 주문을 조회하는 findOrders() 에 넘겨줍니다.

[ orderList.html ]

<!-- 검색 조건 -->
<form th:object="${orderSearch}" class="form-inline">
    <div class="form-group mb-2">
        <input type="text" th:field="*{memberName}" class="formcontrol" 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>

<!-- 조회 -->
<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>

<!-- 주문 취소 호출 -->
<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>
post-custom-banner

0개의 댓글