일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 예제 도메인 모델
- 페이징
- jpa 활용
- 스프링 mvc
- 값 타입 컬렉션
- 프로젝트 환경설정
- Spring Data JPA
- Bean Validation
- 벌크 연산
- 트위터
- QueryDSL
- 로그인
- 검증 애노테이션
- 타임리프
- API 개발 고급
- 컬렉션 조회 최적화
- 타임리프 문법
- 김영한
- 임베디드 타입
- JPA
- 스프링
- 불변 객체
- 일론머스크
- JPA 활용 2
- 스프링MVC
- JPA 활용2
- 스프링 데이터 JPA
- JPQL
- 기본문법
- 실무활용
- Today
- Total
RE-Heat 개발자 일지
[JPA 활용1] [4] 상품·주문 도메인 개발 본문
인프런 김영한 님의 강의를 듣고 작성한 글입니다.
[1] 상품 도메인 개발
■ 상품 엔티티 개발
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {
@Id
@GeneratedValue
private Long id;
private String name;
private int price;
private int stockQuantity;
@ManyToMany(mappedBy = "items")
private List<Category> categories = new ArrayList<>();
//==비즈니스 로직==/
/**
* 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;
}
}
① 비즈니스 로직
1. addStock() 메서드는 파라미터로 넘어온 수만큼 재고를 늘린다. 이 메서드는 재고가 증가하거나 상품 주
문을 취소해서 재고를 다시 늘려야 할 때 사용한다.
2. removeStock() 메서드는 파라미터로 넘어온 수만큼 재고를 줄인다. 만약 재고가 부족하면 예외가 발생한
다. 주로 상품을 주문할 때 사용한다.
② 엔티티에서 비즈니스 로직을 처리하는 방식을 도메인 패턴 모델이라 부름. 서비스 단에서 처리하는 것보다 엔티티에서 처리하는 게 응집도가 높은 설계라 볼 수 있다.
NotEnoughStockException
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);
}
protected NotEnoughStockException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
① RuntimeException을 상속받은 후 오버라이딩.
■ 상품 리포지토리 개발
@Repository
@RequiredArgsConstructor
public class ItemRepository {
private final EntityManager em;
public void save(Item item){
if (item.getId() == null) { //idX 새로 생성한 객체 그러니 신규 등록
em.persist(item);
} else {
em.merge(item); //update
}
}
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();
}
}
① RequiredArgsConstructor로 생성자 주입 방식으로 처리
② findAll은 createQuery로 SQL을 날려 값을 가져온다.
참고] em.merge()는 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용한다. 향후 더 자세히 알아볼 예정
■ 상품 서비스 개발
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
@Transactional(readOnly = false)//읽기가 아닌 쓰기다.
public void saveItem(Item item){
itemRepository.save(item);
}
public List<Item> findItems(){
return itemRepository.findAll();
}
public Item findOne(Long itemId){
return itemRepository.findOne(itemId);
}
}
① 상품 서비스는 상품 리포지토리에 위임하는 역할만 한다. 비즈니스 로직은 엔티티에서 구체적으로 구현됨.
[2] 주문 도메인 개발
■ 주문·주문상품 엔티티 개발
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.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;
}
① 생성 메서드( createOrder() ) : 주문엔티티를 생성할 때 사용한다. 주문회원, 배송정보, 주문 상품의 정보
를 받아서 실제 주문 엔티티를 생성한다.
② 주문 취소( cancel() ) : 주문 취소 시 사용한다. 주문 상태를 취소로 변경하고 주문 상품에 주문 취소를 알린
다. 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.
③ 전체주문 가격조회 : 주문 시 사용한 전체주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품
가격을 알아야 한다. 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다.
OrderItem
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
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; //주문 수량
//==생성 메서드==//
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); //order가 들어오면 재고를 빼줘야 한다.
return orderItem;
}
//==비즈니스 로직==//
public void cancel() {
getItem().addStock(count); //재고수량을 원복해준다.
}
/**
* 주문상품 전체 가격 조회
*/
//==조회 로직==//
public int getTotalPrice() {
return getOrderPrice() * getCount();
}
}
① 생성 메서드로 상품 정보(주문 상품·가격·수량)를 받아와서 처리한다. 추가로 item.removeStock(count)로 주문한 수량만큼 상품 재고를 줄인다.
② 주문취소( cancel() ) : getItem().addStock(count)를 호출해서 취소한 주문 수량만큼 상품의 재고를 증가시킨다.
③ 주문 가격 조회(getTotalPrice()) : 주문 가격 * 수량을 반환한다.
■ 주문 리포지토리 개발
@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(){
// return em.createQuery("select o from Order o", Order.class)
// .getResultList();
// }
}
- findAll 메서드는 주문 검색 기능 파트에서 다룰 예정
■ 주문 서비스 개발
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
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);//왜 Order 한 번만 저장하면 되냐면 Cascade 때문.
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);
// }
}
① 생성자 메서드를 써서 가독성이 UP
② Cascade를 지정해 order만 persist해도 orderItem과 delivery도 자동으로 persist된다.
=> OrderItem과 Delivery가 Order에서만 쓰이므로 cascade를 써도 문제가 없지만, 만일 Delivery 등이 다른 곳에서도 사용한다면 Cascade를 쓰면 안 된다.
③ 다른 사람이 @Setter메서드를 써서 정보를 입력하는 걸 방지하려면 기본 생성자에 protected를 써주면 된다.
- 롬복으로 기본생성자의 AccessLevel을 지정해 왼쪽 방식을 대체할 수도 있다.
참고]
1. 엔티티가 비즈니스 로직을 가진 패턴을 도메인 모델 패턴이라고 부른다. (JPA, ORM 쓸 땐 이 패턴을 많이 쓴다.)
2. 서비스 계층에서 비즈니스 로직 대부분을 처리하는 방식을 트랜잭션 스크립트 패턴이라고 부른다.
■ 주문 기능 테스트
OrderServiceTest
@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {
@Autowired
EntityManager em;
@Autowired
OrderService orderService;
@Autowired
OrderRepository orderRepository;
@Test
public void 상품주문() throws Exception {
//given
Member member = createMember();
Book 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("주문 수량만큼 재고가 줄어야 한다.", 8, book.getStockQuantity());
}
@Test(expected = NotEnoughStockException.class)
public void 상품주문_재고수량초과() throws Exception {
//given
Member member = createMember();
Book book = createBook("도시 JPA", 10000, 10);
int orderCount = 11;
//when
orderService.order(member.getId(), book.getId(), orderCount);
//then
fail("재고 수량 부족 예외가 발생해야 한다.");
}
@Test
public void 주문취소() throws Exception {
//given
Member member = createMember();
Book 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 Book createBook(String name, int price, int stockQuantity) {
Book 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;
}
}
① 상품주문 테스트
given : 테스트를 위한 Member, 상품 생성
생성자로 값을 넘기기 위해 Address에 @AllArgsConstructor 추가
when : 실제 상품 주문
then : 주문 후 주문 상태, 주문 상품 종류 수 , 주문가격, 재고수량 줄었는지 검증
② 상품주문 재고수량 초과 테스트
- NotEnoughStockException 발생해야 한다. 따라서 @Test(expected = NotEnoughStockException.class)로 지정.
- 순서
- orderservice.order() => orderItem.createOrderItem() => item.removeStock
- 재고수량이 10인데, 주문수량은 11이므로 NotEnoughStockException이 발생
참고] removeStock에 대한 단위테스트가 있는 게 더 좋은 테스트다.
③ 주문취소 테스트
given : 테스트를 위한 회원, 상품 생성 후 주문까지 완료
when : 주문을 취소
then : 주문 상태는 CANCEL, 재고는 복구됐는지 확인
- 순서
- orderService.cancelOrder() => order.cancel() => setStatus CANCEL로 처리
- => orderItem.cancel() => addStock()으로 재고수량 복구
■ 주문 검색 기능 개발
JPA에서 동적 쿼리는 어떻게 해결해야 할까?
① 검색에 필요한 객체 작성
OrderSerarch
@Getter @Setter
public class OrderSearch {
private String memberName; //회원 이름
private OrderStatus orderStatus; //주문 상태[ORDER, CANCEL]
}
② 동적 쿼리 작성
1. JPQL로 처리 - MemberRepository 일부 발췌
//검색
public List<Order> findAll(OrderSearch orderSearch) {
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);
if (orderSearch.getOrderStatus() !=null){
query = query.setParameter("status", orderSearch.getOrderStatus());
}
if (StringUtils.hasText(orderSearch.getMemberName())){
query = query.setParameter("name", orderSearch.getMemberName());
}
return query.getResultList();
}
① isFirstCondition으로 이게 첫 번째 where 조건인지 파악
② 그때그때 각조건에 맞는 JPQL 추가
③ createQuery().setmaxResult()로 조회할 데이터 수 설정(페이징)
④ orderStatus, memberName이 있는지 확인 후 있으면 setParameter를 써서 파라미터 값으로 넘김
단점 : JPQL 쿼리를 문자로 생성하기는 번거롭고, 실수로 인한 버그가 충분히 발생할 수 있다.
2. JPA Criteria로 처리
/**
* JPA 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<Object, Object> 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);
return query.getResultList();
}
단점 : 문자열보단 낫지만, 이 코드를 보면 어떤 쿼리가 만들어질지 생각해 내기 어렵다. 따라서 유지보수 측면에서 좋지 않은 코드다. 그래서 실무에선 JPA Criteria보다 QueryDSL을 쓴다.
③ QueryDSL로 처리
/**
* Querydsl
* */
public List<Order> findAll(OrderSearch orderSearch){
QOrder order = QOrder.order;
QMember member = QMember.member;
return query
.select(order)
.from(order)
.join(order.member, member)
.where(statusEq(orderSearch.getOrderStatus()),
nameLike(orderSearch.getMemberName()))
.limit(1000)
.fetch();
}
private BooleanExpression statusEq(OrderStatus statusCond){
if (statusCond == null){
return null;
}
return order.status.eq(statusCond);
}
private BooleanExpression nameLike(String nameCond){
if (!StringUtils.hasText(nameCond)){
return null;
}
return member.name.like(nameCond);
}
SQL문과 비슷한 QueryDSL. 자세한 내용은 향후 다룰 예정이다.
'백엔드 > JPA' 카테고리의 다른 글
[JPA 활용1] [5] 웹 계층 개발(하편) (0) | 2023.08.27 |
---|---|
[JPA 활용1] [5] 웹 계층 개발(상편) (0) | 2023.08.26 |
[JPA 활용1] [3] 앱 구현 준비 및 회원 도메인 개발 (0) | 2023.08.24 |
[JPA 활용1] [2] 도메인 분석 설계 (0) | 2023.08.20 |
[JPA 활용1] [1] 프로젝트 환경설정 (0) | 2023.08.19 |