현업에서 성능이 너무 나오지 않아 해결하러 가보면, 강의에서 말한 부분에서 모두 해결할 수 있다고 한다. 강의 목적은 현업에서 성능 문제를 해결할 수 있는 API 개발 방법을 배울 수 있을 것 같다..!

 

API 개발 고급 소개


등록, 수정은 성능 문제가 거의 발생하지 않는다. 하지만, 조회의 경우는 다르다. 사람들이 조회를 많이 하기 때문이다. 그렇기 때문에 강의 목적으로 조회에서의 성능 문제를 해결하는 방법에 대해 자세하게 설명해주는 것 같다.

 

조회용 샘플 데이터 입력

  • userA
    • JPA1 BOOK
    • JPA2 BOOK
  • userB
    • SPRING1 BOOK
    • SPRING2 BOOK

 

jpashop.service.InitDB 추가

package jpabook.jpashop.service;

import jpabook.jpashop.domain.*;
import jpabook.jpashop.domain.Item.Book;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.PostConstruct;
import javax.persistence.EntityManager;

/**
 * 총 주문 2개
 * userA
 * * JPA1 BOOK
 * * JPA2 BOOK
 * userB
 * * SPRING1 BOOK
 * * SPRING2 BOOK
 */
@Component
@RequiredArgsConstructor
public class InitDb {
    private final InitService initService;

    @PostConstruct
    public void Init() {
        initService.dbInit1();
        initService.dbInit2();
    }

    @Component
    @Transactional
    @RequiredArgsConstructor
    static class InitService {
        private final EntityManager em;

        public void dbInit1() {
            Member member = createMember("userA", "인천", "남동구", "123");
            em.persist(member);

            Book book1 = createBook("JPA1 BOOK", 10000, 100);
            em.persist(book1);

            Book book2 = createBook("JPA2 BOOK", 20000, 100);
            em.persist(book2);

            OrderItem orderItem1 = OrderItem.createOrderItem(book1, 10000, 1);
            OrderItem orderItem2 = OrderItem.createOrderItem(book2, 20000, 1);

            Delivery delivery = new Delivery();
            delivery.setAddress(member.getAddress());
            Order order = Order.createOrder(member, delivery, orderItem1, orderItem2);
            em.persist(order);
        }

        public void dbInit2() {
            Member member = createMember("userB", "인천", "연수구", "321");
            em.persist(member);

            Book book1 = createBook("SPRING1 BOOK", 10000, 100);
            em.persist(book1);

            Book book2 = createBook("SPRING2 BOOK", 20000, 100);
            em.persist(book2);

            OrderItem orderItem1 = OrderItem.createOrderItem(book1, 10000, 1);
            OrderItem orderItem2 = OrderItem.createOrderItem(book2, 20000, 1);

            Delivery delivery = new Delivery();
            delivery.setAddress(member.getAddress());
            Order order = Order.createOrder(member, delivery, orderItem1, orderItem2);
            em.persist(order);
        }

        private Book createBook(String name, int price, int stockQuantity) {
            Book book1 = new Book();
            book1.setName(name);
            book1.setPrice(price);
            book1.setStockQuantity(stockQuantity);
            return book1;
        }

        private Member createMember(String name, String city, String street, String zipcode) {
            Member member = new Member();
            member.setName(name);
            member.setAddress(new Address(city, street, zipcode));
            return member;
        }
    }
}

테스트

 

 

 

 

 

 

 

 

출처

 

실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 - 인프런 | 강의

스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com

 

JPA를 사용하며 API를 개발할 때 올바른 방향에 대해 정리할 수 있는 강의

 

강의에 앞서 postman을 설치하자. 다른 restAPI 툴을 사용해도 됩니다.

 

Download Postman | Get Started for Free

Try Postman for free! Join 20 million developers who rely on Postman, the collaboration platform for API development. Create better APIs—faster.

www.postman.com

 

 

회원 등록 API


jpashop.api.memberApiController.java

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

@RestController
@RequiredArgsConstructor
public class MemberApiController {
    private final MemberService memberService;

    @PostMapping("/api/v1/members")
    public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member){
        Long join = memberService.join(member);
        return new CreateMemberResponse(join);
    }

    @Data
    static class CreateMemberResponse {
        private Long id;

        public CreateMemberResponse(Long id){
            this.id = id;
        }
    }
}

postman에서 api test

파라미터로 데이터를 보내는 것이 아니기때문에 Body에서 raw, JSON 형식으로 바꿔준 후, name을 적어 POST 요청을 보내면 id 값이 1으로 정상적으로 동작하는 것을 볼 수 있습니다. 

 

현재는 Entity에 아무런 제약조건을 걸지 않았기때문에 데이터를 넣지 않아도 @GeneratedValue를 설정한 PK를 제외한 모든 필드가 NULL로 만들어 집니다.

show_sql = true 설정으로 인해 발생한 쿼리문

위와 같이 NULL 값을 포함하지 않게 설정을 하고 싶다면 아래와 같이 @NotEmpty를 추가해주면 됩니다.

@NotEmpty 추가 후 돌리면 name에 값을 설정하지 않았기 때문에 에러가 발생한 것을 볼 수 있습니다.

 

알아야 할 점은 화면에 보여지는 계층을 presentation 계층이라 합니다. 현재 프레젠테이션 계층에 대한 검증 로직이 Member Entity에 추가가 된 것 입니다. 아무런 문제가 없어보이지만, 여기서 발생하는 문제가 있습니다.

  1. 두 개 이상의 api가 있을 때, 하나의 api에서는 @NotEmpty와 같은 검증 로직이 필요하지만 다른 하나의 api에서는 검증 로직이 필요가 없을 수도 있습니다. 
  2. Entity의 필드명을 바꿀 때, name으로 사용하고 있던 필드 명을 username으로 어떤 팀이 바꿨다고 가정해봅시다. 기존 api를 사용할 때 name으로 사용하던 코드들이 모두 정상작동하지 않게됩니다. 
    여기서 알아야할 점은 엔티티를 손보았을 때, api 스펙이 자체가 변하는 것이 문제입니다. Entity라는 것은 굉장히 여러 곳에서 사용하기 때문에 바뀔 확률이 높습니다. 그렇기 때문에, Entity를 손보아도 해당 Entity와 연관된 모든 api 스펙에 영향이 가지 않도록 설계하는 것이 중요합니다. 그렇기때문에 DTO 사용하는 습관을 길러야 합니다.

MemberApiController V2 추가

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;

@RestController
@RequiredArgsConstructor
public class MemberApiController {
    ...

    @PostMapping("/api/v2/members")
    public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest createMemberRequest){
        Member member = new Member();
        member.setName(createMemberRequest.getName());
        Long join = memberService.join(member);
        return new CreateMemberResponse(join);
    }

    @Data
    static class CreateMemberRequest{
        private String name;
    }

    ...
}

V2 장점

  1. CreateMemberRequest라는 클래스를 만들어 name만 받도록 api를 설계했습니다. 위와 같이 설계하면 presentation 계층과 Entity 계층을 분리하기 때문에 유지보수가 편리해집니다. 
  2. V1처럼 Member를 파라미터로 두는 경우 알아서 바인딩되어 데이터가 들어가기 때문에 어떤 값이 들어오는지 알 수 없습니다. 하지만, V2의 경우 필요한 데이터만 필드를 만들기 때문에 들어가는 데이터를 알 수 있습니다.
  3. 검증 로직을 Entity에 추가하지 않고 DTO에 추가할 수 있습니다.

 

 

 

회원 수정 API


MemberApiController update 코드 추가

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@RestController
@RequiredArgsConstructor
public class MemberApiController {
    
    ...

    @PutMapping("/api/v2/member/{id}")
    public UpdateMemberResponse updateMemberV2(
            @PathVariable("id") Long id,
            @RequestBody @Valid UpdateMemberRequest request){
        memberService.update(id, request.getName());
        Member findMember = memberService.findOne(id);
        return new UpdateMemberResponse(findMember.getId(), findMember.getName());
    }

    @Data
    static class UpdateMemberRequest{
        private String name;
    }

    @Data
    @AllArgsConstructor
    static class UpdateMemberResponse{
        private Long id;
        private String name;
    }

    ...
}

 

MemberService 코드 추가

package jpabook.jpashop.service;

import jpabook.jpashop.domain.Member;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jpabook.jpashop.repository.MemberRepository;

import java.util.List;

@Service
@RequiredArgsConstructor            // final로 이루어진 필드로만 생성자를 만들어 줌
@Transactional(readOnly = true) //조회 성능 최적화
public class MemberService {

    ...

    @Transactional
    public void update(Long id, String name){
        Member member = memberRepository.findOne(id);
        member.setName(name);
    }
}

 

테스트

왼쪽 : POST                 /               오른쪽 : PUT

 

 

 

 

회원 조회 API


MemberApiController 코드 추가

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.*;

import javax.validation.Valid;

@RestController
@RequiredArgsConstructor
public class MemberApiController {
    ...

    @GetMapping("api/v1/members")
    public List<Member> memberV2() {
        return memberService.findMembers();
    }

    ...
}

테스트

문제점

  1. 회원에 대한 정보만 조회하려했지만, orders에 대한 정보도 포함되어 있는 것을 볼 수 있다.
    • @JsonIgnore라는 기능을 사용해서 포함시키지 않을 수 있다. 하지만, 위에서 말했듯 Entity는 다양한 api를 통해 호출을 한다. 하나의 api는 orders를 포함시키지 않아야하지만, 다른 하나의 api는 orders를 포함해야될 수 있다. 이러한 상황에서 문제가 생긴다.
  2. 만약, 로그인 기능이라 아이디랑 패스워드가 있다면 노출의 우려가 있다.
  3. 엔티티가 변경되면 api 스펙이 변경된다.
다시한번 강조하지만, presentation 계층과 Entity 계층을 분리하자!! DTO를 사용하자

 

MemberApiController 코드 추가

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Member;
import jpabook.jpashop.service.MemberService;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.stream.Collectors;

import javax.validation.Valid;

@RestController
@RequiredArgsConstructor
public class MemberApiController {
    
    ...

    @GetMapping("/api/v2/members")
    public Result memberV2() {
        List<Member> findMembers = memberService.findMembers();
        List<MemberDTO> collect = findMembers.stream()
                .map(m -> new MemberDTO(m.getName()))
                .collect(Collectors.toList());

        return new Result(collect);
    }

    @Data
    @AllArgsConstructor
    static class Result<T> {
        private T data;
    }

    @Data
    @AllArgsConstructor
    static class MemberDTO {
        private String name;
    }

    ...
}

 

테스트

이렇게 두번 감싸면 필요한 필드를 추가할 수도 있다. (array가 첫 시작이 아니기때문에 가능합니다.) 예를들어, 조회한 데이터가 몇개인지 추가해봅시다.

 

MemberApiController 코드 추가

    @GetMapping("/api/v2/members")
    public Result memberV2() {
        List<Member> findMembers = memberService.findMembers();
        List<MemberDTO> collect = findMembers.stream()
                .map(m -> new MemberDTO(m.getName()))
                .collect(Collectors.toList());

        return new Result(collect.size(), collect);
    }

    @Data
    @AllArgsConstructor
    static class Result<T> {
        private int count;
        private T data;
    }

테스트

 

 

 

 

출처

 

실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 - 인프런 | 강의

스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com

 

홈 화면과 레이아웃


HomeContoller

package jpabook.jpashop.controller;

import lombok.extern.slf4j.Slf4j;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@Slf4j	 // log
public class HomeController {

    @RequestMapping("/")
    public String Home() {
        log.info("home controller");
        return "home";
    }
}

 

resources/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>

resources/fragments/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>

resources/fragments/footer.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div class="footer" th:fragment="footer">
    <p>&copy; Hello Shop V2</p>
</div>

resources/fragments/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>

후에 실행해보면 아래와 같은 페이지가 뜬다.

 

깔끔하게 정리해주기 위해 Bootstrap을 사용해 view 리소스 등록하자.

 

Bootstrap

The most popular HTML, CSS, and JS library in the world.

getbootstrap.com

  • 다운로드 받는다.
  • 다운로드 받은 파일을 resources/static 하위에 css, js 추가
  • resources/static/css/jumbotron-narrow.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;
    } 
}

 

바뀐 페이지

 

 

 

회원 등록


MemberController PostMapping 추가

package jpabook.jpashop.controller;

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 javax.validation.Valid;

@Controller
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;

    // get 요청
    @GetMapping("members/new")
    public String createFrom(Model model){
        // controller -> view 의 로직에서 data를 싫어서 보냄.
        // memberForm에 저장된 데이터를 갖고옴 MemberFrom메 매핑하여
        model.addAttribute("memberForm", new MemberForm());
        return "members/createMemberForm";
    }

    // post 요청
    @PostMapping("members/new")
    public String create(@Valid MemberForm memberForm, BindingResult result){
        // name을 필수로 입력해야하는데, 입력을 하지않아 에러가 존재한다면 다시 회원가입 페이지로 이동
        if(result.hasErrors()){
            return "members/createMemberForm";
        }
        Address address = new Address(memberForm.getCity(), memberForm.getStreet(), memberForm.getZipcode());
        Member member = new Member();
        member.setName(memberForm.getName());
        member.setAddress(address);

        memberService.join(member);
        return "redirect:/";
    }
}

post요청이 있다면, MemberForm에 입력 데이터를 파라미터로 가져옵니다. BindingResult는 에러가 있을경우 에러에대한 내용이 BindingResult로 넘어옵니다. 아래 MemberForm을 보시면 NotEmpty를 사용하여 Empty를 허용하지 않도록 했습니다. 그렇기때문에 이름이 null로 넘어올 경우 에러가 발생합니다. 이렇듯 에러가 발생하면 에러페이지를 띄우는 것이 아닌, 이름은 필수라는 문구와 함께 회원가입 페이지를 다시 띄워줍니다.

 

MemberForm

package jpabook.jpashop.controller;

import lombok.Getter;
import lombok.Setter;

import javax.validation.constraints.NotEmpty;

@Getter
@Setter
public class MemberForm {
    @NotEmpty(message = "회원 입력은 필수 입니다.")   //필수
    private String name;

    private String city;
    private String street;
    private String zipcode;

}

resources/members/createMemberFrom.html

<!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"/>
    <!--th:object="${memberForm}를 통해 MemberForm 클래스와 매핑시켜 줍니다.-->
    <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>

이름을 입력하지 않는 경우 아래와 같이 회원가입이 되지 않으며, 에러 문구가 나옵니다.

 

 

 

회원 목록 조회


MemberController "members" GetMapping 추가

package jpabook.jpashop.controller;

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 javax.validation.Valid;
import java.util.List;

@Controller
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;

    ...

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

위는 Member Entity에 있는 데이터들을 모두 사용하기 때문에 Member로 사용했지만, 일부의 데이터만 이용하는 경우 DTO로 만들어서 사용하자.

단, API를 만들 때는 절대로 엔티티를 외부로 반환하면 안된다.

필드가 추가가 되지 않는다면 문제가 되지 않을 수 있겠지만, 필드 수정되는 경우에 문제가 생긴다. 만약, Member에서 password 혹은 주민등록번호와 같은 노출되면 안되는 필드가 생겨버리는 경우에 문제가 생길 수 있다.

 

resources/members/MemberList.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>
        <table class="table table-striped">
            <thead>
            <tr>
                <th>#</th>
                <th>이름</th> <th>도시</th> <th>주소</th> <th>우편번호</th>
            </tr>
            </thead>
            <tbody>
            <!--GetMapping에서 model에 담았던 members를 아래와 같이 웹페이지에 적용한다.-->
            <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>

 

 

 

 

상품 등록


ItemController

package jpabook.jpashop.controller;

import jpabook.jpashop.domain.Item.Book;
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.PostMapping;

@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 = createBook(form);

        itemService.saveItem(book);

        return "redirect:/items";
    }

    private Book createBook(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());
        return book;
    }
}

BookForm

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

resources/items/createItemForm.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: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>
</html>

 

 

 

 

상품 목록


ItemController GetMapping("/items") 추가

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.PostMapping;
import java.util.*;

@Controller
@RequiredArgsConstructor
public class ItemController {

    ...

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

    ...
}

resources/items/itemList.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>
        <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>

 

 

 

 

 

상품 수정


ItemController 코드 추가

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.*;

@Controller
@RequiredArgsConstructor
public class 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(@ModelAttribute("form") BookForm bookForm){
        Book book = new Book();
        book.setName(bookForm.getName());
        book.setId(bookForm.getId());
        book.setPrice(bookForm.getPrice());
        book.setStockQuantity(bookForm.getStockQuantity());
        book.setAuthor(bookForm.getAuthor());
        book.setIsbn(bookForm.getIsbn());

        itemService.saveItem(book);
        return "redirect:/items";
    }

    ...

}

PathVariable은 Mapping에 있는 {}안에 있는 값이 itemId인 곳에 (Long itemId)를 넣는 것을 의미합니다.

 

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>

 

 

 

변경 감지와 병합(merge)


 참고 : 정말 중요한 내용이니 꼭! 완벽하게 이해해야 합니다.

 

준영속 엔티티란?

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

 

 

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

  • 변경 감지 기능 사용 (더티 체킹)
  • 병합 (merge) 사용 

 

변경 감지 기능 사용

package jpabook.jpashop.service;

import jpabook.jpashop.domain.Item.Book;
import jpabook.jpashop.domain.Item.Item;
import jpabook.jpashop.repository.ItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {

    ...

    @Transactional
    public void updateItem(Long itemId, Book bookParam) {
        Item findItem = itemRepository.findOne(itemId);
        findItem.setPrice(bookParam.getPrice());
        findItem.setName(bookParam.getName());
        findItem.setStockQuantity(bookParam.getStockQuantity());
    }

    public List<Item> findItems(){
    ...
}

준영속 엔티티라 더티 체킹이 안될 것 같았지만, 해결책이 있었습니다.

트랜잭션 안에서 엔티티를 조회하여 영속 상태인 엔티티의 값을 변경해주면 됩니다. 이렇게하면 영속 상태이기 때문에 트랜잭션 커밋 시점에 변경 감지가 동작하여 update 쿼리문이 날라갑니다.

 

병합 사용

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

병학 동작 방식

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

 

주의 : 변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만, 병합을 사용하면 모든 속성이 변경된다.
따라서, 병합시 값이 없으면 null로 업데이트 할 위험도 있다. (병합은 모든 필드를 교체한다.)

결론 : merge는 모든 속성을 변경할 때만 사용하자.. (매우 위험) (되도록이면 변경감지를 사용하자)

 

엔티티를 변경할 때는 항상 변경 감지를 사용하세요

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

 

 

 

상품 주문


OrderController

package jpabook.jpashop.controller;

import jpabook.jpashop.domain.Item.Item;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.repository.MemberRepository;
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.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.*;

@Controller
@RequiredArgsConstructor
public class OrderController {
    private final MemberService memberService;
    private final OrderService orderService;
    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 "redirectL/orders";
    }
}

@RequestParam은 form submit 방식으로 select 에 지정한 name이랑 연관되는 이름 입니다.

 

resources/order/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>

 

 

 

주문 목록 검색, 취소


OrderController

package jpabook.jpashop.controller;

import jpabook.jpashop.domain.Item.Item;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.repository.MemberRepository;
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.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.*;

@Controller
@RequiredArgsConstructor
public class OrderController {
    ...

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

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].item.price}"></td>
        <td th:text="${item.orderItems[0].count}"></td>
        <td th:text="${item.status}"></td>
        <td th:text="${item.orderDate}"></td>
        <td>
        <!--상태가 Order이면 버튼이 노출되도록-->
          <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>

 

 

 

 

 

 

 

 

 

 

 

 

 

출처

 

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

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

www.inflearn.com

 

구현 기능

  • 상품 주문
  • 주문 내역 조회
  • 주문 취소

 

순서

  1. 주문 엔티티, 주문 상품 엔티티 개발
  2. 주문 리포지토리 개발
  3. 주문 서비스 개발
  4. 주문 검색 기능 개발
  5. 주문 기능 테스트

 

 

주문, 주문상품 엔티티 개발


Order Entity

package jpabook.jpashop.domain;

import lombok.Getter;
import lombok.Setter;

import java.time.LocalDateTime;
import java.util.*;

import javax.persistence.*;

@Entity
@Table(name = "Orders")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)      // 기본 생성자를 protected로 선언하여 다른 패키지의 클래스에서 빈생성자를 생성을 막음
public class Order {
    ...

    //==생성 메서드==//
    public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems){
        Order order = new Order();
        order.setMember(member);
        order.setDelivery(delivery);
        for(OrderItem orderItem : orderItems){
            order.addOrderItem(orderItem);
        }
        order.setStatus(OrderStatus.ORDER);
        order.setOrderDate(LocalDateTime.now());
        return order;
    }

    //==비즈니스 로직==//
    /**
     * 주문 취소
     */
    public void cancel(){
        if(delivery.getDeliveryStatus() == DeliveryStatus.COMP){
            throw new IllegalStateException("이미 배송완료한 상품은 취소가 불가능합니다.");
        }

        this.setStatus(OrderStatus.CANCLE);
        for(OrderItem orderItem : orderItems){
            orderItem.cancel();
        }
    }

    //==조회 로직==//
    /**
     * 전체 주문 가격 조회
     */
    public int getTotalPrice() {
        return orderItems.stream()
                .mapToInt(OrderItem::getTotalPrice)
                .sum();
        /*
        위와 같은 코드
        int totalPrice = 0;
        for (OrderItem orderItem : orderItems) {
            totalPrice += orderItem.getTotalPrice();
        }
        return totalPrice;*/
    }
}

 

OrderItem Entity

package jpabook.jpashop.domain;

import jpabook.jpashop.domain.Item.Item;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)      // 기본 생성자를 protected로 선언하여 다른 패키지의 클래스에서 빈생성자를 생성을 막음
public class OrderItem {
    ...
    
    //==생성 메서드==//
    public static OrderItem createOrderItem(Item item, int orderPirce, int count){
        OrderItem orderItem = new OrderItem();
        orderItem.setItem(item);
        orderItem.setOrderPirce(orderPirce);
        orderItem.setCount(count);
        item.removeStock(count);
        return orderItem;
    }

    //==비즈니스 로직==//
    public void cancel() {
        getItem().addStock(count);
    }

    public int getTotalPrice(){
        return getOrderPirce()*getCount();
    }
}

 

 

 

 

주문 리포지토리 개발


OrderRepository

package jpabook.jpashop.repository;

import jpabook.jpashop.domain.Order;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.*;

import javax.persistence.EntityManager;

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

    public void save(Order order){
        em.persist(order);
    }

    public Order findOne(Long orderId){
        return em.find(Order.class, orderId);
    }

    //후에 개발
    //public List<Order> findAll(OrderSearch orderSearch) {}
}

 

 

 

 

주문 서비스 개발


OrderService

package jpabook.jpashop.service;

import jpabook.jpashop.domain.Delivery;
import jpabook.jpashop.domain.Item.Item;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderItem;
import jpabook.jpashop.repository.ItemRepository;
import jpabook.jpashop.repository.MemberRepository;
import jpabook.jpashop.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {

    private final OrderRepository orderRepository;
    private final MemberRepository memberRepository;
    private final ItemRepository itemRepository;

    /**
     * 주문
     */
    @Transactional
    public Long order(Long memberId, Long itemId, int count){

        // 엔티티 조회
        Member member = memberRepository.findOne(memberId);
        Item item = itemRepository.findOne(itemId);

        // 배송 정보 생성
        Delivery delivery = new Delivery();
        delivery.setAddress(member.getAddress());

        // 주문 상품 생성
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

        // 주문 생성
        Order order = Order.createOrder(member, delivery, orderItem);

        // 저장
        orderRepository.save(order);

        return order.getId();
    }

    /**
     * 취소
     */
    @Transactional
    public void cancelOrder(Long orderId){
        // 주문 엔티티 조회
        Order order = orderRepository.findOne(orderId);

        // 주문 취소 (비즈니스 로직을 만들어 놓았기 때문에, cancel 메소드만 접근하면 된다.)
        order.cancel();

        // jpa가 아니라면 수정된 데이터들을 update 쿼리문을 날려야하지만,
        // jpa는 더티 체킹을 통해 변경된 내역을 알아서 update 쿼리문을 날린다.
        // 정말 편리
    }


    /**
     * 검색
     */
    //public List<Order> findOrders(OrderSearch orderSearch){
    //    return orderRepository.findAll(orderSearch);
    //}
}

jpa가 아니라면 order.cancel() 아래 코드에서 변경된 데이터에 대한 업데이트 쿼리문을 작성하여 DB에 날려야 하지만, jpa의 경우 더티체킹을 통해 기존의 데이터와 바뀐 데이터가 있다면 알아서 찾아내어 update 쿼리문을 날리기때문에 따로 update 쿼리문을 작성하여 날리지 않아도 됩니다!!! (정말 편리하네요..)

 

참고

엔티티에 핵심 비즈니스 로직을 적는 패턴을 "도메인 모델 패턴"이라고 부릅니다. "도메인 모델 패턴"은 서비스 계층을 단순히 엔티티에 필요한 요청을 위임하는 역할을 합니다. 

반대로, 엔티티에는 비즈니스 로직이 거의 없고, 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 "트랜잭션 스크립트 패턴"이라고 합니다.

두 가지 방법이 있는 것은 어느 패턴이 더 뛰어나기 때문이 아닌 상황에 따라서, 유지 보수에 좋은 상황이 있기 때문입니다.

결론 : 상황에 따라서 유지 보수하기 편리한 패턴을 사용하자!

 

 

 

 

주문 기능 테스트


OrderServiceTest

package jpabook.jpashop.service;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Item.Book;
import jpabook.jpashop.domain.Item.Item;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.exception.NotEnoughStockException;
import jpabook.jpashop.repository.OrderRepository;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;

import static org.junit.Assert.*;

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {
    @Autowired
    private EntityManager em;
    @Autowired OrderService orderService;
    @Autowired
    OrderRepository orderRepository;

    @Test
    public void 상품주문() throws Exception {
        //given
        Member member = createMember();
        Item book = createBook("영진 JPA", 10000, 10);

        int orderCount = 2;

        //when
        Long orderId = orderService.order(member.getId(), book.getId(), orderCount);

        //then
        Order getOrder = orderRepository.findOne(orderId);
        assertEquals("상품 주문시 상태는 ORDER", OrderStatus.ORDER,getOrder.getStatus());
        assertEquals("주문한 상품 종류 수가 정확해야 한다.", 1, getOrder.getOrderItems().size());
        assertEquals("주문 가격은 가격*수량이다.", 10000*orderCount, getOrder.getTotalPrice());
        assertEquals("주문 수량만큼 재고가 줄어야 한다.", 10-orderCount, book.getStockQuantity());
    }

    @Test
    public void 주문취소() throws Exception {
        //given
        Member member = createMember();
        Item book = createBook("영진 JPA", 10000, 10);
        int orderCount = 2;

        Long orderId = orderService.order(member.getId(), book.getId(), orderCount);
        //when
        orderService.cancelOrder(orderId);

        //then
        Order getOrder = orderRepository.findOne(orderId);
        assertEquals("상품 주문시 상태는 CANCEL", OrderStatus.CANCEL,getOrder.getStatus());
        assertEquals("주문이 취소된 상품은 재고가 증가되야 한다.", 10, book.getStockQuantity());
    }


    @Test(expected = NotEnoughStockException.class)
    public void 재고수량초과() throws Exception {
        //given
        Member member = createMember();
        Item book = createBook("영진 JPA", 10000, 10);
        int orderCount = 11;

        //when
        Long orderId = orderService.order(member.getId(), book.getId(), orderCount);    // 여기서 예외가 터져야함.

        //then
        fail("재고 수량 부족 예외가 발생해야 한다.");
    }

    // 만들어 놓은 로직을 ctrl+alt+M 하면 메소드화 가능
    private Item createBook(String name, int price, int stockQuantity) {
        Item book = new Book();
        book.setName(name);
        book.setPrice(price);
        book.setStockQuantity(stockQuantity);
        em.persist(book);
        return book;
    }

    private Member createMember() {
        Member member = new Member();
        member.setName("회원1");
        member.setAddress(new Address("서울", "경기", "123-123"));
        em.persist(member);
        return member;
    }
}

 

위의 코드는 DB와 연동해서 테스트를 하는 예제입니다.

 

참고

도메인 모델 패턴은 도메인 엔티티에 비즈니스 로직이 있기때문에 도메인으로만 로직이 정상적으로 작동하는지 테스트하는 것이 장점입니다. 이를 잘 활용하는 것이 도메인 모델 패턴을 보다 잘 살려내는 방법이라고 생각합니다. 이유는 DB와 연동하지 않기때문에 테스트가 빠르기 때문입니다.

 

 

 

주문 검색 기능 개발


JPA에서 동적 쿼리를 어떻게 해결해야 하는가?

  • JPA Criteria
    • 유지보수성이 너무 떨어진다. 직접 쿼리를 작성해보면 무슨 쿼리가 작성되는지 머리로 그려지지 않기 때문입니다.
  • Querydsl
    • 동적 쿼리를 정말 강력하게 해결할 수 있다.
    • 강력하게라 함은 자바코드이기 때문에 컴파일 시점에서 오타를 다 잡아줍니다.
    • 또한, 코드가 간결하고 유지보수성이 뛰어납니다.

 

복잡한 동적 쿼리는 Querydsl로 해결하는 것을 권장합니다.

Querydsl에 대한 설명은 후에 함.

 

 

 

 

출처

 

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

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

www.inflearn.com

 

구현 기능

  • 상품 등록
  • 상품 목록 조회
  • 상품 수정

 

순서

  • 상품 엔티티 개발 (비즈니스 로직 추가)
  • 상품 리포지토리 개발
  • 상품 서비스 개발, 상품 기능 테스트

 

 

상품 엔티티 개발 (비즈니스 로직 추가)


Item Entity

package jpabook.jpashop.domain.Item;

import jpabook.jpashop.domain.Category;
import jpabook.jpashop.exception.NotEnoughStockException;
import lombok.Getter;
import lombok.Setter;
import java.util.*;

import javax.persistence.*;

@Entity
@Getter
@Setter
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
public abstract class Item {
	...
    
    //==비즈니스 로직==//

    /**
     * stock 증가
     */
    public void addStock(int quantity){
        this.stockQuantity += quantity;
    }

    /**
     * stock 감소
     */
    public void removeStock(int quantity){
        int restStock = this.stockQuantity - quantity;
        if(restStock < 0){
            throw new NotEnoughStockException("need more stock");
        }
        this.stockQuantity = restStock;
    }

}

비즈니스 로직 추가

  • 아이템 수량 추가
  • 아이템 수량 제거
    • 수량이 0개 이하가 되면 에러 

 

NotEnoughStockException 예외 클래스

package jpabook.jpashop.exception;

public class NotEnoughStockException extends RuntimeException {

    public NotEnoughStockException() {
        super();
    }

    public NotEnoughStockException(String message) {
        super(message);
    }

    public NotEnoughStockException(String message, Throwable cause) {
        super(message, cause);
    }

    public NotEnoughStockException(Throwable cause) {
        super(cause);
    }
}

예외처리 클래스는 처음 만들어 보았는데 RuntimeException을 상속받고 Override를 추가해주었다. 

 

 

 

 

상품 리포지토리 개발


ItemRepository

package jpabook.jpashop.repository;

import jpabook.jpashop.domain.Item.Item;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.*;

import javax.persistence.EntityManager;

@Repository
@RequiredArgsConstructor
public class ItemRepository {

    private final EntityManager em;

    public void save(Item item){
        if(item.getId() == null){
            em.persist(item);
        } else {
            em.merge(item);     //merge에 대한 설명은 후에 한다고 합니다.
        }
    }

    public Item findByOne(Long id){
        return em.find(Item.class, id);
    }

    public List<Item> findAll(){
        return em.createQuery("select i from Item i", Item.class)
                .getResultList();
    }
}

 

 

 

 

상품 서비스 개발


ItemService

package jpabook.jpashop.service;

import jpabook.jpashop.domain.Item.Item;
import jpabook.jpashop.repository.ItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {

    private final ItemRepository itemRepository;

    @Transactional
    public void saveItem(Item item){
        itemRepository.save(item);
    }

    public List<Item> findItems(){
        return itemRepository.findAll();
    }

    public Item findOne(Long id){
        return itemRepository.findOne(id);
    }
}
위와 같이 Service가 굳이 필요한 가에 대한 궁금증이 생길 수 있습니다.

이와같은 로직(Service가 위임만 한다.)에서는 Controller에서 바로 Repository에 접근해도 문제가 될 건 없다고 합니다.

 

테스트 코드 생성은 앞선 도메인 테스트와 다를게 없기때문에 하지 않았습니다.

 

 

 

 

 

 

 

출처

 

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

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

www.inflearn.com

 

구현 기능

  • 회원 등록
  • 회원 목록 조회

 

순서

  1. 회원 엔티티 코드 다시 보기
  2. 회원 리포지토리 개발
  3. 회원 서비스 개발
  4. 회원 기능 테스트

 

 

회원 리포지토리 개발


MemberRepository  클래스

package repository;

import jpabook.jpashop.domain.Member;
import org.springframework.stereotype.Repository;
import java.util.*;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

@Repository     // componentscan에 의하여 스프링 빈으로 관리됨
public class MemberRepository {

    // PersistenceContext는 스프링이 생성한 EntityManager를 자동으로 주입시켜줌
    @PersistenceContext
    private EntityManager em;

    public void save(Member member){
        em.persist(member);
    }

    public Member findOne(Long id){
        return em.find(Member.class, id);
    }

    public List<Member> findAll() {
        return em.createQuery("select m from Member m", Member.class)
                .getResultList();
    }

    public List<Member> findByName(String name){
        return em.createQuery("select m from Member m where m.name = :name", Member.class)
                .setParameter("name", name)
                .getResultList();
    }
}
  • @Repository는 @Component 스캔의 대상이기 때문에 자동으로 스프링 빈에서 관리합니다.

  • @PersistenceContext는 스프링이 생선한 EntityManager를 자동으로 주입시켜 줍니다.
참고로 EntityManagerFactory를 쓰고 싶다면 @PersistenceUnit을 사용하시면 됩니다.

 

 

 

회원 서비스 개발


MemberService

package jpabook.jpashop.service;

import jpabook.jpashop.domain.Member;
import lombok.AllArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import repository.MemberRepository;

import java.util.List;

@Service
@RequiredArgsConstructor            // final로 이루어진 필드로만 생성자를 만들어 줌
public class MemberService {

    private final MemberRepository memberRepository;        // 한번 초기화하면 바꿀 일이 없으니 final 선언

    /**
     * 회원 가입
     */
    @Transactional
    public Long join(Member member){
        validateDuplicateMember(member);
        memberRepository.save(member);
        return member.getId();
    }

    /**
     * 같은 이름이 있는 회원은 등록 안되도록
     */
    private void validateDuplicateMember(Member member) {
        //exception
        // 이러한 경우 멀티스레드 환경에서 문제가 될 수 있다.
        // 방지하기 위해서 member의 name을 unique 제약조건을 거는 것을 권장한다.
        List<Member> findMembers = memberRepository.findByName(member.getName());

        if(!findMembers.isEmpty()){
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        }
    }

    /**
     * 회원 전체 조회
     */
    @Transactional(readOnly = true)     // 조회 성능 최적화
    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

    /**
     * 멤버 1명 조회
     */
    @Transactional(readOnly = true)     // 조회 성능 최적화
    public Member findOne(Long memberId){
        return memberRepository.findOne(memberId);
    }
}

코드는 다르지만 같은 동작을 합니다. @RequiredArgsConstructor은 왼쪽의 코드 기능을 제공합니다.

 

Spring Data JPA를 사용하지 않는 경우 @PersistenceContext를 사용해야 bean에서 injection시켜주지만, Spring Data JPA를 사용하는 경우 @PersistenceContext를 사용하지 않고 @Autowired만 사용해도 injection 시켜줍니다. 

따라서, MemberRepository를 아래와 같이 변경할 수 있습니다.

 

 

 

 

회원 기능 테스트


MemberServiceTest

package jpabook.jpashop.service;

import jpabook.jpashop.domain.Member;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;
import jpabook.jpashop.repository.MemberRepository;

import static org.junit.Assert.*;

@RunWith(SpringRunner.class)    // "junit을 실행할 때, 스프링과 엮어서 실행하겠다"를 의미합니다.
@SpringBootTest         // SpringBoot를 띄운 상태에서 Test를 할 때 추가해야되는 어노테이션 (추가하지 않으면 @Autowired기능에서 에러가 발생합니다.)
@Transactional  // 기본적으로 rollback
public class MemberServiceTest {

    @Autowired MemberService memberService;
    @Autowired MemberRepository memberRepository;

    @Test
    // @Rollback(false)  insert query 문을 보고싶다면 해보자
    // 또는 EntityManager를 만들어서 flush() 해주면된다.
    public void 회원가입() throws Exception {
        //given
        Member member = new Member();
        member.setName("kim");

        //when
        Long saveId = memberService.join(member);

        //then
        assertEquals(member, memberRepository.findOne(saveId));
    }
    
    @Test(expected = IllegalStateException.class)
    public void 중복_회원_예외() throws Exception {
        //given
        Member member1 = new Member();
        member1.setName("kim");

        Member member2 = new Member();
        member2.setName("kim");
        
        //when
        memberService.join(member1);
        memberService.join(member2);        // 예외 발생해야 함.

        //then
        fail("예외가 발생해야 한다.");
    }
}

 

 

given, when, then 이란?

"given을 주고 when 상황이 있을 때, then 이러한 결과가 나올 것이다."의 flow로 이어지는 테스트 작성 방법입니다.

 

예외 테스트하는 방법

예외가 터져야하는 테스트라면 @Test에 옵션을 추가하여 코드를 간결화 할 수 있습니다.

아래와 같이 try, catch 문을 쓰지 않아도 됩니다.

좌, 우 같은 코드

 

In-Memory로 테스트 하는 방법

 

H2 Database Engine

Using H2 Documentation Reference: SQL grammar, functions, data types, tools, API Features: fulltext search, encryption, read-only (zip/jar), CSV, auto-reconnect, triggers, user functions Embedded jdbc:h2:~/test 'test' in the user home directory jdbc:h2:/da

www.h2database.com

h2 Database 홈페이지에서 cheat sheet를 보시면,

In-Memory 설정하는 url이 있습니다. test를 할 땐 In-Memory로 하기위해 

프로젝트 test 폴더에 resources 파일을 만들어 main에 있는 application.yml을 복사해서 저장해줍니다. 그리고 application.yml 코드 중 datasource url을 jdbc:h2:mem:test로 수정해주시면 In-Memory로 테스트를 할 수 있습니다.

 

참고로 Spring boot에서 database에 대한 별다른 설정이 없다면 memory DB로 돌리기 때문에 Database 설정에 대한 모든 코드를 지워도 memory 모드로 사용가능 합니다.

 

 

 

 

 

출처

 

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

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

www.inflearn.com

 

구현 요구사항


  • 회원기능
    • 회원 등록
    • 회원 조회
  • 상품 기능
    • 상품 등록
    • 상품 수정
    • 상품 조회
  • 주문 기능
    • 상품 주문
    • 주문 내역 조회
    • 주문 취소

 

예제를 단순화 하기 위해 다음 기능은 구현X

  • 로그인과 권한 관리X
  • 파라미터 검증과 예외 처리X
  • 상품은 도서만 사용
  • 카테고리는 사용X
  • 배송 정보는 사용X

 

 

 

애플리케이션 아키텍쳐


계층형 구조 사용

  • controller, web : 웹 계층
  • service : 비즈니스 로직, 트랜잭션 처리
  • repository : JPA를 직접 사용하는 계층, 엔티티 매니저 사용
  • domain : 엔티티가 모여 있는 계층, 모든 계층에서 사용

 

패키지 구조

  • jpabook.jpashop
    • domain
    • exception
    • repository
    • service
    • web

 

개발 순서

  1. service
  2. repository
  3. testcase
  4. web 적용

 

 

 

 

 

출처

 

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

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

www.inflearn.com

 

 요구사항 분석


 

실제 동작하는 화면을 먼저 확인합니다.

 

기능 목록

  • 회원 기능
    • 회원 등록
    • 회원 조회
  • 상품 기능
    • 상품 등록
    • 상품 수정
    • 상품 조회
  • 주문 기능
    • 상품 주문
    • 주문 내역 조회
    • 주문 취소
  • 기타 요구사항
    • 상품은 제고 관리가 필요하다.
    • 상품의 종류는 도서, 음반 영화가 있다.
    • 상품을 카테고리로 구분할 수 있다.
    • 상품 주문시 배송 정보를 입력할 수 있다.

 

 

도메인 모델과 테이블 설계


회원, 주문, 상품의 관계

회원은 여러 상품을 주문할 수 있다. 그리고 한 번 주문할 때 여러 상품을 선택할 수 있으므로 주문과 상품은 다대다 관계다. 하지만 이런 다대다 관계는 관계형 데이터베이스는 물론이고 엔티티에서도 거의 사용하지 않는다. 따라서 그림처럼 주문상품이라는 엔티티를 추가해서 다대다 관계를 일대다, 다대일 관계로 풀어냈다.

 

상품 분류

상품은 도서, 음반, 영화로 구분되는데 상품이라는 공통 속성을 사용하므로 상속 구조로 표현했다.

 

 

회원 엔티티 분석

회원(Member)

이름과 임베디드 타입인 주소 (Address), 주문(orders) 리스트 

 

주문(Order)

한 번 주문시 여러 상품을 주문할 수 있으므로 주문과 주문상품(OrderItem)은 일대다 관계, 주문은 상품을 주문한 회원과 배송 정보, 주문 날짜, 주문 상태(Status)를 가지고 있습니다. 주문 상태는 열거형으로 사용했는데 주문(Order), 취소(Cancel)를 표현할 수 있습니다.

 

주문 상품(OrderItem)

주문한 상품 정보와 주문 금액(orderPrice), 주문 수량(count)

 

상품(Item)

이름, 가격, 재고수량(stockQuantity)

 

배송(Delivery)

주문 시 하나의 배송 정보를 생성합니다. 주문과 배송은 일대일 관계입니다.

 

카테고리(Category)

상품과 다대다 관계를 맺습니다. parent, child로 부모, 자식 카테고리를 연결합니다.

 

 

 

회원 테이블 분석

MEMBER

회원 엔티티의 Address 임베디드 타입 정보가 회원 테이블에 그대로 들어갔다. DELIVERY 테이블도 마찬가지.

 

ITEM

앨범, 도서, 영화 타입을 통합해서 하나의 테이블로 만들었다. DTYPE 컬럼으로 타입을 구분

 

참고 : 테이블명이 ORDER가 아니라 ORDERS인 것은 데이터베이스가 order by때문에 예약어로 잡고 있는 경우가 많기 때문입니다.
참고 : 실제 코드에서는 DB에 소문자 + _(언터스코어) 스타일을 사용
데이터베이스 테이블명, 컬럼명에 대한 관례는 회사마다 다르다.

 

 

연관관계 매핑 분석

회원과 주문

일대다, 다대일의 양방향 관계다. 따라서 연관관계의 주인을 정해야 하는데, 외래 키가 있는 주문을 연관관계의 주인으로 정하는 것이 좋다. 그러므로 Order.member를 ORDERS.MEMBER_ID 외래키와 매핑한다.

 

주문상품과 주문

다대일 양방향 관계다. 외래 키가 주문상품에 있으므로 주문상품이 연관관계의 주인이다. 그러므로 OrderItem.order를 ORDER_ITEM.ORDER_ID 외래 키와 매핑한다.

 

주문상품과 상품

다대일 단방향 관계다. OrderItem.item을 ORDER_ITEM.ITEM_ID 외래 키와 매핑한다.

 

주문과 배송

일대일 양방향 관계다. Order.delibery를 ORDERS.DELIVERY_ID 외래 키와 매핑한다.

 

카테고리와 상품

@ManyToMany를 사용해서 매핑한다. (실무에서 @ManyToMany는 사용하지 말자. 여기서는 다대다 관계를 예제로 보여주기 위해 추가했을 뿐이다.)

 

참고 : 외래 키가 있는 곳을 연관관계의 주인으로 정해라.
연관관계의 주인은 단순히 외래 키를 누가 관리하냐의 문제이지 비즈니스상 우위에 있다고 주인으로 정하면 안된다.. 예를 들어서 자동차와 바퀴가 있으면, 일대다 관계에서 항상 다쪽에 외래 키가 있으므로 외래 키가 있는 바퀴를 연관관계의 주인으로 정하면 된다. 물론 자동차를 연관관계의 주인으로 정하는 것이 불가능 한 것은 아니지만, 자동차를 연관관계의 주인으로 정하면 자동차가 관리하지 않는 바퀴 테이블의 외래 키 값이 업데이트 되므로 관리와 유지보수가 어렵고, 추가적으로 별도의 업데이트 쿼리가 발생하는 성능 문제도 있다. 자세한 내용은 JPA 기본편을 참고하자.

 

 

 

엔티티 클래스 개발


Member 엔티티

package jpabook.jpashop.domain;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.util.*;

@Entity
@Getter
@Setter
public class Member {

    @Id				// pk
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String name;

    @Embedded
    private Address address;		// Address에 Embeddable을 추가하거나 Address사용할 때 Embedded 어노테이션 추가

    @OneToMany(mappedBy = "member")     // 연관관계 주인이 아님을 명시
    private List<Order> orders = new ArrayList<>();
}

 

Address 값 타입

package jpabook.jpashop.domain;

import lombok.Getter;

import javax.persistence.Embeddable;

@Embeddable
@Getter
public class Address {
    private String city;
    private String street;
    private String zipcode;

    protected Address(){}

    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }
}

 

Order 엔티티

package jpabook.jpashop.domain;

import lombok.Getter;
import lombok.Setter;

import java.time.LocalDateTime;
import java.util.*;

import javax.persistence.*;

@Entity
@Table(name = "Orders")
@Getter
@Setter
public class Order {
    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "member_id")			// Member에 pk값과 join
    private Member member;

	// 연관관계 주인이 아님을 명시 (OrderItem 엔티티에 order 컬럼명이 연관관계의 주인이다.)
    // cascade는 Order를 저장할 때 orderItems에 저장한 것도 같이 저장된다.
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate;		// java 8이상부터 hibernate에서 지원

    @Enumerated(EnumType.STRING)			// EnumType에는 ORDINAL과 STRING이 있다. (디폴트는 ORDINAL인데, 수정할 때 데이터가 원하는대로 들어가지 않아 힘들어질 수 있다. STRING 사용을 권장)
    private OrderStatus status;         // enum (ORDER, CANCLE) 두 개의 상태를 표시
    
    //==연관관계 편의 메서드==//
    public void changeMember(Member member){
        this.member = member;
        member.getOrders().add(this);
    }

    public void addOrderItem(OrderItem orderItem){
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }

    public void setDelivery(Delivery delivery){
        this.delivery = delivery;
        delivery.setOrder(this);
    }
}

OrderStatus Enum

package jpabook.jpashop.domain;

public enum OrderStatus {
    ORDER, CANCLE
}

 

 

Delivery 엔티티

package jpabook.jpashop.domain;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter
@Setter
public class Delivery {
    @Id
    @GeneratedValue
    @Column(name = "delivery_id")
    private long id;

    @OneToOne(mappedBy = "delivery")		// 연관관계 주인이 Order 엔티티의 delivery 컬럼명임을 명시
    private Order order;

    @Embedded
    private Address address;

    @Enumerated(EnumType.STRING)
    private DeliveryStatus deliveryStatus;
}

DeliveryStatus Enum

package jpabook.jpashop.domain;

public enum DeliveryStatus {
    READY,COMP
}

 

OrderItem 엔티티

package jpabook.jpashop.domain;

import jpabook.jpashop.domain.Item.Item;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter
@Setter
public class OrderItem {
    @Id
    @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPirce;    // 주문 가격
    private int count;    // 주문 수량
}

 

Item 엔티티

package jpabook.jpashop.domain.Item;

import jpabook.jpashop.domain.Category;
import lombok.Getter;
import lombok.Setter;
import java.util.*;

import javax.persistence.*;

@Entity
@Getter
@Setter
// SINGLE_TABLE, TABLE_PER_CLASS, JOINED 전략이 있다.
// SINGLE_TABLE은 상속받은 엔티티의 테이블을 만드는 것이 아닌 DTYPE을 통해 상속받은 엔티티 중 어떤 엔티티를
// 사용하는지 파악한다.
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)	
@DiscriminatorColumn(name = "dtype")
public abstract class Item {
    @Id
    @GeneratedValue
    @Column(name = "item_id")
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;

    @ManyToMany(mappedBy = "items")
    private List<Category> categories = new ArrayList<>();
}

Album 엔티티

package jpabook.jpashop.domain.Item;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@Getter
@Setter
@DiscriminatorValue("A")		// Item 중 Dtype이 'A'인 것은 Alumn 엔티티를 가르킨다.
public class Album extends Item {
    private String artist;
    private String etc;
}

Book 엔티티

package jpabook.jpashop.domain.Item;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@Getter
@Setter
@DiscriminatorValue("B")			// Album 엔티티의 주석과 같은 느낌이다.
public class Book extends Item {
    private String author;
    private String isbn;
}

Movie 엔티티

package jpabook.jpashop.domain.Item;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@Getter
@Setter
@DiscriminatorValue("M")		// Album 엔티티의 주석과 같은 느낌이다.
public class Movie extends Item {
    private String director;
    private String actor;
}

 

Category 엔티티

package jpabook.jpashop.domain;

import jpabook.jpashop.domain.Item.Item;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.util.*;

@Entity
@Getter
@Setter
public class Category {
    @Id
    @GeneratedValue
    @Column(name = "category_id")
    private Long id;

    private String name;

    @ManyToMany
    @JoinTable(name = "category_item",
        joinColumns = @JoinColumn(name = "category_id"),
            inverseJoinColumns = @JoinColumn(name = "item_id")
    ) // 다대다의 경우 조인 테이블이 필요하다 (다대다를 일대다, 다대일로 풀어낼 테이블이 필요)
    private List<Item> items = new ArrayList<>();

    @ManyToOne
    @JoinColumn(name = "parent_id")			// 부모
    private Category parent;

    @OneToMany(mappedBy = "parent")				// 자식
    private List<Category> child = new ArrayList<>();
    
    //==연관관계 메서드==//
    public void addChildCategory(Category child){
        this.child.add(child);
        child.setParent(this);
    }
}

 

위의 코드를 추가한 뒤 실행하고 H2 콘솔에서 테이블을 확인해보면 위의 설계대로 테이블이 생성된 것을 확인할 수 있습니다.

생성된 테이블

참고 : 이론적으로 Getter, Setter 모두 제공하지 않고 꼭 필요한 별도의 메서드를 제공하는게 가장 이상적입니다.

하지만, 실무에서 엔티티의 데이터는 조회할 일이 너무 많으므로 Getter의 경우 모두 열어두는 것이 편리합니다. Getter은 아무리 호출해도 어떤 일이 발생하지 않기 때문입니다. 하지만, Setter의 경우는 문제가 다릅니다. Setter를 호출하면 데이터가 변합니다. Setter를 막 열어두면 가까운 미래에 엔티티가 도대체 왜 변경되는지 추적하기 점점 힘들어집니다. 그래서 엔티티를 변경할 때는 Setter 대신에 변경지점이 명확하도록 변경을 위한 비즈니스 메서드를 별도로 제공해야 합니다. 그래야 유지보수성이 올라갑니다. 

결론 : Getter 어노테이션은 추가하되, Setter는 지양

 

참고 : 실무에서는 @ManyToMany를 지양 (지양보다는 절대 사용하지 마라.)

@ManyToMany는 편리한 것 같지만, 중간 테이블(CATEGORY_ITEM)에 컬럼을 추가할 수 없고, 세밀하기 쿼리를 실행하기 어렵기 때문에 실무에서 사용하기에는 한계가 있다. 중간 엔티티 (CategoryItem을 만들고 @ManyToOne, @OneToMany로 매핑해서 사용하자)

결론 : 다대다 매핑 사용 X 필요 시  (다대다 => 일대다 + 다대일) 관계로 풀어내자

 

참고 : 위에서 사용한 Address와 같은 값 타입은 변경 불가능하게 설계해야 합니다.

@Setter를 제거하고, 생성자에서 값을 모두 초기화해서 변경 불가능한 클래스를 만들자. JPA 스펙상 엔티티나 임베디드 타입(@Embeddable)은 자바 기본 생성자(default constructor)를 pulbic 또는 protected로 설정해야 한다. public 보다는 protected로 설정하는 것이 더 안전하다.
>JPA가 이런 제약을 두는 이유는 JPA 구현 라이브러리가 객체를 생성할 때 리플렉션 같은 기술을 사용할 수 있도록 지원해야 하기 때문이다.   

결론 : 값 타입의 경우 기본 생성자로만 변경이 가능하도록 설계하자.

 

 

 

엔티티 설계시 주의점


엔티티에는 가급적 Setter를 사용하지 말자.

  • Setter가 모두 열려있다면, 변경 포인트가 너무 많아 유지보수가 어렵다.
  • 모든 연관관계는 지연로딩으로 설정
    • 즉시로딩은 예측이 어렵고, 어떤 SQL이 실행될지 추적하기 어렵다. 특히 JPQL을 실행할 때 N+1 문제가 자주 발생한다.
    • 실무에서 모든 연관관계는 지연로딩으로 설정해야 한다.
    • 연관된 엔티티를 함께 DB에서 조회해야 하면, fetch join 또는 엔티티 그래프 기능을 사용한다.
    • @XToOne(OneToOne, ManyToOne) 관계는 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야 한다.
  • 컬렉션은 필드에서 초기화 하자
    • 컬렉션은 필드에서 바로 초기화 하는 것이 안전하다.
    • null 문제에서 안전하다. (바로 초기화 시키기 때문)
    • 하이버네이트는 엔티티를 영속화할 때, 컬렉션을 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경한다. 만약 getOrders() 처럼 임의의 메서드에서 컬렉션을 잘못 생성하면 하이버네이트 내부 메커니즘에서 문제가 발생할 수 있다.
    • 따라서, 필드레벨에서 생성하는 것이 안전하고, 코드도 간결하다.
Member member = new Member();
System.out.println(member.getOrders().getClass());
em.persist(member);
System.out.pritnln(member.getOrders().getClass());

// 출력 결과
class java.util.ArrayList
class org.hibernate.collection.internal.PersistentBag
hibernate collection으로 감싸는 이유는 변경 감지(더티체킹)를 위해서 감싸야합니다. 한번 초기화만 해주는 이유는 hibernate에서 감쌋는데, 다시 초기화를 해버리면 hibernate에서 원하는대로 동작할 수 없기 때문입니다.  그렇기 때문에 되도록이면 바꾸지 않는 것을 권장합니다.

 

 

 

 

 

출처

 

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

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

www.inflearn.com

 

+ Recent posts