자바 ORM 표준 JPA 프로그래밍 - 웹 애플리케이션 제작(3)

Song Chae Won·2023년 8월 8일
0
post-thumbnail

📝 웹 애플리케이션 제작(3)

메인화면과 구현 기능

앞선 포스팅에서 요구사항을 분석해서 필요한 엔티티와 테이블 설계를 완료했다. 지금부터 실제 애플리케이션이 동작하도록 구현해보자!

🔻회원 기능

  • 회원 등록
  • 회원 목록 조회

🔻상품 기능

  • 상품 등록
  • 상품 목록 조회
  • 상품 수정

🔻 주문 기능

  • 상품 주문
  • 주문 내역 조회
  • 주문 취소

➕ 구현하지 않을 기능
: 비즈니스 로직을 단순화하기 위해 핵심 기능만 구현함!

  • 로그인과 권한 관리는 하지 않는다.
  • 파라미터 검증과 예외 처리는 하지 않는다.
  • 상품은 도서만 사용한다.
  • 카테고리는 사용하지 않는다.
  • 배송 정보는 사용하지 않는다.

11.3.1 개발 방법

계층형 구조를 사용하며

개발 순서는 비즈니스 로직을 수행하는 서비스와 리포지토리 계층을 먼저 개발하고 테스트 케이스를 작성해서 검증한 후, 검증을 완료하면 컨트롤러와 뷰를 개발하는 순서로 진행!

Controller
: MVC의 컨트롤러가 모여 있는 곳으로, 컨트롤러는 서비스 계층을 호출하고 결과를 뷰(JSP)에 전달
Service
: 서비스 계층에는 비즈니스 로직이 있고 트랜잭션을 시작하며, 서비스 계층은 데이터 접근 계층인 리포지토리를 호출
Repository
: JPA를 직접 사용하는 곳은 리포지토리 계층이며, 여기서 엔티티 매니저는 사용해서 엔티티를 저장하고 조회함
Domain
: 엔티티가 모여 있는 계층으로, 모든 계층에서 사용함

11.3.2 회원 기능

  • 회원 등록
  • 회원 조회

회원 기능 코드

1) 도메인 모델에서 설명한 회원 엔티티 코드

@Entity
public class Member {

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

    private String name;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<Order>();
    ...
}

2) 회원 엔티티를 저장하고 관리할 회원 리포지토리 코드

  • @Repository 어노테이션이 붙어 있으면 <context:component-scan>에 의해 스프링 빈으로 자동 등록
  • 또한 JPA 전용 에외가 발생하면 스프링이 추상화한 예외로 변환해줌
    예) 리포지토리 계층에서 JPA 예외인 NoResultException이 발생하면 스프링이 추상화한 예외인 EmptyResultDataAccessException으로 변환해서 서비스 계층에 반환
  • 따라서 서비스 계층은 JPA에 의존적인 예외를 처리하지 않아도 됨
@Repository
public class MemberRepository {

    @PersistenceUnit
    EntityManagerFactory emf; */
    
    @PersistenceContext // 엔티티 매니저 주입
    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();
    }

    // JPQL을 사용해서 이름(name)으로 회원 엔티티들을 조회
    public List<Member> findByName(String name) {
        return em.createQuery("select m from Member m where m.name = :name", Member.class)
                .setParameter("name", name)
                .getResultList();
    }
}

순수 자바 환경에서는 엔티티 매니저 팩토리에서 엔티티 매니저를 직접 생성해서 사용했지만, 스프링이나 J2EE 컨테이너를 사용하면 컨테이너가 엔티티 매니저를 관리하고 제공해줌. 따라서 엔티티 매니저 팩토리에서 엔티티 매니저를 직접 생성해서 사용하는 것이 아니라 @PersistenceContext 어노테이션으로 컨테이너가 제공하는 엔티티 매니저를 사용! 만약 엔티티 매니저 팩토리를 직접 주입받으려면 @PersistenceUnit 어노테이션을 사용

3) 회원과 관련된 비즈니스 로직이 있는 회원 서비스 코드

// @Service 어노테이션이 붙어 있는 클래스는 <context:conponent-scan>에 의해 스프링 빈으로 등록 
@Service
/* 스프링 프레임워크는 @Transcational 어노테이션이 붙어 있는 클래스나 메소드에 트랜잭션을 적용
   외부에서 이 클래스의 메소드를 호출할 때 트랜잭션을 시작하고 메소드를 종료할 때 트랜잭션을 커밋
   만약 예외가 발생하면 트랜잭션을 롤백 */
@Transactional
public class MemberService {

    // 스프링 컨테이너가 적절한 스프링 빈을 주입해줌
    @Autowired 
    MemberRepository memberRepository; // 여기서는 회원 리포지토리를 주입

    /* 회원 가입
       validateDuplicateMember()로 같은 이름을 가진 회원이 있는지 검증하고 
       검증을 완료하면 회원 리포짙리에 회원 저장을 요청
       만약 같은 이름을 가진 회원이 존재해서 검증에 실패하면 
       "이미 존재하는 회원입니다"라는 메시지를 가지는 예외 발생
       회원가입에 성공하면 생성된 회원 식별자를 반환 */
    public Long join(Member member) {

        validateDuplicateMember(member); // 중복 회원 검증
        memberRepository.save(member);
        return member.getId();
    }

    private void validateDuplicateMember(Member member) {
        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);
    }
}

🔻 회원 기능 테스트
개발한 회원 비즈니스 로직이 정상 동작하는 JUnit으로 테스트를 작성해서 검증해야함

➕ 회원 기능에서 검증해야 할 핵심 비즈니스 로직

  • 회원가입을 성공해야 한다.
  • 회원가입 할 때 같은 이름이 있으면 예외가 발생해야 한다.
/* 테스트를 스프링 프레임워크와 함께 실행하기 위해 스프링 프레임워크와 JUnit을 통합, 이렇게 하면 테스트가 스프링 컨테이너에서 실행되므로 스프링 프레임워크가 제공하는 @Autowired 같은 기능을 사용할 수 있음 */
@RunWith(SpringJUnit4ClassRunner.class)
/* 테스트 케이스를 실행할 때 사용할 스프링 설정 정보를 지정
   여기서는 설정 정보로 appConfig.xml을 지정
   참고로 웹과 관련된 정보는 필요하지 않으므로 webAppConfig.xml은 지정하지 않았음 */
@ContextConfiguration(locations = "classpath:appConfig.xml")
/* 테스트는 반복해서 실행할 수 있어야 하는데 
   회원가입 테스트를 실행하면 데이터베이스에 회원 데이터가 저장됨
   그리고 다시 테스트를 실행하면 이미 저장된 데이터 때문에 테스트를 실패할 수 있다.
   그러므로 @Transactional을 사용하면 각각의 테스트를 실행할 때마다 트랜잭션을 시작하고 
   테스트가 끝나면 트랜잭션을 강제로 롤백하기 때문에
   테스트를 진행하면서 데이터베이스에 저장한 데이터가 테스트가 끝나면 롤백되므로
   반복해서 테스트를 진행할 수 있음 */
@Transactional
public class MemberServiceTest {

    @Autowired MemberService memberService;
    @Autowired MemberRepository memberRepository;

    /* 회원 엔티티를 하나 생성하고 join() 메소드로 회원가입을 시도
       그리고 실제 회원이 저장되었는지 검증하기 위해 
       리포지토리에서 회원 id로 회원을 찾아서 저장한 회원과 같은지 asserEquals로 검증 */ 
    @Test
    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이 발생해야 테스트가 성공함
       이름이 kim인 같은 회원이 두 명 가입하는데, 
       두 번째 회원가입 시에 회원가입 검증 로직에서 IllegalStateException이 발생
       따라서 마지막의 fail()은 호출되지 않게 되며
       만약 fail()을 호출하거나 IllegalStateException 예외가 발생하지 않으면 테스트는 실패하게 됨 */
    @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("예외가 발생해야 한다.");
    }
    
}

11.3.3 상품 기능

  • 상품 등록
  • 상품 수정
  • 상품 목록 조회

상품 기능 코드

1) 도메인 모델에서 설명한 상품 엔티티 코드

  • 재고 관련 비즈니스 로직을 처리하는 메소드도 존재!
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
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>();

    //==비즈니스 로직==//
    /* 파라미터로 넘어온 수만큼 재고를 늘림
       재고가 증가하거나 상품 주문을 취소해서 재고를 다시 늘려야 할 때 사용 */
    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;
    }
    ...
}

2) 상품 엔티티를 저장하고 관리할 상품 리포지토리 코드

@Repository
public class ItemRepository {

    @PersistenceContext
    EntityManager em;

    /* 저장과 수정(병합)을 다 처리함
       식별자 값이 없으면 새로운 엔티티를 판단해서 persist()로 영속화하고
       만약 식별자 값이 있으면 이미 한 번 영속화 되었던 엔티티로 판단해서 merge()로 수정(병합)
       결국 여기서의 저장이라는 의미는 신규 데이터를 저장하는 것뿐만 아니라
       변경된 데이터의 저장이라는 의미도 포함하므로 
       이 메소드를 사용하는 클라이언트는 저장과 수정을 구분하지 않아도 되어 클라이언트의 로직이 단순해짐
       여기서 사용하는 수정(병합)은 준영속 상태의 엔티티를 수정할 때 사용하며
       영속 상태의 엔티티는 변경 감지 기능이 동작해서 트랜잭션을 커밋할 때 자동으로 수정되므로
       별도의 수정 메소드를 호출할 필요가 없음 */
    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();
    }
}

3) 상품 리포지토리에 위임만 하는 단순한 상품 서비스 코드

@Service
@Transactional
public class ItemService {

    @Autowired
    ItemRepository itemRepository;

    public void saveItem(Item item) {
        itemRepository.save(item);
    }

    public List<Item> findItems() {
        return itemRepository.findAll();
    }

    public Item findOne(Long itemId) {
        return itemRepository.findOne(itemId);
    }
}

11.3.4 주문 기능

  • 상품 주문
  • 주문 내역 조회
  • 주문 취소

지금까지 살펴본 회원과 상품 기능은 단순한 CRUD가 대부분이지만 주문에는 의미 있는 비즈니스 로직이 존재한다!

주문 기능 코드
주문 기능을 분석하려면 주문 엔티티와 주문상품 엔티티를 함께 살펴봐야 한다

1) 도메인 모델에서 설명한 주문 엔티티 코드

@Entity
@Table(name = "ORDERS")
public class Order {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "MEMBER_ID")
    private Member member; // 주문 회원

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

    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "DELIVERY_ID")
    private Delivery delivery; // 배송정보

    private Date orderDate; // 주문시간

    @Enumerated(EnumType.STRING)
    private OrderStatus status; // 주문상태

    //==생성 메서드==//
    /* 주문 엔티티를 생성할 때 사용
       주문 회원, 배송정보, 주문상품의 정보를 받아서 실제 주문 엔티티를 생성 */
    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(new Date());
        return order;
    }

    //==비즈니스 로직==//
    /* 주문 취소 
       주문 취소 시 사용
       주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알림
       만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킴 */
    public void cancel() {

        if (delivery.getStatus() == DeliveryStatus.COMP) {
            throw new RuntimeException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }

        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;
    }
    
    ...
}

2) 도메인 모델에서 설명한 주문상품 엔티티 코드

@Entity
@Table(name = "ORDER_ITEM")
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; // 주문 수량

    //==생성 메서드==//
    /* 주문 상품, 가격, 수량 정보를 사용해서 주문 상품 엔티티를 생성
       그리고 item.removeStock(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;
    }

    //==비즈니스 로직==//
    /* 주문 취소 
       getItem().addStock(count)를 호출해서 취소한 주문 수량만큼 상품의 재고를 증가시킴 */
    public void cancel() {
        getItem().addStock(count);
    }

    //==조회 로직==//
    /* 주문상품 전체 가격 조회 
       주문 가격에 수량을 곱한 값을 반환 */
    public int getTotalPrice() {
        return getOrderPrice() * getCount();
    }
    ...
}

3) 주문 엔티티를 저장, 검색하고 관리할 주문 리포지토리 코드

@Repository
public class OrderRepository {

    @PersistenceContext
    EntityManager em;

    public void save(Order order) {
        em.persist(order);
    }

    public Order findOne(Long id) {
        return em.find(Order.class, id);
    }

    /* 화면에서 회원 이름과 주문 상태를 선택하고 검색 버튼을 클릭하면 
       OrderSearch 객체를 통해 검색 조건이 주문 리포지토리의 findAll() 메소드로 전달되며,
       findAll() 메소드는 이 검색 조건을 활용해서 주문 상품을 검색 
       findAll() 메소드는 검색 조건에 따라 Criteria를 동적으로 생성해서 주문 엔티티를 조회 */
    public List<Order> findAll(OrderSearch orderSearch) { // 주문 검색 기능

        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Order> cq = cb.createQuery(Order.class);
        Root<Order> o = cq.from(Order.class);

        List<Predicate> criteria = new ArrayList<Predicate>();

        // 주문 상태 검색
        if (orderSearch.getOrderStatus() != null) {
            Predicate status = cb.equal(o.get("status"), orderSearch.getOrderStatus());
            criteria.add(status);
        }
        // 회원 이름 검색
        if (StringUtils.hasText(orderSearch.getMemberName())) {
            Join<Order, Member> m = o.join("member", JoinType.INNER); // 회원과 조인
            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) 주문과 관련된 비즈니스 로직이 있는 회원 서비스 코드

  • 주문 엔티티와 주문상품 엔티티의 비즈니스 로직을 활용해서 주문, 주문 취소, 주문 내역 검색 기능을 제공
@Service
@Transactional
public class OrderService {

    @Autowired MemberRepository memberRepository;
    @Autowired OrderRepository orderRepository;
    @Autowired ItemService itemService;

    /* 주문 
       주문하는 회원 식별자, 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔티티를 생성한 후 저장 */
    public Long order(Long memberId, Long itemId, int count) {

        // 엔티티 조회
        Member member = memberRepository.findOne(memberId);
        Item item = itemService.findOne(itemId);
 
        // 배송정보 생성
        Delivery delivery = new Delivery(member.getAddress());
        // 주문상품 생성
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
        // 주문 생성
        Order order = Order.createOrder(member, delivery, orderItem);

        // 주문 저장
        orderRepository.save(order);
        return order.getId();
    }


    /* 주문 취소 
       주문 식별자를 받아서 주문 엔티티를 조회한 후 주문 엔티티에 주문 취소를 요청 */
    public void cancelOrder(Long orderId) {

        // 주문 엔티티 조회
        Order order = orderRepository.findOne(orderId);

        // 주문 취소
        order.cancel();
    }

    /* 주문 검색
       검색 조건을 가진 객체로 주문 엔티티를 검색 */
    public List<Order> findOrders(OrderSearch orderSearch) {
        return orderRepository.findAll(orderSearch);
    }

}

5) 주문 검색 기능
회원 이름과 주문 상태를 검색 조건으로 주문 내역을 검색하는 기능

/* 화면에서 회원 이름과 주문 상태를 선택하고 검색 버튼을 클릭하면 
   OrderSearch 객체를 통해 검색 조건이 주문 리포지토리의 findAll() 메소드로 전달되며,
   findAll() 메소드는 이 검색 조건을 활용해서 주문 상품을 검색 */
public class OrderSearch {

    private String memberName; // 회원 이름
    private OrderStatus orderStatus; // 주문 상태

    // Getter, Setter
    public String getMemberName() {
        return memberName;
    }

    public void setMemberName(String memberName) {
        this.memberName = memberName;
    }

    public OrderStatus getOrderStatus() {
        return orderStatus;
    }

    public void setOrderStatus(OrderStatus orderStatus) {
        this.orderStatus = orderStatus;
    }
}

주문 기능 테스트

➕ 주문 기능에서 검증해야 할 핵심 비즈니스 로직

  • 상품 주문이 성공해야 한다.
  • 상품을 주문할 때 재고 수량을 초과하면 안 된다.
  • 주문 취소가 성공해야 한다.
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:appConfig.xml")
@Transactional
// @TransactionConfiguration(defaultRollback = false)
public class OrderServiceTest {

    @PersistenceContext
    EntityManager em;

    @Autowired OrderService orderService;
    @Autowired OrderRepository orderRepository;

    /* 상품 주문 테스트
       Given 절에서 테스트를 위한 회원과 상품을 만들고
       When 절에서 실제 상품을 주문하고
       Then 절에서 주문 가격이 올바른지, 주문 후 재고 수량이 정확히 줄었는지 검증 */
    @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("주문 수량만큼 재고가 줄어야 한다.", 8, item.getStockQuantity());
    }

    /* 재고 수량 초과 테스트 
       재고 수량을 초과해서 상품을 주문하면 NotEnoughStockException 예외가 발생해야 함 */
    @Test(expected = NotEnoughStockException.class)
    public void 상품주문_재고수량초과() throws Exception {

        // Given
        Member member = createMember();
        Item item = createBook("시골 JPA", 10000, 10); // 이름, 가격, 재고

        int orderCount = 11; // 재고 보다 많은 수량 (재고 : 10개, 주문 : 11개)

        // When
        orderService.order(member.getId(), item.getId(), orderCount);

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

    /* 주문 취소 테스트 
       주문을 취소하면 그만큼 재고가 증가해야 함
       Given 절에서 주문을 취소하기 위해 먼저 주문하고
       When 절에서 해당 주문을 취소
       Then 절에서 주문상태가 주문 취소 상태인지, 취소한 만큼 재고가 증가했는지 검증 */
    @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 Book 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;
    }
}

11.3.5 웹 계층 구현

상품 등록
웹 화면에서 상품을 어떻게 등록하는지, 상품 컨트롤러 중에서 상품 등록 시나리오에 사용하는 부분

🔻 상품 등록 폼
첫 화면에서 상품 등록을 선택하면 /item/new URL을 HTTP GET 방식으로 요청하고 스프링 MVC는 HTTP 요청 정보와 @RequestMapping의 속성 값을 비교해서 실행할 메소드를 찾은 후 요청 정보와 매핑되는 createForm() 메소드를 실행

createForm() 메소드는 단순히 items/createItemForm 문자를 반환하고, 스프링 MVC의 뷰 리졸버는 이 정보를 바탕으로 실행할 뷰를 찾으며 방금 반환한 문자(items/createItemForm)와 뷰 리졸버에 등록한 setPrefix(), setSuffix() 정보를 사용해 렌더링할 뷰를 찾고 마지막으로 해당 위치의 JSP를 실행한 결과 HTML을 클라이언트에 응답

  • 변환 전 : items/createItemForm -> {prefix} items/createItemForm {subffix}
  • 변환 후 : items/createItemForm -> /WEBINF/jsp/items/createItemForm.jsp

🔻 상품 등록
상품 등록 폼에서 데이터를 입력하고 Submit 버튼을 클릭하면 /items/new를 POST 방식으로 요청하고 요청 정보와 매핑되는 상품 컨트롤러의 create(Book item) 메소드를 실행

파라미터로 전달한 item에는 화면에서 입력한 데이터가 모두 바인딩되어 있음(HttpServeletRequest의 파라미터와 객체의 프로퍼티 이름을 비교해서 같으면 스프링 프레임워크가 값을 바인딩해줌)

이 메소드는 상품 서비스가 상품 저장을 요청(itemService.saveItem(item))후 저장이 끝나면 상품 목록 화면으로 리다이렉트

// 상품 등록
@Controller
public class ItemController {

    @Autowired ItemService itemService;

    @RequestMapping(value = "/items/new", method = RequestMethod.GET)
    public String createForm() {
        return "items/createItemForm";
    }

    @RequestMapping(value = "/items/new", method = RequestMethod.POST)
    public String create(Book item) {

        itemService.saveItem(item);
        return "redirect:/items";
    }
    ...
}
// webAppConfig.xml에 등록한 뷰 리졸버
// 스프링 빈을 등록 (JSP, JSTL을 사용하도록 뷰 리졸버를 스프링 빈을 등록)
<bean id="viewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="viewClass" value="org.springframework.web.servlet.view.JstlView"/>
    <property name="prefix" value="/WEB-INF/jsp/"/>
    <property name="suffix" value=".jsp"/>
</bean>

🔻 상품 목록
화면에서 상품 목록의 URL은 /items이며, 상품 목록을 클릭하면 ItemController에 있는 list() 메소드를 실행

@Controller
public class ItemController {

    @Autowired ItemService itemService;

    ...

    /* 상품 목록 
       itemService.findItems()를 호출해서 서비스 계층에서 상품 목록을 조회
       그리고 조회한 상품을 뷰에 전달하기 위해 스프링 MVC가 제공하는 모델 객체에 담아둠
       그리고 마지막으로 실행할 뷰 이름을 반환 */
    @RequestMapping(value = "/items", method = RequestMethod.GET)
    public String list(Model model) {

        List<Item> items = itemService.findItems();
        model.addAttribute("items", items);
        return "items/itemList";
    }
}
// itemList.jsp
// 모델에 담아둔 상품 목록인 items를 꺼내서 상품 정보를 출력
...
<c:forEach items="${items}" var="item">
...

🔻 상품 수정

  • 상품 목록 화면에서 수정 버튼을 선택하면 상품 수정 화면으로 이동

상품 수정 폼

  • 수정 버튼을 클릭하면 /items/{itemId}/edit URL을 GET 방식으로 요청하여 updateItemForm() 메소드를 실행시키며, 이 메소드는 itemService.findOne(itemId)를 호출해서 수정할 상품을 조회
    그리고 조회 결과를 모델 객체에 담아서 뷰(items/updateItemForm)에 전달

  • 상품 수정 폼 HTML에는 상품의 id(hidden), 상품명, 가격, 수량 정보가 있음. 상품 수정 폼에서 정보를 수정하고 Submit 버튼을 선택하면 /items/{itemId}/edit URL을 POST 방식으로 요청하고 updataItem() 메소드를 실행하며, 이때 컨트롤러에 파라미터로 넘어온 item 엔티티 인스턴스는 현재 준영속 상태, 따라서 영속성 컨텍스트의 지원을 받을 수 없고 데이터를 수정해도 변경 감지 기능을 동작하지 않는 문제 발생

그러므로 아래의 변경 감지 또는 병합을 이용
updateItem() 메소드는 itemService.saveItem(item)을 호출해서 준영속 상태인 item 엔티티를 상품 서비스에 전달
상품 서비스는 트랜잭션을 시작하고 상품 리포지토리에 저장을 요청하는데
이 메소드는 식별자가 없으면 새로운 엔티티로 판단해서 영속화하고
식별자가 있으면 병합을 수행하므로 준영속 상태인 상품 엔티티를 수정할 때는 id 값이 있으므로 병합을 수행! 그러므로 수정 사항이 반영됨

@Controller
public class ItemController {

    @Autowired ItemService itemService;

    ...
    
    /* 상품 수정 폼 */
    @RequestMapping(value = "/items/{itemId}/edit", method = RequestMethod.GET)
    public String updateItemForm(@PathVariable("itemId") Long itemId, Model model) {

        Item item = itemService.findOne(itemId);
        model.addAttribute("item", item);
        return "items/updateItemForm";
    }

    /* 상품 수정 */
    @RequestMapping(value = "/items/{itemId}/edit", method = RequestMethod.POST)
    public String updateItem(@ModelAttribute("item") Book item) {

        itemService.saveItem(item);
        return "redirect:/items";
    }
    ...
}

변경 감지와 병합

준영속 엔티티를 수정하는 방법은 2가지 존재

🔽 변경 감지 기능 사용
: 영속성 컨텍스트에서 엔티티를 다시 조회한 후 데이터를 수정하는 방법으로 트랜잭션 안에서 준영속 엔티티의 식별자로 엔티티를 다시 조회하면 영속 상태의 엔티티를 얻을 수 있으므로 영속 상태인 엔티티의 값을 파라미터로 엄어온 준영속 상태의 엔티티 값으로 변경 이렇게 하면 이후 트랜잭션이 커밋될 때 변경 감지 기능이 동작해서 데이터베이스에 수정사항이 반영됨

🔽 병합 사용
: 파라미터로 넘긴 준영속 엔티티의 식별자 값으로 영속 엔티티를 조회한 후 영속 엔티티의 값을 준영속 엔티티의 값으로 채워 넣음

변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만 병합을 사용하면 모든 속성이 변경됨

// 변경 감지 기능 사용
@Transactional
// itemParam : 파라미터로 넘어온 준영속 상태의 엔티티
void update(Item itemParam) {
 
    // 같은 엔티티를 조회
    Item findItem = em.find(Item.class, itemParam.getId());
    
    findItem.setPrice(itemParam.getPrice()); // 데이터를 수정
}

// 병합 사용
@Transactional
// itemParam : 파라미터로 넘어온 준영속 상태의 엔티티
void update(Item itemParam) {
 
    Item mergetItem = em.merge(item);
}

상품 주문

🔻 상품 주문 폼

  • 메인 화면에서 상품 주문을 선택하면 /order를 GET 방식으로 호출해서 OrderController의 createForm() 메소드를 실행
  • 주문 화면에는 주문할 고객정보와 상품 정보가 필요하므로 모델 객체에 담아서 뷰에 넘겨줌

🔻 상품 주문

  • 상품 주문 화면에서 주문할 회원과 상품 그리고 수량을 선택해 Submit 버튼을 누르면 /order URL을 POST 방식으로 호출해서 컨트롤러의 order() 메소드를 실행하고, 이는 고객 식별자, 주문할 상품 식별자, 수량 정보를 받아서 주문 서비스에 주문을 요청
  • 주문이 끝나면 상품 주문 내역이 있는 /order URL로 리다이렉트
@Controller
public class OrderController {

    @Autowired OrderService orderService;
    @Autowired MemberService memberService;
    @Autowired ItemService itemService;

    @RequestMapping(value = "/order", method = RequestMethod.GET)
    public String createForm(Model model) {

        List<Member> members = memberService.findMembers();
        List<Item> items = itemService.findItems();

        model.addAttribute("members", members);
        model.addAttribute("items", items);

        return "order/orderForm";
    }

    @RequestMapping(value = "/order", method = RequestMethod.POST)
    public String order(@RequestParam("memberId") Long memberId, @RequestParam("itemId") Long itemId, @RequestParam("count") int count) {

        orderService.order(memberId, itemId, count);
        return "redirect:/orders";
    }
    ...
}
profile
@chhaewxn

1개의 댓글

comment-user-thumbnail
2023년 8월 8일

정보 감사합니다.

답글 달기