공부/JPA

애플리케이션 구현(도메인 개발)

Stair 2024. 11. 21. 13:24
반응형

구현 전 앞서 만든 예제를 통해 도메인 분석과 설계를 하여 구현 준비를 한다

https://surrealcode.tistory.com/97

 

도메인 분석 설계

JPA 프로그래밍의 이론을 알기 전 실전 코딩을 통해 직접 코드를 만들어보고, 기본 학습을 다시 다루어 볼 예정이다. 목차는 다음과 같다. 목차1. 요구사항 분석2. 도메인 모델과 테이블 설계3. 엔

surrealcode.tistory.com

 만들어볼 UI는 다음과 같다.

º 회원 기능

  - 회원 등록

  - 회원 조회

º 상품 기능

  - 상품 등록

  - 상품 수정

  - 상품 조회

º 주문 기능

  - 상품 주문

  - 주문 내역 조회

  - 주문 취소

 

---예제를 단순화 하기 위해 다음 기능은 구현하지 않았다.---

1. 로그인과 권한 관리

2. 파라미터 검증과 예외 처리 단순화

3. 상품은 도서만 사용

4. 카테고리는 사용X

5. 배송 정보는 사용X

 

 

애플리케이션 아키텍쳐

애플리케이션 아키텍쳐는 다음과 같다.

 

*계층형 구조 사용*

- controller, web : 웹 계층

- service : 비즈니스 로직, 트랜잭션 처리

- repository : JPA를 직접 사용하는 계층, 엔티티 매니저 사용

- domain : 엔티티가 모여있는 계층, 모든 계층에서 사용

 

*패키지 구조*

- jpabook.jpashop

  - domain

  - exception

  - repository

  - service

  - web

 

개발 순서 : 서비스, 리포지토리 계층을 개발하고, 테스트 케이스를 작성해서 검증, 마지막에 웹 계층 적용

 

 

Member에 대한 Repository는 다음과 같다.

@Repository
@RequiredArgsConstructor //lombok
public class MemberRepository {

    private final 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();
    }
}

 

MemberService는 다음과 같다.

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

    private final MemberRepository memberRepository;

    /**
     * 회원 가입
     */
    @Transactional //얘가 우선권을 가지고 수정 가능해짐
    public Long join(Member member) {
        validateDuplicateMember(member);
        memberRepository.save(member);
        return member.getId();
    }

    private void validateDuplicateMember(Member member) {
        //EXCEPTION
        List<Member> findMembers = memberRepository.findByName(member.getName());
        if (!findMembers.isEmpty()) {
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        }
    }

    //회원 전체 조회
    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

    public Member findOne(Long memberId) {
        return memberRepository.findOne(memberId);
    }
}

@Transactional(readOnly=true)로 주게 되면 조회만 가능하게 구성할 수 있다. 하지만 join부분은 수정이 필요하기 때문에 join 부분엔 따로 @Transactional을 걸어주었다.

이렇게 되면 join 부분의 @Transactional이 우선권을 가지고 수정을 가능하게 한다.

 

 

다음은 상품 부분이다.

상품을 개발하는 순서는 다음과 같다.

상품 엔티티 개발(비즈니스 로직 추가) -> 상품 리포지토리 개발 -> 상품 서비스 개발

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

    //==비즈니스 로직==//

    /**
     * stock 증가
     */
    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;
    }
}

우선 아이템에 비즈니스 로직을 추가해주었다. stock의 재고 관리를 해주는 로직이다.

notEnoughStockException이라는 예외처리 클래스도 만들었는데, RuntimeException을 상속받아 구현한다.

위처럼 stockQuantity를 변경해야 할 일이 있으면 핵심 비즈니스 로직을 가지고 변경을 해야 한다.(이 안에서 더하고 빼고를 수행하도록 한다)

package jpabook.jpashop.domain.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);
    }

    protected NotEnoughStockException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

 

다음은 ItemRepository이다. 이 Repository는 Item을 저장하고 한개만 조회하고, 전체를 조회할 수 있는 기능을 가지고 있다.

@Repository
@RequiredArgsConstructor
public class ItemRepository {

    private final EntityManager em;

    public void save(Item item) {
        if (item.getId() == null) {
            em.persist(item); //insert
        }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 i", Item.class).getResultList();
    }
}

persist를 보자 item.getId()가 Null이면(DB에 값이 저장된 게 없다면) em.persist(item)을 통해 DB에 insert를 해준다.

만약 Null이 아니라면(DB에 값이 저장된 게 존재한다면) em.merge(item)을 통해 업데이트를 쳐준다(정확하진 않지만 대략 이런 식으로 이해하자)

 

다음은 Service 부분이다.

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


}

Repository의 기능을 그대로 가져다 쓴다. 큰 로직이 별로 없다.

 

 

다음은 제일 중요한 주문 도메인 개발이다.

주문 도메닝에 비즈니스 로직을이 얽혀서 돌아가는걸 JPA나 엔티티를 가지고 어떻게 풀어내는지 확인할 수 있다.

 

*구현 기능*

  - 상품 주문

  - 주문 내역 조회

  - 주문 취소

 

*순서*

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

  - 주문 리포지토리 개발

  - 주문 서비스 개발

  - 주문 검색 기능 개발

  - 주문 기능 테스트

 

 개발하는 순서는 위 개발들과 똑같지만, 도메인 모델 패턴을 적용하였기 때문에 주문 관련한 로직을 Order와 OrderItem에 추가해 준 후 Repository를 개발한다.

그 후 Service를 개발하여 완성한다.

@Entity
@Getter @Setter
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

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


    private LocalDateTime orderDate;

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

    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) : 주문 취소시 사용한다. 주문 상태를 취소로 변경하고 주문상품에 주문취소를 알린다. 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.

전체 주문 가격 조회 : 주문 시 사용한 전체 주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야한다. 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다.

 

@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED) //생성자 protected로 제한
public class OrderItem {

    @Id
    @GeneratedValue
    @Column(name = "order_id_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); //주문을 하면 재고가 사라져야함
        return orderItem;
    }



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

    //==조회 로직==//
    /**
     * 주문상품 전체 가격 조회
     */
    public int getTotalPrice() {
        return getOrderPrice() * getCount();
    }
}

생성 메서드(createOrderItem) : 주문 상품, 가격, 수량 정보를 사용해서 주문상품 엔티티를 생성한다. 그리고 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);
    }

주문 리포지토리에는 주문 엔티티를 저장하고 검색하는 기능이 있다.

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

    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);
        //주문 취소
        order.cancel();
    }

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

 

주문 서비스는 주문 엔티티와 주문 상품 엔티티의 비즈니스 로직을 활용하여 주문, 주문 취소, 주문 내역 검색 기능을 제공한다.

 

주문(order) : 주문하는 회원 식별자, 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔티티를 생성한 후 저장한다.

주문 취소(cancelOrder) : 주문 식별자를 받아서 주문 엔티티를 조회한 후 주문 엔티티에 주문 취소를 요청한다.

 

 

*참고* 

주문서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직이 대부분 엔티티에 있다. 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다. 이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴이라 한다. 반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패이라 한다.

 

 

 

위 코드를 검증하는 테스트 코드는 다음과 같이 구성하였다.

1. 상품 주문이 성공해야 한다.

2. 상품을 주문할 때 재고 수량을 초과하면 안된다.(Exception이 터져야 한다.)

3. 주문 취소가 성공해야 한다.

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderSerivceTest {

    @Autowired
    EntityManager em;
    @Autowired
    OrderSerivce orderSerivce;
    @Autowired
    OrderRepository orderRepository;

    @Test
    public void 상품주문() throws Exception{
        //given
        Member member = createMember();

        Book book = createBook("JPA", 10000, 10);

        //when
        int orderCount = 2;
        Long orderId = orderSerivce.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();
        Item item = createBook("JPA", 10000, 10);

        int orderCount = 11;

        //when
        orderSerivce.order(member.getId(), item.getId(), orderCount);

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

    @Test
    public void 주문취소() throws Exception{
        //given
        Member member = createMember();
        Book item = createBook("JPA",10000,10);

        int orderCount = 2;
        Long orderId = orderSerivce.order(member.getId(), item.getId(), orderCount);

        //when
        orderSerivce.cancelOrder(orderId);

        //then
        Order getOrder = orderRepository.findOne(orderId);

        assertEquals("주문 취소시 상태는 CANCEL이다", OrderStatus.CANCEL, getOrder.getStatus());
        assertEquals("주문이 취소된 상품은 그만큼 재고가 증가해야 한다.", 10, item.getStockQuantity());
    }


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

    @NotNull
    private Member createMember() {
        Member member = new Member();
        member.setName("user1");
        member.setAddress(new Address("seoul", "riverside", "123-123"));
        em.persist(member);
        return member;
    }
}

 

반응형

'공부 > JPA' 카테고리의 다른 글

JPA 영속성 관리  (1) 2024.11.28
JPA 설정하기  (0) 2024.11.26
JPA란?  (0) 2024.11.25
도메인 분석 설계  (2) 2024.11.18
프로젝트 환경 설정  (1) 2024.11.17