1. Spring Boot 개발 환경
- 인텔리제이 단축키 :
- “sout” 단축키 : 인텔리제이에서 “sout”만 입력하면 Sytem.out.println() 단축키
- 커맨드 + 옵션 + V : 변수 뽑아내기!! 변수 생성하는 중요 단축키
1) HikariCP
- HikariCP라는 커넥션 풀이 스프링 2.x 버전부터는 기본이다. 스레드로서 속도가 빠르다. 커넥션 풀이라는 것이 스레드를 미리 만들어놓고 나눠줘서 네트워크 속도가 빠르다.
2) SLF4J
- 로깅을 통합한 인터페이스로서 로깅에 필요한 경우에 쓰인다. JPA에서 자동화 기능 때문에 DB에 접속 시, 접속이 되었는지 직접적으로 확인을 하지 못하여 SQL문에 대한 로그 확인 시 필요!
- 보통, Logback을 플러그인으로 꽂아서 사용하기도 하고 다른 플러그인인 Log4j이나 Log4j2도 꽂아서 사용한다.
log.info()
처럼 사용하고 다른 종류의 log 메서드도 있다.
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
log.info("payload{}", payload);
ChatMessage chatMessage = objectMapper.readValue(payload, ChatMessage.class);
ChatRoom room = chatService.findRoomById(chatMessage.getRoomId());
room.handleActions(session, chatMessage, chatService);
}
3) H2 데이터베이스
- 데이터 베이스를 내장 메모리를 통해 간단하게 실행할 수 있다.
- 개발이나 테스트 용도로 가볍고 편리한 DB이고 따로 웹 화면을 제공해준다.
- 교육용으로 쓰이는데 장점이 많은 데이터 베이스이다.
a. H2 DB 접속 실행 방법 :
- 데이터베이스 파일 생성 방법**
jdbc:h2:~/jpashop
(최소 한번)- 홈 폴더에
~/jpashop.mv.db
파일 생성 확인, 세션 키를 가지고 처음에만 생성해준다. - 세션 키를 등록해서 이후 부터는
jdbc:h2:tcp://localhost/~/jpashop
로 이렇게 DB에 접속한다.
4) Thymeleaf
- MVC : Controller가 Data를 Model에 실어서 View 단으로 넘겨서 출력해준다.
- 타임리프는 내츄럴 템플릿으로 서버를 키지 않아도 브라우저에서 바로 화면에 대해 출력을 확인할 수 있다.
- 자동적으로 컴파일 해주는 도구 : Gradle에 spring-starters-devtools 추가
5) 테스트 라이브러리 : spring-boot-starter-test
- junit: 테스트 프레임워크
- mockito: 목 라이브러리
- assertj: 테스트 코드를 좀 더 편하게 작성하게 도와주는 라이브러리
- spring-test: 스프링 통합 테스트 지원
2. JPA 개발 환경 테스트
1) yml 설정
-
H2 DB를 사용함.
-
application.yml
spring:
datasource:
url: jdbc:h2:tcp://localhost/~/jpashop
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
# show_sql: true
format_sql: true
logging.level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
2) 구현 코드 :
- Member.java
package jpabook.jpashop.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
public class Member {
@Id
@GeneratedValue
private Long id;
private String username;
}
- MemberRepository.java
package jpabook.jpashop.repository;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jpabook.jpashop.entity.Member;
import org.springframework.stereotype.Repository;
@Repository
public class MemberRepository {
@PersistenceContext
EntityManager em;
public Long save(Member member){
em.persist(member);
return member.getId();
}
public Member find(Long id){
return em.find(Member.class, id);
}
}
3) 테스트 코드 :
-
테스트 코드는 서버 실행 시, 처음 하이버네이트가 만들어지므로 그 때, 한번만 테스트 통과가 되고 그 후에는 기존 테이블을 지우고 다시 테스트를 해야하거나 Rollback false 설정을 제거해주면 된다.
-
JUnit4를 사용함.
package jpabook.jpashop.repository;
import jpabook.jpashop.entity.Member;
import org.assertj.core.api.Assertions;
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 static org.junit.jupiter.api.Assertions.assertEquals;
@RunWith(SpringRunner.class)
@SpringBootTest
public class MemberRepositoryTest {
@Autowired
MemberRepository memberRepository;
@Test
@Transactional
@Rollback(value = false)
public void testMember() throws Exception {
Member member = new Member();
member.setUsername("memberA");
Long saveId = memberRepository.save(member);
Member findMember = memberRepository.find(saveId);
Assertions.assertThat(findMember.getUsername()).isEqualTo(member.getUsername());
Assertions.assertThat(findMember.getId()).isEqualTo(member.getId());
}
}
3. 엔티티 개발
1) 주의점 :
a. 도메인 모델 개발 방법 :
-
Setter는 닫아두기!(Getter는 열어두기!)
-
외래키가 있는 곳을 연관관계의 주인으로 정해라!
b. @ManyToMany, @XToMany 처리 방법(LAZY, N+1) :
-
@ManyToMany는 관계로서 사용하지 말 것. 다대일로 풀어낼 것.
-
그리고 왠만해서는 LAZY 사용하기!(즉시 로딩인 EAGER는 예측이 어렵고 N+1 문제가 자주 발생한다!)
-
연관된 Entity를 함께 DB에서 조회해야 하려면, fetch join 또는 엔티티 그래프를 사용한다.
-
@XToOne은 기본적으로 즉시로딩이라서 무조건 추가로 LAZY 설정을 해줘야 한다.
c. mappedBy 처리 방법:
-
JPA 관계에서 parent라면, child 부분에 mappedBy를 parent로 매핑하여 관계를 처리해준다.
-
mappedBy는 매핑되는 상대를 의미한다.
d. 다대다 매핑 처리 방법:
-
계층 관계랑 같은 건가?
-
다대다 매핑은 중간 테이블에 칼럼을 추가할 수도 없어서
-
중간 엔티티인 CategoryItem을 만들어서 다대일과 일대다로 풀어낸다.
e. Inheritance, DiscriminatorColumn
a) Inheritance(strategy=InheritanceType.XXX)
-
객체는 상속을 지원하므로 모델링과 구현이 똑같지만, DB는 상속을 지원하지 않으므로 논리 모델을 물리 모델로 구현할 방법이 필요하다.
-
@Inheritance(strategy=InheritanceType.XXX)의 stategy를 설정해주면 된다.
-
default 전략은 SINGLE_TABLE(단일 테이블 전략)이다.
- InheritanceType 종류
- JOINED
- SINGLE_TABLE
- TABLE_PER_CLASS
b) @DiscriminatorColumn
- 부모 클래스에 선언한다. 하위 클래스를 구분하는 용도의 컬럼이다. 관례는 default = DTYPE
c) @DiscriminatorValue(“XXX”)
-
하위 클래스에 선언한다. 엔티티를 저장할 때 슈퍼타입의 구분 컬럼에 저장할 값을 지정한다.
-
어노테이션을 선언하지 않을 경우 기본값으로 클래스 이름이 들어간다.
2) 실습 코드 :
- Member.java
package jpabook.jpashop.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter @Setter
public class Member {
// Setter는 닫아두기!
// 외래키가 있는 곳을 연관관계의 주인으로 정해라!
@Id
@GeneratedValue
private Long id;
private String name;
@Embedded
private Address Address;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
- Order.java
package jpabook.jpashop.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
@Id @GeneratedValue
@Column(name="order_id")
private Long id;
// @ManyToMany는 관계로서 사용하지 말 것. 다대1로 풀어낼 것.
// 그리고 왠만해서는 LAZY 사용하기!(즉시 로딩인 EAGER는 예측이 어렵고 N+1 문제가 자주 발생한다!)
// 연관된 Entity를 함께 DB에서 조회해야 하려면, fetch join 또는 엔티티 그래프를 사용한다.
// @XToOne은 기본적으로 즉시로딩이라서 무조건 추가로 LAZY 설정을 해줘야 한다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member; // 주문 회원
// mappedBy 중요!(관계에서 매핑되는 부분)
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
// OneToOne 주의하기!!
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "delivery_id")
private Delivery delivery; // 배송정보
private LocalDateTime orderDate; // 주문시간
// enum 클래스 이용!(상태 이용 시, 사용)
@Enumerated(EnumType.STRING)
private OrderStatus status; // 주문 상태[ORDER, CANCEL]
// == 연관관계 메서드 == //
public void setMember(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);
}
}
- OrderItem.java
package jpabook.jpashop.domain;
import jakarta.persistence.*;
import jpabook.jpashop.domain.item.Item;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "order_item")
@Getter @Setter
public class OrderItem {
// 여러 엔티티가 묶여 있으면 '_'로 계층 별로 칼럼명 매핑!
@Id
@GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item; // 주문 상품
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order; // 주문
private int orderPrice; // 주문 가격
private int count; // 주문 수량
}
- OrderStatus.java
package jpabook.jpashop.domain;
public enum OrderStatus {
ORDER, CANCEL
}
- Delivery.java
package jpabook.jpashop.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter @Setter
public class Delivery {
@Id
@GeneratedValue
@Column(name = "delivery_id")
private Long id;
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus status; // ENUM [READY(준비), COMP(배송)]
}
- DeliveryStatus.java
package jpabook.jpashop.domain;
public enum DeliveryStatus {
READY, COMP
}
- Address.java
package jpabook.jpashop.domain;
import jakarta.persistence.Embeddable;
import lombok.Getter;
@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;
}
}
- Category.java
package jpabook.jpashop.domain;
import jakarta.persistence.*;
import jpabook.jpashop.domain.item.Item;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Setter
public class Category {
@Id
@GeneratedValue
@Column(name = "category_id")
private Long id;
private String name;
// 여기 다시 듣기!! ==> 어렵다!
// 계층 관계랑 같은 건가?
// * 다대다 매핑은 중간 테이블에 칼럼을 추가할 수도 없어서
// 중간 엔티티인 CategoryItem을 만들어서 다대일과 일대다로 풀어낸다.
@ManyToMany
@JoinTable(name = "category_item",
joinColumns = @JoinColumn(name="category_id"),
inverseJoinColumns = @JoinColumn(name = "item_id"))
private List<Item> items = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;
// parent, child 를 매핑해준다.
// mappedBy는 매핑되는 상대를 의미한다.
@OneToMany(mappedBy = "parent")
private List<Category> child = new ArrayList<>();
// == 연관관계 메서드 == //
public void addChildCategory(Category child){
this.child.add(child);
child.setParent(this);
}
}
- Item.java
package jpabook.jpashop.domain.item;
import jakarta.persistence.*;
import jpabook.jpashop.domain.Category;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
// Inheritance, DiscriminatorColumn 추가로 공부하기
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
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<Category>();
}
Item 모음
- Book.java
package jpabook.jpashop.domain.item;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.Setter;
@Entity
@DiscriminatorValue("B")
@Getter @Setter
public class Book extends Item{
private String author;
private String isbn;
}
- Album.java
package jpabook.jpashop.domain.item;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.Setter;
@Entity
@DiscriminatorValue("A")
@Getter @Setter
public class Album extends Item {
private String artist;
private String etc;
}
- Movie.java
package jpabook.jpashop.domain.item;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.Setter;
@Entity
@DiscriminatorValue("M")
@Getter @Setter
public class Movie extends Item {
private String director;
private String actor;
}
4. 회원 도메인 개발
1) JPA ORM 개념 정리
a. EntityManager
-
JPA는 스레드가 하나 생성될 때 마다(매 요청마다) EntityManagerFactory에서 EntityManager를 생성한다.
-
EntityManager는 내부적으로 DB 커넥션 풀을 사용해서 DB에 붙는다.
-
EntityManager가 영속성 컨텍스트를 통해서 DB에 저장된 엔티티를 불러온다.
b. EntityManager.persist(entity)
-
이전에 persist()로 db에 객체를 저장하는 것이라고 배웠지만,
-
실제로는 DB에 저장하는 것이 아니라, 영속성 컨텍스트를 통해서 엔티티를 영속화 한다는 뜻이다.
-
정확히 말하면 persist() 시점에는 영속성 컨텍스트에 저장한다. DB 저장은 이후이다.
c. 영속성 컨텍스트
-
영속성 컨텍스트는 논리적인 개념이다. 눈에 보이지 않는다.
-
엔티티 매니저를 통해서 영속성 컨텍스트에 접근한다.
d. 엔티티의 생명주기
a) 비영속(new/transient)
- 영속성 컨텍스트와 전혀 관계가 없는 상태
b) 영속(managed)
-
영속성 컨텍스트에 저장된 상태
-
엔티티가 영속성 컨텍스트에 의해 관리된다.
-
이때 DB에 저장 되지 않는다. 영속 상태가 된다고 DB에 쿼리가 날라가지 않는다.
-
트랜잭션의 커밋 시점에 영속성 컨텍스트에 있는 정보들이 DB에 쿼리로 날라간다.
c) 준영속(detached)
- 영속성 컨텍스트에 저장되었다가 분리된 상태
d) 삭제(removed)
- 삭제된 상태. DB에서도 날린다.
e. flush
-
트랜잭션 내부에서 persist()가 일어날 때, 엔티티들을 1차 캐시에 저장하고, 논리적으로 쓰기 지연 SQL 저장소 라는 곳에 INSERT 쿼리들을 생성해서 쌓아 놓는다.
-
DB에 바로 넣지 않고 기다린다.
-
즉, commit()하는 시점에 DB에 동시에 쿼리들을 쫙 보낸다.(쿼리를 보내는 방식은 동시 or 하나씩 옵션에 따라)
-
이렇게 쌓여있는 쿼리들을 DB에 보내는 동작이 flush() 이다.
-
flush()는 1차캐시를 지우지는 않는다. 쿼리들을 DB에 날려서 DB와 싱크를 맞추는 역할을 한다.
-
실제로 쿼리를 보내고 나서, commit()한다.
-
트랜잭션을 커밋하게 되면, flush() 와 commit() 두가지 일을 하게 되는 것이다.
2) 실습 코드 :
- MemberRepository.java
package jpabook.jpashop.repository;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jpabook.jpashop.domain.Member;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class MemberRepository {
// 1. JPA는 스레드가 하나 생성될 때 마다(매 요청마다) EntityManagerFactory에서 EntityManager를 생성한다.
// EntityManager는 내부적으로 DB 커넥션 풀을 사용해서 DB에 붙는다.
// EntityManager가 영속성 컨텍스트를 통해서 DB에 저장된 엔티티를 불러온다.
// 2. EntityManager.persist(entity);
// 이전에 persist()로 db에 객체를 저장하는 것이라고 배웠지만,
// 실제로는 DB에 저장하는 것이 아니라, 영속성 컨텍스트를 통해서 엔티티를 영속화 한다는 뜻이다.
// 정확히 말하면 persist() 시점에는 영속성 컨텍스트에 저장한다. DB 저장은 이후이다.
@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 a 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();
}
}
- MemberService.java
package jpabook.jpashop.service;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
// readOnly : 데이터 변경이 없는 경우, 서비스 단에서 사용!(읽기 전용일 경우, 약간 성능 향상)
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
// 필드 주입보다는 생성자 주입을 사용할 것!(불변성 보장!)
// @Autowired
// MemberRepository memberRepository;
private final MemberRepository memberRepository;
// public MemberService(MemberRepository memberRepository) {
// this.memberRepository = memberRepository;
// }
/**
* 회원가입
*/
@Transactional
public Long join(Member member){
validateDuplicateMember(member); // 중복 회원 검증
memberRepository.save(member);
return member.getId();
}
/**
* 전체 회원 조회
*/
public List<Member> findMembers(){
return memberRepository.findAll();
}
public Member findOne(Long memberId){
return memberRepository.findOne(memberId);
}
/**
* 중복 회원 검증(private)
*/
private void validateDuplicateMember(Member member) {
List<Member> findMembers = memberRepository.findByName(member.getName());
if(!findMembers.isEmpty()){
throw new IllegalStateException("이미 존재하는 회원입니다.");
}
}
}
- Test 코드 :
- MemberServiceTest.java
package jpabook.jpashop.test.service;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.repository.MemberRepository;
import jpabook.jpashop.service.MemberService;
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 static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
@RunWith(SpringRunner.class)
@SpringBootTest
public class MemberServiceTest {
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Test
@Transactional
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("예외가 발생해야 한다.");
}
}
3) Test yml 설정 변경
-
추가로 Test 패키지 안에 yml 파일을 추가하여 메모리 DB로만으로도 테스트가 가능하다.
-
test/resources/application.yml
- application.yml
spring:
## datasource:
## url: jdbc:h2:tcp://localhost/~/jpashop
## username: sa
## password:
## driver-class-name: org.h2.Driver
#
## jpa:
## hibernate:
## ddl-auto: create
## properties:
## hibernate:
## show_sql: true
## format_sql: true
#
logging.level:
org.hibernate.SQL: debug
## org.hibernate.orm.jdbc.bind: trace
- 참고 : 참고 블로그 글
5. 상품 도메인 개발
1) JPA의 엔티티 개념 정리 :
-
JPA에서는 객체 지향형이라서 Entity에도 역할(책임)을 부여한다.
-
이제는 거의 핵심 비즈니스 로직은 entity에 담는 것인가?
-
다음 장에서 설명!
2) 실습 코드 :
- Item.java
package jpabook.jpashop.domain.item;
import jakarta.persistence.*;
import jpabook.jpashop.domain.Category;
import jpabook.jpashop.exception.NotEnoughStockException;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
// Inheritance, DiscriminatorColumn 추가로 공부하기
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
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<Category>();
// ==== ******** 비즈니스 로직 추가 ******* ==== //
// JPA에서는 객체 지향형이라서 Entity에도 역할(책임)을 부여한다.
// 이제는 거의 핵심 로직은 entity에 담는 것인가?
public void addStock(int quantity){
this.stockQuantity += quantity;
}
public void removeStock(int quantity){
int restStock = this.stockQuantity - quantity;
if(restStock < 0){
throw new NotEnoughStockException("need more stock");
}
this.stockQuantity = restStock;
}
}
- NotEnoughStockException.java
package jpabook.jpashop.exception;
public class NotEnoughStockException extends RuntimeException {
public NotEnoughStockException() {
}
public NotEnoughStockException(String message) {
super(message);
}
public NotEnoughStockException(String message, Throwable cause) {
super(message, cause);
}
public NotEnoughStockException(Throwable cause) {
super(cause);
}
}
- ItemRepository.java
package jpabook.jpashop.repository;
import jakarta.persistence.EntityManager;
import jpabook.jpashop.domain.item.Item;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
@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);
}
}
public Item findOne(Long id){
return em.find(Item.class, id);
}
public List<Item> findAll(){
return em.createQuery("select i from Item i", Item.class).getResultList();
}
}
- ItemService.java
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.List;
@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 itemId){
return itemRepository.findOne(itemId);
}
}
6. 주문 도메인 개발
1) 도메인 모델 패턴 정리 :
-
아래 코드처럼 주문과 주문 취소의 메서드에서 ‘비즈니스 로직의 대부분’이 ‘엔티티’에 있다.
-
즉, 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할만 하게 된다.
-
정리하면, 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것이
도메인 모델 패턴
이라고 부른다! -
이와 반대로 서비스 계층에서 대부분의 비즈니스 로직을 가지고 있었던 이전 방식의 디자인 패턴을
트랜잭션 스크립트 패턴
이라고 한다! -
그래서, 도메인 모델 패턴은 JPA와 사상이 비슷한 디자인 패턴이다.
2) 실습 코드 :
- Order.java
package jpabook.jpashop.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.aspectj.weaver.ast.Or;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
@Id @GeneratedValue
@Column(name="order_id")
private Long id;
// @ManyToMany는 관계로서 사용하지 말 것. 다대1로 풀어낼 것.
// 그리고 왠만해서는 LAZY 사용하기!(즉시 로딩인 EAGER는 예측이 어렵고 N+1 문제가 자주 발생한다!)
// 연관된 Entity를 함께 DB에서 조회해야 하려면, fetch join 또는 엔티티 그래프를 사용한다.
// @XToOne은 기본적으로 즉시로딩이라서 무조건 추가로 LAZY 설정을 해줘야 한다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member; // 주문 회원
// mappedBy 중요!(관계에서 매핑되는 부분)
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
// OneToOne 주의하기!!
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "delivery_id")
private Delivery delivery; // 배송정보
private LocalDateTime orderDate; // 주문시간
// enum 클래스 이용!(상태 이용 시, 사용)
@Enumerated(EnumType.STRING)
private OrderStatus status; // 주문 상태[ORDER, CANCEL]
// == 연관관계 메서드 == //
public void setMember(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);
}
// ===== 생성 메서드 ====== //
// OrderItem... orderItems는 주문 아이템이 여러개일 수도 있어서
// 여러 entity가 복합된 주문(Order) 결과를 뽑아낸다.
// 실제 주문 엔티티!!
public static Order createUser(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.getStatus() == DeliveryStatus.COMP){
throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
}
this.setStatus(OrderStatus.CANCEL);
for(OrderItem orderItem : orderItems){
orderItem.cancel();
}
}
// ==== 조회 로직 ==== //
// *** 전체 주문 가격 조회 *** //
public int getTotalPrice(){
int totalPrice = 0;
for(OrderItem orderItem : orderItems){
totalPrice += orderItem.getTotalPrice();
}
return totalPrice;
}
}
- OrderItem.java
package jpabook.jpashop.domain;
import jakarta.persistence.*;
import jpabook.jpashop.domain.item.Item;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "order_item")
@Getter @Setter
public class OrderItem {
// 여러 엔티티가 묶여 있으면 '_'로 계층 별로 칼럼명 매핑!
@Id
@GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item; // 주문 상품
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order; // 주문
private int orderPrice; // 주문 가격
private int count; // 주문 수량
// ===== 생성 메서드 ======= //
// 주문 1개에 관한 정보(주문 1개의 아이템 엔티티)
// 1개 주문시, 그 아이템의 개수는 1개 줄어야 한다. 진짜 엔티티 관련 로직이 여기에 다 들어가네
public static OrderItem createOrderItem(Item item, int orderPrice, int count){
OrderItem orderItem = new OrderItem();
orderItem.setItem(item);
orderItem.setOrderPrice(orderPrice);
orderItem.setCount(count);
item.removeStock(count);
return orderItem;
}
// ====== 비즈니스 로직 ====== //
// ***** 주문 취소 *****
public void cancel(){
getItem().addStock(count);
}
// ==== 조회 로직 ==== //
// *** 주문 상품의 전체 가격 조회 *** //
public int getTotalPrice(){
return getOrderPrice() * getCount();
}
}
- OrderRepository.java
package jpabook.jpashop.repository;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager;
import jpabook.jpashop.domain.Order;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
public void save(Order order){
em.persist(order);
}
public Order findOne(Long id){
return em.find(Order.class, id);
}
// 검색은 나중에!
// public List<Order> findAll(OrderSearch orderSearch);
}
- OrderService.java
package jpabook.jpashop.service;
import jpabook.jpashop.domain.*;
import jpabook.jpashop.domain.item.Item;
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.List;
@Service
@RequiredArgsConstructor
public class OrderService {
private final MemberRepository memberRepository;
private final ItemRepository itemRepository;
private final OrderRepository orderRepository;
// ****** 주문 ******
// 중요!! : 주문과 주문 취소 메서드는 비즈니스 로직의 대부분이 엔티티에 있다.
// 즉, JPA에서 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할만 하게 된다.
// 정리하면, 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것이 "도메인 모델 패턴"이다!
// 이와 반대로 서비스 계층에서 대부분의 비즈니스 로직을 가지고 있던 이전 프로그래밍 방법론을 '트랜잭션 스크립트 패턴'이라고 한다!
@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());
delivery.setStatus(DeliveryStatus.READY);
// 주문상품 생성
OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
// 주문 생성
Order order = Order.createUser(member, delivery, orderItem);
// 주문 저장
orderRepository.save(order);
return order.getId();
}
// *** 주문 취소 ***
@Transactional
public void cancelOrder(Long orderId){
Order order = orderRepository.findOne(orderId);
order.cancel(); // 주문 취소
}
// *** 주문 검색 ***
/* public List<Order> findOrders(OrderSearch orderSearch){
return orderRepository.findAll(orderSearch);
}*/
}
7. 주문 관련 테스트 코드
1) 개념 정리 :
- TDD 원칙 : given, when, then에 관한 테스트 템플릿 만들기(인텔리제이)
- QueryDSL, JOOQ 개념 정리
2) 테스트 코드 :
-
주문 관련 테스트 코드
-
OrderServiceTest.java
package jpabook.jpashop.test.service;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.domain.item.Book;
import jpabook.jpashop.domain.item.Item;
import jpabook.jpashop.exception.NotEnoughStockException;
import jpabook.jpashop.repository.OrderRepository;
import jpabook.jpashop.service.OrderService;
import org.junit.Test;
import org.junit.jupiter.api.DisplayName;
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 static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {
@PersistenceContext
EntityManager em;
@Autowired
OrderService orderService;
@Autowired
OrderRepository orderRepository;
@Test
public void 상품주문() throws Exception {
// Given
Member member = createMember();
Item item = createBook("시골 JPA", 10000, 10);
int orderCount = 2;
// When
Long orderId = orderService.order(member.getId(), item.getId(), orderCount);
// Then
Order getOrder = orderRepository.findOne(orderId);
assertEquals("상품 주문시 상태는 ORDER", OrderStatus.ORDER, getOrder.getStatus());
assertEquals("주문한 상품 종류 수가 정확해야 한다.",1,getOrder.getOrderItems().size());
assertEquals("주문 가격은 가격 * 수량이다", 10000 * 2, getOrder.getTotalPrice());
assertEquals("상품 주문시 상태는 ORDER",8,item.getStockQuantity());
}
@Test(expected = NotEnoughStockException.class)
public void 상품주문_재고수량초과() throws Exception{
// Given
Member member = createMember();
Item item = createBook("시골 JPA", 10000, 10);
int orderCount = 11; // 재고보다 많은 수량
// When
orderService.order(member.getId(), item.getId(), orderCount);
// Then
// 기존 수량 10개보다 많이 주문해서 에러가 발생한다.
fail("재고 수량 부족 예외가 발생해야 한다.");
}
@Test
public void 주문취소(){
// Given
Member member = createMember();
Item item = createBook("시골 JPA", 10000, 10); // 이름, 가격, 재고
int orderCount = 2;
Long orderId = orderService.order(member.getId(), item.getId(), orderCount);
// When
orderService.cancelOrder(orderId);
// Then
Order getOrder = orderRepository.findOne(orderId);
assertEquals("주문 취소시, 상태는 CANCEL이다.", OrderStatus.CANCEL, getOrder.getStatus());
assertEquals("주문이 취소된 상품은 그만큼 재고가 증가해야 한다.", 10, item.getStockQuantity());
}
private Member createMember() {
Member member = new Member();
member.setName("회원1");
member.setAddress(new Address("서울", "강가", "123-123"));
em.persist(member);
return member;
}
private Item createBook(String name, int price, int stockQuantity) {
Book book = new Book();
book.setName(name);
book.setStockQuantity(stockQuantity);
book.setPrice(price);
em.persist(book);
return book;
}
}
3) JPQL? or Criteria? or QueryDSL? :
- 검색 기능 추가
- JPQL? or Criteria? or QueryDSL?
- QueryDSL를 배워야하는 이유
- 최근에는 JOOQ를 이용한다.
- 개념 정리 :
- QueryDSL : JPQL + 빌더
- JOOQ : Mybatis + TypeSafe
- OrderSearch.java
package jpabook.jpashop.domain;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class OrderSearch {
private String memberName; // 회원 이름
private OrderStatus orderStatus; // 주문 상태(ORDER, CANCEL)
// Getter, Setter
}
- OrderRepository.java
package jpabook.jpashop.repository;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.*;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderSearch;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
public void save(Order order){
em.persist(order);
}
public Order findOne(Long id){
return em.find(Order.class, id);
}
// public List<Order> findAll(OrderSearch orderSearch){
// // .. 검색 로직 필요 : 검색 조건에 동적으로 쿼리를 생성해서 주문 엔티티를 조회!
// // 방법 1 : JPQL 쿼리로 진행 -> 동적 쿼리를 문자로 생성하기가 어렵고 실수로 인한 버그 발생률이 높다.
// // 방법 2 : JPA Criteria로 처리 -> JPA 표준 스펙이지만 실무에서 사용하기 매우 복잡하다! 그래서 Querydsl을 사용한다.
//
// }
// ** 1. JPQL
public List<Order> findAllByString(OrderSearch orderSearch) {
// 1. language = JPAQL
String jpql = "select o From Order o join o.member m";
boolean isFirstCondition = true;
//주문 상태 검색
if (orderSearch.getOrderStatus() != null) {
if (isFirstCondition) {
jpql += " where";
isFirstCondition = false;
} else {
jpql += " and";
}
jpql += " o.status = :status";
}
//회원 이름 검색
if (StringUtils.hasText(orderSearch.getMemberName())) {
if (isFirstCondition) {
jpql += " where";
isFirstCondition = false;
} else {
jpql += " and";
}
jpql += " m.name like :name";
}
TypedQuery<Order> query = em.createQuery(jpql, Order.class) .setMaxResults(1000); //최대 1000건
if (orderSearch.getOrderStatus() != null) {
query = query.setParameter("status", orderSearch.getOrderStatus());
}
if (StringUtils.hasText(orderSearch.getMemberName())) {
query = query.setParameter("name", orderSearch.getMemberName());
}
return query.getResultList();
}
// ** 2. criteria
public List<Order> findAllByCriteria(OrderSearch orderSearch) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> o = cq.from(Order.class);
Join<Order, Member> m = o.join("member", JoinType.INNER); //회원과 조인
List<Predicate> criteria = new ArrayList<>();
//주문 상태 검색
if (orderSearch.getOrderStatus() != null) {
Predicate status = cb.equal(o.get("status"),
orderSearch.getOrderStatus());
criteria.add(status);
}
//회원 이름 검색
if (StringUtils.hasText(orderSearch.getMemberName())) {
Predicate name =
cb.like(m.<String>get("name"), "%" +
orderSearch.getMemberName() + "%");
criteria.add(name);
}
cq.where(cb.and(criteria.toArray(new Predicate[criteria.size()])));
TypedQuery<Order> query = em.createQuery(cq).setMaxResults(1000); //최대 1000건
return query.getResultList();
}
}