Spring/Spring Boot

섹션 7. 웹 계층 개발 [실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발]

광터틀 2024. 1. 1. 23:31

이 포스트는 김영한 강사님의 [실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발] 을 바탕으로 작성되었습니다. 

 

섹션 7. 웹 계층 개발 

 

순서 

1) 홈 화면 레이아웃 

2) 회원 등록 

3) 회원 목록 조회 

4) 상품 등록 

5) 상품 목록 

6) 상품 수정 

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

8) 상품 주문 

9) 주문 목록 검색, 취소 

10 ) 다음으로 

 

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>

 

home.html 파일이다. home에는 <회원가입, 회원목록>, <상품등록, 상품목록>, <상품주문, 주문내역> 버튼들을 클릭할 수 있다. 이제 회원가입을 누르면 회원가입 페이지로 넘어가 등록을 할 수 있게 할 것이며 다른 버튼들도 해당 페이지로 넘어가 작동하도록 해본다. 

하지만 아직은 UI 가 예쁘지 않다. CSS 파일을 넣어줘서 UI를 예쁘게 만들어보자. 이를 위해 부트스트랩을 이용한다. 

**jumbotron-narrow.css 파일 

 

 

2) 회원 등록 

 

<MemberForm.class> 

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

 

 

<MemberController.class> 

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;

import java.util.List;

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

    @GetMapping("/members")
    public String list(Model model) { // 회원 목록 조회 
        List<Member> members = memberService.findMembers();
        model.addAttribute("members", members);
        return "members/memberList";
    }
}

 

엔티티 대신 회원 등록 폼을 만들어 이것을 넘기는 것 같다. 잘 모르는 상황에서 갑자기 Form 이라는 것을 만드니 생소하여 Form이 뭔지, 왜 만드는 것인지, 엔티티를 그냥 이용하면 왜 안되는지 등등을 검색해보았다. 

 

참고1) 

Form 전송 객체 분리 이유(Bean validation 위해서), 폼 데이터 전달에 도메인 객체 사용과 별도의 객체 사용 차이점, 코드

(참고 : https://jddng.tistory.com/263 

 

참고2) 

DTO, Entity, Form 의 차이 

(참고 : https://velog.io/@tjdrhd1207/DTO-Entity-Form%EC%9D%98-%EC%B0%A8%EC%9D%B4-il3i5m9j

 

참고3) 

Form 전송 객체 분리 소개와 개발 

(참고 : https://5bong2-develop.tistory.com/362 

 

참고4) 

강의안 내용 : 폼 객체 vs 엔티티 직접 사용 

요구사항이 정말 다눈할 때는 폼 객체 없이 엔티티를 직접 등록, 수정화면에 사용해도 되지만 화면 요구사항이 복잡해지면 엔티티에 화면을 처리하기 위한 기능이 점점 증가하며 엔티티가 화면 종속적으로 변하게 한다. 이렇게 지저분해진 엔티티는 유지보수가 어려워진다. 

실무에서는 엔티티는 핵심 비즈니스 로직만 가지고 있고, 화면을 위한 로직은 없어야 한다. 화면이나 API에 맞는 폼 객체나 DTO를 사용하자. 그래서 화면이나 API 요구사항을 이것들로 처리하고, 엔티티는 최대한 순수하게 유지하자. 

 

참고5) 

수업 중 : 

실제 실무에서는 엔티티를 그냥 여기저기 넘기는 것은 불가능. 헬로월드에서나 가능한 것. 그래서 우리는 컨트롤러에서 create 메서드를 이용하여 MemberForm form 으로 form으로 받고, 컨트롤러에서 이를 잘 정제한 후, 필요한 데이터만 채워서memberService.join(member) 로 리턴하는 것이 좋다. 

 

 

여기엔 전체를 첨부하진 않았지만 밑의 타임리프 코드들과 함께 적힌 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>

 

위 코드를 보면 타임리프와 스프링이 잘 융합되어 있어 편리하게 제작할 수 있는 것들이 많다. 

header 코드 이후에 이제 핵심인 Form 태그 시작, action 을 통해 members/new로 가고, 메서드는 post 한다. 
컨트롤러에서 GetMapping("/members/new") 해놓고, model.addAttribute("memberForm", new MemberForm()); 해두었으므로 이 모델에 접근할 수 있게 된다. 
타임리프 문법으로 인해 th:object="${memberForm}"을 통해서 이 객체를 사용하여 memberForm에 넣어둔 요소들로 다음 행위들을 할 수 있다. 우선, 타임리프 자체가 익숙치 않아서 어려운 것들도 있다. 다만 궁극적으로 궁금한 것은 

 

백엔드와 프론트엔드의 구분은 어떻게 짓는 것이지? 실무에서는 타임리프 정도는 프론트엔드에서 짜는 것인가? 아니면 백엔드가 짜는 것인가? 백엔드가 짠다면 프론트엔드가 짜둔 html 파일에 짜는건가? 
특히 여기서 "회원 이름은 필수입니다" 이런 것들은 백엔드가 짜는게 맞는 것인가? 

 

 

참고: Hierarchical-style layouts** 
예제에서는 뷰 템플릿을 최대한 간단하게 설명하려고, `header` , `footer` 같은 템플릿 파일을 반복해서 포함한
다. 다음 링크의 Hierarchical-style layouts을 참고하면 이런 부분도 중복을 제거할 수 있다.
https://www.thymeleaf.org/doc/articles/layouts.html 

 

 

3) 회원 목록 조회 

    @GetMapping("/members")
    public String list(Model model) { // 회원 목록 조회 
        List<Member> members = memberService.findMembers();
        model.addAttribute("members", members);
        return "members/memberList";
    }

 

위에 적어둔 MemberController.class에서 회원 목록 조회와 관련된 코드만 발췌하면 위와 같다. GetMapping("/members") 를 통해 /members 로 이동하라는 명령을 받으면 list 메서드가 실행된다. list 메서드는 조회한 상품을 뷰에 전달하기 위해 스프링 MVC가 제공하는 model 객체에 <Member> List 안의 정보를 넣어 보관한다.  이는 "members/memberList" 뷰로로 반환한다. 

 

 

4) 상품 등록  

 

<BookForm.class> 

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

 

 

<ItemController.class> 

package jpabook.jpashop.controller;

import jpabook.jpashop.domain.item.Book;
import jpabook.jpashop.domain.item.Item;
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.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;

import java.util.List;

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

    /**
     * 상품 목록
     */
    @GetMapping("/items")
    public String list(Model model) {
        List<Item> items = itemService.findItems();
        model.addAttribute("items", items);
        return "items/itemList";
    }

    /**
     * 상품 수정 폼
     */
    @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);
        */
        itemService.updateItem(itemId, form.getName(), form.getPrice(), form.getStockQuantity());
        return "redirect:/items";
    }
}

 

 

5) 상품 목록 

    @GetMapping("/items")
    public String list(Model model) {
        List<Item> items = itemService.findItems();
        model.addAttribute("items", items);
        return "items/itemList";
    }

 

ItemController.class 에서 상품 목록에 관련된 코드만 발췌하면 위와 같다. model 에 담아둔 상품 목록인 items 를 꺼내서 상품 정보를 출력하게 된다. 

 

 

6) 상품 수정 

    /**
     * 상품 수정 폼
     */
    @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);
        */
        itemService.updateItem(itemId, form.getName(), form.getPrice(), form.getStockQuantity());
        return "redirect:/items";
    }

ItemController.class 에서 상품 수정에 관련된 코드만 발췌하면 위와 같다.

 

 

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

 

변경감지와 병합의 차이를 모르면 시간을 많이 날릴 수도 있다. 굉장히 중요!!! 

준영속 엔티티란 영속성 컨텍스트가 더는 관리하지 않는 엔티티를 말한다. (여기서는 `itemService.saveItem(book)` 에서 수정을 시도하는 `Book` 객체다. `Book` 객체는 이미 DB에 한번 저장되어서 식별자가 존재한다. 이렇게 임의로 만들어낸 엔티티도 기존 식별자를 가지고 있으면 준영속 엔티티로 볼 수 있다.) 

영속 엔티티는 JPA에서 관리해줄 수 있지만 준영속 엔티티는 불가능하다. 

 

이러한 준영속 엔티티를 수정하는 방법에는 2가지가 있다. 

1) 변경 감지 기능 

2) 병합 (merge) 

 

결론부터 말하자면 merge를 쓰면 안 되고, 항상 변경 감지 기능을 써야 한다. 

 

 

 

영속성 컨텍스트에서는 엔티티를 다시 조회한 후, 데이터를 수정하는 방법은

우선, 트랜잭션 안에 엔티티를 다시 조회, 변경할 값을 선택

-> 트랜잭션 커밋 시점에 변경 감지가 동작하여 데이터베이스에 UPDATE SQL을 실행한다. 

 

병합은 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용하는 기능이다. 

 

병합(merge)를 사용했을 때 순서는 다음과 같다. 

 

1. merge()를 실행한다. 
2. 파라미터로 넘어온 준영속 엔티티의 식별자값으로 1차 캐시에서 엔티티를 조회한다. 
    (2-1. 만약 1차 캐시에 엔티티가 없으면 데이터베이스에서 엔티티를 조회하고, 1차 캐시에 저장한다.) 
3. 조회한 영속 엔티티(mergeMember)에 member 엔티티의 값을 채워 넣는다. (member 엔티티의 모든 값을 mergeMember에 밀어 넣는다. 이때 mergeMember의 “회원1”이라는 이름이 “회원명변경”으로 바뀐다.)
4. 영속 상태인 mergeMember를 반환한다. 

 

즉, 간단히 정리하면 준영속 엔티티의 식별자 값으로 영속 엔티티를 조회한 후, 영속 엔티티의 값을 준영속 엔티티 값으로 모두 교체(병합)하는 것이다. 트랜잭션 커밋 시점이 오면 변경 감지 기능이 동작하여 데이터베이스에 UPDATE SQL이 실행된다. 

 

다만, 주의할 점이 있다. 변경 감지 기능을 사용하면 원하는 속성만 딱 선택해서 변경할 수 있지만, 병합을 사용하면 넘어온 파라미터로 속성이 다 변하고, 이에 따라 병합 시 값이 없으면 null 로 업데이트할 위험도 있다. 

 

따라서 merge를 쓰면 안되고, 조금 귀찮더라도 변경감지기능에서 했던 것처럼 set set set ... 해서 반환해야 한다. 최대한 머릿속에 그냥 merge를 쓰지 않는다~ 하고 생각하는게 좋아보인다. 물론 실무에서는 set 쓰면 안되고, change 같은 의미있는 메서드 사용해야 한다.

(위 식에서 set set set ... 을 쓴 코드는 주석으로 표현해뒀고, change 메서드로 작동하도록 해두었다.) 

강사님의 네줄 요약은 다음과 같다. 

- 컨트롤러에서 어설프게 엔티티를 생성하지 마세요. 

- 트랜잭션이 있는 서비스 계층에 식별자(id)와 변경할 데이터를 명확하게 전달하세요. (파라미터 or dto) 

- 트랜잭션이 있는 서비스 계층에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경하세요. 

- 트랜잭션 커밋 시점에 변경 감지가 실행됩니다. 

 

 

8) 상품 주문 

 

<OrderController.class> 

package jpabook.jpashop.controller;

import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.item.Item;
import jpabook.jpashop.repository.OrderSearch;
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.*;

import java.util.List;

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

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

 

 

9) 주문 목록 검색, 취소 

 

코드는 위의 OrderController 에 함께 적어두었다. 

 

다만, 다 끝나는 상황에서 마지막 확인 중에 이름 및 ORDER, CANCEL 상태를 바꾸면 검색이 안 되어야하는데 검색이 되는 문제가 발생했다. 즉, 검색 기능이 작동하지 않는다. 


1) 최근에 따라 쓴 코드들부터 오타가 있는지 눈빠지게 찾아보았다. OrderController, OrderService 에는 오타가 없었고, html은 복붙했으니 당연히 오타가 없겠지! 하는 순간에 복붙과정에서 form-control이 formcontrol 이 되어있었다. 
2) 아... 이거떄문에 하면서 다시 돌려봤는데 정말 놀랍게도 전혀 고쳐지지 않았다. 
3) 인프런 커뮤니티 들어가서 질문들을 싹 다 뒤져보다가 30분만에 OrderSearch class 에 @Setter을 넣어야함을 알아냈다... 생각해보면 여기에 @Setter가 있어야 되는건 당연한건데 왜 몰랐을까 + 계속 똑같이 따라쳤는데 나는 왜 이걸 못봤지? 가 겹친다... 잠시 놓쳤나? 
4) 결국 해결!