관리 메뉴

나구리의 개발공부기록

웹 계층 개발, 홈 화면과 레이아웃, 회원 등록, 회원 목록 조회, 상품 등록, 상품 목록, 상품 수정, 변경 감지와 병합(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>&copy; 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. 회원 등록

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>