Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 31 |
Tags
- 스프링 mvc2 - 검증
- 자바의 정석 기초편 ch8
- 자바의 정석 기초편 ch4
- 자바의 정석 기초편 ch2
- 스프링 mvc2 - 타임리프
- 자바의 정석 기초편 ch11
- 자바의 정석 기초편 ch3
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch6
- @Aspect
- 자바의 정석 기초편 ch9
- 2024 정보처리기사 수제비 실기
- 스프링 db2 - 데이터 접근 기술
- 자바의 정석 기초편 ch5
- 자바의 정석 기초편 ch1
- 스프링 입문(무료)
- 스프링 mvc1 - 스프링 mvc
- 자바의 정석 기초편 ch14
- 자바의 정석 기초편 ch13
- 스프링 mvc1 - 서블릿
- 자바의 정석 기초편 ch7
- 스프링 db1 - 스프링과 문제 해결
- 2024 정보처리기사 시나공 필기
- jpa 활용2 - api 개발 고급
- jpa - 객체지향 쿼리 언어
- 자바의 정석 기초편 ch12
- 스프링 mvc2 - 로그인 처리
- 코드로 시작하는 자바 첫걸음
- 게시글 목록 api
- 타임리프 - 기본기능
Archives
- Today
- Total
나구리의 개발공부기록
주문 도메인 개발, 주문 및 주문상품 엔터티 개발, 주문 리포지토리 개발, 주문 서비스 개발, 주문 기능 테스트, 주문 검색 기능 개발 본문
인프런 - 스프링부트와 JPA실무 로드맵/실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발
주문 도메인 개발, 주문 및 주문상품 엔터티 개발, 주문 리포지토리 개발, 주문 서비스 개발, 주문 기능 테스트, 주문 검색 기능 개발
소소한나구리 2024. 9. 30. 11:56출처 : 인프런 - 실전! 스프링 부트와 JPA활용1 - 웹 애플리케이션 개발(유료) / 김영한님
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용
1. 주문 및 주문상품 엔터티 개발
1) Order - 메서드 추가
(1) 생성 메서드, createOrder()
- 주문 엔터티를 생성할 때 사용하며, 주문 회원, 배송정보, 주문상품의 정보를 받아서 실제 주문 엔터티를 생성
- 주문 생성에 대한 복잡한 비즈니스 로직을 엔터티안에 만듦으로써 밖에서 Order를 생성하여 값을 set하는 방식이 아니라 주문 생성이 되면 createOrder를 생성에 필요한 정보 및 설정을함
- 이렇게 생성 메서드를 만들면 생성에 관련한 변경 점이 있을 때 해당 엔터티에서 이 생성메서드만 고치면 되므로 유지 보수성이 좋아지고 코드도 호출해서 사용할 수 있으므로 재사용성이 높아지며 생성 시 항상 동일한 로직으로 생성하게 되므로 일관성을 유지할 수 있는 등의 다양한 장점이 존재함
- 생성메서드는 실무에서는 훨씬더 복잡함
(2) 비즈니스 로직 - 주문 취소, cancel()
- 주문 취소시 사용하며 주문 상태를 취소로 변경하고 주문 상품에 취소를 알림 -> oderItem.candel() 호출
- 배송상태가 COMP(완료)면 주문을 취소하지 못하도록 예외를 발생시킴
(3) 비즈니스 로직 - 전체 주문 가격 조회, getTotalPrice()
- 주문 시 사용한 전체 주문 가격을 조회
- 전체 주문 가격을 알려면 각각의 주문 상품 가격을 알아야 하기 때문에 연관된 주문 상품들의 가격들 조회해서 더한 값을 반환함
- 반복문을 사용해서 주문 상품에 대한 가격을 꺼낼 수도 있고 Java8에서 지원하는 스트림을 이용하면 훨씬 깔끔하게 연관된 주문 상품의 값을 꺼내서 반환할 수 있음
- 실무에서는 주로 주문에 전체 주문 가격필드를 두고 역정규화를 함
// 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() == DeliverStatus.COMP) {
throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다");
}
// 취소 상태일 때는 재고를 원복
// OrderItem의 cancel()를 호출해서 재고를 증가
this.setStatus(OrderStatus.CANCEL);
for (OrderItem orderItem : orderItems) {
orderItem.cancel();
}
}
// 전체 주문 가격 조회
public int getTotalPrice() {
// stream 문법으로 작성
// mapToInt(): 스트림의 각 요소를 int 타입으로 변환
// OrderItem.getTotalPrice의 값을 모두 더해서 반환
return orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();
// 기본 문법으로 작성
// int totalPrice = 0;
// for (OrderItem orderItem : orderItems) {
// totalPrice += orderItem.getTotalPrice();
// }
// return totalPrice;
}
2) OrderItem - 메서드 추가
(1) 생성 메서드, createOrderItem()
- 주문 상품, 가격, 수량 정보를 사용해서 주문 상품 엔터티를 생성
- item.removeStock(count)을 호출해서 주문 수량만큼 상품의 재고를 줄임
(2) 비즈니스 메서드, cancel(), getTotalPrice()
- 주문취소, cancel(): 주문이 취소되면 getItem().addStock(count)을 호출해서 취소한 주문 수량 만큼 상품의 재고를 증가시킴(재고 원복)
- 주문 가격 조회, getTotalPrice(): 주문 가격에 수량을 곱한 값을 반환
// OrderItem 클래스
/* 생성 메서드 */
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(); // 주문 가격 * 주문 수량 = 상품 주문 가격
}
2. 주문 리포지토리 개발
1) OrderRepository
- 주문 엔터티 저장, 검색 기능이 있음
- findAll(): 전체 조회 메서드는 주문 검색 기능 개발 시 자세히 설명(동적 쿼리 필요)
@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) {}
}
3. 주문 서비스 개발
- 주문 엔터티와 주문 상품 엔터티의 비즈니스 로직을 활용해서 주문, 주문취소, 주문내역검색 기능을 제공
- 예제를 단순화 하려고 한 번에 하나의 상품만 주문할 수 있음
(1) order(), 주문 생성
- 주문하는 회원 식별자와 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔터티를 생성한 후 저장
- OrderItem과 Order의 생성을 만들어둔 생성 메서드가 아닌 new를 사용해서 생성할 수 없도록 OderItem과 Order에 @NoArgsConstructor(assess = AccessLevel.PROTECTED)를 적용
- 엔터티 개발시 cascade를 적용하여 orderRepository(save)하나로도 OrderItem, Delivery에서도 persist가 일어남
- 지금과 같이 Order에서만 OrderItem, Delivery가 참조가 일어나는 구조에서는 적용해도 되지만 cascade를 적용한 대상이 여러 곳에서 참조가 일어나는 경우에는 별도의 Repository를 만들어서 persist를 따로 하는 것이 좋음
- 이러한 개념이 잡혀져 있지 않다면 cascade를 개발시에는 적용하지 말고 추후 리펙토링 때 필요시에 변경하는 것을 권장
(2) cancelOrder(), 주문 취소
- 주문 식별자를 받아서 주문 엔터티를 조회한 후 주문 엔터티에 주문 취소를 요청
- 보통은 SQL을 직접 다루는 MyBatis나 JDBC template을 사용하는 경우는 데이터 변경시 업데이트에 일어나는 상세한 변경에 대한 쿼리를 파라티러를 입력해서 전부 작성해야 해야 해서 Service 계층에 비즈니스 로직이 많이 몰리게 되었음
- JPA를 확용하면 엔터티 안에서 데이터들만 바꿔주면 변경 내역 감지가 일어나면서 변경된 내역들을 찾아 데이터베이스에 업데이트쿼리들을 알아서 적용해줌
(3) findOrder(), 주문 검색 - 주문 검색 기능에서 개발
- 껍데기만 만들어 놓고 이후에 개발, orderSearch라는 검색 조건을 가진 객체로 주문 엔터리를 검색
** 참고
- 주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔터티에 있으며 서비스 계층은 단순히 엔터티에 필요한 요청을 위힘하는 역할을 하고 있음
- 엔터티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴이라고 함 -> JPA(ORM 적용시) 사용 할 때 많이 적용하는 방식
- 엔터티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라 함 -> SQL을 다룰 때 사용하는 방식
- 어떤 방식이 더 좋다고 말하기는 애매하며, 유지보수 측면에서 어떤 방식이 더 좋을지 생각해보고 적용하는 것이 좋으며 실무에서 한 프로젝트 안에서도 두가지의 패턴이 양립을 함
package jpabook.jpashop.service;
@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());
delivery.setStatus(DeliveryStatus.READY);
// 주문상품 생성 - new OrderItem으로 직접 생성하는 것을 막기위해 OrderItem에 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 적용
OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
// 주문 생성 - 위와 동일
Order order = Order.createOrder(member, delivery, orderItem);
// 주문 저장
/*
cascade 적용으로 order만 저장되면 OrderItem, Delivery도 같이 저장됨
cascade의 적용 범위에 대해서는 고민이 필요한데, cascade를 적용할 대상이 다른곳에서 참조하지 않을 때(private owner) 적용하면 됨
만약 해당 객체가 중요해서 여러곳에서 참조가 일어나면 별도의 repository를 생성해서 별도의 persist를 하는것이 좋음
이러한 상황이 개발시에 떠오르지 않거나 개념이 잘 잡혀있지 않다면 바로 적용하는 것이 아니라 추후에 리펙토링할때 적용을 해보는 것이 좋음
*/
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);
// }
}
(4) Order, OrderItem에 @NoArgsConstructor 적용
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 다른곳에서 new로 직접 생성하는 것을 방지
public class Order { // 코드 생략 }
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 다른곳에서 new로 직점 생성하는 것을 방지
public class OrderItem {
4. 주문 기능 테스트
1) OrderServiceTest - Junit5 사용
- junit4 사용시에는 메서드에 관행적으로 throws Exception을 입력했지만 junit5에서는 더 많은 기능과 유연성을 제공하기 때문에 해당 관습을 덜 사용하는 경향으로 변했고, Assertion.assertThrows나 Assertions.assertThatThrownBy를 통해 명시적으로 예외 테스트를 함
더보기
JUnit4에서 throws Exception을 사용했던 이유 - ChatGpt-4.o 답변
- 예외 발생을 처리하기 위해:
JUnit 4의 테스트 메서드는 특정 예외가 발생할 가능성이 있는 코드를 포함할 수 있습니다. 테스트 중에 예외가 발생할 수 있는 다양한 상황을 처리할 때, throws Exception을 붙이면 개별적으로 try-catch 블록을 쓰지 않고 간단히 예외를 상위로 던질 수 있었습니다. - 테스트 코드 단순화:
모든 예외를 명시적으로 처리하지 않고 테스트 메서드에서 한 번에 처리할 수 있게 해주므로, 코드가 더 깔끔하게 보이는 장점이 있었습니다. - 레거시 코드와의 호환성:
초기에는 레거시 코드를 테스트할 때 예외를 많이 던지던 시절이 있었고, 많은 테스트 코드에서 일관되게 throws Exception을 사용하면서 예외를 묵시적으로 처리할 수 있게 하려는 관습이 생겼습니다.
(1) createBook(), createMember()
- 테스트를 위한 객체를 생성하는 메서드를 별도로 생성하여 각 테스트마다 재사용
(2) 통합 테스트
- 상품주문() : 정상 주문을 실행하여 주문상태와 주문 수량, 총 가격, 줄어든 재고 등을 검증
- 재고수량초과() : 재고 수량보다 더 많은 수량을 주문했을 시 만들어둔 NotEnoughStockException이 터지는지 검증
- 주문취소() : 주문 후 취소하여 주문상태와 재고가 다시 원복이 되었는지를 검증
** 참고사항
- 실무에서는 이것보다 훨씬 더 꼼꼼하게 테스트를 하며 단위 테스트를 하는 것이 좋음
- 재고수량초과에서 터지는 예외를 검증하는 것보다 실제 수량을 감소시키는 removeStock에 대한 단위테스트를 시행하는 등의 별도의 비즈니스 메서드에 대한 꼼꼼한 검증을 하는 것이 좋음
- domain 모델을 사용하면 엔터티에 비즈니스 로직이 들어있어 db와 관계없이 순수하게 로직의 단위테스트 작성을 할 수 있음
package jpabook.jpashop.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Transactional
class OrderServiceTest {
@Autowired EntityManager em;
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepository;
@Test
void 상품주문() {
// given
Member member = createMember();
Book book = createBook("테스트 북", 10000, 10);
int orderCount = 2;
// when
Long orderId = orderService.order(member.getId(), book.getId(), orderCount);
// then
Order getOrder = orderRepository.findOne(orderId);
assertEquals(OrderStatus.ORDER, getOrder.getStatus(), "상품 주문시 상태는 ORDER");
assertEquals(1, getOrder.getOrderItems().size(), "주문한 상품 종류 수가 정확해야 함");
assertEquals(10000 * orderCount, getOrder.getTotalPrice(), "주문 가격은 가격 * 수량임");
assertEquals(8, book.getStockQuantity(), "주문 수량만큼 재고가 줄어야 함");
}
@Test
void 재고수량초과() {
// given
Member member = createMember();
Book book = createBook("테스트 북", 10000, 10);
// when
int orderCount = 11; // 실제 재고보다 주문 수량이 많음
// then
assertThatThrownBy(() -> orderService.order(member.getId(), book.getId(), orderCount))
.isInstanceOf(NotEnoughStockException.class);
}
@Test
void 주문취소() {
// given
Member member = createMember();
Book book = createBook("테스트북", 10000, 10);
int orderCount = 2;
Long orderId = orderService.order(member.getId(), book.getId(), orderCount);
// when
orderService.cancelOrder(orderId);
// then
Order getOrder = orderRepository.findOne(orderId);
// junit - Assertions 사용
assertEquals(OrderStatus.CANCEL, getOrder.getStatus(), "주문 취소시 상태는 CANCLE");
// assertj - Assertions 사용
assertThat(book.getStockQuantity()).isEqualTo(10).as("주문이 취소된 상품은 그만큼 재고가 증가해야 함");
}
// Book 재고 생성
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;
}
}
5. 주문 검색 기능 개발
- 전체를 조회할 때 조건에 따라 검색이 되도록 JPA 동적쿼리를 해결
(1) OrderSearch 추가
- 동적쿼리를 구현하기위해서 검색조건을 파라미터로 넘기기 위한 클래스 생성
package jpabook.jpashop.repository;
@Getter @Setter
public class OrderSearch {
private String memberName; // 회원 이름
private OrderStatus orderStatus; // 주문 상태
}
(2) JPQL로 해결 - 동적쿼리를 JPQL로 해결하는 것은 번거로움
- 번거롭고 복잡함
- 실수로 인한 버그가 충분히 발생할 수 있음(특히 공백)
- JPQL을 직접 동적으로 만드는 것은 JDBC에서 SQL을 직접 작성하는 것과 다를바가 없음
// jpql로 해결 - 실무에서 사용안함, 너무 복잡하고 버그가 발생할 확률이 너무 높음
public List<Order> findAllByString(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";
}
// 최대 1000건
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();
}
(3) JPA Criteria로 처리 - 실무에서 사용하라고 만든것이 아닌것같은 JPA 표준.. 안씀
- JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡함
- 동적 쿼리를 자바코드로 표현하여 타입체크도 되는 부분이 조금더 수월하긴 하지만, 유지보수에서 최악의 모습을 보여주는데 이 코드를 보고 어떤 쿼리가 진행되는지 떠오르기가 매우 어려움
// JPA Criteria - JPA 표준으로 동적쿼리를 작성, 하지만 이마저도 번거로워서 Querydsl을 활용하는것이 좋음
public List<Order> findAllByCriteria(OrderSearch orderSearch) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> orderRoot = cq.from(Order.class);
Join<Order, Member> m = orderRoot.join("member", JoinType.INNER); // 회원과 조인
List<Predicate> criteria = new ArrayList<>();
// 주문 상태 검색
if (orderSearch.getOrderStatus() != null) {
Predicate status = cb.equal(orderRoot.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();
}
(4) 실질적인 대안은 Querydsl - JPA 동적 쿼리의 해결책
- JPA에서 동적쿼리에 대한 부분은 Querydsl로 사용함 - 이후 강의에서 코드 리펙토링으로 Querydsl로 변경하고, Querydsl에 대한 자세한 강의도 따로 다룰 예정
- 실무에서 JPA를 사용할 때 스프링 데이터 JPA, Qeurydsl은 세트로 사용한다고 봐도 무방함
- 자바코드로 구성하기 때문에 타입체크도 되고, 체인형식으로 작성하기에 가독성도 매우 좋음
- querydsl의 간단한 사용법 및 소개