실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 강의 - 인프런
실무에 가까운 예제로, 스프링 부트와 JPA를 활용해서 웹 애플리케이션을 설계하고 개발합니다. 이 과정을 통해 스프링 부트와 JPA를 실무에서 어떻게 활용해야 하는지 이해할 수 있습니다., 스프
www.inflearn.com
해당 강의는 Inflearn에 등록된 김영한님의 Spring Boot 강의입니다.
이번 시간에는 주문 도메인의 리포지토리,서비스(회원가입,조회) 작성하며 실제 JPA에서의 비즈니스 로직을 공부해보자
주문 도메인 개발은 새로운 테스트 방법과 새로운 개발 방식이 있기에 중요하다고 볼 수 있다. 동적 쿼리나 수량 연산과 같이 지금까지와는 다르게 조금 머리를 쓰는 개발이므로 실수들이 많이 나올 것으로 보인다.
1. 주문, 주문상품 엔티티 개발
Order
엔티티 개발(주문 생성, 주문 취소, 젠체 주문 가격 조회 코드 추가)
/**
*Order 생성 메서드
*/
public static Order createOrder(Member member,Delivery delivery,OrderItem...orderItems)
{ //★★★ OrderItem...orderItems -> 이렇게...으로 선언하면 여러개 값을 받을 수 있다.
Order order=new Order();
//1. 회원 / 2. 배송 / 3. 주문상품들 / 4. 주문상태 / 5.주문 시각
order.setMember(member); //회원
order.setDelivery(delivery); //배송
for(OrderItem orderItem: orderItems) //주문 상품들
{
order.addOrderItem(orderItem);
}
order.setStatus(OrderStatus.ORDER); //주문 상태
order.setOrderDate(LocalDateTime.now()); //주문 시각
return order; //위 5가지 setting 값을 반환
}
//★★★주문 핵심비즈니스 로직(주문취소,전체 주문 가격 조회)★★★
//비즈니스 로직
/**
* 주문 취소메서드
*/
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()
: 주문 취소시 사용한다. 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린다. 만 약 이미 배송을 완료한상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.
getTotalPrice
: 주문 시 사용한 전체 주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야 한다. 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다.
(실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화 한다.
OrderItem
엔티티에 다음과 같은 코드 추가
/**
*OrderItem 생성 메서드
*/
public static OrderItem createOrderItem(Item item,int orderPrice,int count)
{
OrderItem orderItem=new OrderItem();
//1.아이템 설정 / 2. 아이템에 대한 가격 설정 / 3. 아이템에 대한 개수 설정
orderItem.setItem(item);
orderItem.setOrderPrice(orderPrice);
orderItem.setCount(count);
item.removeStock(count);//★★일단 오더 아이템으로 넘어오면 주문한 개수만큼 전체 수량 감소★★
return orderItem;
}
/** ★★비즈니스 로직 추가★★
*주문취소
*/
public void cancel() {
getItem().addStock(count);//취소 시 재고수량 증가
}
/** ★★조회 로직 추가★★
*주문상품 전체 가격 조회
*/
//주문시 주문가격* 주문수량 =total가격이므로 orderItem에서 가격을 반환
public int getTotalPrice() {
return getOrderPrice()*getCount();
}
기능 설명
createOrderItem()
: 주문 상품, 가격, 수량 정보를 사용해서 주문상품 엔티티를 생성한다. 주문상품은 주문시 재고에서 깎이므로 주문한 개수만큼 재고를 감소했다.
cancel()
: 주문상품취소로 인해 재고수량 증가
getTotalPrice()
: 주문 아이템 각각의 주문아이템가격 *주문아이템수량 만큼으로 반환
2. 주문 리포지토리 개발
@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);
}
/**
* 주문 검색 기능 (추후 생성)
*/
}
주문 등록, 단건 조회만 생성하자. 주문 검색 기능에서 동적쿼리 사용을 예로 들어 개발한다.
3. 주문 서비스 개발
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final MemberRepository memberRepository;
private final ItemRepository itemRepository;
/**
* 주문 서비스의 구현할 기능
* 1. 상품 주문
* 2. 주문 내역 취소
* 3. 주문 내역 조회
*/
//1. 주문 (사용자 입력: 회원 아이디, 아이템 아이디, 주문 수량 입력)
@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.createOrder(member,delivery,orderItem); //회원,배송정보,주문 상품을 갖는 주문 생성
//주문 저장
orderRepository.save(order);
//저장한 주문을 객체로 반환
return order.getId();
}
//2. 주문 내역 취소
//취소시 주문 아이디로만 넘겨받아서 취소함
@Transactional
public void cancelOrder(Long orderId){
//주문 엔티티 조회
Order order= orderRepository.findOne(orderId);
//주문 취소
order.cancel();
}
1. 주문 기능 메서드 생성 (저장한 order엔티티의 id를 반환)
-> 회원id, 아이템id, 주문수량 입력받으면 상품 주문
- 위의 id 파라미터로 회원과 아이템에 대한 객체를 조회함.
- 주문 생성-> 회원객체, 배송객체, 주문 상품 객체 입력
- ㄴ배송객체: 회원의 주소로 입력
- ㄴ주문상품 객체: 상품객체,상품 가격,주문수량 입력
2. 주문 취소 기능 메서드 생성(cancelOrder… void)
-> orderId를 입력 받음.
- 주문 객체를 리포지토리에서 조회하여 주문 식별자(id)를 받음.
- 주문 객체에서 취소 메서드(order.cancel) 실행
- order.cancel()은 주문 상태를 “CANCEL”로 변경하고, 주문의 모든 주문 아이템 각각에 대하여 취소함. 이를 위해서 해당 주문 아이템은 OrderItem객체에서 취소 처리하는 로직으로 처리
- OrderItem()은 해당 아이템의 재고수량을 증가하기만해주는 로직 실행.
4. 주문기능 테스트
@RunWith(SpringRunner.class) //junit을 spring과 실행
@SpringBootTest //스프링 부트 컨테이너에서 테스트(autowired)
@Transactional //트랜잭션
public class OrderServiceTest {
@Autowired EntityManager em;
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepository;
//3가지 테스트: 상품주문,상품주문_재고수량초과,주문취소
@Test
public void 상품주문()throws Exception{
//given
//1. 회원,2. 아이템,3. 주문수량 -> 3가지 입력
//회원 객체 생성
Member member = createMember();
//아이템(상품) 객체 생성
Book book = createBook("시골 JPA",10000,10);
//주문수량
int orderCount=2;
//when
//주문한 주문번호 ,그걸로 리포지토리에서 검색하여 찾은 주문 객체
Long orderId =orderService.order(member.getId(), book.getId(), orderCount);
Order getOrder= orderRepository.findOne(orderId);
//then(검증)
//1. 주문 상태, 2.상품 종류수 검증 3. 총 가격 (기대,데베에서 뽑은 실제값)
assertEquals("상품 주문시 상태는 ORDER", OrderStatus.ORDER , getOrder.getStatus());
assertEquals("주문한 상품 종류 수가 정확해야 한다.",1 , getOrder.getOrderItems().size());
assertEquals("주문 가격은 가격*수량이다.", 10000*orderCount , getOrder.getTotalPrice());
assertEquals("주문 수량만큼 재고가 줄어야한다.", 10-orderCount,book.getStockQuantity());
}
//Item의 메서드 중 removeStock(int qunantity) 메서드 검증 테스트
@Test(expected = NotEnoughStockException.class)
public void 상품주문_재고수량초과()throws Exception{
//given
Member member=createMember();
Item item=createBook("시골 JPA",10000,10);
int orderCount=11;//재고수량보다 더 많으면?
//when
//주문 메서드에서 Order객체의 createOrder메서드 실행
// -> OrderItem에서 createOrderItem 실행-> Item의 removeStock메서드 실행
// -> Exception발생 -> 이 메서드는expected = NotEnoughStockException.class이므로 예외 발생시 테스트 성공
orderService.order(member.getId(),item.getId(),orderCount);
//then(테스트 실패에 대한처리)
fail("재고수량 예외가 발생해야 한다.");
}
@Test
public void 주문취소()throws Exception{
//given
Member member=createMember();
Book item=createBook("JPA 활용1",10000,10);
int orderCount=2; //주문 수
Long orderId = orderService.order(member.getId(), item.getId(), orderCount); //취소할 주문 id
//when(주문 취소를 하면?)
orderService.cancelOrder(orderId);
//then(취소당한 주문의 상태가 맞는지, 취소 실행 후 재고수량이 다시 10개로 복구 되었는지 검증)
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("서울","낙성대역 8길","58"));
em.persist(member);
return member;
}
}
각 테스트는 주석으로 단계별로 자세히 설명했다.
테스트 결과
4.1 테스트 실패작 모음
위와 같이 결과가 성공에 나오면 좋았겠지만 `상품 주문_재고수량초과` 테스트에서 1시간 동안 헤맸다.
테스트 실패 결과
위의 이미지를 보면 62번째 줄인 order 메서드 호출시 InvaliDataException이 나왔다.
InvaliDataException 은 주로 데이터 처리 과정에서 유효하지 않은 데이터에 대한 연산을 시도했거나, 데이터 형식이 기대하는 스펙과 맞지 않을 때 나타나는 예외이다.
저번 웹 MVC 포스팅때 `Member`객체의 `id`를 Long이아닌 long으로 쓰면 안된다고 했다. 정확히 자료형을 명시하지 않으면 저렇게 오류가 난다. 이 오류를 찾느라 30분은 소요했다. 처음보는 오류였기에 그랬던 것 같기도 하다.
(다른 포스팅이나 글에 엔티티의 id는 모두 Long형으로 명시 했으니 안심하셔도 됩니다.)
5. 주문 검색 기능 개발
다음과 같이 주문 상태별로 조회 기능이 있다. 그렇다는 의미는 검색 기능에 조건
이 추가된다는 의미이다. 이러한 조건
을 두고 조회하는 형식을 동적 쿼리
라고 한다.
하지만 추후 QueryDsl
로 이 동적쿼리를 훨씬 간단하게 해결할 예정이므로 이번에는 그냥 코드만 보면 된다.
JPA에서의 동적퀴리(조회를 위해) 활용(주문리포지토리에서 회원명과 주문상태 두개로 검색)
검색 조건 파라미터 OrderSearch
를 repository
에 추가
@Getter @Setter
public class OrderSearch {
//주문 검색...회원명, 회원상태를 통해 검색
private String memberName;
private OrderStatus orderStatus;
}
OrderRepository
에 아래와 같은 코드 추가
/**
* 주문 검색 기능
* Criteria API(JPA 표준)를 이용하여 동적으로 쿼리를 생성함.
*/
// 동적 쿼리(조건이 있는 경우)로 조회하는 메서드....JPA활용2편에서 queryDsl로 다시 작성함
public List<Order> findAllByCriteria(OrderSearch orderSearch) {
// CriteriaBuilder를 생성합니다.
CriteriaBuilder cb = em.getCriteriaBuilder();
// CriteriaQuery를 생성하고 반환타입을 Order.class로 지정합니다.
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
// 시작점을 설정합니다. Order 엔티티를 대상으로 쿼리를 시작
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를 생성하여 criteria 리스트에 추가합니다.
Predicate status = cb.equal(o.get("status"), orderSearch.getOrderStatus());
criteria.add(status); //상태 추가
}
// 회원 이름 검색 조건이 주어진 경우에 대한 처리입니다.
if (StringUtils.hasText(orderSearch.getMemberName())) {
// 회원 이름에 대한 Predicate를 생성하여 criteria 리스트에 추가합니다.
Predicate name = cb.like(m.<String>get("name"), "%" + orderSearch.getMemberName() + "%");
criteria.add(name);
}
// 생성된 Predicate들을 AND 조건으로 연결하여 where 절을 구성합니다.
cq.where(cb.and(criteria.toArray(new Predicate[criteria.size()])));
// TypedQuery를 생성하고 최대 결과 수를 1000건으로 설정합니다.
TypedQuery<Order> query = em.createQuery(cq).setMaxResults(1000);
// 쿼리를 실행하고 결과를 반환합니다.
return query.getResultList();
}
JPA Criteria는 JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡하다. 결국 다른 대안이 필요하다. 많은 개발자가 비슷한 고민을 했지만, 가장 멋진 해결책은 Querydsl이 제시했다.
'Spring Boot > Spring Boot JPA-활용편1 강의 정리' 카테고리의 다른 글
[SpringBoot-JPA 활용편1] 자바 ORM 표준 JPA 프로그래밍 - 활용편1: 7. 웹 계층 개발 (2) | 2024.02.27 |
---|---|
[SpringBoot-JPA 활용편1] 자바 ORM 표준 JPA 프로그래밍 - 활용편1: 5. 상품 도메인 개발 (1) | 2024.02.26 |
[SpringBoot-JPA 활용편1] 자바 ORM 표준 JPA 프로그래밍 - 활용편1: 4. 회원 도메인 개발 (1) | 2024.02.26 |
[SpringBoot-JPA 활용편1] 자바 ORM 표준 JPA 프로그래밍 - 활용편1: 3. 애플리케이션 구현 준비 (0) | 2024.02.26 |
[SpringBoot-JPA 활용편1] 자바 ORM 표준 JPA 프로그래밍 - 활용편1: 2. 도메인 분석 설계 (1) | 2024.02.26 |